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
+9
View File
@@ -0,0 +1,9 @@
import { getDatabase } from './client';
export async function newResourceId(): Promise<string> {
const db = await getDatabase();
const row = await db.getFirstAsync<{ id: string }>(
'SELECT lower(hex(randomblob(16))) AS id',
);
return row!.id;
}
+42 -2
View File
@@ -1,4 +1,44 @@
export { getDatabase, closeDatabase, withTransaction } from './client';
export { migrateDatabase, type Migration, type MigrationDb } from './migrations';
export * from './repositories';
export type { FolderContext, StoredFile, SyncStatus, UserPreferences } from './types';
export { DATABASE_NAME, DATABASE_VERSION } from './schema';
export type { AccessCheck, AccessSource } from './repositories/permissions';
export {
transitionSyncStatus,
type SyncEvent,
type SyncTransitionResult,
} from './transitions';
export type {
AccessLevel,
NewPendingOperation,
NewResourcePermission,
NewShare,
NewShareLink,
PendingOperation,
PendingOperationRefType,
PendingOperationRow,
PendingOperationStatus,
PendingOperationType,
PushStatus,
Recipient,
RecipientRow,
RecipientType,
ResourcePermission,
ResourceType,
Share,
ShareLink,
ShareLinkRow,
ShareRow,
StoredFile,
StoredFolder,
SyncStatus,
UserPreferences,
FolderRow,
FileRow,
} from './types';
export {
DATABASE_NAME,
DATABASE_VERSION,
DEVICE_USER_ID_KEY,
PREFERENCES_KEY,
PERMISSION_TTL_MS,
} from './schema';
+186 -9
View File
@@ -1,12 +1,17 @@
import type { SQLiteDatabase } from 'expo-sqlite';
import { DATABASE_VERSION } from './schema';
import { DATABASE_VERSION, DEVICE_USER_ID_KEY } from './schema';
type Migration = {
version: number;
up: (db: SQLiteDatabase) => Promise<void>;
export type MigrationDb = {
execAsync(source: string): Promise<void>;
getFirstAsync<T>(source: string, ...params: unknown[]): Promise<T | null>;
withExclusiveTransactionAsync(task: (txn: MigrationDb) => Promise<void>): Promise<void>;
};
const MIGRATIONS: Migration[] = [
export type Migration = {
version: number;
up: (db: MigrationDb) => Promise<void>;
};
export const MIGRATIONS: Migration[] = [
{
version: 1,
up: async (db) => {
@@ -52,21 +57,193 @@ CREATE INDEX IF NOT EXISTS idx_files_folder_uri ON files(folder_uri);
`);
},
},
{
version: 3,
up: async (db) => {
await db.execAsync(`
CREATE TABLE IF NOT EXISTS resource_permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
resource_id TEXT NOT NULL,
resource_type TEXT NOT NULL CHECK (resource_type IN ('folder', 'file')),
effective_access TEXT NOT NULL CHECK (effective_access IN ('owner', 'editor', 'commenter', 'viewer')),
inherit INTEGER NOT NULL DEFAULT 1,
owner_id TEXT,
shared_by_id TEXT,
expires_at INTEGER,
cached_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE (resource_id, resource_type)
);
CREATE TABLE IF NOT EXISTS shares (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
resource_id TEXT NOT NULL,
resource_type TEXT NOT NULL CHECK (resource_type IN ('folder', 'file')),
recipient_type TEXT NOT NULL CHECK (recipient_type IN ('user', 'group')),
recipient_id TEXT NOT NULL,
relation TEXT NOT NULL CHECK (relation IN ('owner', 'editor', 'commenter', 'viewer')),
inherit INTEGER NOT NULL DEFAULT 1,
expires_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE (resource_id, resource_type, recipient_type, recipient_id)
);
CREATE TABLE IF NOT EXISTS recipients (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
recipient_type TEXT NOT NULL CHECK (recipient_type IN ('user', 'group')),
recipient_id TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS share_links (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
token TEXT NOT NULL UNIQUE,
resource_id TEXT NOT NULL,
resource_type TEXT NOT NULL CHECK (resource_type IN ('folder', 'file')),
has_password INTEGER NOT NULL DEFAULT 0,
allow_download INTEGER NOT NULL DEFAULT 1,
expires_at INTEGER,
max_downloads INTEGER,
downloads_count INTEGER NOT NULL DEFAULT 0,
is_revoked INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
CHECK (max_downloads IS NULL OR max_downloads >= 0),
CHECK (downloads_count >= 0)
);
CREATE TABLE IF NOT EXISTS pending_operations (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
resource_id TEXT,
resource_type TEXT CHECK (resource_type IN ('folder', 'file')),
ref_type TEXT CHECK (ref_type IN ('resource', 'share', 'share_link')),
ref_id INTEGER,
operation TEXT NOT NULL CHECK (operation IN (
'create_resource', 'update_metadata', 'delete_resource', 'move_resource',
'share', 'revoke_share', 'update_share',
'create_link', 'revoke_link'
)),
payload TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'in_progress', 'completed', 'failed', 'cancelled')),
attempts INTEGER NOT NULL DEFAULT 0,
error TEXT,
created_at INTEGER NOT NULL,
next_retry_at INTEGER,
last_error_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_pending_queue ON pending_operations(created_at) WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS idx_pending_resource ON pending_operations(resource_id);
`);
await db.execAsync(
`INSERT OR IGNORE INTO user_preferences ("key", "value", updated_at)
VALUES ('${DEVICE_USER_ID_KEY}', lower(hex(randomblob(16))), ${Date.now()});`,
);
},
},
{
version: 4,
up: async (db) => {
await db.execAsync('PRAGMA foreign_keys = OFF;');
await db.withExclusiveTransactionAsync(async (txn) => {
await txn.execAsync(`
CREATE TABLE folders_new (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
resource_id TEXT NOT NULL UNIQUE,
uri TEXT,
name TEXT NOT NULL,
"exists" INTEGER,
parent_resource_id TEXT REFERENCES folders_new(resource_id) ON DELETE CASCADE,
owner_id TEXT NOT NULL,
sync_status TEXT NOT NULL DEFAULT 'local',
added_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
`);
await txn.execAsync(`
INSERT INTO folders_new (id, resource_id, uri, name, "exists", parent_resource_id, owner_id, sync_status, added_at, updated_at)
SELECT id, lower(hex(randomblob(16))), uri, name, "exists", NULL,
(SELECT "value" FROM user_preferences WHERE "key" = '${DEVICE_USER_ID_KEY}'),
sync_status, added_at, added_at FROM folders;
`);
await txn.execAsync(`
UPDATE folders_new SET parent_resource_id = (
SELECT parent_new.resource_id
FROM folders old_child
JOIN folders old_parent ON old_parent.uri = old_child.parent_uri
JOIN folders_new parent_new ON parent_new.uri = old_parent.uri
WHERE old_child.uri = folders_new.uri
);
`);
await txn.execAsync('DROP TABLE folders;');
await txn.execAsync('ALTER TABLE folders_new RENAME TO folders;');
await txn.execAsync(`
CREATE TABLE files_new (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
resource_id TEXT NOT NULL UNIQUE,
uri TEXT,
name TEXT NOT NULL,
folder_resource_id TEXT NOT NULL REFERENCES folders(resource_id) ON DELETE CASCADE,
extension TEXT,
size INTEGER,
"type" TEXT,
"exists" INTEGER,
last_modified INTEGER,
owner_id TEXT NOT NULL,
sync_status TEXT NOT NULL DEFAULT 'local',
added_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
`);
await txn.execAsync(`
INSERT INTO files_new (id, resource_id, uri, name, folder_resource_id, extension, size, "type", "exists", last_modified, owner_id, sync_status, added_at, updated_at)
SELECT f.id, lower(hex(randomblob(16))), f.uri, f.name,
fn.resource_id,
f.extension, f.size, f."type", f."exists", f.last_modified,
(SELECT "value" FROM user_preferences WHERE "key" = '${DEVICE_USER_ID_KEY}'),
f.sync_status, f.added_at, f.added_at
FROM files f
JOIN folders fn ON fn.uri = f.folder_uri;
`);
await txn.execAsync('DROP TABLE files;');
await txn.execAsync('ALTER TABLE files_new RENAME TO files;');
await txn.execAsync(`
CREATE INDEX IF NOT EXISTS idx_folders_resource_id ON folders(resource_id);
CREATE INDEX IF NOT EXISTS idx_files_resource_id ON files(resource_id);
CREATE INDEX IF NOT EXISTS idx_files_folder_resource ON files(folder_resource_id);
CREATE INDEX IF NOT EXISTS idx_folders_parent ON folders(parent_resource_id);
`);
await txn.execAsync(`PRAGMA user_version = 4;`);
});
await db.execAsync('PRAGMA foreign_keys = ON;');
},
},
];
async function readUserVersion(db: SQLiteDatabase): Promise<number> {
async function readUserVersion(db: MigrationDb): Promise<number> {
const row = await db.getFirstAsync<{ user_version: number }>('PRAGMA user_version');
return row?.user_version ?? 0;
}
async function writeUserVersion(db: SQLiteDatabase, version: number): Promise<void> {
async function writeUserVersion(db: MigrationDb, version: number): Promise<void> {
await db.execAsync(`PRAGMA user_version = ${version}`);
}
const migrationPromises = new WeakMap<MigrationDb, Promise<void>>();
export async function migrateDatabase(
db: SQLiteDatabase,
db: MigrationDb,
targetVersion: number = DATABASE_VERSION,
): Promise<void> {
const existing = migrationPromises.get(db);
if (existing) return existing;
const promise = runMigrations(db, targetVersion).finally(() => {
migrationPromises.delete(db);
});
migrationPromises.set(db, promise);
return promise;
}
async function runMigrations(db: MigrationDb, targetVersion: number): Promise<void> {
let currentVersion = await readUserVersion(db);
for (const migration of MIGRATIONS) {
+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,
};
}
+14 -4
View File
@@ -1,24 +1,30 @@
export const DATABASE_NAME = 'dot.db';
export const DATABASE_VERSION = 2;
export const DATABASE_VERSION = 4;
export const PREFERENCES_KEY = 'user_preferences';
export const DEVICE_USER_ID_KEY = 'device_user_id';
export const FOLDER_COLUMNS = [
'resource_id',
'uri',
'name',
'"exists"',
'sync_status',
'added_at',
'parent_uri',
'updated_at',
'parent_resource_id',
'owner_id',
] as const;
export const FOLDER_COLUMNS_SQL = FOLDER_COLUMNS.join(', ');
export const FILE_COLUMNS = [
'resource_id',
'uri',
'name',
'folder_uri',
'folder_resource_id',
'extension',
'size',
'"type"',
@@ -26,6 +32,10 @@ export const FILE_COLUMNS = [
'last_modified',
'sync_status',
'added_at',
'updated_at',
'owner_id',
] as const;
export const FILE_COLUMNS_SQL = FILE_COLUMNS.join(', ');
export const FILE_COLUMNS_SQL = FILE_COLUMNS.join(', ');
export const PERMISSION_TTL_MS = 24 * 60 * 60 * 1000;
+48
View File
@@ -0,0 +1,48 @@
import type { SyncStatus } from './types';
export type SyncEvent =
| 'created_local'
| 'uploaded'
| 'downloaded'
| 'deleted_cloud';
export type SyncTransitionResult = SyncStatus | 'gone';
export function transitionSyncStatus(
from: SyncStatus,
event: SyncEvent,
): SyncTransitionResult {
switch (from) {
case 'local':
switch (event) {
case 'created_local':
return 'local';
case 'uploaded':
return 'cloud';
case 'downloaded':
return 'local-cloud';
case 'deleted_cloud':
return 'gone';
}
case 'cloud':
switch (event) {
case 'created_local':
return 'local-cloud';
case 'uploaded':
return 'cloud';
case 'downloaded':
return 'local-cloud';
case 'deleted_cloud':
return 'gone';
}
case 'local-cloud':
switch (event) {
case 'created_local':
case 'uploaded':
case 'downloaded':
return 'local-cloud';
case 'deleted_cloud':
return 'local';
}
}
}
+237 -14
View File
@@ -4,40 +4,263 @@ export type { FileEntry, Folder } from '../safDirectory.types';
export type SyncStatus = 'local' | 'cloud' | 'local-cloud';
export type FolderContext = {
syncStatus: SyncStatus;
addedAt: number;
folder: Folder;
};
export type AccessLevel = 'owner' | 'editor' | 'commenter' | 'viewer';
export type StoredFile = FileEntry & {
folderUri: string;
syncStatus: SyncStatus;
addedAt: number;
};
export type ResourceType = 'folder' | 'file';
export type RecipientType = 'user' | 'group';
export type PushStatus = 'pending' | 'synced' | 'failed';
export type UserPreferences = {
syncMode: 'full' | 'manual' | 'none';
};
export type FolderRow = {
uri: string;
id: number;
resource_id: string;
uri: string | null;
name: string;
exists: number | null;
parent_resource_id: string | null;
owner_id: string;
sync_status: SyncStatus;
added_at: number;
parent_uri: string | null;
updated_at: number;
};
export type FileRow = {
uri: string;
id: number;
resource_id: string;
uri: string | null;
name: string;
folder_uri: string;
folder_resource_id: string;
extension: string | null;
size: number;
type: string | null;
exists: number;
last_modified: number | null;
owner_id: string;
sync_status: SyncStatus;
added_at: number;
updated_at: number;
};
export type StoredFolder = {
resource_id: string;
uri: string | null;
name: string;
exists: boolean;
parent_resource_id: string | null;
owner_id: string;
syncStatus: SyncStatus;
addedAt: number;
updatedAt: number;
};
export type StoredFile = {
resource_id: string;
uri: string | null;
name: string;
folder_resource_id: string;
extension: string;
exists: boolean;
size: number;
type: string;
lastModified: number | null;
owner_id: string;
syncStatus: SyncStatus;
addedAt: number;
updatedAt: number;
};
export type ResourcePermissionRow = {
id: number;
resource_id: string;
resource_type: ResourceType;
effective_access: AccessLevel;
inherit: number;
owner_id: string | null;
shared_by_id: string | null;
expires_at: number | null;
cached_at: number;
updated_at: number;
};
export type ResourcePermission = {
resource_id: string;
resourceType: ResourceType;
effectiveAccess: AccessLevel;
inherit: boolean;
ownerId: string | null;
sharedById: string | null;
expiresAt: number | null;
cachedAt: number;
updatedAt: number;
};
export type NewResourcePermission = {
resource_id: string;
resourceType: ResourceType;
effectiveAccess: AccessLevel;
inherit?: boolean;
ownerId?: string | null;
sharedById?: string | null;
expiresAt?: number | null;
};
export type ShareRow = {
id: number;
resource_id: string;
resource_type: ResourceType;
recipient_type: RecipientType;
recipient_id: string;
relation: AccessLevel;
inherit: number;
expires_at: number | null;
created_at: number;
updated_at: number;
};
export type Share = {
id: number;
resourceId: string;
resourceType: ResourceType;
recipientType: RecipientType;
recipientId: string;
relation: AccessLevel;
inherit: boolean;
expiresAt: number | null;
createdAt: number;
updatedAt: number;
pushStatus: PushStatus;
};
export type NewShare = {
resourceId: string;
resourceType: ResourceType;
recipientType: RecipientType;
recipientId: string;
relation: AccessLevel;
inherit?: boolean;
expiresAt?: number | null;
};
export type RecipientRow = {
id: number;
recipient_type: RecipientType;
recipient_id: string;
display_name: string;
is_active: number;
updated_at: number;
};
export type Recipient = {
recipientType: RecipientType;
recipientId: string;
displayName: string;
isActive: boolean;
updatedAt: number;
};
export type ShareLinkRow = {
id: number;
token: string;
resource_id: string;
resource_type: ResourceType;
has_password: number;
allow_download: number;
expires_at: number | null;
max_downloads: number | null;
downloads_count: number;
is_revoked: number;
created_at: number;
updated_at: number;
};
export type ShareLink = {
id: number;
token: string;
resourceId: string;
resourceType: ResourceType;
hasPassword: boolean;
allowDownload: boolean;
expiresAt: number | null;
maxDownloads: number | null;
downloadsCount: number;
isRevoked: boolean;
createdAt: number;
updatedAt: number;
pushStatus: PushStatus;
};
export type NewShareLink = {
token?: string;
resourceId: string;
resourceType: ResourceType;
hasPassword?: boolean;
allowDownload?: boolean;
expiresAt?: number | null;
maxDownloads?: number | null;
};
export type PendingOperationStatus =
| 'pending'
| 'in_progress'
| 'completed'
| 'failed'
| 'cancelled';
export type PendingOperationType =
| 'create_resource'
| 'update_metadata'
| 'delete_resource'
| 'move_resource'
| 'share'
| 'revoke_share'
| 'update_share'
| 'create_link'
| 'revoke_link';
export type PendingOperationRefType = 'resource' | 'share' | 'share_link';
export type PendingOperationRow = {
id: number;
resource_id: string | null;
resource_type: ResourceType | null;
ref_type: PendingOperationRefType | null;
ref_id: number | null;
operation: PendingOperationType;
payload: string;
status: PendingOperationStatus;
attempts: number;
error: string | null;
created_at: number;
next_retry_at: number | null;
last_error_at: number | null;
};
export type PendingOperation = {
id: number;
resourceId: string | null;
resourceType: ResourceType | null;
refType: PendingOperationRefType | null;
refId: number | null;
operation: PendingOperationType;
payload: unknown;
status: PendingOperationStatus;
attempts: number;
error: string | null;
createdAt: number;
nextRetryAt: number | null;
lastErrorAt: number | null;
};
export type NewPendingOperation = {
resourceId?: string | null;
resourceType?: ResourceType | null;
refType?: PendingOperationRefType | null;
refId?: number | null;
operation: PendingOperationType;
payload?: unknown;
};
+48 -1
View File
@@ -1,14 +1,61 @@
export {
getUserPreferences,
getDeviceUserId,
getFolders,
getFolderFolders,
getFolder,
getFiles,
getFile,
removeFolder,
removeFile,
saveFile,
saveFolder,
saveDirectory,
saveUserPreferences,
getResourcePermission,
saveResourcePermission,
canAccess,
canWrite,
isOwner,
saveShare,
getShare,
getShares,
removeShare,
removeSharesForResource,
createShareLink,
getShareLinkById,
getShareLinkByToken,
getShareLinks,
incrementLinkDownloads,
revokeShareLink,
removeShareLinksForResource,
saveRecipient,
getRecipients,
setRecipientActive,
enqueuePendingOperation,
getPendingOperations,
getNextQueuedOperation,
markPendingOperation,
transitionSyncStatus,
} from './db';
export type { FolderContext, StoredFile, SyncStatus, UserPreferences } from './db';
export type {
AccessLevel,
AccessCheck,
AccessSource,
StoredFile,
StoredFolder,
SyncStatus,
PushStatus,
UserPreferences,
ResourceType,
ResourcePermission,
RecipientType,
Recipient,
Share,
ShareLink,
PendingOperation,
PendingOperationStatus,
SyncEvent,
SyncTransitionResult,
} from './db';