@statewalker/webrun-files-sqlite 0.11.1 → 0.12.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.
package/README.md CHANGED
@@ -109,6 +109,24 @@ await files.init();
109
109
  const removed = await files.sweep({ olderThan: 60 * 60_000 }); // contents untouched for an hour
110
110
  ```
111
111
 
112
+ ### A lock row, shared by every connection
113
+
114
+ ```typescript
115
+ const locked = await files.createExclusive('/jobs/nightly.lock', [new TextEncoder().encode(worker)]);
116
+ if (!locked) return; // another connection, in this process or another, holds it
117
+ try {
118
+ // ...
119
+ } finally {
120
+ await files.remove('/jobs/nightly.lock');
121
+ }
122
+ ```
123
+
124
+ Both classes implement `createExclusive` with an `INSERT` that does nothing on a conflict, and
125
+ read whether a row was inserted. SQLite runs the statement atomically and serializes writers, so
126
+ every connection to the database file is excluded, in any process. An existing file, directory
127
+ or (in `SqlarFilesApi`) symlink makes it return `false`, as does a name that only exists as the
128
+ prefix of others. Nothing is visible until the content is complete.
129
+
112
130
  ### A portable archive with `SqlarFilesApi`
113
131
 
114
132
  ```typescript
@@ -187,9 +205,16 @@ page starts.
187
205
  Streaming happens outside transactions. Every step that changes what a path shows is one
188
206
  transaction: storing each block, finishing a write, `remove`, `copy` and `move`. A failure inside
189
207
  any of them leaves the database as it was; `move` onto an existing target never loses the target.
208
+ `move` renames the path rows by prefix and keeps their content ids, so no content is copied, and
209
+ the content of a replaced target is collected in the same transaction when no other path shares it.
190
210
  A write stays invisible until it finishes: if it fails, the path keeps its previous content and the
191
211
  partial content is deleted.
192
212
 
213
+ `createExclusive` streams exactly like a write, and turns an existing path away before reading
214
+ the content. Its last transaction inserts the path with `ON CONFLICT(path) DO NOTHING`: when
215
+ another writer created the path while the content streamed, the insert changes nothing, the call
216
+ returns `false`, and the unused content is deleted in that same transaction.
217
+
193
218
  If the process dies mid-stream, the partial content is left behind, unreferenced. `sweep` deletes
194
219
  contents that no path references and that have not been updated for `olderThan` milliseconds, with
195
220
  their blocks. A write refreshes its content with every block, so only a write stalled longer than
@@ -226,14 +251,26 @@ does. A dangling link, or a chain longer than 8, behaves as a missing path. `rem
226
251
 
227
252
  ### `SqlarFilesApi` limits
228
253
 
229
- - A file is one blob: `write` buffers it, `read` inflates it whole and yields one chunk.
254
+ - A file is one blob: `write` and `createExclusive` buffer it, `read` inflates it whole and
255
+ yields one chunk. `createExclusive` compresses before its single
256
+ `INSERT ... SELECT ... WHERE NOT EXISTS (names below) ON CONFLICT(name) DO NOTHING RETURNING name`,
257
+ so the check for an implicit directory and the insert are one statement.
230
258
  - On Durable Objects the stored (possibly compressed) size of one file must stay under 2 MB, since
231
259
  SQLAR keeps each file in one row. A larger file fails the `write` with `string or blob too big`
232
260
  — a value limit, not corruption. Store big files with `SqliteFilesApi`, whose blocks stay under
233
261
  the limit.
234
- - Multi-row mutations (`copy`, `move`, `remove` of a tree) are not wrapped in a transaction: a
235
- failure part-way leaves the rows already changed in place, and a `move` can leave the entry under
236
- both paths.
262
+ - `move` is one transaction through `inTransaction` (so the `Db` must carry db-sqlite's hook, as
263
+ for `SqliteFilesApi`): it deletes the target's subtree, creates the target's parents and renames
264
+ the source row and every row under it by rewriting the name prefix. The stored data is never
265
+ copied; mode, mtime and data stay as they were. Readers see the old state or the new one, and a
266
+ failure leaves source and target unchanged. Like `SqliteFilesApi`, it replaces the target's
267
+ subtree rather than merging into it, and throws
268
+ `sqlar: cannot move /a to /a/b: one contains the other` when one path contains the other. A
269
+ target below a file or a symlink returns `false` and changes nothing. A `Db` without the hook
270
+ (a wrapper that does not forward it) makes `move` reject with `inTransaction`'s
271
+ `this Db has no SQLite transaction hook` error.
272
+ - The other multi-row mutations (`copy`, `remove` of a tree) are not wrapped in a transaction: a
273
+ failure part-way leaves the rows already changed in place.
237
274
  - `list` loads every row under the directory, then sorts the entries into path order before
238
275
  applying `after`.
239
276
 
@@ -38,12 +38,27 @@ export declare class SqliteFilesApi implements FilesApi {
38
38
  * removes the pending content; a crash leaves it for {@link sweep}.
39
39
  */
40
40
  write(path: string, content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>): Promise<void>;
41
+ /**
42
+ * Streams the content exactly as {@link write} does, into a content no path shows, then
43
+ * points the path at it in one transaction with `INSERT ... ON CONFLICT DO NOTHING`:
44
+ * exclusive against every connection to the database, since SQLite serializes write
45
+ * transactions. Nothing is visible until the content is complete. A path that already
46
+ * exists is turned away before the content is read; one created meanwhile makes the
47
+ * final insert change nothing, and the content is collected in that same transaction.
48
+ */
49
+ createExclusive(path: string, content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>): Promise<boolean>;
41
50
  mkdir(path: string): Promise<void>;
42
51
  stats(path: string): Promise<FileStats | undefined>;
43
52
  exists(path: string): Promise<boolean>;
44
53
  list(path: string, options?: ListOptions): AsyncIterable<FileInfo>;
45
54
  remove(path: string): Promise<boolean>;
46
55
  copy(source: string, target: string): Promise<boolean>;
56
+ /**
57
+ * One transaction (see the private `#transfer`): the target's subtree is removed, its
58
+ * contents collected when no other path shares them, and the source's path rows are
59
+ * renamed by prefix, keeping their content ids, so no content is copied. Atomic for
60
+ * readers on every connection; a failure leaves source and target unchanged.
61
+ */
47
62
  move(source: string, target: string): Promise<boolean>;
48
63
  /**
49
64
  * Deletes, in one transaction, every content not updated for `olderThan`
@@ -1 +1 @@
1
- {"version":3,"file":"sqlite-files-api.d.ts","sourceRoot":"","sources":["../../src/blocks/sqlite-files-api.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,qBAAqB,CAAC;AAE9C,OAAO,KAAK,EACV,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,WAAW,EACX,WAAW,EACZ,MAAM,2BAA2B,CAAC;AAMnC,OAAO,EAAE,KAAK,WAAW,EAAmB,MAAM,mBAAmB,CAAC;AAOtE,MAAM,WAAW,qBAAqB;IACpC,0DAA0D;IAC1D,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,WAAW,CAAC,EAAE,WAAW,GAAG,IAAI,CAAC;IACjC;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAcD;;;;;;;;;GASG;AACH,qBAAa,cAAe,YAAW,QAAQ;;IAS7C,YAAY,EAAE,EAAE,EAAE,EAAE,IAAI,GAAE,qBAA0B,EAqBnD;IAED,iFAAiF;IAC3E,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CA0B1B;IAIM,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,WAAgB,GAAG,aAAa,CAAC,UAAU,CAAC,CA8C9E;IAID;;;;OAIG;IACG,KAAK,CACT,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,aAAa,CAAC,UAAU,CAAC,GACxD,OAAO,CAAC,IAAI,CAAC,CAuEf;IA8CK,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAQvC;IAIK,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC,CAKxD;IAEK,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAE3C;IAEM,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,WAAgB,GAAG,aAAa,CAAC,QAAQ,CAAC,CA4B5E;IAIK,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAG3C;IAgCK,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAE3D;IAEK,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAE3D;IA6CD;;;;;;;;OAQG;IACG,KAAK,CAAC,OAAO,EAAE;QAAE,SAAS,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAqB3D;CAqEF"}
1
+ {"version":3,"file":"sqlite-files-api.d.ts","sourceRoot":"","sources":["../../src/blocks/sqlite-files-api.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,qBAAqB,CAAC;AAE9C,OAAO,KAAK,EACV,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,WAAW,EACX,WAAW,EACZ,MAAM,2BAA2B,CAAC;AAMnC,OAAO,EAAE,KAAK,WAAW,EAAmB,MAAM,mBAAmB,CAAC;AAOtE,MAAM,WAAW,qBAAqB;IACpC,0DAA0D;IAC1D,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,WAAW,CAAC,EAAE,WAAW,GAAG,IAAI,CAAC;IACjC;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAcD;;;;;;;;;GASG;AACH,qBAAa,cAAe,YAAW,QAAQ;;IAS7C,YAAY,EAAE,EAAE,EAAE,EAAE,IAAI,GAAE,qBAA0B,EAqBnD;IAED,iFAAiF;IAC3E,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CA0B1B;IAIM,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,WAAgB,GAAG,aAAa,CAAC,UAAU,CAAC,CA8C9E;IAID;;;;OAIG;IACG,KAAK,CACT,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,aAAa,CAAC,UAAU,CAAC,GACxD,OAAO,CAAC,IAAI,CAAC,CAEf;IAED;;;;;;;OAOG;IACG,eAAe,CACnB,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,aAAa,CAAC,UAAU,CAAC,GACxD,OAAO,CAAC,OAAO,CAAC,CAIlB;IAuIK,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAQvC;IAIK,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC,CAKxD;IAEK,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAE3C;IAEM,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,WAAgB,GAAG,aAAa,CAAC,QAAQ,CAAC,CA6B5E;IAIK,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAG3C;IAgCK,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAE3D;IAED;;;;;OAKG;IACG,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAE3D;IA+CD;;;;;;;;OAQG;IACG,KAAK,CAAC,OAAO,EAAE;QAAE,SAAS,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAqB3D;CAqEF"}
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- //#region ../../node_modules/.pnpm/@statewalker+db-sqlite@0.1.0/node_modules/@statewalker/db-sqlite/dist/index.mjs
2
+ // ../../node_modules/.pnpm/@statewalker+db-sqlite@0.1.0/node_modules/@statewalker/db-sqlite/dist/index.mjs
3
3
  /** Key under which a SQLite Db carries its transaction hook. Shared across bundles via Symbol.for. */
