sqlite local
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user