@tinycloud/replica 0.2.4-beta.2 → 0.3.0-beta.10
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 +29 -0
- package/dist/browser.d.ts +6 -0
- package/dist/browser.js +7 -1
- package/dist/browser.js.map +1 -1
- package/dist/index.cjs +20 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +15 -3
- package/dist/index.d.ts +15 -3
- package/dist/index.js +20 -2
- package/dist/index.js.map +1 -1
- package/dist/kv-storage-testing.d.ts +83 -0
- package/dist/kv-storage-testing.js +1145 -0
- package/dist/kv-storage-testing.js.map +1 -0
- package/dist/kv-storage.d.ts +155 -0
- package/dist/kv-storage.js +2588 -0
- package/dist/kv-storage.js.map +1 -0
- package/dist/kv-storage.worker.d.ts +2 -0
- package/dist/kv-storage.worker.f6f98ad5dc547a29274c0b539fb78e25f8a2051ccfaafc9490cb307051a0e0ef.js +19004 -0
- package/dist/kv-storage.worker.js +19004 -0
- package/dist/kv-storage.worker.js.map +1 -0
- package/dist/profile-gate.cjs +560 -0
- package/dist/profile-gate.cjs.map +1 -0
- package/dist/profile-gate.d.cts +89 -0
- package/dist/profile-gate.d.ts +89 -0
- package/dist/profile-gate.js +517 -0
- package/dist/profile-gate.js.map +1 -0
- package/dist/replica.worker.js +1276 -891
- package/dist/replica.worker.js.map +1 -1
- package/dist/sqlite.cjs +1107 -277
- package/dist/sqlite.cjs.map +1 -1
- package/dist/sqlite.d.cts +101 -8
- package/dist/sqlite.d.ts +101 -8
- package/dist/sqlite.js +1095 -267
- package/dist/sqlite.js.map +1 -1
- package/dist/{types-f3-kYLQZ.d.cts → types-CQ61VnsP.d.cts} +1 -1
- package/dist/{types-f3-kYLQZ.d.ts → types-CQ61VnsP.d.ts} +1 -1
- package/package.json +23 -5
package/dist/sqlite.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// src/sqlite/store.ts
|
|
2
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
2
3
|
import { randomBytes } from "crypto";
|
|
3
|
-
import { closeSync, constants as
|
|
4
|
-
import { chmod, link, lstat, mkdir, open, readFile, readdir, rm, rmdir, stat, unlink } from "fs/promises";
|
|
5
|
-
import { dirname, join, resolve } from "path";
|
|
4
|
+
import { closeSync, constants as fsConstants2, fstatSync, lstatSync, openSync, readdirSync, readlinkSync } from "fs";
|
|
5
|
+
import { chmod, link, lstat as lstat2, mkdir, open as open2, readFile, readdir as readdir2, rm, rmdir as rmdir2, stat, unlink as unlink2 } from "fs/promises";
|
|
6
|
+
import { dirname, join as join2, resolve as resolve2 } from "path";
|
|
6
7
|
|
|
7
8
|
// src/errors.ts
|
|
8
9
|
var ReplicaErrorCode = {
|
|
@@ -60,7 +61,13 @@ var ReplicaErrorCode = {
|
|
|
60
61
|
/** A stored blob no longer hashes to its name. */
|
|
61
62
|
INTEGRITY_ERROR: "INTEGRITY_ERROR",
|
|
62
63
|
/** The replica handle was closed; in-flight calls settle with this code. */
|
|
63
|
-
CLOSED: "REPLICA_CLOSED"
|
|
64
|
+
CLOSED: "REPLICA_CLOSED",
|
|
65
|
+
/** The lifecycle gate refused the operation (shared contract `REPLICA_GATED`); `detail.gate` says why. */
|
|
66
|
+
GATED: "REPLICA_GATED",
|
|
67
|
+
/** Quota admission refused a page (`maxBytes` or the free-space floor); nothing committed. */
|
|
68
|
+
STORAGE_BUDGET: "STORAGE_BUDGET",
|
|
69
|
+
/** The consent deadline passed: the grant is not served until the user consents again. */
|
|
70
|
+
CONSENT_EXPIRED: "CONSENT_EXPIRED"
|
|
64
71
|
};
|
|
65
72
|
var REPLICA_ERROR = /* @__PURE__ */ Symbol.for("tinycloud.replica.error");
|
|
66
73
|
var ReplicaError = class extends Error {
|
|
@@ -197,32 +204,13 @@ async function loadSqlite() {
|
|
|
197
204
|
return (path, options) => wrap(options?.readonly ? new DatabaseSync(path, { readOnly: true }) : new DatabaseSync(path));
|
|
198
205
|
}
|
|
199
206
|
|
|
200
|
-
// src/sqlite/
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
release = resolve2;
|
|
208
|
-
});
|
|
209
|
-
await previous;
|
|
210
|
-
return release;
|
|
211
|
-
}
|
|
212
|
-
async function captureNamedPath(path) {
|
|
213
|
-
const names = [];
|
|
214
|
-
for (let current = resolve(path); ; current = dirname(current)) {
|
|
215
|
-
names.push(current);
|
|
216
|
-
if (dirname(current) === current) break;
|
|
217
|
-
}
|
|
218
|
-
const identities = [];
|
|
219
|
-
for (const name of names.reverse()) {
|
|
220
|
-
const stats = await lstat(name, { bigint: true });
|
|
221
|
-
identities.push({ path: name, dev: stats.dev, ino: stats.ino });
|
|
222
|
-
}
|
|
223
|
-
return identities;
|
|
224
|
-
}
|
|
225
|
-
var DURABILITY = "sqlite-wal-synchronous-full+fsynced-blobs";
|
|
207
|
+
// src/sqlite/layout.ts
|
|
208
|
+
import { constants as fsConstants } from "fs";
|
|
209
|
+
import { lstat, open, readdir, rmdir, unlink } from "fs/promises";
|
|
210
|
+
import { join, parse, resolve, sep } from "path";
|
|
211
|
+
|
|
212
|
+
// src/sqlite/schema.ts
|
|
213
|
+
var SCHEMA_VERSION = 2;
|
|
226
214
|
var SCHEMA = `
|
|
227
215
|
CREATE TABLE IF NOT EXISTS replica (
|
|
228
216
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
@@ -282,7 +270,403 @@ CREATE TABLE IF NOT EXISTS entry (
|
|
|
282
270
|
)
|
|
283
271
|
) STRICT, WITHOUT ROWID;
|
|
284
272
|
CREATE INDEX IF NOT EXISTS entry_hash ON entry (hash) WHERE hash IS NOT NULL;
|
|
273
|
+
CREATE TABLE IF NOT EXISTS grant_install (
|
|
274
|
+
cid TEXT PRIMARY KEY,
|
|
275
|
+
installed_at INTEGER NOT NULL,
|
|
276
|
+
effective_expires_at INTEGER NOT NULL,
|
|
277
|
+
consent_deadline INTEGER NOT NULL,
|
|
278
|
+
consent_anchor_at INTEGER NOT NULL,
|
|
279
|
+
consent_anchor_source TEXT NOT NULL
|
|
280
|
+
) STRICT, WITHOUT ROWID;
|
|
285
281
|
`;
|
|
282
|
+
|
|
283
|
+
// src/sqlite/layout.ts
|
|
284
|
+
var POLICY_COLUMNS = [
|
|
285
|
+
"grant_installed_at REAL",
|
|
286
|
+
"grant_effective_expires_at REAL",
|
|
287
|
+
"grant_consent_deadline REAL",
|
|
288
|
+
"grant_consent_anchor_at REAL",
|
|
289
|
+
"grant_consent_anchor_source TEXT",
|
|
290
|
+
"pending_grant_installed_at REAL",
|
|
291
|
+
"pending_grant_effective_expires_at REAL",
|
|
292
|
+
"pending_grant_consent_deadline REAL",
|
|
293
|
+
"pending_grant_consent_anchor_at REAL",
|
|
294
|
+
"pending_grant_consent_anchor_source TEXT"
|
|
295
|
+
];
|
|
296
|
+
var normalizeSchema = (sql) => sql.replace(" IF NOT EXISTS", "").replace(/\s+/g, " ").trim();
|
|
297
|
+
var SOURCE_SCHEMA = SCHEMA.trim().split(";").map((sql) => sql.trim()).filter(Boolean).map((sql) => {
|
|
298
|
+
const match = /^CREATE (TABLE|INDEX) IF NOT EXISTS ([a-z_]+)/.exec(sql);
|
|
299
|
+
if (match === null) throw new Error("Invalid source replica schema.");
|
|
300
|
+
return { type: match[1].toLowerCase(), name: match[2], sql: normalizeSchema(sql) };
|
|
301
|
+
});
|
|
302
|
+
var V1_SCHEMA = SOURCE_SCHEMA.filter((entry) => entry.name !== "grant_install");
|
|
303
|
+
var POLICY_REPLICA = V1_SCHEMA.find((entry) => entry.name === "replica").sql.replace(/\) STRICT$/, `, ${POLICY_COLUMNS.join(", ")}) STRICT`);
|
|
304
|
+
var POLICY_SCHEMA = V1_SCHEMA.map((entry) => entry.name === "replica" ? { ...entry, sql: POLICY_REPLICA } : entry);
|
|
305
|
+
async function sourceMirrorSchema(path) {
|
|
306
|
+
let db;
|
|
307
|
+
try {
|
|
308
|
+
db = (await loadSqlite())(path, { readonly: true });
|
|
309
|
+
db.exec("BEGIN");
|
|
310
|
+
const version = db.get("PRAGMA user_version")?.user_version;
|
|
311
|
+
if (version !== 1 && version !== SCHEMA_VERSION) return false;
|
|
312
|
+
const rows = db.all(
|
|
313
|
+
"SELECT type, name, sql FROM sqlite_schema WHERE sql IS NOT NULL ORDER BY name"
|
|
314
|
+
);
|
|
315
|
+
const expected = version === 1 ? V1_SCHEMA : SOURCE_SCHEMA;
|
|
316
|
+
const matches = (schema) => rows.length === schema.length && rows.every((row) => {
|
|
317
|
+
const source = schema.find((entry) => entry.name === row.name);
|
|
318
|
+
return source?.type === row.type && source.sql === normalizeSchema(row.sql);
|
|
319
|
+
});
|
|
320
|
+
if (matches(expected)) return true;
|
|
321
|
+
if (version !== 2) return false;
|
|
322
|
+
return matches(POLICY_SCHEMA);
|
|
323
|
+
} catch {
|
|
324
|
+
return false;
|
|
325
|
+
} finally {
|
|
326
|
+
try {
|
|
327
|
+
db?.close();
|
|
328
|
+
} catch {
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
var SQLITE_FILE = /^(?:replica\.db|replica\.db-(?:wal|shm|journal))$/;
|
|
333
|
+
var SHARD = /^[0-9a-f]{2}$/;
|
|
334
|
+
var HASH = /^[0-9a-f]{64}$/;
|
|
335
|
+
var TEMP = /^[0-9a-f]{64}\.[0-9a-f]{12}$/;
|
|
336
|
+
function sourceKind(parent, name, allowAdapterFence) {
|
|
337
|
+
if (parent === "") {
|
|
338
|
+
if (SQLITE_FILE.test(name) || allowAdapterFence && (name === "fence.json" || /^fence[.]json[.][0-9a-f]{12}[.]tmp$/.test(name))) return "file";
|
|
339
|
+
return name === "blobs" ? "directory" : "unknown";
|
|
340
|
+
}
|
|
341
|
+
if (parent === "blobs") return name === ".tmp" || SHARD.test(name) ? "directory" : "unknown";
|
|
342
|
+
if (parent === "blobs/.tmp") return TEMP.test(name) ? "file" : "unknown";
|
|
343
|
+
if (parent.startsWith("blobs/") && SHARD.test(parent.slice(6))) {
|
|
344
|
+
return HASH.test(name) && name.startsWith(parent.slice(6)) ? "file" : "unknown";
|
|
345
|
+
}
|
|
346
|
+
return "unknown";
|
|
347
|
+
}
|
|
348
|
+
function sameFile(a, b) {
|
|
349
|
+
return a.dev === b.dev && a.ino === b.ino;
|
|
350
|
+
}
|
|
351
|
+
function identityOf(path, kind, details) {
|
|
352
|
+
return { path, kind, dev: details.dev.toString(), ino: details.ino.toString() };
|
|
353
|
+
}
|
|
354
|
+
function fileKind(details) {
|
|
355
|
+
if (details.isFile()) return "file";
|
|
356
|
+
if (details.isDirectory()) return "directory";
|
|
357
|
+
if (details.isSymbolicLink()) return "symlink";
|
|
358
|
+
throw new Error("Unsupported replica entry type.");
|
|
359
|
+
}
|
|
360
|
+
async function pathComponents(path) {
|
|
361
|
+
const absolute = resolve(path);
|
|
362
|
+
const parts = absolute.slice(parse(absolute).root.length).split(sep).filter(Boolean);
|
|
363
|
+
let current = parse(absolute).root;
|
|
364
|
+
const components = [];
|
|
365
|
+
for (const part of ["", ...parts]) {
|
|
366
|
+
if (part) current = join(current, part);
|
|
367
|
+
const details = await lstat(current, { bigint: true });
|
|
368
|
+
if (details.isSymbolicLink()) throw new Error(`Symlink in replica path: ${current}`);
|
|
369
|
+
if (!details.isDirectory()) throw new Error(`Replica path component is not a directory: ${current}`);
|
|
370
|
+
components.push({ path: current, identity: details });
|
|
371
|
+
}
|
|
372
|
+
return components;
|
|
373
|
+
}
|
|
374
|
+
async function pinnedDirectory(path, parent, relative = "") {
|
|
375
|
+
const components = process.platform === "linux" ? void 0 : parent?.components ?? await pathComponents(path);
|
|
376
|
+
const before = await lstat(path, { bigint: true });
|
|
377
|
+
if (before.isSymbolicLink()) throw new Error(`Symlink in replica path: ${path}`);
|
|
378
|
+
if (!before.isDirectory()) throw new Error("Replica directory is not a real directory.");
|
|
379
|
+
const handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW);
|
|
380
|
+
try {
|
|
381
|
+
if (!sameFile(before, await handle.stat({ bigint: true }))) {
|
|
382
|
+
throw new Error("Replica directory changed during inspection.");
|
|
383
|
+
}
|
|
384
|
+
return {
|
|
385
|
+
handle,
|
|
386
|
+
identity: before,
|
|
387
|
+
parent,
|
|
388
|
+
components,
|
|
389
|
+
children: [],
|
|
390
|
+
relative,
|
|
391
|
+
name: path,
|
|
392
|
+
path: process.platform === "linux" ? `/proc/self/fd/${handle.fd}` : path
|
|
393
|
+
};
|
|
394
|
+
} catch (error) {
|
|
395
|
+
await handle.close();
|
|
396
|
+
throw error;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
async function checkDirectory(directory) {
|
|
400
|
+
const details = await lstat(directory.name, { bigint: true });
|
|
401
|
+
if (!details.isDirectory() || !sameFile(directory.identity, details)) {
|
|
402
|
+
throw new Error(`Replica directory changed: ${directory.relative || directory.name}`);
|
|
403
|
+
}
|
|
404
|
+
if (directory.parent) await checkDirectory(directory.parent);
|
|
405
|
+
else if (directory.components) {
|
|
406
|
+
for (const component of directory.components) {
|
|
407
|
+
const current = await lstat(component.path, { bigint: true });
|
|
408
|
+
if (current.isSymbolicLink() || !current.isDirectory() || !sameFile(component.identity, current)) {
|
|
409
|
+
throw new Error(`Replica path component changed: ${component.path}`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
async function inspect(dir, allowAdapterFence) {
|
|
415
|
+
const root = await pinnedDirectory(dir);
|
|
416
|
+
const directories = [root];
|
|
417
|
+
const unknownPaths = [];
|
|
418
|
+
const knownEntries = [];
|
|
419
|
+
const unknownEntries = [];
|
|
420
|
+
let hasDatabase = false;
|
|
421
|
+
try {
|
|
422
|
+
async function walk(parent) {
|
|
423
|
+
await checkDirectory(parent);
|
|
424
|
+
for (const name of (await readdir(parent.path)).sort()) {
|
|
425
|
+
await checkDirectory(parent);
|
|
426
|
+
const relative = parent.relative ? `${parent.relative}/${name}` : name;
|
|
427
|
+
const path = join(parent.path, name);
|
|
428
|
+
const details = await lstat(path, { bigint: true });
|
|
429
|
+
if (process.platform !== "linux" && details.isSymbolicLink()) {
|
|
430
|
+
throw new Error(`Symlink in replica path: ${relative}`);
|
|
431
|
+
}
|
|
432
|
+
const kind = fileKind(details);
|
|
433
|
+
const child = { path, relative, kind, identity: details, parent };
|
|
434
|
+
parent.children.push(child);
|
|
435
|
+
if (parent.relative === "" && name === "replica.db" && kind === "file") hasDatabase = true;
|
|
436
|
+
if (sourceKind(parent.relative, name, allowAdapterFence) !== kind) {
|
|
437
|
+
unknownPaths.push(relative);
|
|
438
|
+
unknownEntries.push(identityOf(relative, kind, details));
|
|
439
|
+
} else {
|
|
440
|
+
knownEntries.push(identityOf(relative, kind, details));
|
|
441
|
+
}
|
|
442
|
+
if (kind === "directory") {
|
|
443
|
+
const directory = await pinnedDirectory(path, parent, relative);
|
|
444
|
+
if (!sameFile(details, directory.identity)) {
|
|
445
|
+
await directory.handle.close();
|
|
446
|
+
throw new Error(`Replica directory changed during inspection: ${relative}`);
|
|
447
|
+
}
|
|
448
|
+
child.directory = directory;
|
|
449
|
+
directories.push(directory);
|
|
450
|
+
await walk(directory);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
await walk(root);
|
|
455
|
+
if (!hasDatabase) {
|
|
456
|
+
unknownPaths.push("replica.db");
|
|
457
|
+
} else if (!await sourceMirrorSchema(join(root.path, "replica.db"))) {
|
|
458
|
+
const index = knownEntries.findIndex((entry) => entry.path === "replica.db");
|
|
459
|
+
if (index >= 0) unknownEntries.push(knownEntries.splice(index, 1)[0]);
|
|
460
|
+
unknownPaths.push("replica.db");
|
|
461
|
+
}
|
|
462
|
+
await checkDirectory(root);
|
|
463
|
+
const names = new Set(await readdir(root.path));
|
|
464
|
+
for (let index = root.children.length - 1; index >= 0; index--) {
|
|
465
|
+
const child = root.children[index];
|
|
466
|
+
if (!names.has(child.relative)) {
|
|
467
|
+
if (child.directory) throw new Error(`Replica directory vanished: ${child.relative}`);
|
|
468
|
+
root.children.splice(index, 1);
|
|
469
|
+
for (const list of [knownEntries, unknownEntries]) {
|
|
470
|
+
const entryIndex = list.findIndex((entry) => entry.path === child.relative);
|
|
471
|
+
if (entryIndex >= 0) list.splice(entryIndex, 1);
|
|
472
|
+
}
|
|
473
|
+
const pathIndex = unknownPaths.indexOf(child.relative);
|
|
474
|
+
if (pathIndex >= 0) unknownPaths.splice(pathIndex, 1);
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
const current = await lstat(child.path, { bigint: true });
|
|
478
|
+
if (!sameFile(child.identity, current) || fileKind(current) !== child.kind) {
|
|
479
|
+
throw new Error(`Replica child changed during schema inspection: ${child.relative}`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
for (const name of names) {
|
|
483
|
+
if (root.children.some((child) => child.relative === name)) continue;
|
|
484
|
+
const path = join(root.path, name);
|
|
485
|
+
const details = await lstat(path, { bigint: true });
|
|
486
|
+
if (!/^replica\.db-(?:wal|shm|journal)$/.test(name) || !details.isFile()) {
|
|
487
|
+
throw new Error(`Unexpected replica child during schema inspection: ${name}`);
|
|
488
|
+
}
|
|
489
|
+
root.children.push({ path, relative: name, kind: "file", identity: details, parent: root });
|
|
490
|
+
knownEntries.push(identityOf(name, "file", details));
|
|
491
|
+
}
|
|
492
|
+
return { root, directories, knownEntries, unknownPaths, unknownEntries };
|
|
493
|
+
} catch (error) {
|
|
494
|
+
await closeDirectories(directories);
|
|
495
|
+
throw error;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
async function closeDirectories(directories) {
|
|
499
|
+
for (const directory of directories.reverse()) await directory.handle.close();
|
|
500
|
+
}
|
|
501
|
+
async function inspectSqliteReplicaLayout(dir, options = {}) {
|
|
502
|
+
const inventory = await inspect(dir, options.allowAdapterFence === true);
|
|
503
|
+
try {
|
|
504
|
+
return {
|
|
505
|
+
knownEntries: inventory.knownEntries,
|
|
506
|
+
unknownPaths: inventory.unknownPaths,
|
|
507
|
+
unknownEntries: inventory.unknownEntries,
|
|
508
|
+
rootIdentity: { dev: inventory.root.identity.dev.toString(), ino: inventory.root.identity.ino.toString() }
|
|
509
|
+
};
|
|
510
|
+
} finally {
|
|
511
|
+
await closeDirectories(inventory.directories);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
function approvedEntries(entries) {
|
|
515
|
+
const approved = /* @__PURE__ */ new Map();
|
|
516
|
+
for (const entry of entries) {
|
|
517
|
+
if (typeof entry !== "object" || entry === null || Object.keys(entry).sort().join(",") !== "dev,ino,kind,path" || typeof entry.path !== "string" || !entry.path || entry.path.split("/").some((part) => !part || part === "." || part === ".." || part.includes("\\")) || !["file", "directory", "symlink"].includes(entry.kind) || typeof entry.dev !== "string" || !/^(0|[1-9][0-9]*)$/.test(entry.dev) || typeof entry.ino !== "string" || !/^(0|[1-9][0-9]*)$/.test(entry.ino) || approved.has(entry.path)) {
|
|
518
|
+
throw new Error("Invalid or duplicate replica approval.");
|
|
519
|
+
}
|
|
520
|
+
approved.set(entry.path, entry);
|
|
521
|
+
}
|
|
522
|
+
return approved;
|
|
523
|
+
}
|
|
524
|
+
var LAYOUT_FAULTS = /* @__PURE__ */ Symbol("tinycloud.replica.layout-faults");
|
|
525
|
+
async function removeSqliteReplicaLayout(dir, options = {}) {
|
|
526
|
+
const inventory = await inspect(dir, options.allowAdapterFence === true);
|
|
527
|
+
try {
|
|
528
|
+
if (options.expectedRoot && (options.expectedRoot.dev !== inventory.root.identity.dev.toString() || options.expectedRoot.ino !== inventory.root.identity.ino.toString())) {
|
|
529
|
+
throw new Error("Replica root identity changed since approval.");
|
|
530
|
+
}
|
|
531
|
+
const approved = approvedEntries(options.approvedUnknown ?? []);
|
|
532
|
+
for (const entry of inventory.unknownEntries) {
|
|
533
|
+
const match = approved.get(entry.path);
|
|
534
|
+
if (!match || match.kind !== entry.kind || match.dev !== entry.dev || match.ino !== entry.ino) {
|
|
535
|
+
throw new Error(`Unknown replica child or approval identity mismatch: ${entry.path}`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
if (options.expectedKnown) {
|
|
539
|
+
const expected = approvedEntries(options.expectedKnown);
|
|
540
|
+
for (const entry of inventory.knownEntries) {
|
|
541
|
+
const match = expected.get(entry.path);
|
|
542
|
+
if (!match || match.kind !== entry.kind || match.dev !== entry.dev || match.ino !== entry.ino) {
|
|
543
|
+
throw new Error(`Known replica child changed since preflight: ${entry.path}`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (options.allowMissingDatabase && inventory.knownEntries.some((entry) => entry.path === "replica.db")) {
|
|
548
|
+
throw new Error("Replica database appeared after approval of its absence.");
|
|
549
|
+
}
|
|
550
|
+
if (inventory.unknownPaths.length !== inventory.unknownEntries.length && inventory.root.children.length > 0 && !options.allowMissingDatabase) {
|
|
551
|
+
throw new Error("Replica database is missing; cannot approve removal.");
|
|
552
|
+
}
|
|
553
|
+
async function removeDirectory(directory) {
|
|
554
|
+
for (const child of directory.children.sort((a, b) => a.relative === "replica.db" ? 1 : b.relative === "replica.db" ? -1 : 0)) {
|
|
555
|
+
if (child.directory) {
|
|
556
|
+
await removeDirectory(child.directory);
|
|
557
|
+
}
|
|
558
|
+
await options[LAYOUT_FAULTS]?.beforeUnlink?.(child.relative);
|
|
559
|
+
await checkDirectory(directory);
|
|
560
|
+
const details = await lstat(child.path, { bigint: true });
|
|
561
|
+
if (process.platform !== "linux" && details.isSymbolicLink()) {
|
|
562
|
+
throw new Error(`Symlink in replica path: ${child.relative}`);
|
|
563
|
+
}
|
|
564
|
+
if (fileKind(details) !== child.kind || !sameFile(child.identity, details)) {
|
|
565
|
+
throw new Error(`Replica child identity changed: ${child.relative}`);
|
|
566
|
+
}
|
|
567
|
+
if (child.directory) await rmdir(child.path);
|
|
568
|
+
else await unlink(child.path);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
await removeDirectory(inventory.root);
|
|
572
|
+
await checkDirectory(inventory.root);
|
|
573
|
+
await rmdir(inventory.root.name);
|
|
574
|
+
} finally {
|
|
575
|
+
await closeDirectories(inventory.directories);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// src/sqlite/store.ts
|
|
580
|
+
var sqliteOpenQueue = Promise.resolve();
|
|
581
|
+
async function acquireSqliteOpen() {
|
|
582
|
+
const previous = sqliteOpenQueue;
|
|
583
|
+
let release;
|
|
584
|
+
sqliteOpenQueue = new Promise((resolve3) => {
|
|
585
|
+
release = resolve3;
|
|
586
|
+
});
|
|
587
|
+
await previous;
|
|
588
|
+
return release;
|
|
589
|
+
}
|
|
590
|
+
async function captureNamedPath(path) {
|
|
591
|
+
const names = [];
|
|
592
|
+
for (let current = resolve2(path); ; current = dirname(current)) {
|
|
593
|
+
names.push(current);
|
|
594
|
+
if (dirname(current) === current) break;
|
|
595
|
+
}
|
|
596
|
+
const identities = [];
|
|
597
|
+
for (const name of names.reverse()) {
|
|
598
|
+
const stats = await lstat2(name, { bigint: true });
|
|
599
|
+
identities.push({ path: name, dev: stats.dev, ino: stats.ino });
|
|
600
|
+
}
|
|
601
|
+
return identities;
|
|
602
|
+
}
|
|
603
|
+
async function openVerifiedDatabase(path, opener, options = {}) {
|
|
604
|
+
const leaf = await open2(path, fsConstants2.O_RDONLY | fsConstants2.O_NOFOLLOW);
|
|
605
|
+
let db;
|
|
606
|
+
try {
|
|
607
|
+
const identity = await leaf.stat();
|
|
608
|
+
const named = await lstat2(path);
|
|
609
|
+
if (!identity.isFile() || !named.isFile() || named.dev !== identity.dev || named.ino !== identity.ino || options.expected !== void 0 && (options.expected.dev !== identity.dev || options.expected.ino !== identity.ino)) {
|
|
610
|
+
throw new Error("The replica database changed before SQLite opened it.");
|
|
611
|
+
}
|
|
612
|
+
const previousFds = process.platform === "linux" && options.discoverDescriptors !== false ? new Map(readdirSync("/proc/self/fd").map((fd) => {
|
|
613
|
+
try {
|
|
614
|
+
return [fd, readlinkSync(`/proc/self/fd/${fd}`)];
|
|
615
|
+
} catch {
|
|
616
|
+
return [fd, ""];
|
|
617
|
+
}
|
|
618
|
+
})) : null;
|
|
619
|
+
db = options.readonly ? opener(path, { readonly: true }) : opener(path);
|
|
620
|
+
const afterOpen = lstatSync(path);
|
|
621
|
+
if (!afterOpen.isFile() || afterOpen.dev !== identity.dev || afterOpen.ino !== identity.ino) {
|
|
622
|
+
throw new Error("The replica database changed while SQLite opened it.");
|
|
623
|
+
}
|
|
624
|
+
if (previousFds) db.get("SELECT count(*) FROM sqlite_master");
|
|
625
|
+
if (previousFds) {
|
|
626
|
+
const databases = readdirSync("/proc/self/fd").filter((fd) => {
|
|
627
|
+
try {
|
|
628
|
+
const target = readlinkSync(`/proc/self/fd/${fd}`);
|
|
629
|
+
return target.endsWith("/replica.db") && previousFds.get(fd) !== target;
|
|
630
|
+
} catch {
|
|
631
|
+
return false;
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
const sharedPager = options.readonly && databases.length === 0 && [...previousFds].some(([fd, target]) => {
|
|
635
|
+
if (Number(fd) === leaf.fd || !target.endsWith("/replica.db")) return false;
|
|
636
|
+
try {
|
|
637
|
+
const actual = fstatSync(Number(fd));
|
|
638
|
+
return actual.dev === identity.dev && actual.ino === identity.ino;
|
|
639
|
+
} catch {
|
|
640
|
+
return false;
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
if (!databases.length && !sharedPager || databases.some((fd) => {
|
|
644
|
+
try {
|
|
645
|
+
const actual = fstatSync(Number(fd));
|
|
646
|
+
return actual.dev !== identity.dev || actual.ino !== identity.ino;
|
|
647
|
+
} catch {
|
|
648
|
+
return true;
|
|
649
|
+
}
|
|
650
|
+
})) {
|
|
651
|
+
throw new Error("SQLite opened a different replica database.");
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
const after = lstatSync(path);
|
|
655
|
+
if (!after.isFile() || after.dev !== identity.dev || after.ino !== identity.ino) {
|
|
656
|
+
throw new Error("The replica database changed while SQLite opened it.");
|
|
657
|
+
}
|
|
658
|
+
const checked = db;
|
|
659
|
+
db = void 0;
|
|
660
|
+
return { db: checked, identity };
|
|
661
|
+
} finally {
|
|
662
|
+
try {
|
|
663
|
+
db?.close();
|
|
664
|
+
} finally {
|
|
665
|
+
await leaf.close();
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
var DURABILITY = "sqlite-wal-synchronous-full+fsynced-blobs";
|
|
286
670
|
function grantOf(cid, bytes, audience, issuer, nbf, exp) {
|
|
287
671
|
if (cid === null || bytes === null || audience === null || issuer === null) return null;
|
|
288
672
|
return { cid, bytes: new Uint8Array(bytes), audience, issuer, notBefore: nbf, expiresAt: exp };
|
|
@@ -311,9 +695,79 @@ var isHash = (name) => /^[0-9a-f]{64}$/.test(name);
|
|
|
311
695
|
function errnoOf(error) {
|
|
312
696
|
return typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
|
|
313
697
|
}
|
|
698
|
+
var isShard = (name) => /^[0-9a-f]{2}$/.test(name);
|
|
699
|
+
var isTempName = (name) => /^[0-9a-f]{64}\.[0-9a-f]{12}$/.test(name);
|
|
700
|
+
async function writerBlobFiles(root) {
|
|
701
|
+
const out = [];
|
|
702
|
+
let shards;
|
|
703
|
+
try {
|
|
704
|
+
shards = await readdir2(root, { withFileTypes: true });
|
|
705
|
+
} catch (error) {
|
|
706
|
+
if (errnoOf(error) === "ENOENT" || errnoOf(error) === "ENOTDIR") return out;
|
|
707
|
+
throw error;
|
|
708
|
+
}
|
|
709
|
+
for (const shard of shards) {
|
|
710
|
+
const temp = shard.name === ".tmp";
|
|
711
|
+
if (!shard.isDirectory() || !(temp || isShard(shard.name))) continue;
|
|
712
|
+
const shardPath = join2(root, shard.name);
|
|
713
|
+
let files;
|
|
714
|
+
try {
|
|
715
|
+
files = await readdir2(shardPath, { withFileTypes: true });
|
|
716
|
+
} catch (error) {
|
|
717
|
+
if (errnoOf(error) === "ENOENT" || errnoOf(error) === "ENOTDIR") continue;
|
|
718
|
+
throw error;
|
|
719
|
+
}
|
|
720
|
+
for (const file of files) {
|
|
721
|
+
if (!file.isFile()) continue;
|
|
722
|
+
const ok = temp ? isTempName(file.name) : isHash(file.name) && file.name.startsWith(shard.name);
|
|
723
|
+
if (!ok) continue;
|
|
724
|
+
const path = join2(shardPath, file.name);
|
|
725
|
+
const info = await lstat2(path).catch(() => void 0);
|
|
726
|
+
if (!info?.isFile()) continue;
|
|
727
|
+
out.push({ path, hash: temp ? null : file.name, size: info.size });
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
return out;
|
|
731
|
+
}
|
|
732
|
+
async function unlinkWriterOrphans(root, keep) {
|
|
733
|
+
let removed = 0;
|
|
734
|
+
const touched = /* @__PURE__ */ new Set();
|
|
735
|
+
for (const file of await writerBlobFiles(root)) {
|
|
736
|
+
if (file.hash !== null && keep.has(file.hash)) continue;
|
|
737
|
+
await rm(file.path, { force: true });
|
|
738
|
+
touched.add(dirname(file.path));
|
|
739
|
+
removed += 1;
|
|
740
|
+
}
|
|
741
|
+
for (const dir of touched) if (!dir.endsWith(".tmp")) await rmdir2(dir).catch(() => void 0);
|
|
742
|
+
return removed;
|
|
743
|
+
}
|
|
744
|
+
async function unlinkBlobsIn(root, keep) {
|
|
745
|
+
let removed = 0;
|
|
746
|
+
for (const shard of await entriesOf(root)) {
|
|
747
|
+
const shardPath = join2(root, shard);
|
|
748
|
+
if (shard === ".tmp") {
|
|
749
|
+
for (const name of await entriesOf(shardPath)) {
|
|
750
|
+
await rm(join2(shardPath, name), { force: true });
|
|
751
|
+
removed += 1;
|
|
752
|
+
}
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
let kept = 0;
|
|
756
|
+
for (const name of await entriesOf(shardPath)) {
|
|
757
|
+
if (isHash(name) && keep.has(name)) {
|
|
758
|
+
kept += 1;
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
await rm(join2(shardPath, name), { force: true });
|
|
762
|
+
removed += 1;
|
|
763
|
+
}
|
|
764
|
+
if (kept === 0) await rmdir2(shardPath).catch(() => void 0);
|
|
765
|
+
}
|
|
766
|
+
return removed;
|
|
767
|
+
}
|
|
314
768
|
async function entriesOf(path) {
|
|
315
769
|
try {
|
|
316
|
-
return await
|
|
770
|
+
return await readdir2(path);
|
|
317
771
|
} catch (error) {
|
|
318
772
|
if (errnoOf(error) === "ENOENT") return [];
|
|
319
773
|
throw error;
|
|
@@ -326,29 +780,77 @@ var pinnedDirectories = new FinalizationRegistry((fd) => {
|
|
|
326
780
|
} catch {
|
|
327
781
|
}
|
|
328
782
|
});
|
|
783
|
+
var writableStores = /* @__PURE__ */ new Map();
|
|
784
|
+
function releaseWritable(shared) {
|
|
785
|
+
if (shared.references === 0 || --shared.references !== 0) return;
|
|
786
|
+
if (writableStores.get(shared.key) === shared) writableStores.delete(shared.key);
|
|
787
|
+
try {
|
|
788
|
+
shared.db.close();
|
|
789
|
+
} finally {
|
|
790
|
+
if (shared.directoryFd !== void 0) closeSync(shared.directoryFd);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
var sharedStores = new FinalizationRegistry((shared) => {
|
|
794
|
+
try {
|
|
795
|
+
releaseWritable(shared);
|
|
796
|
+
} catch {
|
|
797
|
+
}
|
|
798
|
+
});
|
|
799
|
+
async function acquireWritableMutation(shared) {
|
|
800
|
+
const previous = shared.mutationTail;
|
|
801
|
+
let release;
|
|
802
|
+
shared.mutationTail = new Promise((resolve3) => {
|
|
803
|
+
release = resolve3;
|
|
804
|
+
});
|
|
805
|
+
await previous;
|
|
806
|
+
return release;
|
|
807
|
+
}
|
|
808
|
+
var activeReadSection = new AsyncLocalStorage();
|
|
329
809
|
var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
330
810
|
dir;
|
|
331
|
-
#
|
|
811
|
+
#database;
|
|
812
|
+
get #db() {
|
|
813
|
+
if (this.#closed) throw new ReplicaError(ReplicaErrorCode.CLOSED, "The replica store is closed.");
|
|
814
|
+
return this.#database;
|
|
815
|
+
}
|
|
816
|
+
#closed = false;
|
|
817
|
+
#closing = false;
|
|
818
|
+
#closePromise;
|
|
819
|
+
#activeMutations = 0;
|
|
820
|
+
#drained;
|
|
821
|
+
#shared;
|
|
822
|
+
#readonlySnapshot;
|
|
332
823
|
#pinnedDir;
|
|
333
824
|
#directoryFd;
|
|
334
|
-
#holder
|
|
825
|
+
#holder;
|
|
335
826
|
#now;
|
|
336
827
|
#faults;
|
|
337
828
|
#guard;
|
|
338
829
|
/** Identity of the database file this store opened: a deleted-and-recreated replica is a different file. */
|
|
339
830
|
#ino;
|
|
831
|
+
#admit;
|
|
832
|
+
#partitionId;
|
|
340
833
|
#dev;
|
|
341
834
|
#namedAncestors;
|
|
342
|
-
constructor(dir, db, identity, options, directoryFd, namedAncestors = []) {
|
|
835
|
+
constructor(dir, db, identity, options, directoryFd, namedAncestors = [], shared, readonlySnapshot = false) {
|
|
343
836
|
this.dir = dir;
|
|
344
|
-
this.#pinnedDir = directoryFd !== void 0 && process.platform === "linux" ? `/proc/self/fd/${directoryFd}` : dir;
|
|
837
|
+
this.#pinnedDir = shared?.pinnedDir ?? (directoryFd !== void 0 && process.platform === "linux" ? `/proc/self/fd/${directoryFd}` : dir);
|
|
345
838
|
this.#directoryFd = directoryFd;
|
|
346
|
-
|
|
347
|
-
this.#
|
|
839
|
+
this.#shared = shared;
|
|
840
|
+
this.#readonlySnapshot = readonlySnapshot;
|
|
841
|
+
if (shared) {
|
|
842
|
+
shared.references++;
|
|
843
|
+
sharedStores.register(this, shared, this);
|
|
844
|
+
} else if (directoryFd !== void 0) {
|
|
845
|
+
pinnedDirectories.register(this, directoryFd, this);
|
|
846
|
+
}
|
|
847
|
+
this.#database = db;
|
|
348
848
|
this.#ino = identity.ino;
|
|
349
849
|
this.#dev = identity.dev;
|
|
350
850
|
this.#now = options.now ?? Date.now;
|
|
351
851
|
this.#faults = options[FAULTS] ?? {};
|
|
852
|
+
this.#admit = options.admit;
|
|
853
|
+
this.#partitionId = options.partitionId ?? "";
|
|
352
854
|
this.#guard = options.guard ?? ((section) => section());
|
|
353
855
|
this.#namedAncestors = namedAncestors;
|
|
354
856
|
}
|
|
@@ -362,10 +864,10 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
362
864
|
if (options.signal?.aborted) throw options.signal.reason ?? new ReplicaError(ReplicaErrorCode.CLOSED, "Replica status inspection was cancelled.");
|
|
363
865
|
};
|
|
364
866
|
checkAbort();
|
|
365
|
-
const dbPath =
|
|
867
|
+
const dbPath = join2(dir, "replica.db");
|
|
366
868
|
let identity;
|
|
367
869
|
try {
|
|
368
|
-
identity = await
|
|
870
|
+
identity = await lstat2(dbPath);
|
|
369
871
|
if (!identity.isFile()) return null;
|
|
370
872
|
} catch (error) {
|
|
371
873
|
if (errnoOf(error) === "ENOENT") return null;
|
|
@@ -375,21 +877,30 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
375
877
|
const opener = await loadSqlite();
|
|
376
878
|
checkAbort();
|
|
377
879
|
let db;
|
|
880
|
+
let releaseOpen;
|
|
378
881
|
try {
|
|
379
|
-
|
|
882
|
+
releaseOpen = await acquireSqliteOpen();
|
|
883
|
+
const checked = await openVerifiedDatabase(dbPath, opener, { readonly: true, expected: identity });
|
|
884
|
+
db = checked.db;
|
|
885
|
+
releaseOpen();
|
|
886
|
+
releaseOpen = void 0;
|
|
380
887
|
db.exec("PRAGMA busy_timeout = 0");
|
|
381
888
|
checkAbort();
|
|
382
|
-
const store = new _SqliteReplicaStore(dir, db, identity, { create: false, ...options.now ? { now: options.now } : {} });
|
|
889
|
+
const store = new _SqliteReplicaStore(dir, db, checked.identity, { create: false, ...options.now ? { now: options.now } : {} });
|
|
383
890
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
384
891
|
const before = db.get("PRAGMA data_version").data_version;
|
|
385
892
|
const state = await store.open();
|
|
386
893
|
checkAbort();
|
|
387
894
|
if (state === null) return null;
|
|
388
895
|
const status = await store.status();
|
|
389
|
-
|
|
896
|
+
const latest = state.pendingGrant ?? state.grant;
|
|
897
|
+
const version = db.get("PRAGMA user_version")?.user_version ?? 0;
|
|
898
|
+
const grantInstall = latest === null || version < 2 ? null : await store.getGrantInstall(latest.cid);
|
|
899
|
+
const activeGrantInstall = state.grant === null || version < 2 ? null : state.grant === latest ? grantInstall : await store.getGrantInstall(state.grant.cid);
|
|
900
|
+
await new Promise((resolve3) => setImmediate(resolve3));
|
|
390
901
|
checkAbort();
|
|
391
902
|
const after = db.get("PRAGMA data_version").data_version;
|
|
392
|
-
if (before === after) return { state, status };
|
|
903
|
+
if (before === after) return { state, status, grantInstall, activeGrantInstall };
|
|
393
904
|
}
|
|
394
905
|
throw new ReplicaError(ReplicaErrorCode.BUSY, "The replica changed during status inspection.");
|
|
395
906
|
} catch (error) {
|
|
@@ -397,31 +908,87 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
397
908
|
throw new ReplicaError(ReplicaErrorCode.BUSY, "The replica is busy during status inspection.", void 0, { cause: error });
|
|
398
909
|
}
|
|
399
910
|
throw storageError(error, "Inspecting the replica");
|
|
911
|
+
} finally {
|
|
912
|
+
releaseOpen?.();
|
|
913
|
+
if (db) {
|
|
914
|
+
const releaseClose = await acquireSqliteOpen();
|
|
915
|
+
try {
|
|
916
|
+
db.close();
|
|
917
|
+
} finally {
|
|
918
|
+
releaseClose();
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* Blob content bytes on disk in the replica at `dir` (0 when absent): the writer's blob files under `blobs/`,
|
|
925
|
+
* including writer temp files and blobs no entry references any more, so garbage a failed sync
|
|
926
|
+
* left behind still counts. Counts exactly what `collectOrphans` may delete: foreign entries
|
|
927
|
+
* (stray files, symlinks, unknown names) are neither counted nor touched. Only blob content is counted: not the database, its WAL, rows or the
|
|
928
|
+
* pending store. Filesystem-only: it opens no
|
|
929
|
+
* database, so it can sum a profile's replicas under the caller's own mutation hold.
|
|
930
|
+
*/
|
|
931
|
+
static async contentBytes(dir) {
|
|
932
|
+
try {
|
|
933
|
+
let bytes = 0;
|
|
934
|
+
for (const file of await writerBlobFiles(join2(dir, "blobs"))) bytes += file.size;
|
|
935
|
+
return bytes;
|
|
936
|
+
} catch (error) {
|
|
937
|
+
throw storageError(error, "Measuring replica content");
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* Remove blobs and temp files in the replica at `dir` that no committed row references: what a
|
|
942
|
+
* crash between writing blobs and committing (or between committing and collecting) leaves.
|
|
943
|
+
* Returns the number of files removed; 0 when the replica is absent or unreadable.
|
|
944
|
+
*
|
|
945
|
+
* The caller MUST hold the profile mutation guard, which every writer of this replica holds
|
|
946
|
+
* across its blob write and commit; that is what makes an unreferenced blob safe to delete
|
|
947
|
+
* (it cannot belong to an in-flight write). Never call it without that hold. The database is
|
|
948
|
+
* read only, so nothing here can take a lease or change committed state.
|
|
949
|
+
*/
|
|
950
|
+
static async collectOrphans(dir) {
|
|
951
|
+
const dbPath = join2(dir, "replica.db");
|
|
952
|
+
const identity = await lstat2(dbPath).catch(() => void 0);
|
|
953
|
+
if (!identity?.isFile()) return 0;
|
|
954
|
+
const opener = await loadSqlite();
|
|
955
|
+
let keep;
|
|
956
|
+
let db;
|
|
957
|
+
try {
|
|
958
|
+
db = opener(dbPath, { readonly: true });
|
|
959
|
+
db.exec("PRAGMA busy_timeout = 0");
|
|
960
|
+
keep = new Set(db.all("SELECT DISTINCT hash FROM entry WHERE content = 1").map((row) => row.hash));
|
|
961
|
+
} catch {
|
|
962
|
+
return 0;
|
|
400
963
|
} finally {
|
|
401
964
|
db?.close();
|
|
402
965
|
}
|
|
966
|
+
try {
|
|
967
|
+
return await unlinkWriterOrphans(join2(dir, "blobs"), keep);
|
|
968
|
+
} catch (error) {
|
|
969
|
+
throw storageError(error, "Collecting orphaned content");
|
|
970
|
+
}
|
|
403
971
|
}
|
|
404
972
|
/**
|
|
405
973
|
* Open the replica store in `dir`. With `create: false` a missing replica is
|
|
406
974
|
* REPLICA_NOT_FOUND and nothing is written to disk.
|
|
407
975
|
*/
|
|
408
976
|
static async open(dir, options) {
|
|
409
|
-
const namedDbPath =
|
|
977
|
+
const namedDbPath = join2(dir, "replica.db");
|
|
410
978
|
if (!options.create) {
|
|
411
|
-
const exists = await
|
|
979
|
+
const exists = await lstat2(namedDbPath).then(
|
|
412
980
|
(stats) => stats.isFile(),
|
|
413
981
|
() => false
|
|
414
982
|
);
|
|
415
983
|
if (!exists) throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, `No regular replica database at ${dir}.`);
|
|
416
984
|
}
|
|
417
|
-
const opener = options.sqlite ?? await loadSqlite();
|
|
418
985
|
const releaseOpen = await acquireSqliteOpen();
|
|
419
986
|
let store;
|
|
420
987
|
let directoryFd;
|
|
421
988
|
try {
|
|
422
989
|
if (options.create) await mkdir(dir, { recursive: true, mode: 448 });
|
|
423
990
|
if (process.platform === "linux") {
|
|
424
|
-
directoryFd = openSync(dir,
|
|
991
|
+
directoryFd = openSync(dir, fsConstants2.O_RDONLY | fsConstants2.O_DIRECTORY | fsConstants2.O_NOFOLLOW);
|
|
425
992
|
}
|
|
426
993
|
const opened = directoryFd === void 0 ? lstatSync(dir, { bigint: true }) : fstatSync(directoryFd, { bigint: true });
|
|
427
994
|
if (!opened.isDirectory() || opened.isSymbolicLink() || options.expectedDirectory && (opened.dev !== BigInt(options.expectedDirectory.dev) || opened.ino !== BigInt(options.expectedDirectory.ino))) {
|
|
@@ -432,11 +999,11 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
432
999
|
throw new Error("The replica directory changed while checking its ancestry.");
|
|
433
1000
|
}
|
|
434
1001
|
const pinnedDir = directoryFd === void 0 ? dir : `/proc/self/fd/${directoryFd}`;
|
|
435
|
-
const dbPath =
|
|
1002
|
+
const dbPath = join2(pinnedDir, "replica.db");
|
|
436
1003
|
if (options.create) {
|
|
437
|
-
await mkdir(
|
|
438
|
-
await Promise.all([chmod(pinnedDir, 448), chmod(
|
|
439
|
-
const handle = await
|
|
1004
|
+
await mkdir(join2(pinnedDir, "blobs", ".tmp"), { recursive: true, mode: 448 });
|
|
1005
|
+
await Promise.all([chmod(pinnedDir, 448), chmod(join2(pinnedDir, "blobs"), 448), chmod(join2(pinnedDir, "blobs", ".tmp"), 448)]);
|
|
1006
|
+
const handle = await open2(dbPath, fsConstants2.O_CREAT | fsConstants2.O_EXCL | fsConstants2.O_RDWR | fsConstants2.O_NOFOLLOW, 384).catch((error) => {
|
|
440
1007
|
if (errnoOf(error) === "EEXIST") return null;
|
|
441
1008
|
throw error;
|
|
442
1009
|
});
|
|
@@ -448,71 +1015,107 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
448
1015
|
}
|
|
449
1016
|
}
|
|
450
1017
|
}
|
|
451
|
-
const leaf = await
|
|
1018
|
+
const leaf = await open2(dbPath, fsConstants2.O_RDONLY | fsConstants2.O_NOFOLLOW);
|
|
452
1019
|
let db;
|
|
453
1020
|
try {
|
|
454
1021
|
const identity = await leaf.stat();
|
|
455
|
-
const named = await
|
|
1022
|
+
const named = await lstat2(dbPath);
|
|
456
1023
|
if (!identity.isFile() || !named.isFile() || named.dev !== identity.dev || named.ino !== identity.ino) {
|
|
457
1024
|
throw new Error("The replica database changed before SQLite opened it.");
|
|
458
1025
|
}
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
1026
|
+
const key = `${opened.dev}:${opened.ino}`;
|
|
1027
|
+
const active = writableStores.get(key);
|
|
1028
|
+
if (active && (active.dev !== identity.dev || active.ino !== identity.ino)) {
|
|
1029
|
+
throw new Error("The replica database changed while another controller holds it open.");
|
|
1030
|
+
}
|
|
1031
|
+
if (active && !options.sqlite) {
|
|
1032
|
+
const namedDirectory = lstatSync(dir, { bigint: true });
|
|
1033
|
+
if (!namedDirectory.isDirectory() || namedDirectory.dev !== opened.dev || namedDirectory.ino !== opened.ino) {
|
|
1034
|
+
throw new Error("The replica directory changed before its checked SQLite handle could be shared.");
|
|
1035
|
+
}
|
|
1036
|
+
const pinned = active.directoryFd === void 0 ? lstatSync(active.pinnedDir, { bigint: true }) : fstatSync(active.directoryFd, { bigint: true });
|
|
1037
|
+
const activeLeaf = lstatSync(join2(active.pinnedDir, "replica.db"));
|
|
1038
|
+
if (!pinned.isDirectory() || pinned.dev !== opened.dev || pinned.ino !== opened.ino || !activeLeaf.isFile() || activeLeaf.dev !== identity.dev || activeLeaf.ino !== identity.ino || active.directoryFd === void 0 && active.namedPath !== dir) {
|
|
1039
|
+
throw new Error("The live replica changed before its checked SQLite handle could be shared.");
|
|
464
1040
|
}
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
1041
|
+
if (directoryFd !== void 0) {
|
|
1042
|
+
closeSync(directoryFd);
|
|
1043
|
+
directoryFd = void 0;
|
|
1044
|
+
}
|
|
1045
|
+
store = new _SqliteReplicaStore(dir, active.db, identity, options, active.directoryFd, namedAncestors, active);
|
|
470
1046
|
}
|
|
471
|
-
if (
|
|
472
|
-
|
|
473
|
-
const
|
|
1047
|
+
if (!store) {
|
|
1048
|
+
const opener = options.sqlite ?? await loadSqlite();
|
|
1049
|
+
const previousFds = process.platform === "linux" && !options.sqlite ? new Map(readdirSync("/proc/self/fd").map((fd) => {
|
|
474
1050
|
try {
|
|
475
|
-
|
|
476
|
-
return target.endsWith("/replica.db") && previousFds.get(fd) !== target;
|
|
1051
|
+
return [fd, readlinkSync(`/proc/self/fd/${fd}`)];
|
|
477
1052
|
} catch {
|
|
478
|
-
return
|
|
1053
|
+
return [fd, ""];
|
|
479
1054
|
}
|
|
480
|
-
});
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
1055
|
+
})) : null;
|
|
1056
|
+
db = opener(dbPath);
|
|
1057
|
+
const afterOpen = lstatSync(dbPath);
|
|
1058
|
+
if (!afterOpen.isFile() || afterOpen.dev !== identity.dev || afterOpen.ino !== identity.ino) {
|
|
1059
|
+
throw new Error("The replica database changed while SQLite opened it.");
|
|
1060
|
+
}
|
|
1061
|
+
if (previousFds) db.get("SELECT count(*) FROM sqlite_master");
|
|
1062
|
+
if (previousFds) {
|
|
1063
|
+
const databases = readdirSync("/proc/self/fd").filter((fd) => {
|
|
1064
|
+
try {
|
|
1065
|
+
const target = readlinkSync(`/proc/self/fd/${fd}`);
|
|
1066
|
+
return target.endsWith("/replica.db") && previousFds.get(fd) !== target;
|
|
1067
|
+
} catch {
|
|
1068
|
+
return false;
|
|
1069
|
+
}
|
|
1070
|
+
});
|
|
1071
|
+
if (databases.length === 0 || databases.some((fd) => {
|
|
1072
|
+
try {
|
|
1073
|
+
const actual = fstatSync(Number(fd));
|
|
1074
|
+
return actual.dev !== identity.dev || actual.ino !== identity.ino;
|
|
1075
|
+
} catch {
|
|
1076
|
+
return true;
|
|
1077
|
+
}
|
|
1078
|
+
})) {
|
|
1079
|
+
throw new Error("SQLite opened a different replica database.");
|
|
487
1080
|
}
|
|
488
|
-
})) {
|
|
489
|
-
throw new Error("SQLite opened a different replica database.");
|
|
490
1081
|
}
|
|
1082
|
+
const after = lstatSync(dbPath);
|
|
1083
|
+
if (!after.isFile() || after.dev !== identity.dev || after.ino !== identity.ino) {
|
|
1084
|
+
throw new Error("The replica database changed while SQLite opened it.");
|
|
1085
|
+
}
|
|
1086
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
1087
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
1088
|
+
db.exec("PRAGMA synchronous = FULL");
|
|
1089
|
+
db.exec("PRAGMA secure_delete = ON");
|
|
1090
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
1091
|
+
const version = db.get("PRAGMA user_version")?.user_version ?? 0;
|
|
1092
|
+
if (version > SCHEMA_VERSION) {
|
|
1093
|
+
throw new ReplicaError(
|
|
1094
|
+
ReplicaErrorCode.STORAGE_ERROR,
|
|
1095
|
+
`The replica at ${dir} was written by a newer tc (schema ${version}); upgrade tc to read it.`
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
if (version < SCHEMA_VERSION) {
|
|
1099
|
+
db.exec("BEGIN IMMEDIATE");
|
|
1100
|
+
db.exec(SCHEMA);
|
|
1101
|
+
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
1102
|
+
db.exec("COMMIT");
|
|
1103
|
+
}
|
|
1104
|
+
const shared = {
|
|
1105
|
+
key,
|
|
1106
|
+
db,
|
|
1107
|
+
directoryFd,
|
|
1108
|
+
pinnedDir,
|
|
1109
|
+
namedPath: dir,
|
|
1110
|
+
dev: identity.dev,
|
|
1111
|
+
ino: identity.ino,
|
|
1112
|
+
references: 0,
|
|
1113
|
+
mutationTail: Promise.resolve()
|
|
1114
|
+
};
|
|
1115
|
+
store = new _SqliteReplicaStore(dir, db, identity, options, directoryFd, namedAncestors, shared);
|
|
1116
|
+
if (!options.sqlite) writableStores.set(key, shared);
|
|
1117
|
+
db = void 0;
|
|
491
1118
|
}
|
|
492
|
-
const after = lstatSync(dbPath);
|
|
493
|
-
if (!after.isFile() || after.dev !== identity.dev || after.ino !== identity.ino) {
|
|
494
|
-
throw new Error("The replica database changed while SQLite opened it.");
|
|
495
|
-
}
|
|
496
|
-
db.exec("PRAGMA busy_timeout = 5000");
|
|
497
|
-
db.exec("PRAGMA journal_mode = WAL");
|
|
498
|
-
db.exec("PRAGMA synchronous = FULL");
|
|
499
|
-
db.exec("PRAGMA secure_delete = ON");
|
|
500
|
-
db.exec("PRAGMA foreign_keys = ON");
|
|
501
|
-
const version = db.get("PRAGMA user_version")?.user_version ?? 0;
|
|
502
|
-
if (version > SCHEMA_VERSION) {
|
|
503
|
-
throw new ReplicaError(
|
|
504
|
-
ReplicaErrorCode.STORAGE_ERROR,
|
|
505
|
-
`The replica at ${dir} was written by a newer tc (schema ${version}); upgrade tc to read it.`
|
|
506
|
-
);
|
|
507
|
-
}
|
|
508
|
-
if (version < SCHEMA_VERSION) {
|
|
509
|
-
db.exec("BEGIN IMMEDIATE");
|
|
510
|
-
db.exec(SCHEMA);
|
|
511
|
-
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
512
|
-
db.exec("COMMIT");
|
|
513
|
-
}
|
|
514
|
-
store = new _SqliteReplicaStore(dir, db, identity, options, directoryFd, namedAncestors);
|
|
515
|
-
db = void 0;
|
|
516
1119
|
} finally {
|
|
517
1120
|
try {
|
|
518
1121
|
db?.close();
|
|
@@ -538,12 +1141,109 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
538
1141
|
throw error;
|
|
539
1142
|
}
|
|
540
1143
|
}
|
|
1144
|
+
/**
|
|
1145
|
+
* A gated read borrows this controller's already-verified writable handle.
|
|
1146
|
+
* Borrow before the first await: the parent may close in the callback, but
|
|
1147
|
+
* its pin and native handle remain live until the snapshot releases them.
|
|
1148
|
+
* Serialize BEGIN through COMMIT/ROLLBACK with writes on the shared handle.
|
|
1149
|
+
*/
|
|
1150
|
+
async readTransaction(generation, checkGate, section) {
|
|
1151
|
+
this.#assertAccepting();
|
|
1152
|
+
const shared = this.#shared;
|
|
1153
|
+
if (!shared) throw new ReplicaError(ReplicaErrorCode.CLOSED, "The replica handle is closed.");
|
|
1154
|
+
const snapshot = new _SqliteReplicaStore(
|
|
1155
|
+
this.dir,
|
|
1156
|
+
shared.db,
|
|
1157
|
+
{ dev: this.#dev, ino: this.#ino },
|
|
1158
|
+
{ create: false, now: this.#now },
|
|
1159
|
+
shared.directoryFd,
|
|
1160
|
+
this.#namedAncestors,
|
|
1161
|
+
shared,
|
|
1162
|
+
true
|
|
1163
|
+
);
|
|
1164
|
+
let release;
|
|
1165
|
+
let active = false;
|
|
1166
|
+
let callbackFailed = false;
|
|
1167
|
+
try {
|
|
1168
|
+
release = await acquireWritableMutation(shared);
|
|
1169
|
+
await snapshot.#assertReadIdentity();
|
|
1170
|
+
snapshot.#db.exec("BEGIN");
|
|
1171
|
+
active = true;
|
|
1172
|
+
const tombstone = snapshot.#db.get("SELECT generation FROM replica WHERE id = 1");
|
|
1173
|
+
try {
|
|
1174
|
+
await checkGate();
|
|
1175
|
+
} catch (error) {
|
|
1176
|
+
callbackFailed = true;
|
|
1177
|
+
throw error;
|
|
1178
|
+
}
|
|
1179
|
+
if (tombstone === void 0 || tombstone.generation !== generation) {
|
|
1180
|
+
throw new ReplicaError(ReplicaErrorCode.RESET_REQUIRED, "The replica generation changed; reopen it to continue.");
|
|
1181
|
+
}
|
|
1182
|
+
let result;
|
|
1183
|
+
const context = { controller: this, active: true };
|
|
1184
|
+
try {
|
|
1185
|
+
try {
|
|
1186
|
+
result = await activeReadSection.run(context, () => section(snapshot));
|
|
1187
|
+
} catch (error) {
|
|
1188
|
+
callbackFailed = true;
|
|
1189
|
+
throw error;
|
|
1190
|
+
}
|
|
1191
|
+
} finally {
|
|
1192
|
+
context.active = false;
|
|
1193
|
+
}
|
|
1194
|
+
try {
|
|
1195
|
+
await checkGate();
|
|
1196
|
+
} catch (error) {
|
|
1197
|
+
callbackFailed = true;
|
|
1198
|
+
throw error;
|
|
1199
|
+
}
|
|
1200
|
+
await snapshot.#assertReadIdentity();
|
|
1201
|
+
snapshot.#db.exec("COMMIT");
|
|
1202
|
+
active = false;
|
|
1203
|
+
return result;
|
|
1204
|
+
} catch (error) {
|
|
1205
|
+
if (callbackFailed) throw error;
|
|
1206
|
+
if (isReplicaError(error) || typeof error === "object" && error !== null && "code" in error && error.code === "REPLICA_GATED") throw error;
|
|
1207
|
+
if (["ENOENT", "ELOOP", "ENOTDIR"].includes(String(errnoOf(error))) || /replica database changed|SQLite opened a different replica database/i.test(error instanceof Error ? error.message : String(error))) {
|
|
1208
|
+
throw new ReplicaError(ReplicaErrorCode.RESET_REQUIRED, "The replica was replaced; reopen it to continue.", void 0, { cause: error });
|
|
1209
|
+
}
|
|
1210
|
+
throw storageError(error, "Reading the replica snapshot");
|
|
1211
|
+
} finally {
|
|
1212
|
+
if (active) {
|
|
1213
|
+
try {
|
|
1214
|
+
snapshot.#db.exec("ROLLBACK");
|
|
1215
|
+
} catch {
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
try {
|
|
1219
|
+
await snapshot.close();
|
|
1220
|
+
} finally {
|
|
1221
|
+
release?.();
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
async #assertReadIdentity() {
|
|
1226
|
+
const shared = this.#shared;
|
|
1227
|
+
const pinned = shared.directoryFd === void 0 ? lstatSync(shared.pinnedDir) : fstatSync(shared.directoryFd);
|
|
1228
|
+
const namedDir = lstatSync(this.dir);
|
|
1229
|
+
const namedDb = lstatSync(join2(this.dir, "replica.db"));
|
|
1230
|
+
const pinnedDb = lstatSync(join2(shared.pinnedDir, "replica.db"));
|
|
1231
|
+
if (!pinned.isDirectory() || !namedDir.isDirectory() || pinned.dev !== namedDir.dev || pinned.ino !== namedDir.ino || !namedDb.isFile() || !pinnedDb.isFile() || namedDb.dev !== this.#dev || namedDb.ino !== this.#ino || pinnedDb.dev !== this.#dev || pinnedDb.ino !== this.#ino) {
|
|
1232
|
+
throw new ReplicaError(ReplicaErrorCode.RESET_REQUIRED, "The replica was replaced during its read; reopen it to continue.");
|
|
1233
|
+
}
|
|
1234
|
+
if (this.#namedAncestors.length) {
|
|
1235
|
+
const ancestors = await captureNamedPath(this.dir);
|
|
1236
|
+
if (ancestors.length !== this.#namedAncestors.length || ancestors.some((entry, index) => entry.dev !== this.#namedAncestors[index].dev || entry.ino !== this.#namedAncestors[index].ino)) {
|
|
1237
|
+
throw new ReplicaError(ReplicaErrorCode.RESET_REQUIRED, "The replica directory was replaced; reopen it to continue.");
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
541
1241
|
#row() {
|
|
542
1242
|
return this.#db.get("SELECT * FROM replica WHERE id = 1");
|
|
543
1243
|
}
|
|
544
1244
|
/** Refuse to touch the filesystem for a replica that was deleted (or replaced) under us. */
|
|
545
1245
|
async #assertPresent() {
|
|
546
|
-
const identity = await
|
|
1246
|
+
const identity = await lstat2(join2(this.#pinnedDir, "replica.db")).catch(() => null);
|
|
547
1247
|
if (!identity?.isFile() || identity.dev !== this.#dev || identity.ino !== this.#ino) {
|
|
548
1248
|
throw new ReplicaError(
|
|
549
1249
|
ReplicaErrorCode.NOT_FOUND,
|
|
@@ -551,30 +1251,63 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
551
1251
|
);
|
|
552
1252
|
}
|
|
553
1253
|
}
|
|
1254
|
+
/** New calls stop at close; accepted mutation sections may still finish their transactions. */
|
|
1255
|
+
#assertAccepting() {
|
|
1256
|
+
if (this.#closing || this.#closed) {
|
|
1257
|
+
throw new ReplicaError(ReplicaErrorCode.CLOSED, "The replica store is closed.");
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
554
1260
|
/** A short mutation section: guarded (profile lock) and checked against the database this store opened. */
|
|
555
1261
|
#mutate(section) {
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
}
|
|
1262
|
+
this.#assertAccepting();
|
|
1263
|
+
if (this.#readonlySnapshot) {
|
|
1264
|
+
throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "A read-only replica snapshot cannot mutate the shared database.");
|
|
1265
|
+
}
|
|
1266
|
+
const read = activeReadSection.getStore();
|
|
1267
|
+
if (read?.active && read.controller === this) {
|
|
1268
|
+
throw new ReplicaError(
|
|
1269
|
+
ReplicaErrorCode.STORAGE_ERROR,
|
|
1270
|
+
"mutation attempted on the same controller while its readTransaction section is active; this would deadlock"
|
|
1271
|
+
);
|
|
1272
|
+
}
|
|
1273
|
+
this.#activeMutations++;
|
|
1274
|
+
return (async () => {
|
|
1275
|
+
try {
|
|
1276
|
+
return await this.#guard(async () => {
|
|
1277
|
+
await this.#assertPresent();
|
|
1278
|
+
return section();
|
|
1279
|
+
});
|
|
1280
|
+
} finally {
|
|
1281
|
+
if (--this.#activeMutations === 0) {
|
|
1282
|
+
const drained = this.#drained;
|
|
1283
|
+
this.#drained = void 0;
|
|
1284
|
+
drained?.();
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
})();
|
|
560
1288
|
}
|
|
561
1289
|
/** Run `body` in a write transaction (BEGIN IMMEDIATE). Callers outside a mutation section use `#guardedWrite`. */
|
|
562
|
-
#write(action, body) {
|
|
563
|
-
|
|
564
|
-
this.#db.exec("BEGIN IMMEDIATE");
|
|
565
|
-
} catch (error) {
|
|
566
|
-
throw storageError(error, action);
|
|
567
|
-
}
|
|
1290
|
+
async #write(action, body) {
|
|
1291
|
+
const release = this.#shared ? await acquireWritableMutation(this.#shared) : void 0;
|
|
568
1292
|
try {
|
|
569
|
-
const result = body();
|
|
570
|
-
this.#db.exec("COMMIT");
|
|
571
|
-
return result;
|
|
572
|
-
} catch (error) {
|
|
573
1293
|
try {
|
|
574
|
-
this.#db.exec("
|
|
575
|
-
} catch {
|
|
1294
|
+
this.#db.exec("BEGIN IMMEDIATE");
|
|
1295
|
+
} catch (error) {
|
|
1296
|
+
throw storageError(error, action);
|
|
1297
|
+
}
|
|
1298
|
+
try {
|
|
1299
|
+
const result = body();
|
|
1300
|
+
this.#db.exec("COMMIT");
|
|
1301
|
+
return result;
|
|
1302
|
+
} catch (error) {
|
|
1303
|
+
try {
|
|
1304
|
+
this.#db.exec("ROLLBACK");
|
|
1305
|
+
} catch {
|
|
1306
|
+
}
|
|
1307
|
+
throw storageError(error, action);
|
|
576
1308
|
}
|
|
577
|
-
|
|
1309
|
+
} finally {
|
|
1310
|
+
release?.();
|
|
578
1311
|
}
|
|
579
1312
|
}
|
|
580
1313
|
/** One write transaction as its own mutation section. */
|
|
@@ -601,6 +1334,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
601
1334
|
}
|
|
602
1335
|
}
|
|
603
1336
|
async open() {
|
|
1337
|
+
this.#assertAccepting();
|
|
604
1338
|
let row;
|
|
605
1339
|
try {
|
|
606
1340
|
row = this.#row();
|
|
@@ -679,6 +1413,110 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
679
1413
|
);
|
|
680
1414
|
});
|
|
681
1415
|
}
|
|
1416
|
+
/** Per-CID first-install anchor and policy bound share the pending-grant transaction. */
|
|
1417
|
+
#grantInstall(cid) {
|
|
1418
|
+
const row = this.#db.get(
|
|
1419
|
+
"SELECT installed_at, effective_expires_at, consent_deadline, consent_anchor_at, consent_anchor_source FROM grant_install WHERE cid = ?",
|
|
1420
|
+
cid
|
|
1421
|
+
);
|
|
1422
|
+
if (row === void 0) return null;
|
|
1423
|
+
if (!(row.consent_anchor_source === "siwe" || row.consent_anchor_source === "delegation_iat" || row.consent_anchor_source === "delegation_nbf" || row.consent_anchor_source === "first_install") || !Number.isSafeInteger(row.installed_at) || row.installed_at < 0 || !Number.isSafeInteger(row.effective_expires_at) || row.effective_expires_at < 0 || !Number.isSafeInteger(row.consent_deadline) || row.consent_deadline < 0 || !Number.isSafeInteger(row.consent_anchor_at) || row.consent_anchor_at < 0) {
|
|
1424
|
+
throw new ReplicaError(ReplicaErrorCode.GRANT_EXPIRED, "The stored grant expiry or consent anchor is invalid.");
|
|
1425
|
+
}
|
|
1426
|
+
return {
|
|
1427
|
+
installedAt: row.installed_at,
|
|
1428
|
+
effectiveExpiresAt: row.effective_expires_at,
|
|
1429
|
+
consentDeadline: row.consent_deadline,
|
|
1430
|
+
consentAnchor: { at: row.consent_anchor_at, source: row.consent_anchor_source }
|
|
1431
|
+
};
|
|
1432
|
+
}
|
|
1433
|
+
async getGrantInstall(cid) {
|
|
1434
|
+
return this.#grantInstall(cid);
|
|
1435
|
+
}
|
|
1436
|
+
/** Every installed CID, including superseded ones, for an origin-wide purge floor. */
|
|
1437
|
+
async grantInstallTimes() {
|
|
1438
|
+
const rows = this.#db.all(
|
|
1439
|
+
"SELECT cid, installed_at FROM grant_install"
|
|
1440
|
+
);
|
|
1441
|
+
return rows.map((row) => {
|
|
1442
|
+
if (typeof row.cid !== "string" || row.cid.length === 0 || !Number.isSafeInteger(row.installed_at) || row.installed_at < 0) {
|
|
1443
|
+
throw new ReplicaError(ReplicaErrorCode.GRANT_EXPIRED, "The stored grant install time is invalid.");
|
|
1444
|
+
}
|
|
1445
|
+
return { cid: row.cid, installedAt: row.installed_at };
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
/** Phase 2 installed grants cannot commit a page after their shorter local TTL. */
|
|
1449
|
+
#assertInstalledGrantTime() {
|
|
1450
|
+
const row = this.#row();
|
|
1451
|
+
const cid = row?.pending_grant_cid ?? row?.grant_cid;
|
|
1452
|
+
if (cid === void 0 || cid === null) return;
|
|
1453
|
+
const installed = this.#grantInstall(cid);
|
|
1454
|
+
const now = this.#now();
|
|
1455
|
+
if (installed !== null && (now >= installed.effectiveExpiresAt || now >= installed.consentDeadline)) {
|
|
1456
|
+
throw new ReplicaError(ReplicaErrorCode.GRANT_EXPIRED, "The installed grant expired before this page committed.");
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
#storeGrantPolicy(g, policy, existing) {
|
|
1460
|
+
const installedAt = existing?.installedAt ?? policy.at;
|
|
1461
|
+
const keepExisting = existing !== null && existing.consentDeadline < policy.consentDeadline;
|
|
1462
|
+
const consentDeadline = keepExisting ? existing.consentDeadline : policy.consentDeadline;
|
|
1463
|
+
const consentAnchor = keepExisting ? existing.consentAnchor : policy.consentAnchor;
|
|
1464
|
+
const effectiveExpiresAt = Math.min(
|
|
1465
|
+
g.expiresAt === null ? Infinity : g.expiresAt * 1e3,
|
|
1466
|
+
installedAt + policy.ttlMs,
|
|
1467
|
+
consentDeadline
|
|
1468
|
+
);
|
|
1469
|
+
if (!Number.isSafeInteger(installedAt) || installedAt < 0 || !Number.isSafeInteger(effectiveExpiresAt) || effectiveExpiresAt < 0 || !Number.isSafeInteger(consentDeadline) || consentDeadline < 0 || !Number.isSafeInteger(consentAnchor.at) || consentAnchor.at < 0 || !Number.isSafeInteger(policy.ttlMs) || policy.ttlMs <= 0) {
|
|
1470
|
+
throw new ReplicaError(ReplicaErrorCode.GRANT_EXPIRED, "The replica grant has no finite effective expiry.");
|
|
1471
|
+
}
|
|
1472
|
+
this.#db.run(
|
|
1473
|
+
`INSERT INTO grant_install (cid, installed_at, effective_expires_at, consent_deadline, consent_anchor_at, consent_anchor_source)
|
|
1474
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
1475
|
+
ON CONFLICT (cid) DO UPDATE SET effective_expires_at = excluded.effective_expires_at,
|
|
1476
|
+
consent_deadline = excluded.consent_deadline, consent_anchor_at = excluded.consent_anchor_at,
|
|
1477
|
+
consent_anchor_source = excluded.consent_anchor_source`,
|
|
1478
|
+
g.cid,
|
|
1479
|
+
installedAt,
|
|
1480
|
+
effectiveExpiresAt,
|
|
1481
|
+
consentDeadline,
|
|
1482
|
+
consentAnchor.at,
|
|
1483
|
+
consentAnchor.source
|
|
1484
|
+
);
|
|
1485
|
+
return { installedAt, effectiveExpiresAt, consentDeadline, consentAnchor };
|
|
1486
|
+
}
|
|
1487
|
+
async installGatedGrant(g, policy) {
|
|
1488
|
+
return this.#guardedWrite("Installing the gated grant", () => {
|
|
1489
|
+
const row = this.#row();
|
|
1490
|
+
if (row === void 0) throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica has not been created.");
|
|
1491
|
+
const previous = this.#grantInstall(g.cid);
|
|
1492
|
+
if (previous === null && (row.grant_cid === g.cid || row.pending_grant_cid === g.cid)) {
|
|
1493
|
+
throw new ReplicaError(ReplicaErrorCode.GRANT_EXPIRED, "The installed grant has no durable first-install time.");
|
|
1494
|
+
}
|
|
1495
|
+
const next = this.#storeGrantPolicy(g, policy, previous);
|
|
1496
|
+
if (next.effectiveExpiresAt <= this.#now()) {
|
|
1497
|
+
throw new ReplicaError(ReplicaErrorCode.GRANT_EXPIRED, "The replica grant expired under the current policy.");
|
|
1498
|
+
}
|
|
1499
|
+
if (row.grant_cid !== g.cid && row.pending_grant_cid !== g.cid) {
|
|
1500
|
+
this.#db.run(
|
|
1501
|
+
`UPDATE replica SET pending_grant_cid = ?, pending_grant_bytes = ?, pending_grant_audience = ?,
|
|
1502
|
+
pending_grant_issuer = ?, pending_grant_nbf = ?, pending_grant_exp = ?, pending_grant_error = NULL WHERE id = 1`,
|
|
1503
|
+
g.cid,
|
|
1504
|
+
g.bytes,
|
|
1505
|
+
g.audience,
|
|
1506
|
+
g.issuer,
|
|
1507
|
+
g.notBefore,
|
|
1508
|
+
g.expiresAt
|
|
1509
|
+
);
|
|
1510
|
+
}
|
|
1511
|
+
return next;
|
|
1512
|
+
});
|
|
1513
|
+
}
|
|
1514
|
+
async recomputeGrantInstall(g, policy) {
|
|
1515
|
+
return this.#guardedWrite("Recomputing the gated grant", () => {
|
|
1516
|
+
const previous = this.#grantInstall(g.cid);
|
|
1517
|
+
return previous === null ? null : this.#storeGrantPolicy(g, policy, previous);
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
682
1520
|
async installGrant(g) {
|
|
683
1521
|
await this.#guardedWrite("Installing the grant", () => {
|
|
684
1522
|
const row = this.#row();
|
|
@@ -737,13 +1575,14 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
737
1575
|
const now = this.#now();
|
|
738
1576
|
if (row.lease_holder !== null && row.lease_expires_at !== null && row.lease_expires_at > now) return null;
|
|
739
1577
|
const token = row.lease_token + 1;
|
|
1578
|
+
const holder = this.#holder ??= `${process.pid}-${randomBytes(6).toString("hex")}`;
|
|
740
1579
|
this.#db.run(
|
|
741
1580
|
"UPDATE replica SET lease_token = ?, lease_holder = ?, lease_expires_at = ? WHERE id = 1",
|
|
742
1581
|
token,
|
|
743
|
-
|
|
1582
|
+
holder,
|
|
744
1583
|
now + ttlMs
|
|
745
1584
|
);
|
|
746
|
-
return { token, holder
|
|
1585
|
+
return { token, holder };
|
|
747
1586
|
});
|
|
748
1587
|
}
|
|
749
1588
|
async renewLease(t, ttlMs) {
|
|
@@ -762,6 +1601,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
762
1601
|
});
|
|
763
1602
|
}
|
|
764
1603
|
async hasContent(hashes) {
|
|
1604
|
+
this.#assertAccepting();
|
|
765
1605
|
const held = /* @__PURE__ */ new Set();
|
|
766
1606
|
for (let start = 0; start < hashes.length; start += 500) {
|
|
767
1607
|
const chunk = hashes.slice(start, start + 500);
|
|
@@ -774,7 +1614,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
774
1614
|
return held;
|
|
775
1615
|
}
|
|
776
1616
|
#blobPath(hash) {
|
|
777
|
-
return
|
|
1617
|
+
return join2(this.#pinnedDir, "blobs", hash.slice(0, 2), hash);
|
|
778
1618
|
}
|
|
779
1619
|
/**
|
|
780
1620
|
* Make every blob durable before any row references it: write to a fresh
|
|
@@ -791,12 +1631,12 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
791
1631
|
() => false
|
|
792
1632
|
);
|
|
793
1633
|
if (exists) continue;
|
|
794
|
-
const shard =
|
|
1634
|
+
const shard = join2(this.#pinnedDir, "blobs", hash.slice(0, 2));
|
|
795
1635
|
await mkdir(shard, { mode: 448 }).catch((error) => {
|
|
796
1636
|
if (errnoOf(error) !== "EEXIST") throw error;
|
|
797
1637
|
});
|
|
798
|
-
const temp =
|
|
799
|
-
const handle = await
|
|
1638
|
+
const temp = join2(this.#pinnedDir, "blobs", ".tmp", `${hash}.${randomBytes(6).toString("hex")}`);
|
|
1639
|
+
const handle = await open2(temp, fsConstants2.O_CREAT | fsConstants2.O_EXCL | fsConstants2.O_WRONLY, 384);
|
|
800
1640
|
try {
|
|
801
1641
|
await handle.writeFile(bytes);
|
|
802
1642
|
await handle.sync();
|
|
@@ -808,13 +1648,13 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
808
1648
|
} catch (error) {
|
|
809
1649
|
if (errnoOf(error) !== "EEXIST") throw error;
|
|
810
1650
|
} finally {
|
|
811
|
-
await
|
|
1651
|
+
await unlink2(temp);
|
|
812
1652
|
}
|
|
813
1653
|
dirs.add(shard);
|
|
814
|
-
dirs.add(
|
|
1654
|
+
dirs.add(join2(this.#pinnedDir, "blobs"));
|
|
815
1655
|
}
|
|
816
1656
|
for (const dir of dirs) {
|
|
817
|
-
const handle = await
|
|
1657
|
+
const handle = await open2(dir, fsConstants2.O_RDONLY);
|
|
818
1658
|
try {
|
|
819
1659
|
await handle.sync();
|
|
820
1660
|
} finally {
|
|
@@ -825,6 +1665,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
825
1665
|
async applyPage(t, p) {
|
|
826
1666
|
await this.#mutate(async () => {
|
|
827
1667
|
this.#checkLease(t);
|
|
1668
|
+
this.#assertInstalledGrantTime();
|
|
828
1669
|
const reused = /* @__PURE__ */ new Map();
|
|
829
1670
|
for (const change of p.changes) {
|
|
830
1671
|
if (change.deleted || !change.content || p.blobs.has(change.hash) || reused.has(change.hash)) continue;
|
|
@@ -837,6 +1678,41 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
837
1678
|
}
|
|
838
1679
|
reused.set(change.hash, size);
|
|
839
1680
|
}
|
|
1681
|
+
if (this.#admit) {
|
|
1682
|
+
await this.#collectLocked(t);
|
|
1683
|
+
const changed = new Set(p.changes.map((change) => change.key));
|
|
1684
|
+
const kept = /* @__PURE__ */ new Set();
|
|
1685
|
+
for (const change of p.changes) if (!change.deleted && change.content) kept.add(change.hash);
|
|
1686
|
+
let blobBytes = 0;
|
|
1687
|
+
for (const [hash, bytes] of p.blobs) {
|
|
1688
|
+
const stored = this.#db.get(
|
|
1689
|
+
"SELECT 1 AS hit FROM entry WHERE hash = ? AND content = 1 AND deleted = 0 LIMIT 1",
|
|
1690
|
+
hash
|
|
1691
|
+
);
|
|
1692
|
+
if (stored === void 0) blobBytes += bytes.byteLength;
|
|
1693
|
+
}
|
|
1694
|
+
const released = /* @__PURE__ */ new Map();
|
|
1695
|
+
for (const key of changed) {
|
|
1696
|
+
const old = this.#db.get(
|
|
1697
|
+
"SELECT hash, size FROM entry WHERE key = ? AND deleted = 0 AND content = 1 AND hash IS NOT NULL AND size IS NOT NULL",
|
|
1698
|
+
key
|
|
1699
|
+
);
|
|
1700
|
+
if (old === void 0 || kept.has(old.hash) || released.has(old.hash)) continue;
|
|
1701
|
+
const holders = this.#db.all(
|
|
1702
|
+
"SELECT key FROM entry WHERE hash = ? AND content = 1 AND deleted = 0",
|
|
1703
|
+
old.hash
|
|
1704
|
+
);
|
|
1705
|
+
if (holders.every((holder) => changed.has(holder.key))) released.set(old.hash, old.size);
|
|
1706
|
+
}
|
|
1707
|
+
let freed = 0;
|
|
1708
|
+
for (const size of released.values()) freed += size;
|
|
1709
|
+
await this.#admit({
|
|
1710
|
+
partitionId: this.#partitionId,
|
|
1711
|
+
blobBytes: blobBytes - freed,
|
|
1712
|
+
entries: p.changes.length,
|
|
1713
|
+
freeBytes: null
|
|
1714
|
+
});
|
|
1715
|
+
}
|
|
840
1716
|
try {
|
|
841
1717
|
await this.#writeBlobs(p.blobs);
|
|
842
1718
|
} catch (error) {
|
|
@@ -844,8 +1720,9 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
844
1720
|
}
|
|
845
1721
|
await this.#faults.afterBlobs?.();
|
|
846
1722
|
try {
|
|
847
|
-
this.#write("Committing a sync page", () => {
|
|
1723
|
+
await this.#write("Committing a sync page", () => {
|
|
848
1724
|
this.#checkLease(t);
|
|
1725
|
+
this.#assertInstalledGrantTime();
|
|
849
1726
|
const now = this.#now();
|
|
850
1727
|
if (p.window.notBefore !== null && now < Date.parse(p.window.notBefore)) {
|
|
851
1728
|
throw new ReplicaError(ReplicaErrorCode.GRANT_NOT_YET_VALID, `The grant is not valid before ${p.window.notBefore}; nothing was committed.`);
|
|
@@ -911,72 +1788,59 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
911
1788
|
});
|
|
912
1789
|
} catch (error) {
|
|
913
1790
|
if (isReplicaError(error, ReplicaErrorCode.GRANT_REVOKED)) await this.#purgeContent().catch(() => void 0);
|
|
1791
|
+
else if (this.#admit) await this.#collectLocked(t).catch(() => void 0);
|
|
914
1792
|
throw error;
|
|
915
1793
|
}
|
|
916
1794
|
this.#faults.afterCommit?.();
|
|
1795
|
+
if (this.#admit) await this.#collectLocked(t);
|
|
917
1796
|
});
|
|
918
1797
|
}
|
|
919
1798
|
/** Unlink every blob not in `keep`, and every temp file. Only a missing directory counts as empty. */
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
const root = join(this.#pinnedDir, "blobs");
|
|
923
|
-
for (const shard of await entriesOf(root)) {
|
|
924
|
-
const shardPath = join(root, shard);
|
|
925
|
-
if (shard === ".tmp") {
|
|
926
|
-
for (const name of await entriesOf(shardPath)) {
|
|
927
|
-
await rm(join(shardPath, name), { force: true });
|
|
928
|
-
removed += 1;
|
|
929
|
-
}
|
|
930
|
-
continue;
|
|
931
|
-
}
|
|
932
|
-
let kept = 0;
|
|
933
|
-
for (const name of await entriesOf(shardPath)) {
|
|
934
|
-
if (isHash(name) && keep.has(name)) {
|
|
935
|
-
kept += 1;
|
|
936
|
-
continue;
|
|
937
|
-
}
|
|
938
|
-
await rm(join(shardPath, name), { force: true });
|
|
939
|
-
removed += 1;
|
|
940
|
-
}
|
|
941
|
-
if (kept === 0) await rmdir(shardPath).catch(() => void 0);
|
|
942
|
-
}
|
|
943
|
-
return removed;
|
|
1799
|
+
#unlinkBlobs(keep) {
|
|
1800
|
+
return unlinkBlobsIn(join2(this.#pinnedDir, "blobs"), keep);
|
|
944
1801
|
}
|
|
945
1802
|
/** Whether any content file (a blob or a temp file) is on disk. */
|
|
946
1803
|
async #holdsContentFiles() {
|
|
947
|
-
const root =
|
|
1804
|
+
const root = join2(this.#pinnedDir, "blobs");
|
|
948
1805
|
for (const shard of await entriesOf(root)) {
|
|
949
|
-
if ((await entriesOf(
|
|
1806
|
+
if ((await entriesOf(join2(root, shard))).length > 0) return true;
|
|
950
1807
|
}
|
|
951
1808
|
return false;
|
|
952
1809
|
}
|
|
953
1810
|
async collectGarbage(t) {
|
|
954
|
-
return this.#mutate(
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
1811
|
+
return this.#mutate(() => this.#collectLocked(t));
|
|
1812
|
+
}
|
|
1813
|
+
/** `collectGarbage` for callers already inside the mutation hold (it cannot re-enter `#mutate`). */
|
|
1814
|
+
async #collectLocked(t) {
|
|
1815
|
+
const release = this.#shared ? await acquireWritableMutation(this.#shared) : void 0;
|
|
1816
|
+
try {
|
|
1817
|
+
this.#db.exec("BEGIN IMMEDIATE");
|
|
1818
|
+
} catch (error) {
|
|
1819
|
+
release?.();
|
|
1820
|
+
throw storageError(error, "Collecting unused content");
|
|
1821
|
+
}
|
|
1822
|
+
try {
|
|
1823
|
+
this.#checkLease(t);
|
|
1824
|
+
await this.#faults.afterGarbageCollectionBegin?.();
|
|
1825
|
+
const keep = new Set(
|
|
1826
|
+
this.#db.all("SELECT DISTINCT hash FROM entry WHERE content = 1").map((row) => row.hash)
|
|
1827
|
+
);
|
|
1828
|
+
const removed = await this.#unlinkBlobs(keep);
|
|
1829
|
+
this.#db.exec("COMMIT");
|
|
1830
|
+
return removed;
|
|
1831
|
+
} catch (error) {
|
|
960
1832
|
try {
|
|
961
|
-
this.#
|
|
962
|
-
|
|
963
|
-
this.#db.all("SELECT DISTINCT hash FROM entry WHERE content = 1").map((row) => row.hash)
|
|
964
|
-
);
|
|
965
|
-
const removed = await this.#unlinkBlobs(keep);
|
|
966
|
-
this.#db.exec("COMMIT");
|
|
967
|
-
return removed;
|
|
968
|
-
} catch (error) {
|
|
969
|
-
try {
|
|
970
|
-
this.#db.exec("ROLLBACK");
|
|
971
|
-
} catch {
|
|
972
|
-
}
|
|
973
|
-
throw storageError(error, "Collecting unused content");
|
|
1833
|
+
this.#db.exec("ROLLBACK");
|
|
1834
|
+
} catch {
|
|
974
1835
|
}
|
|
975
|
-
|
|
1836
|
+
throw storageError(error, "Collecting unused content");
|
|
1837
|
+
} finally {
|
|
1838
|
+
release?.();
|
|
1839
|
+
}
|
|
976
1840
|
}
|
|
977
1841
|
async reset(t, reason, options = {}) {
|
|
978
1842
|
await this.#mutate(async () => {
|
|
979
|
-
this.#write("Resetting the replica", () => {
|
|
1843
|
+
await this.#write("Resetting the replica", () => {
|
|
980
1844
|
this.#checkLease(t);
|
|
981
1845
|
this.#db.run("DELETE FROM entry");
|
|
982
1846
|
this.#db.run(
|
|
@@ -995,17 +1859,16 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
995
1859
|
}
|
|
996
1860
|
}
|
|
997
1861
|
/**
|
|
998
|
-
* Remove
|
|
999
|
-
*
|
|
1000
|
-
* store opened, checked under the write lock, so a paused process can
|
|
1001
|
-
* never remove a replica another process took over or recreated. Allowed
|
|
1002
|
-
* on a revoked replica. The store is unusable afterwards; close it.
|
|
1862
|
+
* Remove the source-created mirror layout under a live lease. Unknown files
|
|
1863
|
+
* are removed only when their exact preflight identities were approved.
|
|
1003
1864
|
*/
|
|
1004
|
-
async destroy(t) {
|
|
1865
|
+
async destroy(t, options = {}) {
|
|
1005
1866
|
await this.#mutate(async () => {
|
|
1867
|
+
const release = this.#shared ? await acquireWritableMutation(this.#shared) : void 0;
|
|
1006
1868
|
try {
|
|
1007
1869
|
this.#db.exec("BEGIN IMMEDIATE");
|
|
1008
1870
|
} catch (error) {
|
|
1871
|
+
release?.();
|
|
1009
1872
|
throw storageError(error, "Removing the replica");
|
|
1010
1873
|
}
|
|
1011
1874
|
try {
|
|
@@ -1015,87 +1878,31 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
1015
1878
|
throw new ReplicaError(ReplicaErrorCode.RUNTIME_UNSUPPORTED, "Replica removal requires an opened or identity-checked directory.");
|
|
1016
1879
|
}
|
|
1017
1880
|
const assertDirectory = async () => {
|
|
1018
|
-
const current = await
|
|
1881
|
+
const current = await lstat2(this.dir, { bigint: true });
|
|
1019
1882
|
if (!current.isDirectory() || current.dev !== expected.dev || current.ino !== expected.ino) {
|
|
1020
1883
|
throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica directory changed during removal.");
|
|
1021
1884
|
}
|
|
1022
1885
|
return current;
|
|
1023
1886
|
};
|
|
1024
1887
|
const pinned = await assertDirectory();
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
await assertDirectory();
|
|
1031
|
-
await rmdir(this.dir);
|
|
1032
|
-
} else {
|
|
1033
|
-
const ancestors = this.#namedAncestors;
|
|
1034
|
-
const checkAncestors = async () => {
|
|
1035
|
-
for (const expected2 of ancestors) {
|
|
1036
|
-
const current = await lstat(expected2.path, { bigint: true });
|
|
1037
|
-
if (current.dev !== expected2.dev || current.ino !== expected2.ino) {
|
|
1038
|
-
throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica ancestry changed during removal.");
|
|
1039
|
-
}
|
|
1888
|
+
const assertNamedPath = async () => {
|
|
1889
|
+
for (const ancestor of this.#namedAncestors) {
|
|
1890
|
+
const current = await lstat2(ancestor.path, { bigint: true });
|
|
1891
|
+
if (current.dev !== ancestor.dev || current.ino !== ancestor.ino) {
|
|
1892
|
+
throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica ancestry changed during removal.");
|
|
1040
1893
|
}
|
|
1041
|
-
};
|
|
1042
|
-
const directories = /* @__PURE__ */ new Map([[this.dir, pinned]]);
|
|
1043
|
-
const files = /* @__PURE__ */ new Map();
|
|
1044
|
-
const check = async (path, expected2, directory) => {
|
|
1045
|
-
if (path !== this.dir) {
|
|
1046
|
-
const parent = dirname(path);
|
|
1047
|
-
await check(parent, directories.get(parent), true);
|
|
1048
|
-
}
|
|
1049
|
-
await checkAncestors();
|
|
1050
|
-
const current = await lstat(path, { bigint: true });
|
|
1051
|
-
if ((directory ? !current.isDirectory() : !current.isFile()) || current.dev !== expected2.dev || current.ino !== expected2.ino) {
|
|
1052
|
-
throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica layout changed during removal.");
|
|
1053
|
-
}
|
|
1054
|
-
};
|
|
1055
|
-
const record = async (path, directory) => {
|
|
1056
|
-
const parent = dirname(path);
|
|
1057
|
-
await check(parent, directories.get(parent), true);
|
|
1058
|
-
const current = await lstat(path, { bigint: true });
|
|
1059
|
-
if (!(directory ? current.isDirectory() : current.isFile())) {
|
|
1060
|
-
throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "Unexpected replica layout during removal.");
|
|
1061
|
-
}
|
|
1062
|
-
(directory ? directories : files).set(path, current);
|
|
1063
|
-
};
|
|
1064
|
-
const blobs = join(this.dir, "blobs");
|
|
1065
|
-
for (const name of await readdir(this.dir)) {
|
|
1066
|
-
if (name === "blobs") await record(blobs, true);
|
|
1067
|
-
else if (["replica.db", "replica.db-wal", "replica.db-shm"].includes(name)) await record(join(this.dir, name), false);
|
|
1068
|
-
else throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "Unexpected replica layout during removal.");
|
|
1069
|
-
}
|
|
1070
|
-
if (directories.has(blobs)) {
|
|
1071
|
-
await check(blobs, directories.get(blobs), true);
|
|
1072
|
-
for (const shard of await readdir(blobs)) {
|
|
1073
|
-
if (shard !== ".tmp" && !/^[0-9a-f]{2}$/.test(shard)) {
|
|
1074
|
-
throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "Unexpected replica blob shard during removal.");
|
|
1075
|
-
}
|
|
1076
|
-
const shardPath = join(blobs, shard);
|
|
1077
|
-
await record(shardPath, true);
|
|
1078
|
-
await check(shardPath, directories.get(shardPath), true);
|
|
1079
|
-
for (const name of await readdir(shardPath)) {
|
|
1080
|
-
if (shard === ".tmp" ? !/^[0-9a-f]{64}\.[0-9a-f]{12}$/.test(name) : !isHash(name) || !name.startsWith(shard)) {
|
|
1081
|
-
throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "Unexpected replica blob file during removal.");
|
|
1082
|
-
}
|
|
1083
|
-
await record(join(shardPath, name), false);
|
|
1084
|
-
}
|
|
1085
|
-
}
|
|
1086
|
-
}
|
|
1087
|
-
for (const [path, identity] of files) {
|
|
1088
|
-
await check(path, identity, false);
|
|
1089
|
-
await unlink(path);
|
|
1090
|
-
}
|
|
1091
|
-
for (const [path, identity] of [...directories].reverse()) {
|
|
1092
|
-
if (path === this.dir) continue;
|
|
1093
|
-
await check(path, identity, true);
|
|
1094
|
-
await rmdir(path);
|
|
1095
1894
|
}
|
|
1096
1895
|
await assertDirectory();
|
|
1097
|
-
|
|
1098
|
-
|
|
1896
|
+
};
|
|
1897
|
+
await this.#assertPresent();
|
|
1898
|
+
try {
|
|
1899
|
+
await removeSqliteReplicaLayout(this.dir, {
|
|
1900
|
+
...options,
|
|
1901
|
+
expectedRoot: options.expectedRoot ?? { dev: pinned.dev.toString(), ino: pinned.ino.toString() }
|
|
1902
|
+
});
|
|
1903
|
+
} catch (error) {
|
|
1904
|
+
await assertNamedPath();
|
|
1905
|
+
throw error;
|
|
1099
1906
|
}
|
|
1100
1907
|
} catch (error) {
|
|
1101
1908
|
throw storageError(error, "Removing the replica");
|
|
@@ -1104,6 +1911,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
1104
1911
|
this.#db.exec("ROLLBACK");
|
|
1105
1912
|
} catch {
|
|
1106
1913
|
}
|
|
1914
|
+
release?.();
|
|
1107
1915
|
}
|
|
1108
1916
|
});
|
|
1109
1917
|
}
|
|
@@ -1147,7 +1955,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
1147
1955
|
* when both are done.
|
|
1148
1956
|
*/
|
|
1149
1957
|
async #purgeContent() {
|
|
1150
|
-
this.#write("Starting the revocation purge", () => {
|
|
1958
|
+
await this.#write("Starting the revocation purge", () => {
|
|
1151
1959
|
this.#db.run("UPDATE replica SET purge_pending = 1 WHERE id = 1 AND revoked_detail IS NOT NULL");
|
|
1152
1960
|
});
|
|
1153
1961
|
try {
|
|
@@ -1158,7 +1966,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
1158
1966
|
if (!this.#checkpoint()) {
|
|
1159
1967
|
throw new ReplicaError(ReplicaErrorCode.BUSY, "A reader holds the replica's write-ahead log; the purge finishes on the next open.");
|
|
1160
1968
|
}
|
|
1161
|
-
this.#write("Finishing the revocation purge", () => {
|
|
1969
|
+
await this.#write("Finishing the revocation purge", () => {
|
|
1162
1970
|
this.#db.run("UPDATE replica SET purge_pending = 0 WHERE id = 1 AND revoked_detail IS NOT NULL");
|
|
1163
1971
|
});
|
|
1164
1972
|
}
|
|
@@ -1168,6 +1976,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
1168
1976
|
});
|
|
1169
1977
|
}
|
|
1170
1978
|
async get(key) {
|
|
1979
|
+
this.#assertAccepting();
|
|
1171
1980
|
try {
|
|
1172
1981
|
const row = this.#db.get("SELECT key, deleted, etag, hash, metadata, content FROM entry WHERE key = ?", key);
|
|
1173
1982
|
return row === void 0 ? void 0 : entryOf(row);
|
|
@@ -1176,6 +1985,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
1176
1985
|
}
|
|
1177
1986
|
}
|
|
1178
1987
|
async list(o) {
|
|
1988
|
+
this.#assertAccepting();
|
|
1179
1989
|
const where = ["deleted = 0"];
|
|
1180
1990
|
const params = [];
|
|
1181
1991
|
if (o.prefix !== void 0 && o.prefix !== "") {
|
|
@@ -1261,21 +2071,39 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
1261
2071
|
lastReset: state.lastReset
|
|
1262
2072
|
};
|
|
1263
2073
|
}
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
this.#
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
2074
|
+
close() {
|
|
2075
|
+
if (this.#closePromise) return this.#closePromise;
|
|
2076
|
+
this.#closing = true;
|
|
2077
|
+
this.#closePromise = (async () => {
|
|
2078
|
+
if (this.#activeMutations !== 0) {
|
|
2079
|
+
await new Promise((resolve3) => {
|
|
2080
|
+
this.#drained = resolve3;
|
|
2081
|
+
});
|
|
2082
|
+
}
|
|
2083
|
+
this.#closed = true;
|
|
2084
|
+
const fd = this.#directoryFd;
|
|
2085
|
+
this.#directoryFd = void 0;
|
|
2086
|
+
if (this.#shared) {
|
|
2087
|
+
sharedStores.unregister(this);
|
|
2088
|
+
releaseWritable(this.#shared);
|
|
2089
|
+
} else {
|
|
2090
|
+
if (fd !== void 0) pinnedDirectories.unregister(this);
|
|
2091
|
+
try {
|
|
2092
|
+
this.#database.close();
|
|
2093
|
+
} finally {
|
|
2094
|
+
if (fd !== void 0) closeSync(fd);
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
})();
|
|
2098
|
+
return this.#closePromise;
|
|
1273
2099
|
}
|
|
1274
2100
|
};
|
|
1275
2101
|
export {
|
|
1276
2102
|
DURABILITY,
|
|
1277
2103
|
SqliteReplicaStore,
|
|
2104
|
+
inspectSqliteReplicaLayout,
|
|
1278
2105
|
loadSqlite,
|
|
1279
|
-
nodeSqliteSupported
|
|
2106
|
+
nodeSqliteSupported,
|
|
2107
|
+
removeSqliteReplicaLayout
|
|
1280
2108
|
};
|
|
1281
2109
|
//# sourceMappingURL=sqlite.js.map
|