import { and, eq, inArray, isNull, lte, sql } from "drizzle-orm"; import { randomHex } from "@/worker/auth/bytes"; import { collectionHref, isSameOrDescendantPath, resourceHref, type NormalizedDavPath } from "@/worker/dav/paths"; import type { PendingUploadExpectedState } from "@/worker/db/file-dav-do/types"; import type { FileChangeType } from "@/worker/db/types"; import type { FileDavDoDb } from "./client"; import { blobs, changes, deadProps, fileMeta, locks, nodes, pendingUploads, type FileBlobRow, type FileDeadPropRow, type FileLockRow, type FileNodeRow, type NewFileBlobRow, type NewFileLockRow, type NewFileNodeRow, type PendingUploadRow, } from "./schema"; export { createFileDavDoDb } from "./client"; export interface NodeAtPath { node: FileNodeRow; href: string; segments: string[]; } export interface TreeNode extends NodeAtPath { depth: number; } export interface PendingUploadInput { uploadId: string; path: string; nodeId: string | null; blobId: string; blobKey: string; createdAtMs: number; expiresAtMs: number; expectedState?: PendingUploadExpectedState | null; } export function nodeId(): string { return `fil_${randomHex(16)}`; } export function blobId(): string { return `blob_${randomHex(16)}`; } export function uploadId(): string { return `upl_${randomHex(16)}`; } export function rootNode(db: FileDavDoDb): FileNodeRow | undefined { return db.query.nodes.findFirst({ where: eq(nodes.rootMarker, "root") }).sync(); } export function ensureRoot(db: FileDavDoDb, subjectId: string, nowMs: number): FileNodeRow { const existing = rootNode(db); if (existing) return existing; db.insert(nodes) .values({ id: nodeId(), parentId: null, name: "", kind: "collection", blobId: null, size: 0, contentType: null, contentLanguage: null, etag: null, rootMarker: "root", createdAtMs: nowMs, modifiedAtMs: nowMs, version: 1, }) .run(); db.insert(fileMeta) .values([ { key: "schema", value: "file-dav:v1" }, { key: "subject_id", value: subjectId }, ]) .onConflictDoNothing() .run(); return rootNode(db)!; } export function childByName(db: FileDavDoDb, parentId: string, name: string): FileNodeRow | undefined { return db.query.nodes.findFirst({ where: and(eq(nodes.parentId, parentId), eq(nodes.name, name)) }).sync(); } export function nodeById(db: FileDavDoDb, id: string): FileNodeRow | undefined { return db.query.nodes.findFirst({ where: eq(nodes.id, id) }).sync(); } export function nodeAtPath( db: FileDavDoDb, path: NormalizedDavPath, nowMs: number, subjectId: string, ): NodeAtPath | undefined { let current = ensureRoot(db, subjectId, nowMs); if (path.segments.length === 0) return { node: current, href: "/files/", segments: [] }; const segments: string[] = []; for (const segment of path.segments) { const child = childByName(db, current.id, segment); if (!child) return undefined; segments.push(segment); current = child; } return { node: current, href: resourceHref(segments, current.kind === "collection"), segments }; } export function parentForPath( db: FileDavDoDb, path: NormalizedDavPath, nowMs: number, subjectId: string, ): { parent: NodeAtPath; name: string } | undefined { const name = path.segments[path.segments.length - 1]; if (!name) return undefined; const parentPath = { href: collectionHref(path.segments.slice(0, -1)), segments: path.segments.slice(0, -1), collectionHint: true, }; const parent = nodeAtPath(db, parentPath, nowMs, subjectId); if (!parent || parent.node.kind !== "collection") return undefined; return { parent, name }; } export function childrenOf(db: FileDavDoDb, parentId: string): FileNodeRow[] { return db.select().from(nodes).where(eq(nodes.parentId, parentId)).all(); } export function treeFrom( db: FileDavDoDb, root: NodeAtPath, depth: "0" | "1" | "infinity", maxNodes: number, ): TreeNode[] { const rows: TreeNode[] = [{ ...root, depth: 0 }]; if (depth === "0" || root.node.kind !== "collection") return rows; for (let index = 0; index < rows.length; index += 1) { const current = rows[index]!; if (depth === "1" && current.depth >= 1) continue; if (rows.length >= maxNodes) break; for (const child of childrenOf(db, current.node.id)) { const segments = [...current.segments, child.name]; rows.push({ node: child, href: resourceHref(segments, child.kind === "collection"), segments, depth: current.depth + 1, }); if (rows.length >= maxNodes) break; } } return rows; } export function treeFromLimited( db: FileDavDoDb, root: NodeAtPath, depth: "0" | "1" | "infinity", maxNodes: number, ): { rows: TreeNode[]; exceeded: boolean } { const rows = treeFrom(db, root, depth, maxNodes + 1); return { rows: rows.slice(0, maxNodes), exceeded: rows.length > maxNodes }; } export function deadPropsForNodes(db: FileDavDoDb, nodeIds: string[]): Map { if (nodeIds.length === 0) return new Map(); const rows = db.select().from(deadProps).where(inArray(deadProps.nodeId, nodeIds)).all(); const byNode = new Map(); for (const row of rows) { const props = byNode.get(row.nodeId) ?? []; props.push(row); byNode.set(row.nodeId, props); } return byNode; } export function activeLocks(db: FileDavDoDb, nowMs: number): FileLockRow[] { db.delete(locks).where(lte(locks.expiresAtMs, nowMs)).run(); return db.select().from(locks).all(); } export function locksForNodes(db: FileDavDoDb, nodeIds: string[], nowMs: number): Map { if (nodeIds.length === 0) return new Map(); activeLocks(db, nowMs); const rows = db.select().from(locks).where(inArray(locks.rootNodeId, nodeIds)).all(); const byNode = new Map(); for (const row of rows) { if (!row.rootNodeId) continue; const nodeLocks = byNode.get(row.rootNodeId) ?? []; nodeLocks.push(row); byNode.set(row.rootNodeId, nodeLocks); } return byNode; } export function createCollectionNode( db: FileDavDoDb, input: { parentId: string; name: string; nowMs: number }, ): FileNodeRow { const row: NewFileNodeRow = { id: nodeId(), parentId: input.parentId, name: input.name, kind: "collection", blobId: null, size: 0, contentType: null, contentLanguage: null, etag: null, rootMarker: null, createdAtMs: input.nowMs, modifiedAtMs: input.nowMs, version: 1, }; db.insert(nodes).values(row).run(); return nodeById(db, row.id)!; } export function createPendingUpload(db: FileDavDoDb, input: PendingUploadInput): void { db.insert(pendingUploads) .values({ uploadId: input.uploadId, nodeId: input.nodeId, path: input.path, blobId: input.blobId, blobKey: input.blobKey, createdAtMs: input.createdAtMs, expiresAtMs: input.expiresAtMs, state: "pending", expectedState: input.expectedState ?? null, }) .run(); } export function getPendingUpload(db: FileDavDoDb, id: string): PendingUploadRow | undefined { return db.query.pendingUploads.findFirst({ where: eq(pendingUploads.uploadId, id) }).sync(); } export function pendingUploadByBlob(db: FileDavDoDb, blobIdValue: string): PendingUploadRow | undefined { return db.query.pendingUploads.findFirst({ where: eq(pendingUploads.blobId, blobIdValue) }).sync(); } export function expiredPendingUploads(db: FileDavDoDb, nowMs: number, limit: number): PendingUploadRow[] { return db .select() .from(pendingUploads) .where(and(eq(pendingUploads.state, "pending"), lte(pendingUploads.expiresAtMs, nowMs))) .limit(limit) .all(); } export function markPendingUpload(db: FileDavDoDb, id: string, state: "committed" | "aborted"): void { db.update(pendingUploads).set({ state }).where(eq(pendingUploads.uploadId, id)).run(); } export function insertBlob(db: FileDavDoDb, row: NewFileBlobRow): void { db.insert(blobs).values(row).run(); } export function getBlob(db: FileDavDoDb, id: string): FileBlobRow | undefined { return db.query.blobs.findFirst({ where: eq(blobs.blobId, id) }).sync(); } export function updateBlobRefcount(db: FileDavDoDb, id: string, delta: number): void { db.update(blobs) .set({ refcount: sql`${blobs.refcount} + ${delta}` }) .where(eq(blobs.blobId, id)) .run(); } export function orphanBlobs(db: FileDavDoDb): FileBlobRow[] { return db.select().from(blobs).where(eq(blobs.refcount, 0)).all(); } export function upsertFileNode( db: FileDavDoDb, input: { existing: FileNodeRow | undefined; parentId: string; name: string; blobId: string; size: number; contentType: string | null; contentLanguage: string | null; etag: string; nowMs: number; }, ): { node: FileNodeRow; created: boolean; oldBlobId: string | null } { if (input.existing) { const oldBlobId = input.existing.blobId; db.update(nodes) .set({ blobId: input.blobId, size: input.size, contentType: input.contentType, contentLanguage: input.contentLanguage, etag: input.etag, modifiedAtMs: input.nowMs, version: input.existing.version + 1, }) .where(eq(nodes.id, input.existing.id)) .run(); return { node: nodeById(db, input.existing.id)!, created: false, oldBlobId }; } const row: NewFileNodeRow = { id: nodeId(), parentId: input.parentId, name: input.name, kind: "file", blobId: input.blobId, size: input.size, contentType: input.contentType, contentLanguage: input.contentLanguage, etag: input.etag, rootMarker: null, createdAtMs: input.nowMs, modifiedAtMs: input.nowMs, version: 1, }; db.insert(nodes).values(row).run(); return { node: nodeById(db, row.id)!, created: true, oldBlobId: null }; } export function touchNode(db: FileDavDoDb, nodeIdValue: string | null | undefined, nowMs: number): void { if (!nodeIdValue) return; db.update(nodes) .set({ modifiedAtMs: nowMs, version: sql`${nodes.version} + 1` }) .where(eq(nodes.id, nodeIdValue)) .run(); } export function createEmptyFileNode( db: FileDavDoDb, input: { parentId: string; name: string; contentType: string | null; etag: string; nowMs: number; }, ): FileNodeRow { const row: NewFileNodeRow = { id: nodeId(), parentId: input.parentId, name: input.name, kind: "file", blobId: null, size: 0, contentType: input.contentType, contentLanguage: null, etag: input.etag, rootMarker: null, createdAtMs: input.nowMs, modifiedAtMs: input.nowMs, version: 1, }; db.insert(nodes).values(row).run(); return nodeById(db, row.id)!; } export function setDeadProp( db: FileDavDoDb, nodeIdValue: string, prop: { nsUri: string; localName: string; xmlValue: string }, ): void { db.insert(deadProps) .values({ nodeId: nodeIdValue, ...prop }) .onConflictDoUpdate({ target: [deadProps.nodeId, deadProps.nsUri, deadProps.localName], set: { xmlValue: prop.xmlValue }, }) .run(); } export function removeDeadProp(db: FileDavDoDb, nodeIdValue: string, prop: { nsUri: string; localName: string }): void { db.delete(deadProps) .where( and(eq(deadProps.nodeId, nodeIdValue), eq(deadProps.nsUri, prop.nsUri), eq(deadProps.localName, prop.localName)), ) .run(); } export function insertLock(db: FileDavDoDb, row: NewFileLockRow): void { db.insert(locks).values(row).run(); } export function getLock(db: FileDavDoDb, token: string): FileLockRow | undefined { return db.query.locks.findFirst({ where: eq(locks.token, token) }).sync(); } export function deleteLock(db: FileDavDoDb, token: string): void { db.delete(locks).where(eq(locks.token, token)).run(); } export function expiredLocks(db: FileDavDoDb, nowMs: number, limit: number): FileLockRow[] { return db.select().from(locks).where(lte(locks.expiresAtMs, nowMs)).limit(limit).all(); } export function deleteLocks(db: FileDavDoDb, tokens: string[]): void { if (tokens.length === 0) return; db.delete(locks).where(inArray(locks.token, tokens)).run(); } export function refreshLock(db: FileDavDoDb, token: string, expiresAtMs: number): FileLockRow | undefined { db.update(locks).set({ expiresAtMs }).where(eq(locks.token, token)).run(); return getLock(db, token); } export function insertChange( db: FileDavDoDb, input: { nodeId: string | null; href: string; changeType: FileChangeType; changedAtMs: number }, ): void { db.insert(changes).values(input).run(); } export function deleteSubtree( db: FileDavDoDb, root: NodeAtPath, maxNodes: number, ): { ok: true; blobs: FileBlobRow[] } | { ok: false } { const { rows: tree, exceeded } = treeFromLimited(db, root, "infinity", maxNodes); if (exceeded) return { ok: false }; const fileBlobIds = tree.map((entry) => entry.node.blobId).filter((id): id is string => id !== null); for (const id of fileBlobIds) updateBlobRefcount(db, id, -1); db.delete(nodes).where(eq(nodes.id, root.node.id)).run(); return { ok: true, blobs: orphanBlobs(db) }; } export function copyDeadProps(db: FileDavDoDb, fromNodeId: string, toNodeId: string): void { const rows = db.select().from(deadProps).where(eq(deadProps.nodeId, fromNodeId)).all(); for (const row of rows) { setDeadProp(db, toNodeId, { nsUri: row.nsUri, localName: row.localName, xmlValue: row.xmlValue }); } } export function moveNode(db: FileDavDoDb, nodeIdValue: string, parentId: string, name: string, nowMs: number): void { db.update(nodes) .set({ parentId, name, modifiedAtMs: nowMs, version: sql`${nodes.version} + 1` }) .where(eq(nodes.id, nodeIdValue)) .run(); } // RFC 4918 7.5: MOVE leaves the source URL unmapped, so direct locks rooted at // the source href or its descendants become invalid and must be removed. export function deleteMovedLockRoots(db: FileDavDoDb, oldRootHref: string): void { const rows = db.select().from(locks).all(); for (const row of rows) { if (!isSameOrDescendantPath(row.rootPath, oldRootHref)) continue; db.delete(locks).where(eq(locks.token, row.token)).run(); } } export function countRootRows(db: FileDavDoDb): number { return db.select({ id: nodes.id }).from(nodes).where(isNull(nodes.parentId)).all().length; }