Skip to content
File

Blob: src/worker/db/file-dav-do/repository.ts

typescript478 lines
1import { and, eq, inArray, isNull, lte, sql } from "drizzle-orm";
2 
3import { randomHex } from "@/worker/auth/bytes";
4import { collectionHref, isSameOrDescendantPath, resourceHref, type NormalizedDavPath } from "@/worker/dav/paths";
5import type { PendingUploadExpectedState } from "@/worker/db/file-dav-do/types";
6import type { FileChangeType } from "@/worker/db/types";
7 
8import type { FileDavDoDb } from "./client";
9 
10import {
11 blobs,
12 changes,
13 deadProps,
14 fileMeta,
15 locks,
16 nodes,
17 pendingUploads,
18 type FileBlobRow,
19 type FileDeadPropRow,
20 type FileLockRow,
21 type FileNodeRow,
22 type NewFileBlobRow,
23 type NewFileLockRow,
24 type NewFileNodeRow,
25 type PendingUploadRow,
26} from "./schema";
27 
28export { createFileDavDoDb } from "./client";
29 
30export interface NodeAtPath {
31 node: FileNodeRow;
32 href: string;
33 segments: string[];
34}
35 
36export interface TreeNode extends NodeAtPath {
37 depth: number;
38}
39 
40export interface PendingUploadInput {
41 uploadId: string;
42 path: string;
43 nodeId: string | null;
44 blobId: string;
45 blobKey: string;
46 createdAtMs: number;
47 expiresAtMs: number;
48 expectedState?: PendingUploadExpectedState | null;
49}
50 
51export function nodeId(): string {
52 return `fil_${randomHex(16)}`;
53}
54 
55export function blobId(): string {
56 return `blob_${randomHex(16)}`;
57}
58 
59export function uploadId(): string {
60 return `upl_${randomHex(16)}`;
61}
62 
63export function rootNode(db: FileDavDoDb): FileNodeRow | undefined {
64 return db.query.nodes.findFirst({ where: eq(nodes.rootMarker, "root") }).sync();
65}
66 
67export function ensureRoot(db: FileDavDoDb, subjectId: string, nowMs: number): FileNodeRow {
68 const existing = rootNode(db);
69 if (existing) return existing;
70 
71 db.insert(nodes)
72 .values({
73 id: nodeId(),
74 parentId: null,
75 name: "",
76 kind: "collection",
77 blobId: null,
78 size: 0,
79 contentType: null,
80 contentLanguage: null,
81 etag: null,
82 rootMarker: "root",
83 createdAtMs: nowMs,
84 modifiedAtMs: nowMs,
85 version: 1,
86 })
87 .run();
88 db.insert(fileMeta)
89 .values([
90 { key: "schema", value: "file-dav:v1" },
91 { key: "subject_id", value: subjectId },
92 ])
93 .onConflictDoNothing()
94 .run();
95 return rootNode(db)!;
96}
97 
98export function childByName(db: FileDavDoDb, parentId: string, name: string): FileNodeRow | undefined {
99 return db.query.nodes.findFirst({ where: and(eq(nodes.parentId, parentId), eq(nodes.name, name)) }).sync();
100}
101 
102export function nodeById(db: FileDavDoDb, id: string): FileNodeRow | undefined {
103 return db.query.nodes.findFirst({ where: eq(nodes.id, id) }).sync();
104}
105 
106export function nodeAtPath(
107 db: FileDavDoDb,
108 path: NormalizedDavPath,
109 nowMs: number,
110 subjectId: string,
111): NodeAtPath | undefined {
112 let current = ensureRoot(db, subjectId, nowMs);
113 if (path.segments.length === 0) return { node: current, href: "/files/", segments: [] };
114 
115 const segments: string[] = [];
116 for (const segment of path.segments) {
117 const child = childByName(db, current.id, segment);
118 if (!child) return undefined;
119 segments.push(segment);
120 current = child;
121 }
122 
123 return { node: current, href: resourceHref(segments, current.kind === "collection"), segments };
124}
125 
126export function parentForPath(
127 db: FileDavDoDb,
128 path: NormalizedDavPath,
129 nowMs: number,
130 subjectId: string,
131): { parent: NodeAtPath; name: string } | undefined {
132 const name = path.segments[path.segments.length - 1];
133 if (!name) return undefined;
134 const parentPath = {
135 href: collectionHref(path.segments.slice(0, -1)),
136 segments: path.segments.slice(0, -1),
137 collectionHint: true,
138 };
139 const parent = nodeAtPath(db, parentPath, nowMs, subjectId);
140 if (!parent || parent.node.kind !== "collection") return undefined;
141 return { parent, name };
142}
143 
144export function childrenOf(db: FileDavDoDb, parentId: string): FileNodeRow[] {
145 return db.select().from(nodes).where(eq(nodes.parentId, parentId)).all();
146}
147 
148export function treeFrom(
149 db: FileDavDoDb,
150 root: NodeAtPath,
151 depth: "0" | "1" | "infinity",
152 maxNodes: number,
153): TreeNode[] {
154 const rows: TreeNode[] = [{ ...root, depth: 0 }];
155 if (depth === "0" || root.node.kind !== "collection") return rows;
156 
157 for (let index = 0; index < rows.length; index += 1) {
158 const current = rows[index]!;
159 if (depth === "1" && current.depth >= 1) continue;
160 if (rows.length >= maxNodes) break;
161 for (const child of childrenOf(db, current.node.id)) {
162 const segments = [...current.segments, child.name];
163 rows.push({
164 node: child,
165 href: resourceHref(segments, child.kind === "collection"),
166 segments,
167 depth: current.depth + 1,
168 });
169 if (rows.length >= maxNodes) break;
170 }
171 }
172 
173 return rows;
174}
175 
176export function treeFromLimited(
177 db: FileDavDoDb,
178 root: NodeAtPath,
179 depth: "0" | "1" | "infinity",
180 maxNodes: number,
181): { rows: TreeNode[]; exceeded: boolean } {
182 const rows = treeFrom(db, root, depth, maxNodes + 1);
183 return { rows: rows.slice(0, maxNodes), exceeded: rows.length > maxNodes };
184}
185 
186export function deadPropsForNodes(db: FileDavDoDb, nodeIds: string[]): Map<string, FileDeadPropRow[]> {
187 if (nodeIds.length === 0) return new Map();
188 const rows = db.select().from(deadProps).where(inArray(deadProps.nodeId, nodeIds)).all();
189 const byNode = new Map<string, FileDeadPropRow[]>();
190 for (const row of rows) {
191 const props = byNode.get(row.nodeId) ?? [];
192 props.push(row);
193 byNode.set(row.nodeId, props);
194 }
195 return byNode;
196}
197 
198export function activeLocks(db: FileDavDoDb, nowMs: number): FileLockRow[] {
199 db.delete(locks).where(lte(locks.expiresAtMs, nowMs)).run();
200 return db.select().from(locks).all();
201}
202 
203export function locksForNodes(db: FileDavDoDb, nodeIds: string[], nowMs: number): Map<string, FileLockRow[]> {
204 if (nodeIds.length === 0) return new Map();
205 activeLocks(db, nowMs);
206 const rows = db.select().from(locks).where(inArray(locks.rootNodeId, nodeIds)).all();
207 const byNode = new Map<string, FileLockRow[]>();
208 for (const row of rows) {
209 if (!row.rootNodeId) continue;
210 const nodeLocks = byNode.get(row.rootNodeId) ?? [];
211 nodeLocks.push(row);
212 byNode.set(row.rootNodeId, nodeLocks);
213 }
214 return byNode;
215}
216 
217export function createCollectionNode(
218 db: FileDavDoDb,
219 input: { parentId: string; name: string; nowMs: number },
220): FileNodeRow {
221 const row: NewFileNodeRow = {
222 id: nodeId(),
223 parentId: input.parentId,
224 name: input.name,
225 kind: "collection",
226 blobId: null,
227 size: 0,
228 contentType: null,
229 contentLanguage: null,
230 etag: null,
231 rootMarker: null,
232 createdAtMs: input.nowMs,
233 modifiedAtMs: input.nowMs,
234 version: 1,
235 };
236 db.insert(nodes).values(row).run();
237 return nodeById(db, row.id)!;
238}
239 
240export function createPendingUpload(db: FileDavDoDb, input: PendingUploadInput): void {
241 db.insert(pendingUploads)
242 .values({
243 uploadId: input.uploadId,
244 nodeId: input.nodeId,
245 path: input.path,
246 blobId: input.blobId,
247 blobKey: input.blobKey,
248 createdAtMs: input.createdAtMs,
249 expiresAtMs: input.expiresAtMs,
250 state: "pending",
251 expectedState: input.expectedState ?? null,
252 })
253 .run();
254}
255 
256export function getPendingUpload(db: FileDavDoDb, id: string): PendingUploadRow | undefined {
257 return db.query.pendingUploads.findFirst({ where: eq(pendingUploads.uploadId, id) }).sync();
258}
259 
260export function pendingUploadByBlob(db: FileDavDoDb, blobIdValue: string): PendingUploadRow | undefined {
261 return db.query.pendingUploads.findFirst({ where: eq(pendingUploads.blobId, blobIdValue) }).sync();
262}
263 
264export function expiredPendingUploads(db: FileDavDoDb, nowMs: number, limit: number): PendingUploadRow[] {
265 return db
266 .select()
267 .from(pendingUploads)
268 .where(and(eq(pendingUploads.state, "pending"), lte(pendingUploads.expiresAtMs, nowMs)))
269 .limit(limit)
270 .all();
271}
272 
273export function markPendingUpload(db: FileDavDoDb, id: string, state: "committed" | "aborted"): void {
274 db.update(pendingUploads).set({ state }).where(eq(pendingUploads.uploadId, id)).run();
275}
276 
277export function insertBlob(db: FileDavDoDb, row: NewFileBlobRow): void {
278 db.insert(blobs).values(row).run();
279}
280 
281export function getBlob(db: FileDavDoDb, id: string): FileBlobRow | undefined {
282 return db.query.blobs.findFirst({ where: eq(blobs.blobId, id) }).sync();
283}
284 
285export function updateBlobRefcount(db: FileDavDoDb, id: string, delta: number): void {
286 db.update(blobs)
287 .set({ refcount: sql`${blobs.refcount} + ${delta}` })
288 .where(eq(blobs.blobId, id))
289 .run();
290}
291 
292export function orphanBlobs(db: FileDavDoDb): FileBlobRow[] {
293 return db.select().from(blobs).where(eq(blobs.refcount, 0)).all();
294}
295 
296export function upsertFileNode(
297 db: FileDavDoDb,
298 input: {
299 existing: FileNodeRow | undefined;
300 parentId: string;
301 name: string;
302 blobId: string;
303 size: number;
304 contentType: string | null;
305 contentLanguage: string | null;
306 etag: string;
307 nowMs: number;
308 },
309): { node: FileNodeRow; created: boolean; oldBlobId: string | null } {
310 if (input.existing) {
311 const oldBlobId = input.existing.blobId;
312 db.update(nodes)
313 .set({
314 blobId: input.blobId,
315 size: input.size,
316 contentType: input.contentType,
317 contentLanguage: input.contentLanguage,
318 etag: input.etag,
319 modifiedAtMs: input.nowMs,
320 version: input.existing.version + 1,
321 })
322 .where(eq(nodes.id, input.existing.id))
323 .run();
324 return { node: nodeById(db, input.existing.id)!, created: false, oldBlobId };
325 }
326 
327 const row: NewFileNodeRow = {
328 id: nodeId(),
329 parentId: input.parentId,
330 name: input.name,
331 kind: "file",
332 blobId: input.blobId,
333 size: input.size,
334 contentType: input.contentType,
335 contentLanguage: input.contentLanguage,
336 etag: input.etag,
337 rootMarker: null,
338 createdAtMs: input.nowMs,
339 modifiedAtMs: input.nowMs,
340 version: 1,
341 };
342 db.insert(nodes).values(row).run();
343 return { node: nodeById(db, row.id)!, created: true, oldBlobId: null };
344}
345 
346export function touchNode(db: FileDavDoDb, nodeIdValue: string | null | undefined, nowMs: number): void {
347 if (!nodeIdValue) return;
348 db.update(nodes)
349 .set({ modifiedAtMs: nowMs, version: sql`${nodes.version} + 1` })
350 .where(eq(nodes.id, nodeIdValue))
351 .run();
352}
353 
354export function createEmptyFileNode(
355 db: FileDavDoDb,
356 input: {
357 parentId: string;
358 name: string;
359 contentType: string | null;
360 etag: string;
361 nowMs: number;
362 },
363): FileNodeRow {
364 const row: NewFileNodeRow = {
365 id: nodeId(),
366 parentId: input.parentId,
367 name: input.name,
368 kind: "file",
369 blobId: null,
370 size: 0,
371 contentType: input.contentType,
372 contentLanguage: null,
373 etag: input.etag,
374 rootMarker: null,
375 createdAtMs: input.nowMs,
376 modifiedAtMs: input.nowMs,
377 version: 1,
378 };
379 db.insert(nodes).values(row).run();
380 return nodeById(db, row.id)!;
381}
382 
383export function setDeadProp(
384 db: FileDavDoDb,
385 nodeIdValue: string,
386 prop: { nsUri: string; localName: string; xmlValue: string },
387): void {
388 db.insert(deadProps)
389 .values({ nodeId: nodeIdValue, ...prop })
390 .onConflictDoUpdate({
391 target: [deadProps.nodeId, deadProps.nsUri, deadProps.localName],
392 set: { xmlValue: prop.xmlValue },
393 })
394 .run();
395}
396 
397export function removeDeadProp(db: FileDavDoDb, nodeIdValue: string, prop: { nsUri: string; localName: string }): void {
398 db.delete(deadProps)
399 .where(
400 and(eq(deadProps.nodeId, nodeIdValue), eq(deadProps.nsUri, prop.nsUri), eq(deadProps.localName, prop.localName)),
401 )
402 .run();
403}
404 
405export function insertLock(db: FileDavDoDb, row: NewFileLockRow): void {
406 db.insert(locks).values(row).run();
407}
408 
409export function getLock(db: FileDavDoDb, token: string): FileLockRow | undefined {
410 return db.query.locks.findFirst({ where: eq(locks.token, token) }).sync();
411}
412 
413export function deleteLock(db: FileDavDoDb, token: string): void {
414 db.delete(locks).where(eq(locks.token, token)).run();
415}
416 
417export function expiredLocks(db: FileDavDoDb, nowMs: number, limit: number): FileLockRow[] {
418 return db.select().from(locks).where(lte(locks.expiresAtMs, nowMs)).limit(limit).all();
419}
420 
421export function deleteLocks(db: FileDavDoDb, tokens: string[]): void {
422 if (tokens.length === 0) return;
423 db.delete(locks).where(inArray(locks.token, tokens)).run();
424}
425 
426export function refreshLock(db: FileDavDoDb, token: string, expiresAtMs: number): FileLockRow | undefined {
427 db.update(locks).set({ expiresAtMs }).where(eq(locks.token, token)).run();
428 return getLock(db, token);
429}
430 
431export function insertChange(
432 db: FileDavDoDb,
433 input: { nodeId: string | null; href: string; changeType: FileChangeType; changedAtMs: number },
434): void {
435 db.insert(changes).values(input).run();
436}
437 
438export function deleteSubtree(
439 db: FileDavDoDb,
440 root: NodeAtPath,
441 maxNodes: number,
442): { ok: true; blobs: FileBlobRow[] } | { ok: false } {
443 const { rows: tree, exceeded } = treeFromLimited(db, root, "infinity", maxNodes);
444 if (exceeded) return { ok: false };
445 const fileBlobIds = tree.map((entry) => entry.node.blobId).filter((id): id is string => id !== null);
446 for (const id of fileBlobIds) updateBlobRefcount(db, id, -1);
447 db.delete(nodes).where(eq(nodes.id, root.node.id)).run();
448 return { ok: true, blobs: orphanBlobs(db) };
449}
450 
451export function copyDeadProps(db: FileDavDoDb, fromNodeId: string, toNodeId: string): void {
452 const rows = db.select().from(deadProps).where(eq(deadProps.nodeId, fromNodeId)).all();
453 for (const row of rows) {
454 setDeadProp(db, toNodeId, { nsUri: row.nsUri, localName: row.localName, xmlValue: row.xmlValue });
455 }
456}
457 
458export function moveNode(db: FileDavDoDb, nodeIdValue: string, parentId: string, name: string, nowMs: number): void {
459 db.update(nodes)
460 .set({ parentId, name, modifiedAtMs: nowMs, version: sql`${nodes.version} + 1` })
461 .where(eq(nodes.id, nodeIdValue))
462 .run();
463}
464 
465// RFC 4918 7.5: MOVE leaves the source URL unmapped, so direct locks rooted at
466// the source href or its descendants become invalid and must be removed.
467export function deleteMovedLockRoots(db: FileDavDoDb, oldRootHref: string): void {
468 const rows = db.select().from(locks).all();
469 for (const row of rows) {
470 if (!isSameOrDescendantPath(row.rootPath, oldRootHref)) continue;
471 db.delete(locks).where(eq(locks.token, row.token)).run();
472 }
473}
474 
475export function countRootRows(db: FileDavDoDb): number {
476 return db.select({ id: nodes.id }).from(nodes).where(isNull(nodes.parentId)).all().length;
477}