sqlite local

This commit is contained in:
m
2026-09-09 23:12:15 +02:00
parent e1360c19a2
commit 7dd29239b7
21 changed files with 2284 additions and 101 deletions
+72 -24
View File
@@ -1,60 +1,108 @@
import { getDatabase } from '../client';
import { newResourceId } from '../id';
import { FILE_COLUMNS_SQL } from '../schema';
import type { FileEntry, FileRow, StoredFile } from '../types';
import { getDeviceUserId } from './preferences';
import type { FileEntry, FileRow, StoredFile, SyncStatus } from '../types';
export async function saveFile(file: FileEntry, folderUri: string): Promise<void> {
export type SaveFileOptions = {
syncStatus?: SyncStatus;
};
export async function saveFile(
file: FileEntry,
folderResourceId: string,
options: SaveFileOptions = {},
): Promise<StoredFile> {
const db = await getDatabase();
const now = Date.now();
const exists = file.exists ? 1 : 0;
const ownerId = await getDeviceUserId();
const existing = await db.getFirstAsync<FileRow>(
`SELECT ${FILE_COLUMNS_SQL} FROM files WHERE uri = ?`,
file.uri,
);
const resourceId = existing?.resource_id ?? (await newResourceId());
const baseSync = existing?.sync_status ?? options.syncStatus ?? 'local';
await db.runAsync(
`INSERT INTO files (uri, name, folder_uri, extension, size, "type", "exists", last_modified, added_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(uri) DO UPDATE SET
name = excluded.name, folder_uri = excluded.folder_uri, extension = excluded.extension,
size = excluded.size, "type" = excluded."type", "exists" = excluded."exists",
last_modified = excluded.last_modified`,
`INSERT INTO files
(resource_id, uri, name, folder_resource_id, extension, size, "type", "exists", last_modified, owner_id, sync_status, added_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(resource_id) DO UPDATE SET
uri = excluded.uri,
name = excluded.name,
folder_resource_id = excluded.folder_resource_id,
extension = excluded.extension,
size = excluded.size,
"type" = excluded."type",
"exists" = excluded."exists",
last_modified = excluded.last_modified,
sync_status = excluded.sync_status,
updated_at = excluded.updated_at`,
resourceId,
file.uri,
file.name,
folderUri,
folderResourceId,
file.extension ?? null,
file.size,
file.type ?? null,
exists,
file.lastModified,
Date.now(),
file.lastModified ?? null,
ownerId,
baseSync,
existing?.added_at ?? now,
now,
);
const row = await db.getFirstAsync<FileRow>(
`SELECT ${FILE_COLUMNS_SQL} FROM files WHERE resource_id = ?`,
resourceId,
);
return toStoredFile(row!);
}
export async function getFiles(folderUri?: string): Promise<StoredFile[]> {
export async function getFiles(folderResourceId?: string): Promise<StoredFile[]> {
const db = await getDatabase();
const rows = folderUri
? await db.getAllAsync<FileRow>(
`SELECT ${FILE_COLUMNS_SQL} FROM files WHERE folder_uri = ? ORDER BY name ASC`,
folderUri,
)
: await db.getAllAsync<FileRow>(
`SELECT ${FILE_COLUMNS_SQL} FROM files ORDER BY name ASC`,
);
const rows =
folderResourceId === undefined
? await db.getAllAsync<FileRow>(`SELECT ${FILE_COLUMNS_SQL} FROM files ORDER BY name ASC`)
: await db.getAllAsync<FileRow>(
`SELECT ${FILE_COLUMNS_SQL} FROM files WHERE folder_resource_id = ? ORDER BY name ASC`,
folderResourceId,
);
return rows.map(toStoredFile);
}
export async function removeFile(uri: string): Promise<void> {
export async function getFile(resourceId: string): Promise<StoredFile | null> {
const db = await getDatabase();
await db.runAsync('DELETE FROM files WHERE uri = ?', uri);
const row = await db.getFirstAsync<FileRow>(
`SELECT ${FILE_COLUMNS_SQL} FROM files WHERE resource_id = ?`,
resourceId,
);
return row ? toStoredFile(row) : null;
}
export async function removeFile(resourceId: string): Promise<void> {
const db = await getDatabase();
await db.runAsync('DELETE FROM files WHERE resource_id = ?', resourceId);
}
function toStoredFile(row: FileRow): StoredFile {
return {
resource_id: row.resource_id,
uri: row.uri,
name: row.name,
isDirectory: false,
folder_resource_id: row.folder_resource_id,
extension: row.extension ?? '',
exists: row.exists === 1,
size: row.size,
type: row.type ?? '',
lastModified: row.last_modified,
folderUri: row.folder_uri,
owner_id: row.owner_id,
syncStatus: row.sync_status,
addedAt: row.added_at,
updatedAt: row.updated_at,
};
}
+95 -35
View File
@@ -1,69 +1,129 @@
import { getDatabase } from '../client';
import { newResourceId } from '../id';
import { FOLDER_COLUMNS_SQL } from '../schema';
import type { Folder, FolderContext, FolderRow, SyncStatus } from '../types';
import { getDeviceUserId } from './preferences';
import type { FolderRow, StoredFolder, SyncStatus } from '../types';
type SaveFolderOptions = {
parentUri?: string;
export type SaveFolderInput = {
uri: string | null;
name: string;
exists?: boolean;
resource_id?: string;
};
export type SaveFolderOptions = {
parentResourceId?: string | null;
syncStatus?: SyncStatus;
};
export async function saveFolder(
folder: Folder,
options?: SaveFolderOptions,
): Promise<void> {
input: SaveFolderInput,
options: SaveFolderOptions = {},
): Promise<StoredFolder> {
const db = await getDatabase();
const exists = folder.exists === undefined ? null : folder.exists ? 1 : 0;
const now = Date.now();
const exists =
input.exists === undefined || input.exists === null ? null : input.exists ? 1 : 0;
const ownerId = await getDeviceUserId();
const existing = input.uri
? await db.getFirstAsync<FolderRow>(
`SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE uri = ?`,
input.uri,
)
: input.resource_id
? await db.getFirstAsync<FolderRow>(
`SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE resource_id = ?`,
input.resource_id,
)
: null;
const resourceId = existing?.resource_id ?? input.resource_id ?? (await newResourceId());
const baseSync = existing?.sync_status ?? options.syncStatus ?? 'local';
await db.runAsync(
`INSERT INTO folders (uri, name, "exists", sync_status, added_at, parent_uri)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(uri) DO UPDATE SET
name = excluded.name, "exists" = excluded."exists", sync_status = excluded.sync_status`,
folder.uri,
folder.name,
`INSERT INTO folders
(resource_id, uri, name, "exists", parent_resource_id, owner_id, sync_status, added_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(resource_id) DO UPDATE SET
uri = excluded.uri,
name = excluded.name,
"exists" = excluded."exists",
parent_resource_id = CASE
WHEN excluded.parent_resource_id IS NULL THEN folders.parent_resource_id
ELSE excluded.parent_resource_id
END,
sync_status = excluded.sync_status,
updated_at = excluded.updated_at`,
resourceId,
input.uri,
input.name,
exists,
options?.syncStatus ?? 'local',
Date.now(),
options?.parentUri ?? null,
options.parentResourceId ?? null,
ownerId,
baseSync,
existing?.added_at ?? now,
now,
);
const row = await db.getFirstAsync<FolderRow>(
`SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE resource_id = ?`,
resourceId,
);
return toStoredFolder(row!);
}
export async function saveDirectory(folder: Folder): Promise<boolean> {
await saveFolder(folder);
return true;
export async function saveDirectory(folder: {
uri: string;
name: string;
exists?: boolean;
}): Promise<StoredFolder> {
return saveFolder({ uri: folder.uri, name: folder.name, exists: folder.exists });
}
export async function getFolders(): Promise<FolderContext[]> {
export async function getFolders(): Promise<StoredFolder[]> {
const db = await getDatabase();
const rows = await db.getAllAsync<FolderRow>(
`SELECT ${FOLDER_COLUMNS_SQL} FROM folders ORDER BY added_at ASC, name ASC`,
`SELECT ${FOLDER_COLUMNS_SQL} FROM folders ORDER BY name ASC`,
);
return rows.map(toFolderContext);
return rows.map(toStoredFolder);
}
export async function getFolderFolders(parentUri: string): Promise<FolderContext[]> {
export async function getFolderFolders(
parentResourceId: string | null,
): Promise<StoredFolder[]> {
const db = await getDatabase();
const rows = await db.getAllAsync<FolderRow>(
`SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE parent_uri = ? ORDER BY name ASC`,
parentUri,
`SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE parent_resource_id IS ? ORDER BY name ASC`,
parentResourceId,
);
return rows.map(toFolderContext);
return rows.map(toStoredFolder);
}
export async function removeFolder(uri: string): Promise<void> {
export async function getFolder(resourceId: string): Promise<StoredFolder | null> {
const db = await getDatabase();
await db.runAsync('DELETE FROM folders WHERE uri = ?', uri);
const row = await db.getFirstAsync<FolderRow>(
`SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE resource_id = ?`,
resourceId,
);
return row ? toStoredFolder(row) : null;
}
function toFolderContext(row: FolderRow): FolderContext {
export async function removeFolder(resourceId: string): Promise<void> {
const db = await getDatabase();
await db.runAsync('DELETE FROM folders WHERE resource_id = ?', resourceId);
}
function toStoredFolder(row: FolderRow): StoredFolder {
return {
resource_id: row.resource_id,
uri: row.uri,
name: row.name,
exists: row.exists === 1,
parent_resource_id: row.parent_resource_id,
owner_id: row.owner_id,
syncStatus: row.sync_status,
addedAt: row.added_at,
folder: {
uri: row.uri,
name: row.name,
isDirectory: true,
exists: row.exists === null ? undefined : row.exists === 1,
},
updatedAt: row.updated_at,
};
}
+31 -2
View File
@@ -3,7 +3,36 @@ export {
saveDirectory,
getFolders,
getFolderFolders,
getFolder,
removeFolder,
} from './folders';
export { saveFile, getFiles, removeFile } from './files';
export { saveUserPreferences, getUserPreferences } from './preferences';
export { saveFile, getFiles, getFile, removeFile } from './files';
export {
saveUserPreferences,
getUserPreferences,
getDeviceUserId,
} from './preferences';
export {
getResourcePermission,
saveResourcePermission,
canAccess,
canWrite,
isOwner,
} from './permissions';
export { saveShare, getShare, getShares, removeShare, removeSharesForResource } from './shares';
export {
createShareLink,
getShareLinkById,
getShareLinkByToken,
getShareLinks,
incrementLinkDownloads,
revokeShareLink,
removeShareLinksForResource,
} from './shareLinks';
export { saveRecipient, getRecipients, setRecipientActive } from './recipients';
export {
enqueuePendingOperation,
getPendingOperations,
getNextQueuedOperation,
markPendingOperation,
} from './pendingOps';
@@ -0,0 +1,128 @@
import { getDatabase } from '../client';
import type {
NewPendingOperation,
PendingOperation,
PendingOperationRow,
PendingOperationStatus,
} from '../types';
const PENDING_OPERATION_COLUMNS = `
id, resource_id, resource_type, ref_type, ref_id, operation, payload,
status, attempts, error, created_at, next_retry_at, last_error_at`;
const MAX_BACKOFF_MS = 24 * 60 * 60 * 1000;
const BASE_BACKOFF_MS = 30 * 1000;
export async function enqueuePendingOperation(
operation: NewPendingOperation,
): Promise<number> {
const db = await getDatabase();
const row = await db.getFirstAsync<{ id: number }>(
`INSERT INTO pending_operations
(resource_id, resource_type, ref_type, ref_id, operation, payload, status, attempts, created_at)
VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, ?)
RETURNING id`,
operation.resourceId ?? null,
operation.resourceType ?? null,
operation.refType ?? null,
operation.refId ?? null,
operation.operation,
JSON.stringify(operation.payload ?? {}),
Date.now(),
);
return row!.id;
}
export async function getPendingOperations(
status?: PendingOperationStatus,
): Promise<PendingOperation[]> {
const db = await getDatabase();
const rows = status
? await db.getAllAsync<PendingOperationRow>(
`SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations
WHERE status = ? ORDER BY created_at ASC, id ASC`,
status,
)
: await db.getAllAsync<PendingOperationRow>(
`SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations
ORDER BY created_at ASC, id ASC`,
);
return rows.map(toPendingOperation);
}
export async function getNextQueuedOperation(): Promise<PendingOperation | null> {
const db = await getDatabase();
const row = await db.getFirstAsync<PendingOperationRow>(
`SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations
WHERE status = 'pending'
AND (next_retry_at IS NULL OR next_retry_at <= ?)
ORDER BY created_at ASC, id ASC
LIMIT 1`,
Date.now(),
);
return row ? toPendingOperation(row) : null;
}
export async function markPendingOperation(
id: number,
status: PendingOperationStatus,
error?: string | null,
): Promise<void> {
const db = await getDatabase();
const now = Date.now();
if (status === 'failed') {
const current = await db.getFirstAsync<{ attempts: number }>(
'SELECT attempts FROM pending_operations WHERE id = ?',
id,
);
if (!current) return;
const attempts = current.attempts + 1;
const backoff = Math.min(BASE_BACKOFF_MS * 2 ** attempts, MAX_BACKOFF_MS);
await db.runAsync(
`UPDATE pending_operations SET
status = ?, attempts = ?, error = ?, next_retry_at = ?, last_error_at = ?
WHERE id = ?`,
status,
attempts,
error ?? null,
now + backoff,
now,
id,
);
return;
}
await db.runAsync(
`UPDATE pending_operations SET status = ?, error = ?, next_retry_at = NULL, last_error_at = NULL WHERE id = ?`,
status,
status === 'in_progress' ? null : error ?? null,
id,
);
}
function toPendingOperation(row: PendingOperationRow): PendingOperation {
return {
id: row.id,
resourceId: row.resource_id,
resourceType: row.resource_type,
refType: row.ref_type,
refId: row.ref_id,
operation: row.operation,
payload: safeParse(row.payload),
status: row.status,
attempts: row.attempts,
error: row.error,
createdAt: row.created_at,
nextRetryAt: row.next_retry_at,
lastErrorAt: row.last_error_at,
};
}
function safeParse(json: string): unknown {
try {
return JSON.parse(json);
} catch {
return {};
}
}
@@ -0,0 +1,197 @@
import type { SQLiteDatabase } from 'expo-sqlite';
import { getDatabase } from '../client';
import { PERMISSION_TTL_MS } from '../schema';
import { getDeviceUserId } from './preferences';
import type {
AccessLevel,
NewResourcePermission,
ResourcePermission,
ResourcePermissionRow,
ResourceType,
} from '../types';
const ACCESS_RANK: Record<AccessLevel, number> = {
viewer: 1,
commenter: 2,
editor: 3,
owner: 4,
};
export type AccessSource = 'none' | 'cache' | 'owner' | 'inherited';
export type AccessCheck = {
allowed: boolean;
access: AccessLevel | null;
source: AccessSource;
stale: boolean;
expiresAt: number | null;
};
type LineageNode = {
resource_id: string;
parent_resource_id: string | null;
owner_id: string;
};
function rank(level: AccessLevel): number {
return ACCESS_RANK[level];
}
export async function getResourcePermission(
resourceId: string,
resourceType: ResourceType,
): Promise<ResourcePermission | null> {
const db = await getDatabase();
const row = await db.getFirstAsync<ResourcePermissionRow>(
`SELECT * FROM resource_permissions WHERE resource_id = ? AND resource_type = ?`,
resourceId,
resourceType,
);
return row ? toResourcePermission(row) : null;
}
export async function saveResourcePermission(
permission: NewResourcePermission,
): Promise<void> {
const db = await getDatabase();
const now = Date.now();
await db.runAsync(
`INSERT INTO resource_permissions
(resource_id, resource_type, effective_access, inherit, owner_id, shared_by_id, expires_at, cached_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(resource_id, resource_type) DO UPDATE SET
effective_access = excluded.effective_access,
inherit = excluded.inherit,
owner_id = excluded.owner_id,
shared_by_id = excluded.shared_by_id,
expires_at = excluded.expires_at,
cached_at = excluded.cached_at,
updated_at = excluded.updated_at`,
permission.resource_id,
permission.resourceType,
permission.effectiveAccess,
permission.inherit === false ? 0 : 1,
permission.ownerId ?? null,
permission.sharedById ?? null,
permission.expiresAt ?? null,
now,
now,
);
}
async function resourceLineage(
resourceId: string,
resourceType: ResourceType,
): Promise<LineageNode[]> {
const db = await getDatabase();
const anchor = await db.getFirstAsync<{ folder_resource_id: string }>(
'SELECT folder_resource_id FROM files WHERE resource_id = ?',
resourceId,
);
if (resourceType === 'file') {
if (!anchor) return [];
return folderLineage(db, anchor.folder_resource_id);
}
return folderLineage(db, resourceId);
}
async function folderLineage(
db: SQLiteDatabase,
startResourceId: string,
): Promise<LineageNode[]> {
const rows = await db.getAllAsync<LineageNode>(
`WITH RECURSIVE lineage(resource_id, parent_resource_id, owner_id) AS (
SELECT resource_id, parent_resource_id, owner_id FROM folders WHERE resource_id = ?
UNION ALL
SELECT f.resource_id, f.parent_resource_id, f.owner_id
FROM folders f
JOIN lineage l ON f.resource_id = l.parent_resource_id
)
SELECT resource_id, parent_resource_id, owner_id FROM lineage`,
startResourceId,
);
return rows;
}
function decideFromPermission(
permission: ResourcePermission,
required: AccessLevel,
now: number,
): AccessCheck {
if (permission.expiresAt != null && permission.expiresAt < now) {
return {
allowed: false,
access: permission.effectiveAccess,
source: 'cache',
stale: false,
expiresAt: permission.expiresAt,
};
}
const granted = rank(permission.effectiveAccess) >= rank(required);
return {
allowed: granted,
access: permission.effectiveAccess,
source: 'cache',
stale: now - permission.cachedAt > PERMISSION_TTL_MS,
expiresAt: permission.expiresAt,
};
}
export async function canAccess(
resourceId: string,
resourceType: ResourceType,
required: AccessLevel,
): Promise<AccessCheck> {
const db = await getDatabase();
const now = Date.now();
const deviceUserId = await getDeviceUserId();
const cached = await getResourcePermission(resourceId, resourceType);
if (cached) return decideFromPermission(cached, required, now);
const lineage = await resourceLineage(resourceId, resourceType);
for (const node of lineage) {
if (node.owner_id === deviceUserId) {
return { allowed: true, access: 'owner', source: 'owner', stale: false, expiresAt: null };
}
const nodePermission = await getResourcePermission(node.resource_id, 'folder');
if (nodePermission) {
const decision = decideFromPermission(nodePermission, required, now);
if (decision.source === 'cache') {
return { ...decision, source: 'inherited' };
}
}
}
return { allowed: false, access: null, source: 'none', stale: false, expiresAt: null };
}
export async function canWrite(
resourceId: string,
resourceType: ResourceType,
): Promise<AccessCheck> {
return canAccess(resourceId, resourceType, 'editor');
}
export async function isOwner(
resourceId: string,
resourceType: ResourceType,
): Promise<boolean> {
const check = await canAccess(resourceId, resourceType, 'owner');
return check.allowed;
}
function toResourcePermission(row: ResourcePermissionRow): ResourcePermission {
return {
resource_id: row.resource_id,
resourceType: row.resource_type,
effectiveAccess: row.effective_access,
inherit: row.inherit === 1,
ownerId: row.owner_id,
sharedById: row.shared_by_id,
expiresAt: row.expires_at,
cachedAt: row.cached_at,
updatedAt: row.updated_at,
};
}
+22 -1
View File
@@ -1,11 +1,32 @@
import { getDatabase } from '../client';
import { PREFERENCES_KEY } from '../schema';
import { DEVICE_USER_ID_KEY, PREFERENCES_KEY } from '../schema';
import type { UserPreferences } from '../types';
const DEFAULT_PREFERENCES: UserPreferences = {
syncMode: 'full',
};
export async function getDeviceUserId(): Promise<string> {
const db = await getDatabase();
const row = await db.getFirstAsync<{ value: string }>(
'SELECT "value" FROM user_preferences WHERE "key" = ?',
DEVICE_USER_ID_KEY,
);
if (row) return row.value;
await db.runAsync(
`INSERT OR IGNORE INTO user_preferences ("key", "value", updated_at)
VALUES (?, lower(hex(randomblob(16))), ?)`,
DEVICE_USER_ID_KEY,
Date.now(),
);
const seeded = await db.getFirstAsync<{ value: string }>(
'SELECT "value" FROM user_preferences WHERE "key" = ?',
DEVICE_USER_ID_KEY,
);
return seeded!.value;
}
export async function saveUserPreferences(preferences: UserPreferences): Promise<void> {
const db = await getDatabase();
await db.runAsync(
@@ -0,0 +1,64 @@
import { getDatabase } from '../client';
import type { Recipient, RecipientRow, RecipientType } from '../types';
export async function saveRecipient(
recipientType: RecipientType,
recipientId: string,
displayName: string,
): Promise<Recipient> {
const db = await getDatabase();
await db.runAsync(
`INSERT INTO recipients (recipient_type, recipient_id, display_name, is_active, updated_at)
VALUES (?, ?, ?, 1, ?)
ON CONFLICT(recipient_id) DO UPDATE SET
recipient_type = excluded.recipient_type,
display_name = excluded.display_name,
is_active = 1,
updated_at = excluded.updated_at`,
recipientType,
recipientId,
displayName,
Date.now(),
);
const row = await db.getFirstAsync<RecipientRow>(
'SELECT * FROM recipients WHERE recipient_id = ?',
recipientId,
);
return toRecipient(row!);
}
export async function getRecipients(activeOnly = true): Promise<Recipient[]> {
const db = await getDatabase();
const rows = activeOnly
? await db.getAllAsync<RecipientRow>(
'SELECT * FROM recipients WHERE is_active = 1 ORDER BY display_name ASC',
)
: await db.getAllAsync<RecipientRow>('SELECT * FROM recipients ORDER BY display_name ASC');
return rows.map(toRecipient);
}
export async function setRecipientActive(
recipientType: RecipientType,
recipientId: string,
active: boolean,
): Promise<void> {
const db = await getDatabase();
await db.runAsync(
`UPDATE recipients SET is_active = ?, updated_at = ?
WHERE recipient_type = ? AND recipient_id = ?`,
active ? 1 : 0,
Date.now(),
recipientType,
recipientId,
);
}
function toRecipient(row: RecipientRow): Recipient {
return {
recipientType: row.recipient_type,
recipientId: row.recipient_id,
displayName: row.display_name,
isActive: row.is_active === 1,
updatedAt: row.updated_at,
};
}
@@ -0,0 +1,131 @@
import { getDatabase } from '../client';
import type { NewShareLink, ResourceType, ShareLink, ShareLinkRow } from '../types';
const PUSH_STATUS_SQL = `(
SELECT CASE
WHEN EXISTS (
SELECT 1 FROM pending_operations p
WHERE p.ref_type = 'share_link' AND p.ref_id = sl.id AND p.status IN ('pending', 'in_progress')
) THEN 'pending'
WHEN EXISTS (
SELECT 1 FROM pending_operations p
WHERE p.ref_type = 'share_link' AND p.ref_id = sl.id AND p.status = 'failed'
) THEN 'failed'
ELSE 'synced'
END
) AS push_status`;
export async function createShareLink(input: NewShareLink): Promise<ShareLink> {
const db = await getDatabase();
const now = Date.now();
let token = input.token;
if (!token) {
const tokenRow = await db.getFirstAsync<{ token: string }>(
'SELECT lower(hex(randomblob(16))) AS token',
);
token = tokenRow!.token;
}
const row = await db.getFirstAsync<{ id: number }>(
`INSERT INTO share_links
(token, resource_id, resource_type, has_password, allow_download, expires_at, max_downloads, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id`,
token,
input.resourceId,
input.resourceType,
input.hasPassword ? 1 : 0,
input.allowDownload === false ? 0 : 1,
input.expiresAt ?? null,
input.maxDownloads ?? null,
now,
now,
);
const created = await getShareLinkById(row!.id);
if (!created) throw new Error('share link insert failed');
return created;
}
export async function getShareLinkById(id: number): Promise<ShareLink | null> {
const db = await getDatabase();
const row = await db.getFirstAsync<ShareLinkRow & { push_status: ShareLink['pushStatus'] }>(
`SELECT sl.*, ${PUSH_STATUS_SQL} FROM share_links sl WHERE sl.id = ?`,
id,
);
return row ? toShareLink(row) : null;
}
export async function getShareLinkByToken(token: string): Promise<ShareLink | null> {
const db = await getDatabase();
const row = await db.getFirstAsync<ShareLinkRow & { push_status: ShareLink['pushStatus'] }>(
`SELECT sl.*, ${PUSH_STATUS_SQL} FROM share_links sl WHERE sl.token = ?`,
token,
);
return row ? toShareLink(row) : null;
}
export async function getShareLinks(
resourceId?: string,
resourceType?: ResourceType,
): Promise<ShareLink[]> {
const db = await getDatabase();
let sql = `SELECT sl.*, ${PUSH_STATUS_SQL} FROM share_links sl`;
const params: string[] = [];
if (resourceId) {
sql += ' WHERE sl.resource_id = ?';
params.push(resourceId);
if (resourceType) {
sql += ' AND sl.resource_type = ?';
params.push(resourceType);
}
}
sql += ' ORDER BY sl.created_at ASC';
const rows = await db.getAllAsync<ShareLinkRow & { push_status: ShareLink['pushStatus'] }>(sql, ...params);
return rows.map(toShareLink);
}
export async function incrementLinkDownloads(id: number): Promise<void> {
const db = await getDatabase();
await db.runAsync(
`UPDATE share_links SET downloads_count = downloads_count + 1, updated_at = ? WHERE id = ?`,
Date.now(),
id,
);
}
export async function revokeShareLink(id: number): Promise<void> {
const db = await getDatabase();
await db.runAsync(
`UPDATE share_links SET is_revoked = 1, updated_at = ? WHERE id = ?`,
Date.now(),
id,
);
}
export async function removeShareLinksForResource(
resourceId: string,
resourceType: ResourceType,
): Promise<void> {
const db = await getDatabase();
await db.runAsync('DELETE FROM share_links WHERE resource_id = ? AND resource_type = ?', resourceId, resourceType);
}
function toShareLink(row: ShareLinkRow & { push_status: ShareLink['pushStatus'] }): ShareLink {
return {
id: row.id,
token: row.token,
resourceId: row.resource_id,
resourceType: row.resource_type,
hasPassword: row.has_password === 1,
allowDownload: row.allow_download === 1,
expiresAt: row.expires_at,
maxDownloads: row.max_downloads,
downloadsCount: row.downloads_count,
isRevoked: row.is_revoked === 1,
createdAt: row.created_at,
updatedAt: row.updated_at,
pushStatus: row.push_status,
};
}
+130
View File
@@ -0,0 +1,130 @@
import { getDatabase } from '../client';
import type { NewShare, ResourceType, Share, ShareRow } from '../types';
const PUSH_STATUS_SQL = `(
SELECT CASE
WHEN EXISTS (
SELECT 1 FROM pending_operations p
WHERE p.ref_type = 'share' AND p.ref_id = s.id AND p.status IN ('pending', 'in_progress')
) THEN 'pending'
WHEN EXISTS (
SELECT 1 FROM pending_operations p
WHERE p.ref_type = 'share' AND p.ref_id = s.id AND p.status = 'failed'
) THEN 'failed'
ELSE 'synced'
END
) AS push_status`;
export async function saveShare(share: NewShare): Promise<Share> {
const db = await getDatabase();
const now = Date.now();
await db.runAsync(
`INSERT INTO shares
(resource_id, resource_type, recipient_type, recipient_id, relation, inherit, expires_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(resource_id, resource_type, recipient_type, recipient_id) DO UPDATE SET
relation = excluded.relation,
inherit = excluded.inherit,
expires_at = excluded.expires_at,
updated_at = excluded.updated_at`,
share.resourceId,
share.resourceType,
share.recipientType,
share.recipientId,
share.relation,
share.inherit === false ? 0 : 1,
share.expiresAt ?? null,
now,
now,
);
const row = await db.getFirstAsync<ShareRow & { push_status: Share['pushStatus'] }>(
`SELECT s.*, ${PUSH_STATUS_SQL} FROM shares s
WHERE s.resource_id = ? AND s.resource_type = ? AND s.recipient_type = ? AND s.recipient_id = ?`,
share.resourceId,
share.resourceType,
share.recipientType,
share.recipientId,
);
return toShare(row!);
}
export async function getShare(
resourceId: string,
resourceType: ResourceType,
recipientType: 'user' | 'group',
recipientId: string,
): Promise<Share | null> {
const db = await getDatabase();
const row = await db.getFirstAsync<ShareRow & { push_status: Share['pushStatus'] }>(
`SELECT s.*, ${PUSH_STATUS_SQL} FROM shares s
WHERE s.resource_id = ? AND s.resource_type = ? AND s.recipient_type = ? AND s.recipient_id = ?`,
resourceId,
resourceType,
recipientType,
recipientId,
);
return row ? toShare(row) : null;
}
export async function getShares(
resourceId?: string,
resourceType?: ResourceType,
): Promise<Share[]> {
const db = await getDatabase();
let sql = `SELECT s.*, ${PUSH_STATUS_SQL} FROM shares s`;
const params: string[] = [];
if (resourceId) {
sql += ' WHERE s.resource_id = ?';
params.push(resourceId);
if (resourceType) {
sql += ' AND s.resource_type = ?';
params.push(resourceType);
}
}
sql += ' ORDER BY s.created_at ASC';
const rows = await db.getAllAsync<ShareRow & { push_status: Share['pushStatus'] }>(sql, ...params);
return rows.map(toShare);
}
export async function removeShare(
resourceId: string,
resourceType: ResourceType,
recipientType: 'user' | 'group',
recipientId: string,
): Promise<void> {
const db = await getDatabase();
await db.runAsync(
`DELETE FROM shares
WHERE resource_id = ? AND resource_type = ? AND recipient_type = ? AND recipient_id = ?`,
resourceId,
resourceType,
recipientType,
recipientId,
);
}
export async function removeSharesForResource(
resourceId: string,
resourceType: ResourceType,
): Promise<void> {
const db = await getDatabase();
await db.runAsync('DELETE FROM shares WHERE resource_id = ? AND resource_type = ?', resourceId, resourceType);
}
function toShare(row: ShareRow & { push_status: Share['pushStatus'] }): Share {
return {
id: row.id,
resourceId: row.resource_id,
resourceType: row.resource_type,
recipientType: row.recipient_type,
recipientId: row.recipient_id,
relation: row.relation,
inherit: row.inherit === 1,
expiresAt: row.expires_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
pushStatus: row.push_status,
};
}