4
4
  const SQLITE_TRANSACTION = Symbol.for("@statewalker/db-sqlite:transaction");
5
5
  const NO_HOOK = "inTransaction: this Db has no SQLite transaction hook. It is not a @statewalker/db-sqlite* Db, or it wraps one (a wrapper object loses the hook - pass the driver's Db itself), or it is the handle of a running transaction (nesting is not supported).";
@@ -13,8 +13,7 @@ function inTransaction(db, cb) {
13
13
  if (typeof hook !== "function") return Promise.reject(/* @__PURE__ */ new Error(NO_HOOK));
14
14
  return hook(cb);
15
15
  }
16
- //#endregion
17
- //#region ../webrun-files/dist/esm/index.js
16
+ // ../webrun-files/dist/esm/index.js
18
17
  /**
19
18
  * The order every `FilesApi.list()` yields: paths compared by Unicode code
20
19
  * point, which is the same as comparing their UTF-8 bytes — the order of
@@ -51,16 +50,14 @@ function normalizePath(filePath) {
51
50
  if (segments.length === 0) return "/";
52
51
  return `/${segments.join("/")}`;
53
52
  }
54
- //#endregion
55
- //#region src/bytes.ts
53
+ // src/bytes.ts
56
54
  /** BLOBs arrive as Uint8Array from every @statewalker/db-sqlite driver; SQLAR may hold TEXT. */
57
55
  function toBytes(value) {
58
56
  if (value instanceof Uint8Array) return value;
59
57
  if (typeof value === "string") return new TextEncoder().encode(value);
60
58
  return /* @__PURE__ */ new Uint8Array(0);
61
59
  }
62
- //#endregion
63
- //#region src/run-statements.ts
60
+ // src/run-statements.ts
64
61
  /**
65
62
  * Runs the statements in order on a transaction's Db and returns, per
66
63
  * statement, the rows it changed — zero exactly when it changed nothing. The
@@ -77,8 +74,7 @@ async function runStatements(tx, statements) {
77
74
  }
78
75
  return counts;
79
76
  }
80
- //#endregion
81
- //#region src/blocks/byte-reader.ts
77
+ // src/blocks/byte-reader.ts
82
78
  /**
83
79
  * Pull bytes from a chunk source in bounded pieces.
84
80
  *
@@ -134,8 +130,7 @@ async function collectBlock(chunks) {
134
130
  }
135
131
  return out;
136
132
  }
137
- //#endregion
138
- //#region src/blocks/sha256.ts
133
+ // src/blocks/sha256.ts
139
134
  /**
140
135
  * Incremental SHA-256 (FIPS 180-4). WebCrypto only hashes a whole buffer, and
141
136
  * content here arrives as a stream it must never be collected from.
@@ -294,8 +289,7 @@ var Sha256 = class {
294
289
  h[7] += k;
295
290
  }
296
291
  };
297
- //#endregion
298
- //#region src/blocks/stream-codec.ts
292
+ // src/blocks/stream-codec.ts
299
293
  /**
300
294
  * zlib-wrapped Deflate over Compression Streams — browsers, Node, Deno, Bun,
301
295
  * Workers. The source is wrapped in a pull-only ReadableStream, so the
@@ -381,8 +375,7 @@ async function* iterate(stream) {
381
375
  reader.releaseLock();
382
376
  }
383
377
  }
384
- //#endregion
385
- //#region src/blocks/sqlite-files-api.ts
378
+ // src/blocks/sqlite-files-api.ts
386
379
  const MiB = 1048576;
387
380
  /** Rows on Durable Objects are capped at 2 MB; leave room for deflate's worst case. */
388
381
  const MAX_BLOCK_SIZE_LIMIT = 1.5 * MiB;
