This commit is contained in:
m
2026-09-10 06:39:25 +02:00
parent 7dd29239b7
commit 85b1871c18
18 changed files with 783 additions and 147 deletions
+15 -9
View File
@@ -1,4 +1,4 @@
import { getDatabase } from '../client';
import { getSession } from '../session';
import { newResourceId } from '../id';
import { FILE_COLUMNS_SQL } from '../schema';
import { getDeviceUserId } from './preferences';
@@ -6,6 +6,7 @@ import type { FileEntry, FileRow, StoredFile, SyncStatus } from '../types';
export type SaveFileOptions = {
syncStatus?: SyncStatus;
resource_id?: string;
};
export async function saveFile(
@@ -13,15 +14,20 @@ export async function saveFile(
folderResourceId: string,
options: SaveFileOptions = {},
): Promise<StoredFile> {
const db = await getDatabase();
const db = await getSession();
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 existing = options.resource_id
? await db.getFirstAsync<FileRow>(
`SELECT ${FILE_COLUMNS_SQL} FROM files WHERE resource_id = ?`,
options.resource_id,
)
: 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';
@@ -64,7 +70,7 @@ export async function saveFile(
}
export async function getFiles(folderResourceId?: string): Promise<StoredFile[]> {
const db = await getDatabase();
const db = await getSession();
const rows =
folderResourceId === undefined
? await db.getAllAsync<FileRow>(`SELECT ${FILE_COLUMNS_SQL} FROM files ORDER BY name ASC`)
@@ -76,7 +82,7 @@ export async function getFiles(folderResourceId?: string): Promise<StoredFile[]>
}
export async function getFile(resourceId: string): Promise<StoredFile | null> {
const db = await getDatabase();
const db = await getSession();
const row = await db.getFirstAsync<FileRow>(
`SELECT ${FILE_COLUMNS_SQL} FROM files WHERE resource_id = ?`,
resourceId,
@@ -85,7 +91,7 @@ export async function getFile(resourceId: string): Promise<StoredFile | null> {
}
export async function removeFile(resourceId: string): Promise<void> {
const db = await getDatabase();
const db = await getSession();
await db.runAsync('DELETE FROM files WHERE resource_id = ?', resourceId);
}
+12 -12
View File
@@ -1,4 +1,4 @@
import { getDatabase } from '../client';
import { getSession } from '../session';
import { newResourceId } from '../id';
import { FOLDER_COLUMNS_SQL } from '../schema';
import { getDeviceUserId } from './preferences';
@@ -20,21 +20,21 @@ export async function saveFolder(
input: SaveFolderInput,
options: SaveFolderOptions = {},
): Promise<StoredFolder> {
const db = await getDatabase();
const db = await getSession();
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
const existing = input.resource_id
? await db.getFirstAsync<FolderRow>(
`SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE uri = ?`,
input.uri,
`SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE resource_id = ?`,
input.resource_id,
)
: input.resource_id
: input.uri
? await db.getFirstAsync<FolderRow>(
`SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE resource_id = ?`,
input.resource_id,
`SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE uri = ?`,
input.uri,
)
: null;
@@ -82,7 +82,7 @@ export async function saveDirectory(folder: {
}
export async function getFolders(): Promise<StoredFolder[]> {
const db = await getDatabase();
const db = await getSession();
const rows = await db.getAllAsync<FolderRow>(
`SELECT ${FOLDER_COLUMNS_SQL} FROM folders ORDER BY name ASC`,
);
@@ -92,7 +92,7 @@ export async function getFolders(): Promise<StoredFolder[]> {
export async function getFolderFolders(
parentResourceId: string | null,
): Promise<StoredFolder[]> {
const db = await getDatabase();
const db = await getSession();
const rows = await db.getAllAsync<FolderRow>(
`SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE parent_resource_id IS ? ORDER BY name ASC`,
parentResourceId,
@@ -101,7 +101,7 @@ export async function getFolderFolders(
}
export async function getFolder(resourceId: string): Promise<StoredFolder | null> {
const db = await getDatabase();
const db = await getSession();
const row = await db.getFirstAsync<FolderRow>(
`SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE resource_id = ?`,
resourceId,
@@ -110,7 +110,7 @@ export async function getFolder(resourceId: string): Promise<StoredFolder | null
}
export async function removeFolder(resourceId: string): Promise<void> {
const db = await getDatabase();
const db = await getSession();
await db.runAsync('DELETE FROM folders WHERE resource_id = ?', resourceId);
}
+20 -7
View File
@@ -1,4 +1,4 @@
import { getDatabase } from '../client';
import { getSession } from '../session';
import type {
NewPendingOperation,
PendingOperation,
@@ -13,10 +13,12 @@ const PENDING_OPERATION_COLUMNS = `
const MAX_BACKOFF_MS = 24 * 60 * 60 * 1000;
const BASE_BACKOFF_MS = 30 * 1000;
export const MAX_PENDING_ATTEMPTS = 5;
export async function enqueuePendingOperation(
operation: NewPendingOperation,
): Promise<number> {
const db = await getDatabase();
const db = await getSession();
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)
@@ -36,7 +38,7 @@ export async function enqueuePendingOperation(
export async function getPendingOperations(
status?: PendingOperationStatus,
): Promise<PendingOperation[]> {
const db = await getDatabase();
const db = await getSession();
const rows = status
? await db.getAllAsync<PendingOperationRow>(
`SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations
@@ -51,7 +53,7 @@ export async function getPendingOperations(
}
export async function getNextQueuedOperation(): Promise<PendingOperation | null> {
const db = await getDatabase();
const db = await getSession();
const row = await db.getFirstAsync<PendingOperationRow>(
`SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations
WHERE status = 'pending'
@@ -68,7 +70,7 @@ export async function markPendingOperation(
status: PendingOperationStatus,
error?: string | null,
): Promise<void> {
const db = await getDatabase();
const db = await getSession();
const now = Date.now();
if (status === 'failed') {
@@ -78,12 +80,23 @@ export async function markPendingOperation(
);
if (!current) return;
const attempts = current.attempts + 1;
if (attempts >= MAX_PENDING_ATTEMPTS) {
await db.runAsync(
`UPDATE pending_operations SET
status = 'failed', attempts = ?, error = ?, next_retry_at = NULL, last_error_at = ?
WHERE id = ?`,
attempts,
error ?? null,
now,
id,
);
return;
}
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 = ?
status = 'pending', attempts = ?, error = ?, next_retry_at = ?, last_error_at = ?
WHERE id = ?`,
status,
attempts,
error ?? null,
now + backoff,
+89 -46
View File
@@ -1,5 +1,5 @@
import type { SQLiteDatabase } from 'expo-sqlite';
import { getDatabase } from '../client';
import type { DbSession } from '../session';
import { getSession } from '../session';
import { PERMISSION_TTL_MS } from '../schema';
import { getDeviceUserId } from './preferences';
import type {
@@ -29,6 +29,7 @@ export type AccessCheck = {
type LineageNode = {
resource_id: string;
resource_type: ResourceType;
parent_resource_id: string | null;
owner_id: string;
};
@@ -41,7 +42,7 @@ export async function getResourcePermission(
resourceId: string,
resourceType: ResourceType,
): Promise<ResourcePermission | null> {
const db = await getDatabase();
const db = await getSession();
const row = await db.getFirstAsync<ResourcePermissionRow>(
`SELECT * FROM resource_permissions WHERE resource_id = ? AND resource_type = ?`,
resourceId,
@@ -53,7 +54,7 @@ export async function getResourcePermission(
export async function saveResourcePermission(
permission: NewResourcePermission,
): Promise<void> {
const db = await getDatabase();
const db = await getSession();
const now = Date.now();
await db.runAsync(
`INSERT INTO resource_permissions
@@ -83,21 +84,32 @@ async function resourceLineage(
resourceId: string,
resourceType: ResourceType,
): Promise<LineageNode[]> {
const db = await getDatabase();
const db = await getSession();
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);
const file = await db.getFirstAsync<{
folder_resource_id: string;
owner_id: string;
}>(
'SELECT folder_resource_id, owner_id FROM files WHERE resource_id = ?',
resourceId,
);
if (!file) return [];
return [
{
resource_id: resourceId,
resource_type: 'file',
parent_resource_id: file.folder_resource_id,
owner_id: file.owner_id,
},
...(await folderLineage(db, file.folder_resource_id)),
];
}
return folderLineage(db, resourceId);
}
async function folderLineage(
db: SQLiteDatabase,
db: DbSession,
startResourceId: string,
): Promise<LineageNode[]> {
const rows = await db.getAllAsync<LineageNode>(
@@ -108,34 +120,18 @@ async function folderLineage(
FROM folders f
JOIN lineage l ON f.resource_id = l.parent_resource_id
)
SELECT resource_id, parent_resource_id, owner_id FROM lineage`,
SELECT resource_id, 'folder' AS resource_type, 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,
};
function isStale(cachedAt: number, now: number): boolean {
return now - cachedAt > PERMISSION_TTL_MS;
}
function readOnlyAccess(permission: ResourcePermission, now: number): AccessLevel {
return isStale(permission.cachedAt, now) ? 'viewer' : permission.effectiveAccess;
}
export async function canAccess(
@@ -143,28 +139,75 @@ export async function canAccess(
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 exactCache = await getResourcePermission(resourceId, resourceType);
if (exactCache) {
if (exactCache.expiresAt != null && exactCache.expiresAt < now) {
return {
allowed: false,
access: exactCache.effectiveAccess,
source: 'cache',
stale: false,
expiresAt: exactCache.expiresAt,
};
}
const stale = isStale(exactCache.cachedAt, now);
const applyAccess = readOnlyAccess(exactCache, now);
return {
allowed: rank(applyAccess) >= rank(required),
access: applyAccess,
source: 'cache',
stale,
expiresAt: exactCache.expiresAt,
};
}
const lineage = await resourceLineage(resourceId, resourceType);
for (const node of lineage) {
let best: {
access: AccessLevel;
source: AccessSource;
stale: boolean;
expiresAt: number | null;
} | null = null;
for (const [index, node] of lineage.entries()) {
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' };
}
const nodePermission = await getResourcePermission(node.resource_id, node.resource_type);
if (!nodePermission) continue;
if (nodePermission.expiresAt != null && nodePermission.expiresAt < now) continue;
if (index > 0 && nodePermission.inherit === false) continue;
const stale = isStale(nodePermission.cachedAt, now);
const candidate = {
access: readOnlyAccess(nodePermission, now),
source: (index === 0 ? 'cache' : 'inherited') as AccessSource,
stale,
expiresAt: nodePermission.expiresAt,
};
if (
!best ||
rank(candidate.access) > rank(best.access) ||
(rank(candidate.access) === rank(best.access) && !candidate.stale && best.stale)
) {
best = candidate;
}
}
return { allowed: false, access: null, source: 'none', stale: false, expiresAt: null };
if (!best) {
return { allowed: false, access: null, source: 'none', stale: false, expiresAt: null };
}
return {
allowed: rank(best.access) >= rank(required),
access: best.access,
source: best.source,
stale: best.stale,
expiresAt: best.expiresAt,
};
}
export async function canWrite(
@@ -1,4 +1,4 @@
import { getDatabase } from '../client';
import { getSession } from '../session';
import { DEVICE_USER_ID_KEY, PREFERENCES_KEY } from '../schema';
import type { UserPreferences } from '../types';
@@ -7,7 +7,7 @@ const DEFAULT_PREFERENCES: UserPreferences = {
};
export async function getDeviceUserId(): Promise<string> {
const db = await getDatabase();
const db = await getSession();
const row = await db.getFirstAsync<{ value: string }>(
'SELECT "value" FROM user_preferences WHERE "key" = ?',
DEVICE_USER_ID_KEY,
@@ -28,7 +28,7 @@ export async function getDeviceUserId(): Promise<string> {
}
export async function saveUserPreferences(preferences: UserPreferences): Promise<void> {
const db = await getDatabase();
const db = await getSession();
await db.runAsync(
`INSERT INTO user_preferences ("key", "value", updated_at) VALUES (?, ?, ?)
ON CONFLICT("key") DO UPDATE SET "value" = excluded."value", updated_at = excluded.updated_at`,
@@ -39,7 +39,7 @@ export async function saveUserPreferences(preferences: UserPreferences): Promise
}
export async function getUserPreferences(): Promise<UserPreferences> {
const db = await getDatabase();
const db = await getSession();
const row = await db.getFirstAsync<{ value: string }>(
'SELECT "value" FROM user_preferences WHERE "key" = ?',
PREFERENCES_KEY,
@@ -1,4 +1,4 @@
import { getDatabase } from '../client';
import { getSession } from '../session';
import type { Recipient, RecipientRow, RecipientType } from '../types';
export async function saveRecipient(
@@ -6,7 +6,7 @@ export async function saveRecipient(
recipientId: string,
displayName: string,
): Promise<Recipient> {
const db = await getDatabase();
const db = await getSession();
await db.runAsync(
`INSERT INTO recipients (recipient_type, recipient_id, display_name, is_active, updated_at)
VALUES (?, ?, ?, 1, ?)
@@ -28,7 +28,7 @@ export async function saveRecipient(
}
export async function getRecipients(activeOnly = true): Promise<Recipient[]> {
const db = await getDatabase();
const db = await getSession();
const rows = activeOnly
? await db.getAllAsync<RecipientRow>(
'SELECT * FROM recipients WHERE is_active = 1 ORDER BY display_name ASC',
@@ -42,7 +42,7 @@ export async function setRecipientActive(
recipientId: string,
active: boolean,
): Promise<void> {
const db = await getDatabase();
const db = await getSession();
await db.runAsync(
`UPDATE recipients SET is_active = ?, updated_at = ?
WHERE recipient_type = ? AND recipient_id = ?`,
@@ -1,4 +1,4 @@
import { getDatabase } from '../client';
import { getSession } from '../session';
import type { NewShareLink, ResourceType, ShareLink, ShareLinkRow } from '../types';
const PUSH_STATUS_SQL = `(
@@ -16,7 +16,7 @@ const PUSH_STATUS_SQL = `(
) AS push_status`;
export async function createShareLink(input: NewShareLink): Promise<ShareLink> {
const db = await getDatabase();
const db = await getSession();
const now = Date.now();
let token = input.token;
@@ -49,7 +49,7 @@ export async function createShareLink(input: NewShareLink): Promise<ShareLink> {
}
export async function getShareLinkById(id: number): Promise<ShareLink | null> {
const db = await getDatabase();
const db = await getSession();
const row = await db.getFirstAsync<ShareLinkRow & { push_status: ShareLink['pushStatus'] }>(
`SELECT sl.*, ${PUSH_STATUS_SQL} FROM share_links sl WHERE sl.id = ?`,
id,
@@ -58,7 +58,7 @@ export async function getShareLinkById(id: number): Promise<ShareLink | null> {
}
export async function getShareLinkByToken(token: string): Promise<ShareLink | null> {
const db = await getDatabase();
const db = await getSession();
const row = await db.getFirstAsync<ShareLinkRow & { push_status: ShareLink['pushStatus'] }>(
`SELECT sl.*, ${PUSH_STATUS_SQL} FROM share_links sl WHERE sl.token = ?`,
token,
@@ -70,7 +70,7 @@ export async function getShareLinks(
resourceId?: string,
resourceType?: ResourceType,
): Promise<ShareLink[]> {
const db = await getDatabase();
const db = await getSession();
let sql = `SELECT sl.*, ${PUSH_STATUS_SQL} FROM share_links sl`;
const params: string[] = [];
if (resourceId) {
@@ -87,7 +87,7 @@ export async function getShareLinks(
}
export async function incrementLinkDownloads(id: number): Promise<void> {
const db = await getDatabase();
const db = await getSession();
await db.runAsync(
`UPDATE share_links SET downloads_count = downloads_count + 1, updated_at = ? WHERE id = ?`,
Date.now(),
@@ -96,7 +96,7 @@ export async function incrementLinkDownloads(id: number): Promise<void> {
}
export async function revokeShareLink(id: number): Promise<void> {
const db = await getDatabase();
const db = await getSession();
await db.runAsync(
`UPDATE share_links SET is_revoked = 1, updated_at = ? WHERE id = ?`,
Date.now(),
@@ -108,7 +108,7 @@ export async function removeShareLinksForResource(
resourceId: string,
resourceType: ResourceType,
): Promise<void> {
const db = await getDatabase();
const db = await getSession();
await db.runAsync('DELETE FROM share_links WHERE resource_id = ? AND resource_type = ?', resourceId, resourceType);
}
+6 -6
View File
@@ -1,4 +1,4 @@
import { getDatabase } from '../client';
import { getSession } from '../session';
import type { NewShare, ResourceType, Share, ShareRow } from '../types';
const PUSH_STATUS_SQL = `(
@@ -16,7 +16,7 @@ const PUSH_STATUS_SQL = `(
) AS push_status`;
export async function saveShare(share: NewShare): Promise<Share> {
const db = await getDatabase();
const db = await getSession();
const now = Date.now();
await db.runAsync(
@@ -56,7 +56,7 @@ export async function getShare(
recipientType: 'user' | 'group',
recipientId: string,
): Promise<Share | null> {
const db = await getDatabase();
const db = await getSession();
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 = ?`,
@@ -72,7 +72,7 @@ export async function getShares(
resourceId?: string,
resourceType?: ResourceType,
): Promise<Share[]> {
const db = await getDatabase();
const db = await getSession();
let sql = `SELECT s.*, ${PUSH_STATUS_SQL} FROM shares s`;
const params: string[] = [];
if (resourceId) {
@@ -94,7 +94,7 @@ export async function removeShare(
recipientType: 'user' | 'group',
recipientId: string,
): Promise<void> {
const db = await getDatabase();
const db = await getSession();
await db.runAsync(
`DELETE FROM shares
WHERE resource_id = ? AND resource_type = ? AND recipient_type = ? AND recipient_id = ?`,
@@ -109,7 +109,7 @@ export async function removeSharesForResource(
resourceId: string,
resourceType: ResourceType,
): Promise<void> {
const db = await getDatabase();
const db = await getSession();
await db.runAsync('DELETE FROM shares WHERE resource_id = ? AND resource_type = ?', resourceId, resourceType);
}