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