@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 +41 -4
- package/dist/blocks/sqlite-files-api.d.ts +15 -0
- package/dist/blocks/sqlite-files-api.d.ts.map +1 -1
- package/dist/cjs/index.cjs +131 -38
- package/dist/esm/index.js +131 -38
- package/dist/sqlar-files-api.d.ts +23 -0
- package/dist/sqlar-files-api.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/blocks/sqlite-files-api.ts +57 -11
- package/src/sqlar-files-api.ts +92 -6
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`
|
|
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
|
-
-
|
|
235
|
-
|
|
236
|
-
|
|
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,
|
|
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"}
|
package/dist/cjs/index.cjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
534
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
1021
|
+
if (deflated.byteLength < bytes.byteLength) return deflated;
|
|
971
1022
|
}
|
|
972
|
-
|
|
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
|
-
|
|
1073
|
-
|
|
1074
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
533
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
1020
|
+
if (deflated.byteLength < bytes.byteLength) return deflated;
|
|
970
1021
|
}
|
|
971
|
-
|
|
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
|
-
|
|
1072
|
-
|
|
1073
|
-
return
|
|
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;
|
|
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.
|
|
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.
|
|
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
|
|
235
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
478
|
+
params: [to, rest, now, ...range.params],
|
|
433
479
|
},
|
|
434
480
|
];
|
|
435
481
|
const counts = await this.#transaction(statements);
|
package/src/sqlar-files-api.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
121
|
+
if (deflated.byteLength < bytes.byteLength) return deflated;
|
|
90
122
|
}
|
|
91
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
return
|
|
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}`;
|