@@ -485,7 +478,23 @@ var SqliteFilesApi = class {
485
478
  * removes the pending content; a crash leaves it for {@link sweep}.
486
479
  */
487
480
  async write(path, content) {
481
+ await this.#writeContent(normalizePath(path), content, false);
482
+ }
483
+ /**
484
+ * Streams the content exactly as {@link write} does, into a content no path shows, then
485
+ * points the path at it in one transaction with `INSERT ... ON CONFLICT DO NOTHING`:
486
+ * exclusive against every connection to the database, since SQLite serializes write
487
+ * transactions. Nothing is visible until the content is complete. A path that already
488
+ * exists is turned away before the content is read; one created meanwhile makes the
489
+ * final insert change nothing, and the content is collected in that same transaction.
490
+ */
491
+ async createExclusive(path, content) {
488
492
  const name = normalizePath(path);
493
+ if (name === "/" || await this.#entry(name)) return false;
494
+ return this.#writeContent(name, content, true);
495
+ }
496
+ /** {@link write}, or with `exclusive` {@link createExclusive}; whether the path was set. */
497
+ async #writeContent(name, content, exclusive) {
489
498
  const compression = this.#codec?.name ?? "none";
490
499
  const fid = await inTransaction(this.#db, async (tx) => (await tx.query(`INSERT INTO ${this.#files}(size, compression, block_size, hash, updated)
491
500
  VALUES (NULL, ?, ?, NULL, ?) RETURNING fid`, [
@@ -529,9 +538,11 @@ var SqliteFilesApi = class {
529
538
  if (alive === 0) throw sweptDuringWrite(name);
530
539
  size += raw;
531
540
  }
532
- const previous = await this.#entry(name);
533
- const [finished] = await this.#transaction(this.#finishWrite(name, fid, size, hash.digestHex(), compression, previous?.fid ?? null));
534
- if (finished === 0) throw sweptDuringWrite(name);
541
+ const previous = exclusive ? void 0 : await this.#entry(name);
542
+ const statements = this.#finishWrite(name, fid, size, hash.digestHex(), compression, previous?.fid ?? null, exclusive);
543
+ const counts = await this.#transaction(statements.statements);
544
+ if (counts[0] === 0) throw sweptDuringWrite(name);
545
+ return counts[statements.pathIndex] > 0;
535
546
  } catch (error) {
536
547
  try {
537
548
  await reader.close();
@@ -550,9 +561,10 @@ var SqliteFilesApi = class {
550
561
  * an identical content if one exists or else at this one, then collect this
551
562
  * content if it ended up unused and the path's previous content. Everything
552
563
  * after the first statement is conditioned on the content still existing, so
553
- * a swept write changes nothing.
564
+ * a swept write changes nothing. With `exclusive` an existing path is left
565
+ * as it is, and `pathIndex` names the statement whose change count says so.
554
566
  */
555
- #finishWrite(name, fid, size, digest, compression, previous) {
567
+ #finishWrite(name, fid, size, digest, compression, previous, exclusive) {
556
568
  const now = Date.now();
557
569
  const exists = `SELECT 1 FROM ${this.#files} WHERE fid = ?`;
558
570
  const statements = [
@@ -582,7 +594,7 @@ var SqliteFilesApi = class {
582
594
  ORDER BY f.fid LIMIT 1
583
595
  ), ?), ?
584
596
  WHERE EXISTS (${exists})
585
- ON CONFLICT(path) DO UPDATE SET fid = excluded.fid, mtime = excluded.mtime`,
597
+ ON CONFLICT(path) ${exclusive ? "DO NOTHING" : "DO UPDATE SET fid = excluded.fid, mtime = excluded.mtime"}`,
586
598
  params: [
587
599
  name,
588
600
  digest,
@@ -597,8 +609,12 @@ var SqliteFilesApi = class {
597
609
  },
598
610
  ...this.#collect(fid)
599
611
  ];
612
+ const pathIndex = 1 + ancestorsAndSelf$1(parentOf(name)).length;
600
613
  if (previous !== null && previous !== fid) statements.push(...this.#collect(previous));
601
- return statements;
614
+ return {
615
+ statements,
616
+ pathIndex
617
+ };
602
618
  }
603
619
  async mkdir(path) {
604
620
  const now = Date.now();
@@ -617,7 +633,7 @@ var SqliteFilesApi = class {
617
633
  const base = normalizePath(path);
618
634
  if ((await this.stats(base))?.kind !== "directory") return;
619
635
  const range = descendants$1(base);
620
- const prefixLength = base === "/" ? 1 : base.length + 1;
636
+ const prefixLength = base === "/" ? 1 : [...base].length + 1;
621
637
  const directOnly = options.recursive ? "" : "AND instr(substr(p.path, ?), '/') = 0";
622
638
  let cursor = options.after ?? "";
623
639
  for (;;) {
@@ -679,6 +695,12 @@ var SqliteFilesApi = class {
679
695
  async copy(source, target) {
680
696
  return this.#transfer(source, target, "copy");
681
697
  }
698
+ /**
699
+ * One transaction (see the private `#transfer`): the target's subtree is removed, its
700
+ * contents collected when no other path shares them, and the source's path rows are
701
+ * renamed by prefix, keeping their content ids, so no content is copied. Atomic for
702
+ * readers on every connection; a failure leaves source and target unchanged.
703
+ */
682
704
  async move(source, target) {
683
705
  return this.#transfer(source, target, "move");
684
706
  }
@@ -691,7 +713,7 @@ var SqliteFilesApi = class {
691
713
  const from = normalizePath(source);
692
714
  const to = normalizePath(target);
693
715
  if (from === to) return this.exists(from);
694
- if (isInside(to, from) || isInside(from, to)) throw new Error(`sqlite-files: cannot ${mode} ${from} to ${to}: one contains the other`);
716
+ if (isInside$1(to, from) || isInside$1(from, to)) throw new Error(`sqlite-files: cannot ${mode} ${from} to ${to}: one contains the other`);
695
717
  const range = selfAndDescendants$1(from, "p");
696
718
  const inS = selfAndDescendants$1(from, "s");
697
719
  const guard = {
@@ -699,6 +721,7 @@ var SqliteFilesApi = class {
699
721
  params: inS.params
700
722
  };
701
723
  const now = Date.now();
724
+ const rest = [...from].length + 1;
702
725
  const statements = [
703
726
  ...this.#removeTree(to, guard),
704
727
  ...ancestorsAndSelf$1(parentOf(to)).map((dir) => ({
@@ -715,7 +738,7 @@ var SqliteFilesApi = class {
715
738
  SELECT ? || substr(p.path, ?), p.fid, ? FROM ${this.#paths} p WHERE ${range.where}`,
716
739
  params: [
717
740
  to,
718
- from.length + 1,
741
+ rest,
719
742
  now,
720
743
  ...range.params
721
744
  ]
@@ -724,7 +747,7 @@ var SqliteFilesApi = class {
724
747
  WHERE ${range.where}`,
725
748
  params: [
726
749
  to,
727
- from.length + 1,
750
+ rest,
728
751
  now,
729
752
  ...range.params
730
753
  ]
@@ -847,7 +870,7 @@ function selfAndDescendants$1(path, alias = "p") {
847
870
  params: [path, ...range.params]
848
871
  };
849
872
  }
850
- function isInside(path, ancestor) {
873
+ function isInside$1(path, ancestor) {
851
874
  return ancestor === "/" ? path !== "/" : path.startsWith(`${ancestor}/`);
852
875
  }
853
876
  function parentOf(path) {
@@ -859,8 +882,7 @@ function ancestorsAndSelf$1(path) {
859
882
  const parts = path.split("/").filter(Boolean);
860
883
  return parts.map((_, i) => `/${parts.slice(0, i + 1).join("/")}`);
861
884
  }
862
- //#endregion
863
- //#region src/codec.ts
885
+ // src/codec.ts
864
886
  const MIN_SIZE = 64;
865
887
  /** Compression Streams — browsers, Node, Workers. No dependency. */
866
888
  function webCodec(opts = {}) {
@@ -910,8 +932,7 @@ async function pipe(bytes, transform) {
910
932
  const stream = new Blob([bytes]).stream().pipeThrough(transform);
911
933
  return new Uint8Array(await new Response(stream).arrayBuffer());
912
934
  }
913
- //#endregion
914
- //#region src/sqlar-files-api.ts
935
+ // src/sqlar-files-api.ts
915
936
  /**
916
937
  * The table, verbatim from the SQLite Archive specification. Five columns,
917
938
  * `name` as the primary key and nothing more: any extra column, index or table
@@ -964,12 +985,42 @@ var SqlarFilesApi = class {
964
985
  const name = toName(path);
965
986
  const bytes = await concat(content);
966
987
  await this.mkdir(parentName(name));
967
- let data = bytes;
988
+ await this.#put(name, FILE_MODE, bytes.byteLength, await this.#stored(bytes));
989
+ }
990
+ /**
991
+ * One `INSERT ... ON CONFLICT DO NOTHING RETURNING`, which SQLite runs atomically:
992
+ * exclusive against every connection to the database file, in any process. The
993
+ * statement inserts nothing when a row has the name — a file, a directory or a
994
+ * symlink, dangling or not — or when names below it make it an implicit directory.
995
+ * The content is collected (and compressed) before the statement runs, so nothing is
996
+ * visible until it is complete and a failing source writes nothing.
997
+ */
998
+ async createExclusive(path, content) {
999
+ const name = toName(path);
1000
+ if (name === "") return false;
1001
+ const bytes = await concat(content);
1002
+ const data = await this.#stored(bytes);
1003
+ await this.mkdir(parentName(name));
1004
+ const below = descendants(name);
1005
+ return (await this.#db.query(`INSERT INTO sqlar (name, mode, mtime, sz, data)
1006
+ SELECT ?, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM sqlar WHERE ${below.where})
1007
+ ON CONFLICT(name) DO NOTHING
1008
+ RETURNING name`, [
1009
+ name,
1010
+ FILE_MODE,
1011
+ Math.floor(Date.now() / 1e3),
1012
+ bytes.byteLength,
1013
+ data,
1014
+ ...below.params
1015
+ ])).length > 0;
1016
+ }
1017
+ /** The `data` stored for `bytes`: deflated when that is strictly shorter. */
1018
+ async #stored(bytes) {
968
1019
  if (bytes.byteLength >= this.#codec.minSize) {
969
1020
  const deflated = await this.#codec.deflate(bytes);
970
- if (deflated.byteLength < bytes.byteLength) data = deflated;
1021
+ if (deflated.byteLength < bytes.byteLength) return deflated;
971
1022
  }
972
- await this.#put(name, FILE_MODE, bytes.byteLength, data);
1023
+ return bytes;
973
1024
  }
974
1025
  /** `sz = 0` with `data IS NULL` is the format's only marker for a directory. */
975
1026
  async mkdir(path) {
@@ -1068,10 +1119,49 @@ var SqlarFilesApi = class {
1068
1119
  }
1069
1120
  return true;
1070
1121
  }
1122
+ /**
1123
+ * One transaction, through `inTransaction` (so the `Db` must carry db-sqlite's
1124
+ * transaction hook): delete the target's subtree, create the target's parents, then
1125
+ * rename the source row and every row under it by rewriting the name prefix. Content
1126
+ * is never copied, only re-keyed, and mode, mtime and data stay as they were, as with
1127
+ * `rename(2)`. Readers on any connection see the old state or the new one; a failure
1128
+ * rolls everything back, leaving source and target unchanged.
1129
+ *
1130
+ * Like `SqliteFilesApi`, an existing target is replaced with its whole subtree,
1131
+ * not merged into, and a move into the source's own subtree or onto one of its
1132
+ * ancestors throws. A target below a file or a symlink (an ancestor of the target is
1133
+ * one) returns false, changing nothing. A symlink is moved as a link. A move onto
1134
+ * itself is a no-op.
1135
+ */
1071
1136
  async move(source, target) {
1072
- if (!await this.copy(source, target)) return false;
1073
- await this.remove(source);
1074
- return true;
1137
+ const from = toName(source);
1138
+ const to = toName(target);
1139
+ if (from === to) return this.exists(source);
1140
+ if (isInside(to, from) || isInside(from, to)) throw new Error(`sqlar: cannot move /${from} to /${to}: one contains the other`);
1141
+ const src = selfAndDescendants(from);
1142
+ const dst = selfAndDescendants(to);
1143
+ const now = Math.floor(Date.now() / 1e3);
1144
+ return inTransaction(this.#db, async (tx) => {
1145
+ if ((await tx.query(`SELECT 1 FROM sqlar WHERE ${src.where} LIMIT 1`, src.params)).length === 0) return false;
1146
+ const ancestors = ancestorsAndSelf(parentName(to));
1147
+ if (ancestors.length > 0) {
1148
+ if ((await tx.query(`SELECT 1 FROM sqlar WHERE name IN (${ancestors.map(() => "?").join(", ")})
1149
+ AND (sz = -1 OR NOT (sz = 0 AND typeof(data) = 'null')) LIMIT 1`, ancestors)).length > 0) return false;
1150
+ }
1151
+ await tx.query(`DELETE FROM sqlar WHERE ${dst.where}`, dst.params);
1152
+ for (const dir of ancestors) await tx.query(`INSERT INTO sqlar (name, mode, mtime, sz, data) VALUES (?, ?, ?, 0, NULL)
1153
+ ON CONFLICT(name) DO NOTHING`, [
1154
+ dir,
1155
+ DIR_MODE,
1156
+ now
1157
+ ]);
1158
+ await tx.query(`UPDATE sqlar SET name = ? || substr(name, ?) WHERE ${src.where}`, [
1159
+ to,
1160
+ [...from].length + 1,
1161
+ ...src.params
1162
+ ]);
1163
+ return true;
1164
+ });
1075
1165
  }
1076
1166
  /** `length(data) = sz` is plaintext, `< sz` is compressed, `> sz` is corrupt. */
1077
1167
  async #content(name, sz, data) {
@@ -1171,6 +1261,10 @@ function selfAndDescendants(name) {
1171
1261
  function toName(path) {
1172
1262
  return normalizePath(path).slice(1);
1173
1263
  }
1264
+ /** Whether `name` lies strictly under `ancestor`; everything lies under the root "". */
1265
+ function isInside(name, ancestor) {
1266
+ return ancestor === "" ? name !== "" : name.startsWith(`${ancestor}/`);
1267
+ }
1174
1268
  function joinName(base, rest) {
1175
1269
  if (base === "") return rest;
1176
1270
  return rest === "" ? base : `${base}/${rest}`;
@@ -1222,7 +1316,6 @@ async function concat(content) {
1222
1316
  }
1223
1317
  return out;
1224
1318
  }
1225
- //#endregion
1226
1319
  exports.SqlarFilesApi = SqlarFilesApi;
1227
1320
  exports.SqliteFilesApi = SqliteFilesApi;
1228
1321
  exports.defaultCodec = defaultCodec;
package/dist/esm/index.js CHANGED
@@ -1,4 +1,4 @@
1
- //#region ../../node_modules/.pnpm/@statewalker+db-sqlite@0.1.0/node_modules/@statewalker/db-sqlite/dist/index.mjs
1
+ // ../../node_modules/.pnpm/@statewalker+db-sqlite@0.1.0/node_modules/@statewalker/db-sqlite/dist/index.mjs
2
2
  /** Key under which a SQLite Db carries its transaction hook. Shared across bundles via Symbol.for. */
3
3
  const SQLITE_TRANSACTION = Symbol.for("@statewalker/db-sqlite:transaction");
4
4
  const NO_HOOK = "inTransaction: this Db has no SQLite transaction hook. It is not a @statewalker/db-sqlite* Db, or it wraps one (a wrapper object loses the hook - pass the driver's Db itself), or it is the handle of a running transaction (nesting is not supported).";
@@ -12,8 +12,7 @@ function inTransaction(db, cb) {
12
12
  if (typeof hook !== "function") return Promise.reject(/* @__PURE__ */ new Error(NO_HOOK));
13
13
  return hook(cb);
14
14
  }
15
- //#endregion
16
- //#region ../webrun-files/dist/esm/index.js
15
+ // ../webrun-files/dist/esm/index.js
17
16
  /**
18
17
  * The order every `FilesApi.list()` yields: paths compared by Unicode code
19
18
  * point, which is the same as comparing their UTF-8 bytes — the order of
@@ -50,16 +49,14 @@ function normalizePath(filePath) {
50
49
  if (segments.length === 0) return "/";
51
50
  return `/${segments.join("/")}`;
52
51
  }
53
- //#endregion
54
- //#region src/bytes.ts
52
+ // src/bytes.ts
55
53
  /** BLOBs arrive as Uint8Array from every @statewalker/db-sqlite driver; SQLAR may hold TEXT. */
56
54
  function toBytes(value) {
57
55
  if (value instanceof Uint8Array) return value;
58
56
  if (typeof value === "string") return new TextEncoder().encode(value);
59
57
  return /* @__PURE__ */ new Uint8Array(0);
60
58
  }
61
- //#endregion
62
- //#region src/run-statements.ts
59
+ // src/run-statements.ts
63
60
  /**
64
61
  * Runs the statements in order on a transaction's Db and returns, per
65
62
  * statement, the rows it changed — zero exactly when it changed nothing. The
@@ -76,8 +73,7 @@ async function runStatements(tx, statements) {
76
73
  }
77
74
  return counts;
78
75
  }
79
- //#endregion
80
- //#region src/blocks/byte-reader.ts
76
+ // src/blocks/byte-reader.ts
81
77
  /**
82
78
  * Pull bytes from a chunk source in bounded pieces.
83
79
  *
@@ -133,8 +129,7 @@ async function collectBlock(chunks) {
133
129
  }
134
130
  return out;
135
131
  }
136
- //#endregion
137
- //#region src/blocks/sha256.ts
132
+ // src/blocks/sha256.ts
138
133
  /**
139
134
  * Incremental SHA-256 (FIPS 180-4). WebCrypto only hashes a whole buffer, and
140
135
  * content here arrives as a stream it must never be collected from.
@@ -293,8 +288,7 @@ var Sha256 = class {
293
288
  h[7] += k;
294
289
  }
295
290
  };
296
- //#endregion
297
- //#region src/blocks/stream-codec.ts
291
+ // src/blocks/stream-codec.ts
298
292
  /**
299
293
  * zlib-wrapped Deflate over Compression Streams — browsers, Node, Deno, Bun,
300
294
  * Workers. The source is wrapped in a pull-only ReadableStream, so the
@@ -380,8 +374,7 @@ async function* iterate(stream) {
380
374
  reader.releaseLock();
381
375
  }
382
376
  }
383
- //#endregion
384
- //#region src/blocks/sqlite-files-api.ts
377
+ // src/blocks/sqlite-files-api.ts
385
378
  const MiB = 1048576;
386
379
  /** Rows on Durable Objects are capped at 2 MB; leave room for deflate's worst case. */
387
380
  const MAX_BLOCK_SIZE_LIMIT = 1.5 * MiB;
@@ -484,7 +477,23 @@ var SqliteFilesApi = class {
484
477
  * removes the pending content; a crash leaves it for {@link sweep}.
485
478
  */
486
479
  async write(path, content) {
480
+ await this.#writeContent(normalizePath(path), content, false);
481
+ }
482
+ /**
483
+ * Streams the content exactly as {@link write} does, into a content no path shows, then
484
+ * points the path at it in one transaction with `INSERT ... ON CONFLICT DO NOTHING`:
485
+ * exclusive against every connection to the database, since SQLite serializes write
486
+ * transactions. Nothing is visible until the content is complete. A path that already
487
+ * exists is turned away before the content is read; one created meanwhile makes the
488
+ * final insert change nothing, and the content is collected in that same transaction.
489
+ */
490
+ async createExclusive(path, content) {
487
491
  const name = normalizePath(path);
492
+ if (name === "/" || await this.#entry(name)) return false;
493
+ return this.#writeContent(name, content, true);
494
+ }
495
+ /** {@link write}, or with `exclusive` {@link createExclusive}; whether the path was set. */
496
+ async #writeContent(name, content, exclusive) {
488
497
  const compression = this.#codec?.name ?? "none";
489
498
  const fid = await inTransaction(this.#db, async (tx) => (await tx.query(`INSERT INTO ${this.#files}(size, compression, block_size, hash, updated)
490
499
  VALUES (NULL, ?, ?, NULL, ?) RETURNING fid`, [
@@ -528,9 +537,11 @@ var SqliteFilesApi = class {
528
537
  if (alive === 0) throw sweptDuringWrite(name);
529
538
  size += raw;
530
539
  }
531
- const previous = await this.#entry(name);
532
- const [finished] = await this.#transaction(this.#finishWrite(name, fid, size, hash.digestHex(), compression, previous?.fid ?? null));
533
- if (finished === 0) throw sweptDuringWrite(name);
540
+ const previous = exclusive ? void 0 : await this.#entry(name);
541
+ const statements = this.#finishWrite(name, fid, size, hash.digestHex(), compression, previous?.fid ?? null, exclusive);
542
+ const counts = await this.#transaction(statements.statements);
543
+ if (counts[0] === 0) throw sweptDuringWrite(name);
544
+ return counts[statements.pathIndex] > 0;
534
545
  } catch (error) {
535
546
  try {
536
547
  await reader.close();
@@ -549,9 +560,10 @@ var SqliteFilesApi = class {
549
560
  * an identical content if one exists or else at this one, then collect this
550
561
  * content if it ended up unused and the path's previous content. Everything
551
562
  * after the first statement is conditioned on the content still existing, so
552
- * a swept write changes nothing.
563
+ * a swept write changes nothing. With `exclusive` an existing path is left
564
+ * as it is, and `pathIndex` names the statement whose change count says so.
553
565
  */
554
- #finishWrite(name, fid, size, digest, compression, previous) {
566
+ #finishWrite(name, fid, size, digest, compression, previous, exclusive) {
555
567
  const now = Date.now();
556
568
  const exists = `SELECT 1 FROM ${this.#files} WHERE fid = ?`;
557
569
  const statements = [
@@ -581,7 +593,7 @@ var SqliteFilesApi = class {
581
593
  ORDER BY f.fid LIMIT 1
582
594
  ), ?), ?
583
595
  WHERE EXISTS (${exists})
584
- ON CONFLICT(path) DO UPDATE SET fid = excluded.fid, mtime = excluded.mtime`,
596
+ ON CONFLICT(path) ${exclusive ? "DO NOTHING" : "DO UPDATE SET fid = excluded.fid, mtime = excluded.mtime"}`,
585
597
  params: [
586
598
  name,
587
599
  digest,
@@ -596,8 +608,12 @@ var SqliteFilesApi = class {
596
608
  },
597
609
  ...this.#collect(fid)
598
610
  ];
611
+ const pathIndex = 1 + ancestorsAndSelf$1(parentOf(name)).length;
599
612
  if (previous !== null && previous !== fid) statements.push(...this.#collect(previous));
600
- return statements;
613
+ return {
614
+ statements,
615
+ pathIndex
616
+ };
601
617
  }
602
618
  async mkdir(path) {
603
619
  const now = Date.now();
@@ -616,7 +632,7 @@ var SqliteFilesApi = class {
616
632
  const base = normalizePath(path);
617
633
  if ((await this.stats(base))?.kind !== "directory") return;
618
634
  const range = descendants$1(base);
619
- const prefixLength = base === "/" ? 1 : base.length + 1;
635
+ const prefixLength = base === "/" ? 1 : [...base].length + 1;
620
636
  const directOnly = options.recursive ? "" : "AND instr(substr(p.path, ?), '/') = 0";
621
637
  let cursor = options.after ?? "";
622
638
  for (;;) {
@@ -678,6 +694,12 @@ var SqliteFilesApi = class {
678
694
  async copy(source, target) {
679
695
  return this.#transfer(source, target, "copy");
680
696
  }
697
+ /**
698
+ * One transaction (see the private `#transfer`): the target's subtree is removed, its
699
+ * contents collected when no other path shares them, and the source's path rows are
700
+ * renamed by prefix, keeping their content ids, so no content is copied. Atomic for
701
+ * readers on every connection; a failure leaves source and target unchanged.
702
+ */
681
703
  async move(source, target) {
682
704
  return this.#transfer(source, target, "move");
683
705
  }
@@ -690,7 +712,7 @@ var SqliteFilesApi = class {
690
712
  const from = normalizePath(source);
691
713
  const to = normalizePath(target);
692
714
  if (from === to) return this.exists(from);
693
- if (isInside(to, from) || isInside(from, to)) throw new Error(`sqlite-files: cannot ${mode} ${from} to ${to}: one contains the other`);
715
+ if (isInside$1(to, from) || isInside$1(from, to)) throw new Error(`sqlite-files: cannot ${mode} ${from} to ${to}: one contains the other`);
694
716
  const range = selfAndDescendants$1(from, "p");
695
717
  const inS = selfAndDescendants$1(from, "s");
696
718
  const guard = {
@@ -698,6 +720,7 @@ var SqliteFilesApi = class {
698
720
  params: inS.params
699
721
  };
700
722
  const now = Date.now();
723
+ const rest = [...from].length + 1;
701
724
  const statements = [
702
725
  ...this.#removeTree(to, guard),
703
726
  ...ancestorsAndSelf$1(parentOf(to)).map((dir) => ({
@@ -714,7 +737,7 @@ var SqliteFilesApi = class {
714
737
  SELECT ? || substr(p.path, ?), p.fid, ? FROM ${this.#paths} p WHERE ${range.where}`,
715
738
  params: [
716
739
  to,
717
- from.length + 1,
740
+ rest,
718
741
  now,
719
742
  ...range.params
720
743
  ]
@@ -723,7 +746,7 @@ var SqliteFilesApi = class {
723
746
  WHERE ${range.where}`,
724
747
  params: [
725
748
  to,
726
- from.length + 1,
749
+ rest,
727
750
  now,
728
751
  ...range.params
729
752
  ]
@@ -846,7 +869,7 @@ function selfAndDescendants$1(path, alias = "p") {
846
869
  params: [path, ...range.params]
847
870
  };
848
871
  }
849
- function isInside(path, ancestor) {
872
+ function isInside$1(path, ancestor) {
850
873
  return ancestor === "/" ? path !== "/" : path.startsWith(`${ancestor}/`);
851
874
  }
852
875
  function parentOf(path) {
@@ -858,8 +881,7 @@ function ancestorsAndSelf$1(path) {
858
881
  const parts = path.split("/").filter(Boolean);
859
882
  return parts.map((_, i) => `/${parts.slice(0, i + 1).join("/")}`);
860
883
  }
861
- //#endregion
862
- //#region src/codec.ts
884
+ // src/codec.ts
863
885
  const MIN_SIZE = 64;
864
886
  /** Compression Streams — browsers, Node, Workers. No dependency. */
865
887
  function webCodec(opts = {}) {
@@ -909,8 +931,7 @@ async function pipe(bytes, transform) {
909
931
  const stream = new Blob([bytes]).stream().pipeThrough(transform);
910
932
  return new Uint8Array(await new Response(stream).arrayBuffer());
911
933
  }
912
- //#endregion
913
- //#region src/sqlar-files-api.ts
934
+ // src/sqlar-files-api.ts
914
935
  /**
915
936
  * The table, verbatim from the SQLite Archive specification. Five columns,
916
937
  * `name` as the primary key and nothing more: any extra column, index or table
@@ -963,12 +984,42 @@ var SqlarFilesApi = class {
963
984
  const name = toName(path);
964
985
  const bytes = await concat(content);
965
986
  await this.mkdir(parentName(name));
966
- let data = bytes;
987
+ await this.#put(name, FILE_MODE, bytes.byteLength, await this.#stored(bytes));
988
+ }
989
+ /**
990
+ * One `INSERT ... ON CONFLICT DO NOTHING RETURNING`, which SQLite runs atomically:
991
+ * exclusive against every connection to the database file, in any process. The
992
+ * statement inserts nothing when a row has the name — a file, a directory or a
993
+ * symlink, dangling or not — or when names below it make it an implicit directory.
994
+ * The content is collected (and compressed) before the statement runs, so nothing is
995
+ * visible until it is complete and a failing source writes nothing.
996
+ */
997
+ async createExclusive(path, content) {
998
+ const name = toName(path);
999
+ if (name === "") return false;
1000
+ const bytes = await concat(content);
1001
+ const data = await this.#stored(bytes);
1002
+ await this.mkdir(parentName(name));
1003
+ const below = descendants(name);
1004
+ return (await this.#db.query(`INSERT INTO sqlar (name, mode, mtime, sz, data)
1005
+ SELECT ?, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM sqlar WHERE ${below.where})
1006
+ ON CONFLICT(name) DO NOTHING
1007
+ RETURNING name`, [
1008
+ name,
1009
+ FILE_MODE,
1010
+ Math.floor(Date.now() / 1e3),
1011
+ bytes.byteLength,
1012
+ data,
1013
+ ...below.params
1014
+ ])).length > 0;
1015
+ }
1016
+ /** The `data` stored for `bytes`: deflated when that is strictly shorter. */
1017
+ async #stored(bytes) {
967
1018
  if (bytes.byteLength >= this.#codec.minSize) {
968
1019
  const deflated = await this.#codec.deflate(bytes);
969
- if (deflated.byteLength < bytes.byteLength) data = deflated;
1020
+ if (deflated.byteLength < bytes.byteLength) return deflated;
970
1021
  }
971
- await this.#put(name, FILE_MODE, bytes.byteLength, data);
1022
+ return bytes;
972
1023
  }
973
1024
  /** `sz = 0` with `data IS NULL` is the format's only marker for a directory. */
974
1025
  async mkdir(path) {
@@ -1067,10 +1118,49 @@ var SqlarFilesApi = class {
1067
1118
  }
1068
1119
  return true;
1069
1120
  }
1121
+ /**
1122
+ * One transaction, through `inTransaction` (so the `Db` must carry db-sqlite's
1123
+ * transaction hook): delete the target's subtree, create the target's parents, then
1124
+ * rename the source row and every row under it by rewriting the name prefix. Content
1125
+ * is never copied, only re-keyed, and mode, mtime and data stay as they were, as with
1126
+ * `rename(2)`. Readers on any connection see the old state or the new one; a failure
1127
+ * rolls everything back, leaving source and target unchanged.
1128
+ *
1129
+ * Like `SqliteFilesApi`, an existing target is replaced with its whole subtree,
1130
+ * not merged into, and a move into the source's own subtree or onto one of its
1131
+ * ancestors throws. A target below a file or a symlink (an ancestor of the target is
1132
+ * one) returns false, changing nothing. A symlink is moved as a link. A move onto
1133
+ * itself is a no-op.
1134
+ */
1070
1135
  async move(source, target) {
1071
- if (!await this.copy(source, target)) return false;
1072
- await this.remove(source);
1073
- return true;
1136
+ const from = toName(source);
1137
+ const to = toName(target);
1138
+ if (from === to) return this.exists(source);
1139
+ if (isInside(to, from) || isInside(from, to)) throw new Error(`sqlar: cannot move /${from} to /${to}: one contains the other`);
1140
+ const src = selfAndDescendants(from);
1141
+ const dst = selfAndDescendants(to);
1142
+ const now = Math.floor(Date.now() / 1e3);
1143
+ return inTransaction(this.#db, async (tx) => {
1144
+ if ((await tx.query(`SELECT 1 FROM sqlar WHERE ${src.where} LIMIT 1`, src.params)).length === 0) return false;
1145
+ const ancestors = ancestorsAndSelf(parentName(to));
1146
+ if (ancestors.length > 0) {
1147
+ if ((await tx.query(`SELECT 1 FROM sqlar WHERE name IN (${ancestors.map(() => "?").join(", ")})
1148
+ AND (sz = -1 OR NOT (sz = 0 AND typeof(data) = 'null')) LIMIT 1`, ancestors)).length > 0) return false;
1149
+ }
1150
+ await tx.query(`DELETE FROM sqlar WHERE ${dst.where}`, dst.params);
1151
+ for (const dir of ancestors) await tx.query(`INSERT INTO sqlar (name, mode, mtime, sz, data) VALUES (?, ?, ?, 0, NULL)
1152
+ ON CONFLICT(name) DO NOTHING`, [
1153
+ dir,
1154
+ DIR_MODE,
1155
+ now
1156
+ ]);
1157
+ await tx.query(`UPDATE sqlar SET name = ? || substr(name, ?) WHERE ${src.where}`, [
1158
+ to,
1159
+ [...from].length + 1,
1160
+ ...src.params
1161
+ ]);
1162
+ return true;
1163
+ });
1074
1164
  }
1075
1165
  /** `length(data) = sz` is plaintext, `< sz` is compressed, `> sz` is corrupt. */
1076
1166
  async #content(name, sz, data) {
@@ -1170,6 +1260,10 @@ function selfAndDescendants(name) {
1170
1260
  function toName(path) {
1171
1261
  return normalizePath(path).slice(1);
1172
1262
  }
1263
+ /** Whether `name` lies strictly under `ancestor`; everything lies under the root "". */
1264
+ function isInside(name, ancestor) {
1265
+ return ancestor === "" ? name !== "" : name.startsWith(`${ancestor}/`);
1266
+ }
1173
1267
  function joinName(base, rest) {
1174
1268
  if (base === "") return rest;
1175
1269
  return rest === "" ? base : `${base}/${rest}`;
@@ -1221,5 +1315,4 @@ async function concat(content) {
1221
1315
  }
1222
1316
  return out;
1223
1317
  }
1224
- //#endregion
1225
1318
  export { SqlarFilesApi, SqliteFilesApi, defaultCodec, pakoCodec, pakoDeflateCodec, rawCodec, webCodec, webDeflateCodec };
@@ -20,6 +20,15 @@ export declare class SqlarFilesApi implements FilesApi {
20
20
  /** A missing path or a directory yields nothing; a file yields one chunk. */
21
21
  read(path: string, options?: ReadOptions): AsyncIterable<Uint8Array>;
22
22
  write(path: string, content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>): Promise<void>;
23
+ /**
24
+ * One `INSERT ... ON CONFLICT DO NOTHING RETURNING`, which SQLite runs atomically:
25
+ * exclusive against every connection to the database file, in any process. The
26
+ * statement inserts nothing when a row has the name — a file, a directory or a
27
+ * symlink, dangling or not — or when names below it make it an implicit directory.
28
+ * The content is collected (and compressed) before the statement runs, so nothing is
29
+ * visible until it is complete and a failing source writes nothing.
30
+ */
31
+ createExclusive(path: string, content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>): Promise<boolean>;
23
32
  /** `sz = 0` with `data IS NULL` is the format's only marker for a directory. */
24
33
  mkdir(path: string): Promise<void>;
25
34
  /** Writes a symlink row: `sz = -1`, the target verbatim as TEXT. */
@@ -47,6 +56,20 @@ export declare class SqlarFilesApi implements FilesApi {
47
56
  * row is never inflated and deflated again. Only mtime is fresh.
48
57
  */
49
58
  copy(source: string, target: string): Promise<boolean>;
59
+ /**
60
+ * One transaction, through `inTransaction` (so the `Db` must carry db-sqlite's
61
+ * transaction hook): delete the target's subtree, create the target's parents, then
62
+ * rename the source row and every row under it by rewriting the name prefix. Content
63
+ * is never copied, only re-keyed, and mode, mtime and data stay as they were, as with
64
+ * `rename(2)`. Readers on any connection see the old state or the new one; a failure
65
+ * rolls everything back, leaving source and target unchanged.
66
+ *
67
+ * Like `SqliteFilesApi`, an existing target is replaced with its whole subtree,
68
+ * not merged into, and a move into the source's own subtree or onto one of its
69
+ * ancestors throws. A target below a file or a symlink (an ancestor of the target is
70
+ * one) returns false, changing nothing. A symlink is moved as a link. A move onto
71
+ * itself is a no-op.
72
+ */
50
73
  move(source: string, target: string): Promise<boolean>;
51
74
  }
52
75
  //# sourceMappingURL=sqlar-files-api.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"sqlar-files-api.d.ts","sourceRoot":"","sources":["../src/sqlar-files-api.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,qBAAqB,CAAC;AAC9C,OAAO,KAAK,EACV,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,WAAW,EACX,WAAW,EACZ,MAAM,2BAA2B,CAAC;AAGnC,OAAO,EAAE,KAAK,KAAK,EAAgB,MAAM,YAAY,CAAC;AAuBtD,MAAM,WAAW,oBAAoB;IACnC,wCAAwC;IACxC,KAAK,CAAC,EAAE,KAAK,CAAC;CACf;AAED;;;;;;GAMG;AACH,qBAAa,aAAc,YAAW,QAAQ;;IAI5C,YAAY,EAAE,EAAE,EAAE,EAAE,IAAI,GAAE,oBAAyB,EAGlD;IAED,0EAA0E;IACpE,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAE1B;IAED,6EAA6E;IACtE,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,WAAgB,GAAG,aAAa,CAAC,UAAU,CAAC,CAc9E;IAEK,KAAK,CACT,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,aAAa,CAAC,UAAU,CAAC,GACxD,OAAO,CAAC,IAAI,CAAC,CAYf;IAED,gFAAgF;IAC1E,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAKvC;IAED,oEAAoE;IAC9D,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAIzD;IAED,wEAAwE;IAClE,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAGxD;IAEK,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC,CAExD;IAEK,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAE3C;IAED;;;;;OAKG;IACH;;;;;OAKG;IACI,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,WAAgB,GAAG,aAAa,CAAC,QAAQ,CAAC,CAO5E;IAwCK,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAU3C;IAED;;;OAGG;IACG,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAsB3D;IAEK,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAI3D;CAoEF"}
1
+ {"version":3,"file":"sqlar-files-api.d.ts","sourceRoot":"","sources":["../src/sqlar-files-api.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,qBAAqB,CAAC;AAE9C,OAAO,KAAK,EACV,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,WAAW,EACX,WAAW,EACZ,MAAM,2BAA2B,CAAC;AAGnC,OAAO,EAAE,KAAK,KAAK,EAAgB,MAAM,YAAY,CAAC;AAuBtD,MAAM,WAAW,oBAAoB;IACnC,wCAAwC;IACxC,KAAK,CAAC,EAAE,KAAK,CAAC;CACf;AAED;;;;;;GAMG;AACH,qBAAa,aAAc,YAAW,QAAQ;;IAI5C,YAAY,EAAE,EAAE,EAAE,EAAE,IAAI,GAAE,oBAAyB,EAGlD;IAED,0EAA0E;IACpE,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAE1B;IAED,6EAA6E;IACtE,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,WAAgB,GAAG,aAAa,CAAC,UAAU,CAAC,CAc9E;IAEK,KAAK,CACT,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,aAAa,CAAC,UAAU,CAAC,GACxD,OAAO,CAAC,IAAI,CAAC,CAKf;IAED;;;;;;;OAOG;IACG,eAAe,CACnB,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,aAAa,CAAC,UAAU,CAAC,GACxD,OAAO,CAAC,OAAO,CAAC,CAelB;IAYD,gFAAgF;IAC1E,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAKvC;IAED,oEAAoE;IAC9D,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAIzD;IAED,wEAAwE;IAClE,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAGxD;IAEK,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC,CAExD;IAEK,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAE3C;IAED;;;;;OAKG;IACH;;;;;OAKG;IACI,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,WAAgB,GAAG,aAAa,CAAC,QAAQ,CAAC,CAO5E;IAwCK,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAU3C;IAED;;;OAGG;IACG,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAsB3D;IAED;;;;;;;;;;;;;OAaG;IACG,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAuC3D;CAoEF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statewalker/webrun-files-sqlite",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "SQLite-backed FilesApi implementations",
@@ -36,7 +36,7 @@
36
36
  "dependencies": {
37
37
  "@statewalker/db-api": "^0.1.3",
38
38
  "@statewalker/db-sqlite": "^0.1.0",
39
- "@statewalker/webrun-files": "^0.10.4"
39
+ "@statewalker/webrun-files": "^0.11.0"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@statewalker/db-sqlite-do": "^0.1.0",
@@ -179,7 +179,32 @@ export class SqliteFilesApi implements FilesApi {
179
179
  path: string,
180
180
  content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>,
181
181
  ): Promise<void> {
182
+ await this.#writeContent(normalizePath(path), content, false);
183
+ }
184
+
185
+ /**
186
+ * Streams the content exactly as {@link write} does, into a content no path shows, then
187
+ * points the path at it in one transaction with `INSERT ... ON CONFLICT DO NOTHING`:
188
+ * exclusive against every connection to the database, since SQLite serializes write
189
+ * transactions. Nothing is visible until the content is complete. A path that already
190
+ * exists is turned away before the content is read; one created meanwhile makes the
191
+ * final insert change nothing, and the content is collected in that same transaction.
192
+ */
193
+ async createExclusive(
194
+ path: string,
195
+ content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>,
196
+ ): Promise<boolean> {
182
197
  const name = normalizePath(path);
198
+ if (name === "/" || (await this.#entry(name))) return false;
199
+ return this.#writeContent(name, content, true);
200
+ }
201
+
202
+ /** {@link write}, or with `exclusive` {@link createExclusive}; whether the path was set. */
203
+ async #writeContent(
204
+ name: string,
205
+ content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>,
206
+ exclusive: boolean,
207
+ ): Promise<boolean> {
183
208
  const compression = this.#codec?.name ?? "none";
184
209
  // In a transaction too, so a Db without db-sqlite's hook fails before writing anything.
185
210
  const fid = await inTransaction(
@@ -230,11 +255,19 @@ export class SqliteFilesApi implements FilesApi {
230
255
  size += raw;
231
256
  }
232
257
 
233
- const previous = await this.#entry(name);
234
- const [finished] = await this.#transaction(
235
- this.#finishWrite(name, fid, size, hash.digestHex(), compression, previous?.fid ?? null),
258
+ const previous = exclusive ? undefined : await this.#entry(name);
259
+ const statements = this.#finishWrite(
260
+ name,
261
+ fid,
262
+ size,
263
+ hash.digestHex(),
264
+ compression,
265
+ previous?.fid ?? null,
266
+ exclusive,
236
267
  );
237
- if (finished === 0) throw sweptDuringWrite(name);
268
+ const counts = await this.#transaction(statements.statements);
269
+ if (counts[0] === 0) throw sweptDuringWrite(name);
270
+ return counts[statements.pathIndex] > 0;
238
271
  } catch (error) {
239
272
  try {
240
273
  await reader.close();
@@ -256,7 +289,8 @@ export class SqliteFilesApi implements FilesApi {
256
289
  * an identical content if one exists or else at this one, then collect this
257
290
  * content if it ended up unused and the path's previous content. Everything
258
291
  * after the first statement is conditioned on the content still existing, so
259
- * a swept write changes nothing.
292
+ * a swept write changes nothing. With `exclusive` an existing path is left
293
+ * as it is, and `pathIndex` names the statement whose change count says so.
260
294
  */
261
295
  #finishWrite(
262
296
  name: string,
@@ -265,7 +299,8 @@ export class SqliteFilesApi implements FilesApi {
265
299
  digest: string,
266
300
  compression: string,
267
301
  previous: number | null,
268
- ): SqlStatement[] {
302
+ exclusive: boolean,
303
+ ): { statements: SqlStatement[]; pathIndex: number } {
269
304
  const now = Date.now();
270
305
  const exists = `SELECT 1 FROM ${this.#files} WHERE fid = ?`;
271
306
  const statements: SqlStatement[] = [
@@ -286,13 +321,15 @@ export class SqliteFilesApi implements FilesApi {
286
321
  ORDER BY f.fid LIMIT 1
287
322
  ), ?), ?
288
323
  WHERE EXISTS (${exists})
289
- ON CONFLICT(path) DO UPDATE SET fid = excluded.fid, mtime = excluded.mtime`,
324
+ ON CONFLICT(path) ${exclusive ? "DO NOTHING" : "DO UPDATE SET fid = excluded.fid, mtime = excluded.mtime"}`,
290
325
  params: [name, digest, size, compression, this.#blockSize, fid, fid, now, fid],
291
326
  },
292
327
  ...this.#collect(fid),
293
328
  ];
329
+ // After the content update and the parents.
330
+ const pathIndex = 1 + ancestorsAndSelf(parentOf(name)).length;
294
331
  if (previous !== null && previous !== fid) statements.push(...this.#collect(previous));
295
- return statements;
332
+ return { statements, pathIndex };
296
333
  }
297
334
 
298
335
  async mkdir(path: string): Promise<void> {
@@ -323,7 +360,8 @@ export class SqliteFilesApi implements FilesApi {
323
360
  if ((await this.stats(base))?.kind !== "directory") return;
324
361
 
325
362
  const range = descendants(base);
326
- const prefixLength = base === "/" ? 1 : base.length + 1;
363
+ // SQLite's substr() counts characters (code points), not UTF-16 units.
364
+ const prefixLength = base === "/" ? 1 : [...base].length + 1;
327
365
  const directOnly = options.recursive ? "" : "AND instr(substr(p.path, ?), '/') = 0";
328
366
  // The unique index orders paths by UTF-8 bytes, which is `comparePaths` order,
329
367
  // so resuming after a path is the keyset cursor's starting point.
@@ -389,6 +427,12 @@ export class SqliteFilesApi implements FilesApi {
389
427
  return this.#transfer(source, target, "copy");
390
428
  }
391
429
 
430
+ /**
431
+ * One transaction (see the private `#transfer`): the target's subtree is removed, its
432
+ * contents collected when no other path shares them, and the source's path rows are
433
+ * renamed by prefix, keeping their content ids, so no content is copied. Atomic for
434
+ * readers on every connection; a failure leaves source and target unchanged.
435
+ */
392
436
  async move(source: string, target: string): Promise<boolean> {
393
437
  return this.#transfer(source, target, "move");
394
438
  }
@@ -413,6 +457,8 @@ export class SqliteFilesApi implements FilesApi {
413
457
  params: inS.params,
414
458
  };
415
459
  const now = Date.now();
460
+ // SQLite's substr() counts characters (code points), not UTF-16 units.
461
+ const rest = [...from].length + 1;
416
462
  const statements: SqlStatement[] = [
417
463
  ...this.#removeTree(to, guard),
418
464
  ...ancestorsAndSelf(parentOf(to)).map((dir) => ({
@@ -424,12 +470,12 @@ export class SqliteFilesApi implements FilesApi {
424
470
  ? {
425
471
  sql: `INSERT INTO ${this.#paths}(path, fid, mtime)
426
472
  SELECT ? || substr(p.path, ?), p.fid, ? FROM ${this.#paths} p WHERE ${range.where}`,
427
- params: [to, from.length + 1, now, ...range.params],
473
+ params: [to, rest, now, ...range.params],
428
474
  }
429
475
  : {
430
476
  sql: `UPDATE ${this.#paths} AS p SET path = ? || substr(p.path, ?), mtime = ?
431
477
  WHERE ${range.where}`,
432
- params: [to, from.length + 1, now, ...range.params],
478
+ params: [to, rest, now, ...range.params],
433
479
  },
434
480
  ];
435
481
  const counts = await this.#transaction(statements);
@@ -1,4 +1,5 @@
1
1
  import type { Db } from "@statewalker/db-api";
2
+ import { inTransaction } from "@statewalker/db-sqlite";
2
3
  import type {
3
4
  FileInfo,
4
5
  FileStats,
@@ -81,14 +82,45 @@ export class SqlarFilesApi implements FilesApi {
81
82
  const name = toName(path);
82
83
  const bytes = await concat(content);
83
84
  await this.mkdir(parentName(name));
85
+ await this.#put(name, FILE_MODE, bytes.byteLength, await this.#stored(bytes));
86
+ }
84
87
 
85
- let data: Uint8Array = bytes;
88
+ /**
89
+ * One `INSERT ... ON CONFLICT DO NOTHING RETURNING`, which SQLite runs atomically:
90
+ * exclusive against every connection to the database file, in any process. The
91
+ * statement inserts nothing when a row has the name — a file, a directory or a
92
+ * symlink, dangling or not — or when names below it make it an implicit directory.
93
+ * The content is collected (and compressed) before the statement runs, so nothing is
94
+ * visible until it is complete and a failing source writes nothing.
95
+ */
96
+ async createExclusive(
97
+ path: string,
98
+ content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>,
99
+ ): Promise<boolean> {
100
+ const name = toName(path);
101
+ if (name === "") return false; // the root always exists
102
+ const bytes = await concat(content);
103
+ const data = await this.#stored(bytes);
104
+ await this.mkdir(parentName(name));
105
+ const below = descendants(name);
106
+ const rows = await this.#db.query(
107
+ `INSERT INTO sqlar (name, mode, mtime, sz, data)
108
+ SELECT ?, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM sqlar WHERE ${below.where})
109
+ ON CONFLICT(name) DO NOTHING
110
+ RETURNING name`,
111
+ [name, FILE_MODE, Math.floor(Date.now() / 1000), bytes.byteLength, data, ...below.params],
112
+ );
113
+ return rows.length > 0;
114
+ }
115
+
116
+ /** The `data` stored for `bytes`: deflated when that is strictly shorter. */
117
+ async #stored(bytes: Uint8Array): Promise<Uint8Array> {
86
118
  if (bytes.byteLength >= this.#codec.minSize) {
87
119
  const deflated = await this.#codec.deflate(bytes);
88
120
  // Strictly shorter only: equal length is the plaintext marker.
89
- if (deflated.byteLength < bytes.byteLength) data = deflated;
121
+ if (deflated.byteLength < bytes.byteLength) return deflated;
90
122
  }
91
- await this.#put(name, FILE_MODE, bytes.byteLength, data);
123
+ return bytes;
92
124
  }
93
125
 
94
126
  /** `sz = 0` with `data IS NULL` is the format's only marker for a directory. */
@@ -219,10 +251,59 @@ export class SqlarFilesApi implements FilesApi {
219
251
  return true;
220
252
  }
221
253
 
254
+ /**
255
+ * One transaction, through `inTransaction` (so the `Db` must carry db-sqlite's
256
+ * transaction hook): delete the target's subtree, create the target's parents, then
257
+ * rename the source row and every row under it by rewriting the name prefix. Content
258
+ * is never copied, only re-keyed, and mode, mtime and data stay as they were, as with
259
+ * `rename(2)`. Readers on any connection see the old state or the new one; a failure
260
+ * rolls everything back, leaving source and target unchanged.
261
+ *
262
+ * Like `SqliteFilesApi`, an existing target is replaced with its whole subtree,
263
+ * not merged into, and a move into the source's own subtree or onto one of its
264
+ * ancestors throws. A target below a file or a symlink (an ancestor of the target is
265
+ * one) returns false, changing nothing. A symlink is moved as a link. A move onto
266
+ * itself is a no-op.
267
+ */
222
268
  async move(source: string, target: string): Promise<boolean> {
223
- if (!(await this.copy(source, target))) return false;
224
- await this.remove(source);
225
- return true;
269
+ const from = toName(source);
270
+ const to = toName(target);
271
+ if (from === to) return this.exists(source);
272
+ if (isInside(to, from) || isInside(from, to)) {
273
+ throw new Error(`sqlar: cannot move /${from} to /${to}: one contains the other`);
274
+ }
275
+ const src = selfAndDescendants(from);
276
+ const dst = selfAndDescendants(to);
277
+ const now = Math.floor(Date.now() / 1000);
278
+ return inTransaction(this.#db, async (tx) => {
279
+ const found = await tx.query(`SELECT 1 FROM sqlar WHERE ${src.where} LIMIT 1`, src.params);
280
+ if (found.length === 0) return false;
281
+ // A target below a file or a symlink: refuse, changing nothing.
282
+ const ancestors = ancestorsAndSelf(parentName(to));
283
+ if (ancestors.length > 0) {
284
+ const blocking = await tx.query(
285
+ `SELECT 1 FROM sqlar WHERE name IN (${ancestors.map(() => "?").join(", ")})
286
+ AND (sz = -1 OR NOT (sz = 0 AND typeof(data) = 'null')) LIMIT 1`,
287
+ ancestors,
288
+ );
289
+ if (blocking.length > 0) return false;
290
+ }
291
+ await tx.query(`DELETE FROM sqlar WHERE ${dst.where}`, dst.params);
292
+ for (const dir of ancestors) {
293
+ await tx.query(
294
+ `INSERT INTO sqlar (name, mode, mtime, sz, data) VALUES (?, ?, ?, 0, NULL)
295
+ ON CONFLICT(name) DO NOTHING`,
296
+ [dir, DIR_MODE, now],
297
+ );
298
+ }
299
+ // SQLite's substr() counts characters (code points), not UTF-16 units.
300
+ await tx.query(`UPDATE sqlar SET name = ? || substr(name, ?) WHERE ${src.where}`, [
301
+ to,
302
+ [...from].length + 1,
303
+ ...src.params,
304
+ ]);
305
+ return true;
306
+ });
226
307
  }
227
308
 
228
309
  /** `length(data) = sz` is plaintext, `< sz` is compressed, `> sz` is corrupt. */
@@ -345,6 +426,11 @@ function toName(path: string): string {
345
426
  return normalizePath(path).slice(1);
346
427
  }
347
428
 
429
+ /** Whether `name` lies strictly under `ancestor`; everything lies under the root "". */
430
+ function isInside(name: string, ancestor: string): boolean {
431
+ return ancestor === "" ? name !== "" : name.startsWith(`${ancestor}/`);
432
+ }
433
+
348
434
  function joinName(base: string, rest: string): string {
349
435
  if (base === "") return rest;
350
436
  return rest === "" ? base : `${base}/${rest}`;