@statewalker/webrun-files-sqlite 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,587 @@
1
+ import type {
2
+ FileInfo,
3
+ FileStats,
4
+ FilesApi,
5
+ ListOptions,
6
+ ReadOptions,
7
+ } from "@statewalker/webrun-files";
8
+ import { normalizePath } from "@statewalker/webrun-files";
9
+ import { toBytes } from "../bytes.js";
10
+ import type { SqlDriver, SqlStatement } from "../sql.types.js";
11
+ import { ByteReader, collectBlock } from "./byte-reader.js";
12
+ import { Sha256 } from "./sha256.js";
13
+ import { type StreamCodec, webDeflateCodec } from "./stream-codec.js";
14
+
15
+ const MiB = 1024 * 1024;
16
+ /** Rows on Durable Objects and D1 are capped at 2 MB; leave room for deflate's worst case. */
17
+ const MAX_BLOCK_SIZE_LIMIT = 1.5 * MiB;
18
+ const LIST_PAGE = 256;
19
+
20
+ export interface SqliteFilesApiOptions {
21
+ /** Prefix of the three table names. Defaults to "fs_". */
22
+ tablePrefix?: string;
23
+ /**
24
+ * Codec for new writes; `null` stores content uncompressed. Defaults to
25
+ * {@link webDeflateCodec} where Compression Streams exist, uncompressed elsewhere.
26
+ */
27
+ compression?: StreamCodec | null;
28
+ /**
29
+ * Uncompressed bytes per block for new writes; only a file's last block is
30
+ * shorter. An integer from 1 to 1.5 MiB, default 1 MiB. Each content records
31
+ * the size it was written with, so changing this never affects existing files.
32
+ */
33
+ blockSize?: number;
34
+ }
35
+
36
+ interface EntryRow {
37
+ fid: number | null;
38
+ mtime: number;
39
+ size: number | null;
40
+ compression: string | null;
41
+ block_size: number | null;
42
+ }
43
+
44
+ interface ListRow extends EntryRow {
45
+ path: string;
46
+ }
47
+
48
+ /**
49
+ * A FilesApi over three SQLite tables: paths point at immutable contents, and
50
+ * a content is a sequence of bounded, optionally compressed blocks. Reads and
51
+ * writes stream one block at a time, so no file is ever held whole and no row
52
+ * outgrows the 2 MB limit of Durable Objects and D1.
53
+ *
54
+ * Streaming happens outside transactions; every step that changes what a path
55
+ * shows is one transaction, so an interruption leaves at most an unreferenced
56
+ * content, which {@link sweep} removes.
57
+ */
58
+ export class SqliteFilesApi implements FilesApi {
59
+ readonly #sql: SqlDriver;
60
+ readonly #codec: StreamCodec | null;
61
+ readonly #blockSize: number;
62
+ readonly #paths: string;
63
+ readonly #files: string;
64
+ readonly #blocks: string;
65
+ readonly #prefix: string;
66
+
67
+ constructor(sql: SqlDriver, opts: SqliteFilesApiOptions = {}) {
68
+ const prefix = opts.tablePrefix ?? "fs_";
69
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(prefix)) {
70
+ throw new Error(`SqliteFilesApi: tablePrefix must be a plain SQL identifier, got ${prefix}`);
71
+ }
72
+ this.#blockSize = opts.blockSize ?? MiB;
73
+ if (
74
+ !Number.isInteger(this.#blockSize) ||
75
+ this.#blockSize < 1 ||
76
+ this.#blockSize > MAX_BLOCK_SIZE_LIMIT
77
+ ) {
78
+ throw new Error(
79
+ `SqliteFilesApi: blockSize must be an integer from 1 to ${MAX_BLOCK_SIZE_LIMIT}, got ${this.#blockSize}`,
80
+ );
81
+ }
82
+ this.#sql = sql;
83
+ this.#codec = opts.compression === undefined ? availableWebCodec() : opts.compression;
84
+ this.#prefix = prefix;
85
+ this.#paths = `${prefix}paths`;
86
+ this.#files = `${prefix}files`;
87
+ this.#blocks = `${prefix}blocks`;
88
+ }
89
+
90
+ /** Creates the tables and indexes if missing. Await it before any other call. */
91
+ async init(): Promise<void> {
92
+ const statements = [
93
+ `CREATE TABLE IF NOT EXISTS ${this.#files}(
94
+ fid INTEGER PRIMARY KEY,
95
+ size INTEGER,
96
+ compression TEXT NOT NULL,
97
+ block_size INTEGER NOT NULL,
98
+ hash TEXT,
99
+ updated INTEGER NOT NULL
100
+ )`,
101
+ `CREATE TABLE IF NOT EXISTS ${this.#blocks}(
102
+ fid INTEGER NOT NULL,
103
+ shift INTEGER NOT NULL,
104
+ block BLOB NOT NULL,
105
+ PRIMARY KEY (fid, shift)
106
+ ) WITHOUT ROWID`,
107
+ `CREATE TABLE IF NOT EXISTS ${this.#paths}(
108
+ pid INTEGER PRIMARY KEY,
109
+ path TEXT NOT NULL UNIQUE,
110
+ fid INTEGER,
111
+ mtime INTEGER NOT NULL
112
+ )`,
113
+ `CREATE INDEX IF NOT EXISTS ${this.#prefix}paths_fid ON ${this.#paths}(fid)`,
114
+ `CREATE INDEX IF NOT EXISTS ${this.#prefix}files_hash ON ${this.#files}(hash, size)`,
115
+ ];
116
+ for (const statement of statements) await this.#sql.run(statement);
117
+ }
118
+
119
+ // ---------------------------------------------------------------- read
120
+
121
+ async *read(path: string, options: ReadOptions = {}): AsyncIterable<Uint8Array> {
122
+ options.signal?.throwIfAborted();
123
+ const name = normalizePath(path);
124
+ const entry = await this.#entry(name);
125
+ if (!entry || entry.fid === null || entry.size === null) return;
126
+
127
+ const start = Math.max(0, options.start ?? 0);
128
+ const end = Math.min(
129
+ entry.size,
130
+ options.length === undefined ? entry.size : start + options.length,
131
+ );
132
+ if (start >= end) return;
133
+
134
+ const { fid, size } = entry;
135
+ const blockSize = entry.block_size ?? this.#blockSize;
136
+ const decoder = this.#decoder(name, entry.compression ?? "none");
137
+
138
+ // Fixed-size blocks: the block holding `start` is found by arithmetic, and
139
+ // every block's uncompressed length is known before it is read.
140
+ for (let shift = start - (start % blockSize); shift < end; shift += blockSize) {
141
+ options.signal?.throwIfAborted();
142
+ const block = await this.#block(fid, shift);
143
+ if (!block) throw new Error(`sqlite-files: ${name} changed during read`);
144
+ const expected = Math.min(blockSize, size - shift);
145
+ const bytes = toBytes(block.block);
146
+
147
+ const chunks = decoder ? decoder.decompress(once(bytes)) : once(bytes);
148
+ let offset = shift;
149
+ let stoppedEarly = false;
150
+ for await (const chunk of chunks) {
151
+ if (offset + chunk.length - shift > expected) {
152
+ throw corruptBlock(name, shift, `at least ${offset + chunk.length - shift}`, expected);
153
+ }
154
+ const from = Math.max(0, start - offset);
155
+ const to = Math.min(chunk.length, end - offset);
156
+ offset += chunk.length;
157
+ if (to > from) yield chunk.subarray(from, to);
158
+ if (offset >= end) {
159
+ stoppedEarly = true;
160
+ break;
161
+ }
162
+ }
163
+ if (!stoppedEarly && offset - shift !== expected) {
164
+ throw corruptBlock(name, shift, offset - shift, expected);
165
+ }
166
+ }
167
+ }
168
+
169
+ // --------------------------------------------------------------- write
170
+
171
+ /**
172
+ * Streams the content into a new, invisible content row, one block per
173
+ * transaction, then points the path at it in one transaction. Any failure
174
+ * removes the pending content; a crash leaves it for {@link sweep}.
175
+ */
176
+ async write(
177
+ path: string,
178
+ content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>,
179
+ ): Promise<void> {
180
+ const name = normalizePath(path);
181
+ const compression = this.#codec?.name ?? "none";
182
+ const created = await this.#sql.all<{ fid: number }>(
183
+ `INSERT INTO ${this.#files}(size, compression, block_size, hash, updated)
184
+ VALUES (NULL, ?, ?, NULL, ?) RETURNING fid`,
185
+ compression,
186
+ this.#blockSize,
187
+ Date.now(),
188
+ );
189
+ const fid = created[0].fid;
190
+
191
+ const limit = this.#blockSize;
192
+ const reader = new ByteReader(content);
193
+ const hash = new Sha256();
194
+ try {
195
+ let size = 0;
196
+ for (;;) {
197
+ const first = await reader.read(limit);
198
+ if (!first) break;
199
+ let raw = 0;
200
+ // Pulls the source only while the block is being built: never ahead of storage.
201
+ const pieces = async function* () {
202
+ let piece: Uint8Array | undefined = first;
203
+ while (piece) {
204
+ hash.update(piece);
205
+ raw += piece.length;
206
+ yield piece;
207
+ if (raw === limit) return;
208
+ piece = await reader.read(limit - raw);
209
+ }
210
+ };
211
+ const block = await collectBlock(this.#codec ? this.#codec.compress(pieces()) : pieces());
212
+ const [alive] = await this.#sql.transaction([
213
+ {
214
+ sql: `UPDATE ${this.#files} SET updated = ? WHERE fid = ? AND size IS NULL`,
215
+ params: [Date.now(), fid],
216
+ },
217
+ {
218
+ sql: `INSERT INTO ${this.#blocks}(fid, shift, block)
219
+ SELECT ?, ?, ? WHERE EXISTS (${this.#pending("?")})`,
220
+ params: [fid, size, block, fid],
221
+ },
222
+ ]);
223
+ if (alive === 0) throw sweptDuringWrite(name);
224
+ size += raw;
225
+ }
226
+
227
+ const previous = await this.#entry(name);
228
+ const [finished] = await this.#sql.transaction(
229
+ this.#finishWrite(name, fid, size, hash.digestHex(), compression, previous?.fid ?? null),
230
+ );
231
+ if (finished === 0) throw sweptDuringWrite(name);
232
+ } catch (error) {
233
+ try {
234
+ await reader.close();
235
+ } catch {}
236
+ // Best effort, and never allowed to replace the error that brought us here —
237
+ // including a synchronous throw from a synchronous driver.
238
+ try {
239
+ await this.#sql.transaction([
240
+ { sql: `DELETE FROM ${this.#files} WHERE fid = ? AND size IS NULL`, params: [fid] },
241
+ ...this.#deleteBlocksOfMissing(fid),
242
+ ]);
243
+ } catch {}
244
+ throw error;
245
+ }
246
+ }
247
+
248
+ /**
249
+ * One transaction: finish the content, create the parents, point the path at
250
+ * an identical content if one exists or else at this one, then collect this
251
+ * content if it ended up unused and the path's previous content. Everything
252
+ * after the first statement is conditioned on the content still existing, so
253
+ * a swept write changes nothing.
254
+ */
255
+ #finishWrite(
256
+ name: string,
257
+ fid: number,
258
+ size: number,
259
+ digest: string,
260
+ compression: string,
261
+ previous: number | null,
262
+ ): SqlStatement[] {
263
+ const now = Date.now();
264
+ const exists = `SELECT 1 FROM ${this.#files} WHERE fid = ?`;
265
+ const statements: SqlStatement[] = [
266
+ {
267
+ sql: `UPDATE ${this.#files} SET size = ?, hash = ?, updated = ? WHERE fid = ? AND size IS NULL`,
268
+ params: [size, digest, now, fid],
269
+ },
270
+ ...ancestorsAndSelf(parentOf(name)).map((dir) => ({
271
+ sql: `INSERT INTO ${this.#paths}(path, fid, mtime) SELECT ?, NULL, ? WHERE EXISTS (${exists})
272
+ ON CONFLICT(path) DO NOTHING`,
273
+ params: [dir, now, fid],
274
+ })),
275
+ {
276
+ sql: `INSERT INTO ${this.#paths}(path, fid, mtime)
277
+ SELECT ?, COALESCE((
278
+ SELECT f.fid FROM ${this.#files} f
279
+ WHERE f.hash = ? AND f.size = ? AND f.compression = ? AND f.block_size = ? AND f.fid <> ?
280
+ ORDER BY f.fid LIMIT 1
281
+ ), ?), ?
282
+ WHERE EXISTS (${exists})
283
+ ON CONFLICT(path) DO UPDATE SET fid = excluded.fid, mtime = excluded.mtime`,
284
+ params: [name, digest, size, compression, this.#blockSize, fid, fid, now, fid],
285
+ },
286
+ ...this.#collect(fid),
287
+ ];
288
+ if (previous !== null && previous !== fid) statements.push(...this.#collect(previous));
289
+ return statements;
290
+ }
291
+
292
+ async mkdir(path: string): Promise<void> {
293
+ const now = Date.now();
294
+ for (const dir of ancestorsAndSelf(normalizePath(path))) {
295
+ await this.#sql.run(
296
+ `INSERT INTO ${this.#paths}(path, fid, mtime) VALUES (?, NULL, ?) ON CONFLICT(path) DO NOTHING`,
297
+ dir,
298
+ now,
299
+ );
300
+ }
301
+ }
302
+
303
+ // --------------------------------------------------------------- stats
304
+
305
+ async stats(path: string): Promise<FileStats | undefined> {
306
+ const name = normalizePath(path);
307
+ if (name === "/") return { kind: "directory" };
308
+ const entry = await this.#entry(name);
309
+ return entry && toStats(entry);
310
+ }
311
+
312
+ async exists(path: string): Promise<boolean> {
313
+ return (await this.stats(path)) !== undefined;
314
+ }
315
+
316
+ async *list(path: string, options: ListOptions = {}): AsyncIterable<FileInfo> {
317
+ const base = normalizePath(path);
318
+ if ((await this.stats(base))?.kind !== "directory") return;
319
+
320
+ const range = descendants(base);
321
+ const prefixLength = base === "/" ? 1 : base.length + 1;
322
+ const directOnly = options.recursive ? "" : "AND instr(substr(p.path, ?), '/') = 0";
323
+ // The unique index orders paths by UTF-8 bytes, which is `comparePaths` order,
324
+ // so resuming after a path is the keyset cursor's starting point.
325
+ let cursor = options.after ?? "";
326
+ for (;;) {
327
+ const params: unknown[] = [...range.params, cursor];
328
+ if (!options.recursive) params.push(prefixLength + 1);
329
+ const rows = await this.#sql.all<ListRow>(
330
+ `SELECT p.path, p.fid, p.mtime, f.size, f.compression, f.block_size
331
+ FROM ${this.#paths} p LEFT JOIN ${this.#files} f ON f.fid = p.fid
332
+ WHERE ${range.where} AND p.path > ? ${directOnly}
333
+ ORDER BY p.path LIMIT ${LIST_PAGE}`,
334
+ ...params,
335
+ );
336
+ for (const row of rows) {
337
+ const stats = toStats(row);
338
+ if (stats)
339
+ yield { ...stats, name: row.path.slice(row.path.lastIndexOf("/") + 1), path: row.path };
340
+ }
341
+ if (rows.length < LIST_PAGE) return;
342
+ cursor = rows[rows.length - 1].path;
343
+ }
344
+ }
345
+
346
+ // ------------------------------------------------------------- mutation
347
+
348
+ async remove(path: string): Promise<boolean> {
349
+ const counts = await this.#sql.transaction(this.#removeTree(normalizePath(path)));
350
+ return counts[counts.length - 1] > 0;
351
+ }
352
+
353
+ /**
354
+ * Deletes available contents and blocks referenced only from inside the tree, then the tree's
355
+ * path rows. Set-based, so it needs no ids read beforehand. `guard` conditions every statement.
356
+ */
357
+ #removeTree(path: string, guard: Guard = NO_GUARD): SqlStatement[] {
358
+ const inP = selfAndDescendants(path, "p");
359
+ const inQ = selfAndDescendants(path, "q");
360
+ const referenced = `SELECT p.fid FROM ${this.#paths} p WHERE ${inP.where}`;
361
+ return [
362
+ {
363
+ sql: `DELETE FROM ${this.#files} WHERE size IS NOT NULL
364
+ AND fid IN (${referenced})
365
+ AND NOT EXISTS (
366
+ SELECT 1 FROM ${this.#paths} q WHERE q.fid = ${this.#files}.fid AND NOT ${inQ.where}
367
+ ) ${guard.sql}`,
368
+ params: [...inP.params, ...inQ.params, ...guard.params],
369
+ },
370
+ {
371
+ sql: `DELETE FROM ${this.#blocks} WHERE fid IN (${referenced})
372
+ AND NOT EXISTS (SELECT 1 FROM ${this.#files} f WHERE f.fid = ${this.#blocks}.fid)
373
+ ${guard.sql}`,
374
+ params: [...inP.params, ...guard.params],
375
+ },
376
+ {
377
+ sql: `DELETE FROM ${this.#paths} AS p WHERE ${inP.where} ${guard.sql}`,
378
+ params: [...inP.params, ...guard.params],
379
+ },
380
+ ];
381
+ }
382
+
383
+ async copy(source: string, target: string): Promise<boolean> {
384
+ return this.#transfer(source, target, "copy");
385
+ }
386
+
387
+ async move(source: string, target: string): Promise<boolean> {
388
+ return this.#transfer(source, target, "move");
389
+ }
390
+
391
+ /**
392
+ * One transaction: replace the target's subtree, create its parents, then copy
393
+ * or rename. Everything before the last statement is conditioned on the source
394
+ * existing, so a source that vanished leaves the target untouched.
395
+ */
396
+ async #transfer(source: string, target: string, mode: "copy" | "move"): Promise<boolean> {
397
+ const from = normalizePath(source);
398
+ const to = normalizePath(target);
399
+ if (from === to) return this.exists(from);
400
+ if (isInside(to, from) || isInside(from, to)) {
401
+ throw new Error(`sqlite-files: cannot ${mode} ${from} to ${to}: one contains the other`);
402
+ }
403
+
404
+ const range = selfAndDescendants(from, "p");
405
+ const inS = selfAndDescendants(from, "s");
406
+ const guard: Guard = {
407
+ sql: `AND EXISTS (SELECT 1 FROM ${this.#paths} s WHERE ${inS.where})`,
408
+ params: inS.params,
409
+ };
410
+ const now = Date.now();
411
+ const statements: SqlStatement[] = [
412
+ ...this.#removeTree(to, guard),
413
+ ...ancestorsAndSelf(parentOf(to)).map((dir) => ({
414
+ sql: `INSERT INTO ${this.#paths}(path, fid, mtime) SELECT ?, NULL, ? WHERE 1 ${guard.sql}
415
+ ON CONFLICT(path) DO NOTHING`,
416
+ params: [dir, now, ...guard.params],
417
+ })),
418
+ mode === "copy"
419
+ ? {
420
+ sql: `INSERT INTO ${this.#paths}(path, fid, mtime)
421
+ SELECT ? || substr(p.path, ?), p.fid, ? FROM ${this.#paths} p WHERE ${range.where}`,
422
+ params: [to, from.length + 1, now, ...range.params],
423
+ }
424
+ : {
425
+ sql: `UPDATE ${this.#paths} AS p SET path = ? || substr(p.path, ?), mtime = ?
426
+ WHERE ${range.where}`,
427
+ params: [to, from.length + 1, now, ...range.params],
428
+ },
429
+ ];
430
+ const counts = await this.#sql.transaction(statements);
431
+ return counts[counts.length - 1] > 0;
432
+ }
433
+
434
+ /**
435
+ * Deletes, in one transaction, every content not updated for `olderThan`
436
+ * milliseconds that is still pending or that no path references, with its
437
+ * blocks. Returns how many contents were deleted.
438
+ *
439
+ * A write refreshes its pending content with every block, so only a write
440
+ * stalled longer than `olderThan` can be swept — and it then fails instead of
441
+ * pointing a path at missing content. Never runs on its own.
442
+ */
443
+ async sweep(options: { olderThan: number }): Promise<number> {
444
+ const { olderThan } = options;
445
+ if (!(Number.isFinite(olderThan) && olderThan >= 0)) {
446
+ throw new Error(
447
+ `SqliteFilesApi.sweep: olderThan must be a finite number >= 0, got ${olderThan}`,
448
+ );
449
+ }
450
+ const cutoff = Date.now() - olderThan;
451
+ // A pending content is never referenced — a path only points at a content in
452
+ // the transaction that finishes it — so "unreferenced" covers both cases.
453
+ const eligible = `f.updated < ? AND NOT EXISTS (
454
+ SELECT 1 FROM ${this.#paths} p WHERE p.fid = f.fid
455
+ )`;
456
+ const [, contents] = await this.#sql.transaction([
457
+ {
458
+ sql: `DELETE FROM ${this.#blocks} WHERE fid IN (SELECT f.fid FROM ${this.#files} f WHERE ${eligible})`,
459
+ params: [cutoff],
460
+ },
461
+ { sql: `DELETE FROM ${this.#files} AS f WHERE ${eligible}`, params: [cutoff] },
462
+ ]);
463
+ return contents;
464
+ }
465
+
466
+ // -------------------------------------------------------------- private
467
+
468
+ async #entry(name: string): Promise<EntryRow | undefined> {
469
+ const rows = await this.#sql.all<EntryRow>(
470
+ `SELECT p.fid, p.mtime, f.size, f.compression, f.block_size
471
+ FROM ${this.#paths} p LEFT JOIN ${this.#files} f ON f.fid = p.fid
472
+ WHERE p.path = ?`,
473
+ name,
474
+ );
475
+ return rows[0];
476
+ }
477
+
478
+ #decoder(name: string, compression: string): StreamCodec | null {
479
+ if (compression === "none") return null;
480
+ if (this.#codec?.name === compression) return this.#codec;
481
+ if (compression === "deflate") {
482
+ const web = availableWebCodec();
483
+ if (web) return web;
484
+ }
485
+ throw new Error(
486
+ `sqlite-files: ${name} is compressed with ${compression}, and no codec reads it`,
487
+ );
488
+ }
489
+
490
+ async #block(fid: number, shift: number) {
491
+ const rows = await this.#sql.all<{ shift: number; block: unknown }>(
492
+ `SELECT shift, block FROM ${this.#blocks} WHERE fid = ? AND shift = ?`,
493
+ fid,
494
+ shift,
495
+ );
496
+ return rows[0];
497
+ }
498
+
499
+ /** A subquery selecting the content `fid` while it is still pending. */
500
+ #pending(fid: string): string {
501
+ return `SELECT 1 FROM ${this.#files} WHERE fid = ${fid} AND size IS NULL`;
502
+ }
503
+
504
+ /**
505
+ * Delete a finished content no path references, then its blocks. Inside a
506
+ * transaction the reference check and the delete cannot be separated by a
507
+ * write that shares the content.
508
+ */
509
+ #collect(fid: number): SqlStatement[] {
510
+ return [
511
+ {
512
+ sql: `DELETE FROM ${this.#files} WHERE fid = ? AND size IS NOT NULL
513
+ AND NOT EXISTS (SELECT 1 FROM ${this.#paths} WHERE fid = ?)`,
514
+ params: [fid, fid],
515
+ },
516
+ ...this.#deleteBlocksOfMissing(fid),
517
+ ];
518
+ }
519
+
520
+ #deleteBlocksOfMissing(fid: number): SqlStatement[] {
521
+ return [
522
+ {
523
+ sql: `DELETE FROM ${this.#blocks} WHERE fid = ?
524
+ AND NOT EXISTS (SELECT 1 FROM ${this.#files} WHERE fid = ?)`,
525
+ params: [fid, fid],
526
+ },
527
+ ];
528
+ }
529
+ }
530
+
531
+ interface Guard {
532
+ sql: string;
533
+ params: unknown[];
534
+ }
535
+
536
+ const NO_GUARD: Guard = { sql: "", params: [] };
537
+
538
+ function sweptDuringWrite(name: string): Error {
539
+ return new Error(`sqlite-files: ${name} content was swept during write`);
540
+ }
541
+
542
+ function corruptBlock(name: string, shift: number, holds: number | string, expected: number) {
543
+ return new Error(
544
+ `sqlite-files: ${name} block at ${shift} holds ${holds} bytes, expected ${expected}`,
545
+ );
546
+ }
547
+
548
+ function availableWebCodec(): StreamCodec | null {
549
+ return typeof CompressionStream === "undefined" ? null : webDeflateCodec();
550
+ }
551
+
552
+ function toStats(row: EntryRow): FileStats | undefined {
553
+ if (row.fid === null) return { kind: "directory" };
554
+ if (row.size === null) return undefined;
555
+ return { kind: "file", size: row.size, lastModified: row.mtime };
556
+ }
557
+
558
+ async function* once(bytes: Uint8Array): AsyncGenerator<Uint8Array> {
559
+ yield bytes;
560
+ }
561
+
562
+ /** Rows strictly under `path` as a range on the unique index (case-sensitive, unlike LIKE). */
563
+ function descendants(path: string, alias = "p"): { where: string; params: string[] } {
564
+ if (path === "/") return { where: "1", params: [] };
565
+ return { where: `(${alias}.path > ? AND ${alias}.path < ?)`, params: [`${path}/`, `${path}0`] };
566
+ }
567
+
568
+ function selfAndDescendants(path: string, alias = "p"): { where: string; params: string[] } {
569
+ if (path === "/") return descendants(path, alias);
570
+ const range = descendants(path, alias);
571
+ return { where: `(${alias}.path = ? OR ${range.where})`, params: [path, ...range.params] };
572
+ }
573
+
574
+ function isInside(path: string, ancestor: string): boolean {
575
+ return ancestor === "/" ? path !== "/" : path.startsWith(`${ancestor}/`);
576
+ }
577
+
578
+ function parentOf(path: string): string {
579
+ const i = path.lastIndexOf("/");
580
+ return i <= 0 ? "/" : path.slice(0, i);
581
+ }
582
+
583
+ /** "/a/b" → ["/a", "/a/b"]; the root yields nothing. */
584
+ function ancestorsAndSelf(path: string): string[] {
585
+ const parts = path.split("/").filter(Boolean);
586
+ return parts.map((_, i) => `/${parts.slice(0, i + 1).join("/")}`);
587
+ }