@statewalker/webrun-files-sqlite 0.11.2 → 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"}
@@ -478,7 +478,23 @@ var SqliteFilesApi = class {
478
478
  * removes the pending content; a crash leaves it for {@link sweep}.
479
479
  */
480
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) {
481
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) {
482
498
  const compression = this.#codec?.name ?? "none";
483
499
  const fid = await inTransaction(this.#db, async (tx) => (await tx.query(`INSERT INTO ${this.#files}(size, compression, block_size, hash, updated)
484
500
  VALUES (NULL, ?, ?, NULL, ?) RETURNING fid`, [
@@ -522,9 +538,11 @@ var SqliteFilesApi = class {
522
538
  if (alive === 0) throw sweptDuringWrite(name);
523
539
  size += raw;
524
540
  }
525
- const previous = await this.#entry(name);
526
- const [finished] = await this.#transaction(this.#finishWrite(name, fid, size, hash.digestHex(), compression, previous?.fid ?? null));
527
- 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;
528
546
  } catch (error) {
529
547
  try {
530
548
  await reader.close();
@@ -543,9 +561,10 @@ var SqliteFilesApi = class {
543
561
  * an identical content if one exists or else at this one, then collect this
544
562
  * content if it ended up unused and the path's previous content. Everything
545
563
  * after the first statement is conditioned on the content still existing, so
546
- * 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.
547
566
  */
548
- #finishWrite(name, fid, size, digest, compression, previous) {
567
+ #finishWrite(name, fid, size, digest, compression, previous, exclusive) {
549
568
  const now = Date.now();
550
569
  const exists = `SELECT 1 FROM ${this.#files} WHERE fid = ?`;
551
570
  const statements = [
@@ -575,7 +594,7 @@ var SqliteFilesApi = class {
575
594
  ORDER BY f.fid LIMIT 1
576
595
  ), ?), ?
577
596
  WHERE EXISTS (${exists})
578
- 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"}`,
579
598
  params: [
580
599
  name,
581
600
  digest,
@@ -590,8 +609,12 @@ var SqliteFilesApi = class {
590
609
  },
591
610
  ...this.#collect(fid)
592
611
  ];
612
+ const pathIndex = 1 + ancestorsAndSelf$1(parentOf(name)).length;
593
613
  if (previous !== null && previous !== fid) statements.push(...this.#collect(previous));
594
- return statements;
614
+ return {
615
+ statements,
616
+ pathIndex
617
+ };
595
618
  }
596
619
  async mkdir(path) {
597
620
  const now = Date.now();
@@ -610,7 +633,7 @@ var SqliteFilesApi = class {
610
633
  const base = normalizePath(path);
611
634
  if ((await this.stats(base))?.kind !== "directory") return;
612
635
  const range = descendants$1(base);
613
- const prefixLength = base === "/" ? 1 : base.length + 1;
636
+ const prefixLength = base === "/" ? 1 : [...base].length + 1;
614
637
  const directOnly = options.recursive ? "" : "AND instr(substr(p.path, ?), '/') = 0";
615
638
  let cursor = options.after ?? "";
616
639
  for (;;) {
@@ -672,6 +695,12 @@ var SqliteFilesApi = class {
672
695
  async copy(source, target) {
673
696
  return this.#transfer(source, target, "copy");
674
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
+ */
675
704
  async move(source, target) {
676
705
  return this.#transfer(source, target, "move");
677
706
  }
@@ -684,7 +713,7 @@ var SqliteFilesApi = class {
684
713
  const from = normalizePath(source);
685
714
  const to = normalizePath(target);
686
715
  if (from === to) return this.exists(from);
687
- 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`);
688
717
  const range = selfAndDescendants$1(from, "p");
689
718
  const inS = selfAndDescendants$1(from, "s");
690
719
  const guard = {
@@ -692,6 +721,7 @@ var SqliteFilesApi = class {
692
721
  params: inS.params
693
722
  };
694
723
  const now = Date.now();
724
+ const rest = [...from].length + 1;
695
725
  const statements = [
696
726
  ...this.#removeTree(to, guard),
697
727
  ...ancestorsAndSelf$1(parentOf(to)).map((dir) => ({
@@ -708,7 +738,7 @@ var SqliteFilesApi = class {
708
738
  SELECT ? || substr(p.path, ?), p.fid, ? FROM ${this.#paths} p WHERE ${range.where}`,
709
739
  params: [
710
740
  to,
711
- from.length + 1,
741
+ rest,
712
742
  now,
713
743
  ...range.params
714
744
  ]
@@ -717,7 +747,7 @@ var SqliteFilesApi = class {
717
747
  WHERE ${range.where}`,
718
748
  params: [
719
749
  to,
720
- from.length + 1,
750
+ rest,
721
751
  now,
722
752
  ...range.params
723
753
  ]
@@ -840,7 +870,7 @@ function selfAndDescendants$1(path, alias = "p") {
840
870
  params: [path, ...range.params]
841
871
  };
842
872
  }
843
- function isInside(path, ancestor) {
873
+ function isInside$1(path, ancestor) {
844
874
  return ancestor === "/" ? path !== "/" : path.startsWith(`${ancestor}/`);
845
875
  }
846
876
  function parentOf(path) {
@@ -955,12 +985,42 @@ var SqlarFilesApi = class {
955
985
  const name = toName(path);
956
986
  const bytes = await concat(content);
957
987
  await this.mkdir(parentName(name));
958
- 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) {
959
1019
  if (bytes.byteLength >= this.#codec.minSize) {
960
1020
  const deflated = await this.#codec.deflate(bytes);
961
- if (deflated.byteLength < bytes.byteLength) data = deflated;
1021
+ if (deflated.byteLength < bytes.byteLength) return deflated;
962
1022
  }
963
- await this.#put(name, FILE_MODE, bytes.byteLength, data);
1023
+ return bytes;
964
1024
  }
965
1025
  /** `sz = 0` with `data IS NULL` is the format's only marker for a directory. */
966
1026
  async mkdir(path) {
@@ -1059,10 +1119,49 @@ var SqlarFilesApi = class {
1059
1119
  }
1060
1120
  return true;
1061
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
+ */
1062
1136
  async move(source, target) {
1063
- if (!await this.copy(source, target)) return false;
1064
- await this.remove(source);
1065
- 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
+ });
1066
1165
  }
1067
1166
  /** `length(data) = sz` is plaintext, `< sz` is compressed, `> sz` is corrupt. */
1068
1167
  async #content(name, sz, data) {
@@ -1162,6 +1261,10 @@ function selfAndDescendants(name) {
1162
1261
  function toName(path) {
1163
1262
  return normalizePath(path).slice(1);
1164
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
+ }
1165
1268
  function joinName(base, rest) {
1166
1269
  if (base === "") return rest;
1167
1270
  return rest === "" ? base : `${base}/${rest}`;
package/dist/esm/index.js CHANGED
@@ -477,7 +477,23 @@ var SqliteFilesApi = class {
477
477
  * removes the pending content; a crash leaves it for {@link sweep}.
478
478
  */
479
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) {
480
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) {
481
497
  const compression = this.#codec?.name ?? "none";
482
498
  const fid = await inTransaction(this.#db, async (tx) => (await tx.query(`INSERT INTO ${this.#files}(size, compression, block_size, hash, updated)
483
499
  VALUES (NULL, ?, ?, NULL, ?) RETURNING fid`, [
@@ -521,9 +537,11 @@ var SqliteFilesApi = class {
521
537
  if (alive === 0) throw sweptDuringWrite(name);
522
538
  size += raw;
523
539
  }
524
- const previous = await this.#entry(name);
525
- const [finished] = await this.#transaction(this.#finishWrite(name, fid, size, hash.digestHex(), compression, previous?.fid ?? null));
526
- 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;
527
545
  } catch (error) {
528
546
  try {
529
547
  await reader.close();
@@ -542,9 +560,10 @@ var SqliteFilesApi = class {
542
560
  * an identical content if one exists or else at this one, then collect this
543
561
  * content if it ended up unused and the path's previous content. Everything
544
562
  * after the first statement is conditioned on the content still existing, so
545
- * 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.
546
565
  */
547
- #finishWrite(name, fid, size, digest, compression, previous) {
566
+ #finishWrite(name, fid, size, digest, compression, previous, exclusive) {
548
567
  const now = Date.now();
549
568
  const exists = `SELECT 1 FROM ${this.#files} WHERE fid = ?`;
550
569
  const statements = [
@@ -574,7 +593,7 @@ var SqliteFilesApi = class {
574
593
  ORDER BY f.fid LIMIT 1
575
594
  ), ?), ?
576
595
  WHERE EXISTS (${exists})
577
- 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"}`,
578
597
  params: [
579
598
  name,
580
599
  digest,
@@ -589,8 +608,12 @@ var SqliteFilesApi = class {
589
608
  },
590
609
  ...this.#collect(fid)
591
610
  ];
611
+ const pathIndex = 1 + ancestorsAndSelf$1(parentOf(name)).length;
592
612
  if (previous !== null && previous !== fid) statements.push(...this.#collect(previous));
593
- return statements;
613
+ return {
614
+ statements,
615
+ pathIndex
616
+ };
594
617
  }
595
618
  async mkdir(path) {
596
619
  const now = Date.now();
@@ -609,7 +632,7 @@ var SqliteFilesApi = class {
609
632
  const base = normalizePath(path);
610
633
  if ((await this.stats(base))?.kind !== "directory") return;
611
634
  const range = descendants$1(base);
612
- const prefixLength = base === "/" ? 1 : base.length + 1;
635
+ const prefixLength = base === "/" ? 1 : [...base].length + 1;
613
636
  const directOnly = options.recursive ? "" : "AND instr(substr(p.path, ?), '/') = 0";
614
637
  let cursor = options.after ?? "";
615
638
  for (;;) {
@@ -671,6 +694,12 @@ var SqliteFilesApi = class {
671
694
  async copy(source, target) {
672
695
  return this.#transfer(source, target, "copy");
673
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
+ */
674
703
  async move(source, target) {
675
704
  return this.#transfer(source, target, "move");
676
705
  }
@@ -683,7 +712,7 @@ var SqliteFilesApi = class {
683
712
  const from = normalizePath(source);
684
713
  const to = normalizePath(target);
685
714
  if (from === to) return this.exists(from);
686
- 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`);
687
716
  const range = selfAndDescendants$1(from, "p");
688
717
  const inS = selfAndDescendants$1(from, "s");
689
718
  const guard = {
@@ -691,6 +720,7 @@ var SqliteFilesApi = class {
691
720
  params: inS.params
692
721
  };
693
722
  const now = Date.now();
723
+ const rest = [...from].length + 1;
694
724
  const statements = [
695
725
  ...this.#removeTree(to, guard),
696
726
  ...ancestorsAndSelf$1(parentOf(to)).map((dir) => ({
@@ -707,7 +737,7 @@ var SqliteFilesApi = class {
707
737
  SELECT ? || substr(p.path, ?), p.fid, ? FROM ${this.#paths} p WHERE ${range.where}`,
708
738
  params: [
709
739
  to,
710
- from.length + 1,
740
+ rest,
711
741
  now,
712
742
  ...range.params
713
743
  ]
@@ -716,7 +746,7 @@ var SqliteFilesApi = class {
716
746
  WHERE ${range.where}`,
717
747
  params: [
718
748
  to,
719
- from.length + 1,
749
+ rest,
720
750
  now,
721
751
  ...range.params
722
752
  ]
@@ -839,7 +869,7 @@ function selfAndDescendants$1(path, alias = "p") {
839
869
  params: [path, ...range.params]
840
870
  };
841
871
  }
842
- function isInside(path, ancestor) {
872
+ function isInside$1(path, ancestor) {
843
873
  return ancestor === "/" ? path !== "/" : path.startsWith(`${ancestor}/`);
844
874
  }
845
875
  function parentOf(path) {
@@ -954,12 +984,42 @@ var SqlarFilesApi = class {
954
984
  const name = toName(path);
955
985
  const bytes = await concat(content);
956
986
  await this.mkdir(parentName(name));
957
- 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) {
958
1018
  if (bytes.byteLength >= this.#codec.minSize) {
959
1019
  const deflated = await this.#codec.deflate(bytes);
960
- if (deflated.byteLength < bytes.byteLength) data = deflated;
1020
+ if (deflated.byteLength < bytes.byteLength) return deflated;
961
1021
  }
962
- await this.#put(name, FILE_MODE, bytes.byteLength, data);
1022
+ return bytes;
963
1023
  }
964
1024
  /** `sz = 0` with `data IS NULL` is the format's only marker for a directory. */
965
1025
  async mkdir(path) {
@@ -1058,10 +1118,49 @@ var SqlarFilesApi = class {
1058
1118
  }
1059
1119
  return true;
1060
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
+ */
1061
1135
  async move(source, target) {
1062
- if (!await this.copy(source, target)) return false;
1063
- await this.remove(source);
1064
- 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
+ });
1065
1164
  }
1066
1165
  /** `length(data) = sz` is plaintext, `< sz` is compressed, `> sz` is corrupt. */
1067
1166
  async #content(name, sz, data) {
@@ -1161,6 +1260,10 @@ function selfAndDescendants(name) {
1161
1260
  function toName(path) {
1162
1261
  return normalizePath(path).slice(1);
1163
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
+ }
1164
1267
  function joinName(base, rest) {
1165
1268
  if (base === "") return rest;
1166
1269
  return rest === "" ? base : `${base}/${rest}`;
@@ -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.2",
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.5"
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}`;