@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.cjs
CHANGED
|
@@ -22,16 +22,19 @@ var sqlite_exports = {};
|
|
|
22
22
|
__export(sqlite_exports, {
|
|
23
23
|
DURABILITY: () => DURABILITY,
|
|
24
24
|
SqliteReplicaStore: () => SqliteReplicaStore,
|
|
25
|
+
inspectSqliteReplicaLayout: () => inspectSqliteReplicaLayout,
|
|
25
26
|
loadSqlite: () => loadSqlite,
|
|
26
|
-
nodeSqliteSupported: () => nodeSqliteSupported
|
|
27
|
+
nodeSqliteSupported: () => nodeSqliteSupported,
|
|
28
|
+
removeSqliteReplicaLayout: () => removeSqliteReplicaLayout
|
|
27
29
|
});
|
|
28
30
|
module.exports = __toCommonJS(sqlite_exports);
|
|
29
31
|
|
|
30
32
|
// src/sqlite/store.ts
|
|
33
|
+
var import_node_async_hooks = require("async_hooks");
|
|
31
34
|
var import_node_crypto = require("crypto");
|
|
32
|
-
var
|
|
33
|
-
var
|
|
34
|
-
var
|
|
35
|
+
var import_node_fs2 = require("fs");
|
|
36
|
+
var import_promises2 = require("fs/promises");
|
|
37
|
+
var import_node_path2 = require("path");
|
|
35
38
|
|
|
36
39
|
// src/errors.ts
|
|
37
40
|
var ReplicaErrorCode = {
|
|
@@ -89,7 +92,13 @@ var ReplicaErrorCode = {
|
|
|
89
92
|
/** A stored blob no longer hashes to its name. */
|
|
90
93
|
INTEGRITY_ERROR: "INTEGRITY_ERROR",
|
|
91
94
|
/** The replica handle was closed; in-flight calls settle with this code. */
|
|
92
|
-
CLOSED: "REPLICA_CLOSED"
|
|
95
|
+
CLOSED: "REPLICA_CLOSED",
|
|
96
|
+
/** The lifecycle gate refused the operation (shared contract `REPLICA_GATED`); `detail.gate` says why. */
|
|
97
|
+
GATED: "REPLICA_GATED",
|
|
98
|
+
/** Quota admission refused a page (`maxBytes` or the free-space floor); nothing committed. */
|
|
99
|
+
STORAGE_BUDGET: "STORAGE_BUDGET",
|
|
100
|
+
/** The consent deadline passed: the grant is not served until the user consents again. */
|
|
101
|
+
CONSENT_EXPIRED: "CONSENT_EXPIRED"
|
|
93
102
|
};
|
|
94
103
|
var REPLICA_ERROR = /* @__PURE__ */ Symbol.for("tinycloud.replica.error");
|
|
95
104
|
var ReplicaError = class extends Error {
|
|
@@ -226,32 +235,13 @@ async function loadSqlite() {
|
|
|
226
235
|
return (path, options) => wrap(options?.readonly ? new DatabaseSync(path, { readOnly: true }) : new DatabaseSync(path));
|
|
227
236
|
}
|
|
228
237
|
|
|
229
|
-
// src/sqlite/
|
|
230
|
-
var
|
|
231
|
-
var
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
release = resolve2;
|
|
237
|
-
});
|
|
238
|
-
await previous;
|
|
239
|
-
return release;
|
|
240
|
-
}
|
|
241
|
-
async function captureNamedPath(path) {
|
|
242
|
-
const names = [];
|
|
243
|
-
for (let current = (0, import_node_path.resolve)(path); ; current = (0, import_node_path.dirname)(current)) {
|
|
244
|
-
names.push(current);
|
|
245
|
-
if ((0, import_node_path.dirname)(current) === current) break;
|
|
246
|
-
}
|
|
247
|
-
const identities = [];
|
|
248
|
-
for (const name of names.reverse()) {
|
|
249
|
-
const stats = await (0, import_promises.lstat)(name, { bigint: true });
|
|
250
|
-
identities.push({ path: name, dev: stats.dev, ino: stats.ino });
|
|
251
|
-
}
|
|
252
|
-
return identities;
|
|
253
|
-
}
|
|
254
|
-
var DURABILITY = "sqlite-wal-synchronous-full+fsynced-blobs";
|
|
238
|
+
// src/sqlite/layout.ts
|
|
239
|
+
var import_node_fs = require("fs");
|
|
240
|
+
var import_promises = require("fs/promises");
|
|
241
|
+
var import_node_path = require("path");
|
|
242
|
+
|
|
243
|
+
// src/sqlite/schema.ts
|
|
244
|
+
var SCHEMA_VERSION = 2;
|
|
255
245
|
var SCHEMA = `
|
|
256
246
|
CREATE TABLE IF NOT EXISTS replica (
|
|
257
247
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
@@ -311,7 +301,403 @@ CREATE TABLE IF NOT EXISTS entry (
|
|
|
311
301
|
)
|
|
312
302
|
) STRICT, WITHOUT ROWID;
|
|
313
303
|
CREATE INDEX IF NOT EXISTS entry_hash ON entry (hash) WHERE hash IS NOT NULL;
|
|
304
|
+
CREATE TABLE IF NOT EXISTS grant_install (
|
|
305
|
+
cid TEXT PRIMARY KEY,
|
|
306
|
+
installed_at INTEGER NOT NULL,
|
|
307
|
+
effective_expires_at INTEGER NOT NULL,
|
|
308
|
+
consent_deadline INTEGER NOT NULL,
|
|
309
|
+
consent_anchor_at INTEGER NOT NULL,
|
|
310
|
+
consent_anchor_source TEXT NOT NULL
|
|
311
|
+
) STRICT, WITHOUT ROWID;
|
|
314
312
|
`;
|
|
313
|
+
|
|
314
|
+
// src/sqlite/layout.ts
|
|
315
|
+
var POLICY_COLUMNS = [
|
|
316
|
+
"grant_installed_at REAL",
|
|
317
|
+
"grant_effective_expires_at REAL",
|
|
318
|
+
"grant_consent_deadline REAL",
|
|
319
|
+
"grant_consent_anchor_at REAL",
|
|
320
|
+
"grant_consent_anchor_source TEXT",
|
|
321
|
+
"pending_grant_installed_at REAL",
|
|
322
|
+
"pending_grant_effective_expires_at REAL",
|
|
323
|
+
"pending_grant_consent_deadline REAL",
|
|
324
|
+
"pending_grant_consent_anchor_at REAL",
|
|
325
|
+
"pending_grant_consent_anchor_source TEXT"
|
|
326
|
+
];
|
|
327
|
+
var normalizeSchema = (sql) => sql.replace(" IF NOT EXISTS", "").replace(/\s+/g, " ").trim();
|
|
328
|
+
var SOURCE_SCHEMA = SCHEMA.trim().split(";").map((sql) => sql.trim()).filter(Boolean).map((sql) => {
|
|
329
|
+
const match = /^CREATE (TABLE|INDEX) IF NOT EXISTS ([a-z_]+)/.exec(sql);
|
|
330
|
+
if (match === null) throw new Error("Invalid source replica schema.");
|
|
331
|
+
return { type: match[1].toLowerCase(), name: match[2], sql: normalizeSchema(sql) };
|
|
332
|
+
});
|
|
333
|
+
var V1_SCHEMA = SOURCE_SCHEMA.filter((entry) => entry.name !== "grant_install");
|
|
334
|
+
var POLICY_REPLICA = V1_SCHEMA.find((entry) => entry.name === "replica").sql.replace(/\) STRICT$/, `, ${POLICY_COLUMNS.join(", ")}) STRICT`);
|
|
335
|
+
var POLICY_SCHEMA = V1_SCHEMA.map((entry) => entry.name === "replica" ? { ...entry, sql: POLICY_REPLICA } : entry);
|
|
336
|
+
async function sourceMirrorSchema(path) {
|
|
337
|
+
let db;
|
|
338
|
+
try {
|
|
339
|
+
db = (await loadSqlite())(path, { readonly: true });
|
|
340
|
+
db.exec("BEGIN");
|
|
341
|
+
const version = db.get("PRAGMA user_version")?.user_version;
|
|
342
|
+
if (version !== 1 && version !== SCHEMA_VERSION) return false;
|
|
343
|
+
const rows = db.all(
|
|
344
|
+
"SELECT type, name, sql FROM sqlite_schema WHERE sql IS NOT NULL ORDER BY name"
|
|
345
|
+
);
|
|
346
|
+
const expected = version === 1 ? V1_SCHEMA : SOURCE_SCHEMA;
|
|
347
|
+
const matches = (schema) => rows.length === schema.length && rows.every((row) => {
|
|
348
|
+
const source = schema.find((entry) => entry.name === row.name);
|
|
349
|
+
return source?.type === row.type && source.sql === normalizeSchema(row.sql);
|
|
350
|
+
});
|
|
351
|
+
if (matches(expected)) return true;
|
|
352
|
+
if (version !== 2) return false;
|
|
353
|
+
return matches(POLICY_SCHEMA);
|
|
354
|
+
} catch {
|
|
355
|
+
return false;
|
|
356
|
+
} finally {
|
|
357
|
+
try {
|
|
358
|
+
db?.close();
|
|
359
|
+
} catch {
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
var SQLITE_FILE = /^(?:replica\.db|replica\.db-(?:wal|shm|journal))$/;
|
|
364
|
+
var SHARD = /^[0-9a-f]{2}$/;
|
|
365
|
+
var HASH = /^[0-9a-f]{64}$/;
|
|
366
|
+
var TEMP = /^[0-9a-f]{64}\.[0-9a-f]{12}$/;
|
|
367
|
+
function sourceKind(parent, name, allowAdapterFence) {
|
|
368
|
+
if (parent === "") {
|
|
369
|
+
if (SQLITE_FILE.test(name) || allowAdapterFence && (name === "fence.json" || /^fence[.]json[.][0-9a-f]{12}[.]tmp$/.test(name))) return "file";
|
|
370
|
+
return name === "blobs" ? "directory" : "unknown";
|
|
371
|
+
}
|
|
372
|
+
if (parent === "blobs") return name === ".tmp" || SHARD.test(name) ? "directory" : "unknown";
|
|
373
|
+
if (parent === "blobs/.tmp") return TEMP.test(name) ? "file" : "unknown";
|
|
374
|
+
if (parent.startsWith("blobs/") && SHARD.test(parent.slice(6))) {
|
|
375
|
+
return HASH.test(name) && name.startsWith(parent.slice(6)) ? "file" : "unknown";
|
|
376
|
+
}
|
|
377
|
+
return "unknown";
|
|
378
|
+
}
|
|
379
|
+
function sameFile(a, b) {
|
|
380
|
+
return a.dev === b.dev && a.ino === b.ino;
|
|
381
|
+
}
|
|
382
|
+
function identityOf(path, kind, details) {
|
|
383
|
+
return { path, kind, dev: details.dev.toString(), ino: details.ino.toString() };
|
|
384
|
+
}
|
|
385
|
+
function fileKind(details) {
|
|
386
|
+
if (details.isFile()) return "file";
|
|
387
|
+
if (details.isDirectory()) return "directory";
|
|
388
|
+
if (details.isSymbolicLink()) return "symlink";
|
|
389
|
+
throw new Error("Unsupported replica entry type.");
|
|
390
|
+
}
|
|
391
|
+
async function pathComponents(path) {
|
|
392
|
+
const absolute = (0, import_node_path.resolve)(path);
|
|
393
|
+
const parts = absolute.slice((0, import_node_path.parse)(absolute).root.length).split(import_node_path.sep).filter(Boolean);
|
|
394
|
+
let current = (0, import_node_path.parse)(absolute).root;
|
|
395
|
+
const components = [];
|
|
396
|
+
for (const part of ["", ...parts]) {
|
|
397
|
+
if (part) current = (0, import_node_path.join)(current, part);
|
|
398
|
+
const details = await (0, import_promises.lstat)(current, { bigint: true });
|
|
399
|
+
if (details.isSymbolicLink()) throw new Error(`Symlink in replica path: ${current}`);
|
|
400
|
+
if (!details.isDirectory()) throw new Error(`Replica path component is not a directory: ${current}`);
|
|
401
|
+
components.push({ path: current, identity: details });
|
|
402
|
+
}
|
|
403
|
+
return components;
|
|
404
|
+
}
|
|
405
|
+
async function pinnedDirectory(path, parent, relative = "") {
|
|
406
|
+
const components = process.platform === "linux" ? void 0 : parent?.components ?? await pathComponents(path);
|
|
407
|
+
const before = await (0, import_promises.lstat)(path, { bigint: true });
|
|
408
|
+
if (before.isSymbolicLink()) throw new Error(`Symlink in replica path: ${path}`);
|
|
409
|
+
if (!before.isDirectory()) throw new Error("Replica directory is not a real directory.");
|
|
410
|
+
const handle = await (0, import_promises.open)(path, import_node_fs.constants.O_RDONLY | import_node_fs.constants.O_DIRECTORY | import_node_fs.constants.O_NOFOLLOW);
|
|
411
|
+
try {
|
|
412
|
+
if (!sameFile(before, await handle.stat({ bigint: true }))) {
|
|
413
|
+
throw new Error("Replica directory changed during inspection.");
|
|
414
|
+
}
|
|
415
|
+
return {
|
|
416
|
+
handle,
|
|
417
|
+
identity: before,
|
|
418
|
+
parent,
|
|
419
|
+
components,
|
|
420
|
+
children: [],
|
|
421
|
+
relative,
|
|
422
|
+
name: path,
|
|
423
|
+
path: process.platform === "linux" ? `/proc/self/fd/${handle.fd}` : path
|
|
424
|
+
};
|
|
425
|
+
} catch (error) {
|
|
426
|
+
await handle.close();
|
|
427
|
+
throw error;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
async function checkDirectory(directory) {
|
|
431
|
+
const details = await (0, import_promises.lstat)(directory.name, { bigint: true });
|
|
432
|
+
if (!details.isDirectory() || !sameFile(directory.identity, details)) {
|
|
433
|
+
throw new Error(`Replica directory changed: ${directory.relative || directory.name}`);
|
|
434
|
+
}
|
|
435
|
+
if (directory.parent) await checkDirectory(directory.parent);
|
|
436
|
+
else if (directory.components) {
|
|
437
|
+
for (const component of directory.components) {
|
|
438
|
+
const current = await (0, import_promises.lstat)(component.path, { bigint: true });
|
|
439
|
+
if (current.isSymbolicLink() || !current.isDirectory() || !sameFile(component.identity, current)) {
|
|
440
|
+
throw new Error(`Replica path component changed: ${component.path}`);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
async function inspect(dir, allowAdapterFence) {
|
|
446
|
+
const root = await pinnedDirectory(dir);
|
|
447
|
+
const directories = [root];
|
|
448
|
+
const unknownPaths = [];
|
|
449
|
+
const knownEntries = [];
|
|
450
|
+
const unknownEntries = [];
|
|
451
|
+
let hasDatabase = false;
|
|
452
|
+
try {
|
|
453
|
+
async function walk(parent) {
|
|
454
|
+
await checkDirectory(parent);
|
|
455
|
+
for (const name of (await (0, import_promises.readdir)(parent.path)).sort()) {
|
|
456
|
+
await checkDirectory(parent);
|
|
457
|
+
const relative = parent.relative ? `${parent.relative}/${name}` : name;
|
|
458
|
+
const path = (0, import_node_path.join)(parent.path, name);
|
|
459
|
+
const details = await (0, import_promises.lstat)(path, { bigint: true });
|
|
460
|
+
if (process.platform !== "linux" && details.isSymbolicLink()) {
|
|
461
|
+
throw new Error(`Symlink in replica path: ${relative}`);
|
|
462
|
+
}
|
|
463
|
+
const kind = fileKind(details);
|
|
464
|
+
const child = { path, relative, kind, identity: details, parent };
|
|
465
|
+
parent.children.push(child);
|
|
466
|
+
if (parent.relative === "" && name === "replica.db" && kind === "file") hasDatabase = true;
|
|
467
|
+
if (sourceKind(parent.relative, name, allowAdapterFence) !== kind) {
|
|
468
|
+
unknownPaths.push(relative);
|
|
469
|
+
unknownEntries.push(identityOf(relative, kind, details));
|
|
470
|
+
} else {
|
|
471
|
+
knownEntries.push(identityOf(relative, kind, details));
|
|
472
|
+
}
|
|
473
|
+
if (kind === "directory") {
|
|
474
|
+
const directory = await pinnedDirectory(path, parent, relative);
|
|
475
|
+
if (!sameFile(details, directory.identity)) {
|
|
476
|
+
await directory.handle.close();
|
|
477
|
+
throw new Error(`Replica directory changed during inspection: ${relative}`);
|
|
478
|
+
}
|
|
479
|
+
child.directory = directory;
|
|
480
|
+
directories.push(directory);
|
|
481
|
+
await walk(directory);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
await walk(root);
|
|
486
|
+
if (!hasDatabase) {
|
|
487
|
+
unknownPaths.push("replica.db");
|
|
488
|
+
} else if (!await sourceMirrorSchema((0, import_node_path.join)(root.path, "replica.db"))) {
|
|
489
|
+
const index = knownEntries.findIndex((entry) => entry.path === "replica.db");
|
|
490
|
+
if (index >= 0) unknownEntries.push(knownEntries.splice(index, 1)[0]);
|
|
491
|
+
unknownPaths.push("replica.db");
|
|
492
|
+
}
|
|
493
|
+
await checkDirectory(root);
|
|
494
|
+
const names = new Set(await (0, import_promises.readdir)(root.path));
|
|
495
|
+
for (let index = root.children.length - 1; index >= 0; index--) {
|
|
496
|
+
const child = root.children[index];
|
|
497
|
+
if (!names.has(child.relative)) {
|
|
498
|
+
if (child.directory) throw new Error(`Replica directory vanished: ${child.relative}`);
|
|
499
|
+
root.children.splice(index, 1);
|
|
500
|
+
for (const list of [knownEntries, unknownEntries]) {
|
|
501
|
+
const entryIndex = list.findIndex((entry) => entry.path === child.relative);
|
|
502
|
+
if (entryIndex >= 0) list.splice(entryIndex, 1);
|
|
503
|
+
}
|
|
504
|
+
const pathIndex = unknownPaths.indexOf(child.relative);
|
|
505
|
+
if (pathIndex >= 0) unknownPaths.splice(pathIndex, 1);
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
const current = await (0, import_promises.lstat)(child.path, { bigint: true });
|
|
509
|
+
if (!sameFile(child.identity, current) || fileKind(current) !== child.kind) {
|
|
510
|
+
throw new Error(`Replica child changed during schema inspection: ${child.relative}`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
for (const name of names) {
|
|
514
|
+
if (root.children.some((child) => child.relative === name)) continue;
|
|
515
|
+
const path = (0, import_node_path.join)(root.path, name);
|
|
516
|
+
const details = await (0, import_promises.lstat)(path, { bigint: true });
|
|
517
|
+
if (!/^replica\.db-(?:wal|shm|journal)$/.test(name) || !details.isFile()) {
|
|
518
|
+
throw new Error(`Unexpected replica child during schema inspection: ${name}`);
|
|
519
|
+
}
|
|
520
|
+
root.children.push({ path, relative: name, kind: "file", identity: details, parent: root });
|
|
521
|
+
knownEntries.push(identityOf(name, "file", details));
|
|
522
|
+
}
|
|
523
|
+
return { root, directories, knownEntries, unknownPaths, unknownEntries };
|
|
524
|
+
} catch (error) {
|
|
525
|
+
await closeDirectories(directories);
|
|
526
|
+
throw error;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
async function closeDirectories(directories) {
|
|
530
|
+
for (const directory of directories.reverse()) await directory.handle.close();
|
|
531
|
+
}
|
|
532
|
+
async function inspectSqliteReplicaLayout(dir, options = {}) {
|
|
533
|
+
const inventory = await inspect(dir, options.allowAdapterFence === true);
|
|
534
|
+
try {
|
|
535
|
+
return {
|
|
536
|
+
knownEntries: inventory.knownEntries,
|
|
537
|
+
unknownPaths: inventory.unknownPaths,
|
|
538
|
+
unknownEntries: inventory.unknownEntries,
|
|
539
|
+
rootIdentity: { dev: inventory.root.identity.dev.toString(), ino: inventory.root.identity.ino.toString() }
|
|
540
|
+
};
|
|
541
|
+
} finally {
|
|
542
|
+
await closeDirectories(inventory.directories);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
function approvedEntries(entries) {
|
|
546
|
+
const approved = /* @__PURE__ */ new Map();
|
|
547
|
+
for (const entry of entries) {
|
|
548
|
+
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)) {
|
|
549
|
+
throw new Error("Invalid or duplicate replica approval.");
|
|
550
|
+
}
|
|
551
|
+
approved.set(entry.path, entry);
|
|
552
|
+
}
|
|
553
|
+
return approved;
|
|
554
|
+
}
|
|
555
|
+
var LAYOUT_FAULTS = /* @__PURE__ */ Symbol("tinycloud.replica.layout-faults");
|
|
556
|
+
async function removeSqliteReplicaLayout(dir, options = {}) {
|
|
557
|
+
const inventory = await inspect(dir, options.allowAdapterFence === true);
|
|
558
|
+
try {
|
|
559
|
+
if (options.expectedRoot && (options.expectedRoot.dev !== inventory.root.identity.dev.toString() || options.expectedRoot.ino !== inventory.root.identity.ino.toString())) {
|
|
560
|
+
throw new Error("Replica root identity changed since approval.");
|
|
561
|
+
}
|
|
562
|
+
const approved = approvedEntries(options.approvedUnknown ?? []);
|
|
563
|
+
for (const entry of inventory.unknownEntries) {
|
|
564
|
+
const match = approved.get(entry.path);
|
|
565
|
+
if (!match || match.kind !== entry.kind || match.dev !== entry.dev || match.ino !== entry.ino) {
|
|
566
|
+
throw new Error(`Unknown replica child or approval identity mismatch: ${entry.path}`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
if (options.expectedKnown) {
|
|
570
|
+
const expected = approvedEntries(options.expectedKnown);
|
|
571
|
+
for (const entry of inventory.knownEntries) {
|
|
572
|
+
const match = expected.get(entry.path);
|
|
573
|
+
if (!match || match.kind !== entry.kind || match.dev !== entry.dev || match.ino !== entry.ino) {
|
|
574
|
+
throw new Error(`Known replica child changed since preflight: ${entry.path}`);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
if (options.allowMissingDatabase && inventory.knownEntries.some((entry) => entry.path === "replica.db")) {
|
|
579
|
+
throw new Error("Replica database appeared after approval of its absence.");
|
|
580
|
+
}
|
|
581
|
+
if (inventory.unknownPaths.length !== inventory.unknownEntries.length && inventory.root.children.length > 0 && !options.allowMissingDatabase) {
|
|
582
|
+
throw new Error("Replica database is missing; cannot approve removal.");
|
|
583
|
+
}
|
|
584
|
+
async function removeDirectory(directory) {
|
|
585
|
+
for (const child of directory.children.sort((a, b) => a.relative === "replica.db" ? 1 : b.relative === "replica.db" ? -1 : 0)) {
|
|
586
|
+
if (child.directory) {
|
|
587
|
+
await removeDirectory(child.directory);
|
|
588
|
+
}
|
|
589
|
+
await options[LAYOUT_FAULTS]?.beforeUnlink?.(child.relative);
|
|
590
|
+
await checkDirectory(directory);
|
|
591
|
+
const details = await (0, import_promises.lstat)(child.path, { bigint: true });
|
|
592
|
+
if (process.platform !== "linux" && details.isSymbolicLink()) {
|
|
593
|
+
throw new Error(`Symlink in replica path: ${child.relative}`);
|
|
594
|
+
}
|
|
595
|
+
if (fileKind(details) !== child.kind || !sameFile(child.identity, details)) {
|
|
596
|
+
throw new Error(`Replica child identity changed: ${child.relative}`);
|
|
597
|
+
}
|
|
598
|
+
if (child.directory) await (0, import_promises.rmdir)(child.path);
|
|
599
|
+
else await (0, import_promises.unlink)(child.path);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
await removeDirectory(inventory.root);
|
|
603
|
+
await checkDirectory(inventory.root);
|
|
604
|
+
await (0, import_promises.rmdir)(inventory.root.name);
|
|
605
|
+
} finally {
|
|
606
|
+
await closeDirectories(inventory.directories);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// src/sqlite/store.ts
|
|
611
|
+
var sqliteOpenQueue = Promise.resolve();
|
|
612
|
+
async function acquireSqliteOpen() {
|
|
613
|
+
const previous = sqliteOpenQueue;
|
|
614
|
+
let release;
|
|
615
|
+
sqliteOpenQueue = new Promise((resolve3) => {
|
|
616
|
+
release = resolve3;
|
|
617
|
+
});
|
|
618
|
+
await previous;
|
|
619
|
+
return release;
|
|
620
|
+
}
|
|
621
|
+
async function captureNamedPath(path) {
|
|
622
|
+
const names = [];
|
|
623
|
+
for (let current = (0, import_node_path2.resolve)(path); ; current = (0, import_node_path2.dirname)(current)) {
|
|
624
|
+
names.push(current);
|
|
625
|
+
if ((0, import_node_path2.dirname)(current) === current) break;
|
|
626
|
+
}
|
|
627
|
+
const identities = [];
|
|
628
|
+
for (const name of names.reverse()) {
|
|
629
|
+
const stats = await (0, import_promises2.lstat)(name, { bigint: true });
|
|
630
|
+
identities.push({ path: name, dev: stats.dev, ino: stats.ino });
|
|
631
|
+
}
|
|
632
|
+
return identities;
|
|
633
|
+
}
|
|
634
|
+
async function openVerifiedDatabase(path, opener, options = {}) {
|
|
635
|
+
const leaf = await (0, import_promises2.open)(path, import_node_fs2.constants.O_RDONLY | import_node_fs2.constants.O_NOFOLLOW);
|
|
636
|
+
let db;
|
|
637
|
+
try {
|
|
638
|
+
const identity = await leaf.stat();
|
|
639
|
+
const named = await (0, import_promises2.lstat)(path);
|
|
640
|
+
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)) {
|
|
641
|
+
throw new Error("The replica database changed before SQLite opened it.");
|
|
642
|
+
}
|
|
643
|
+
const previousFds = process.platform === "linux" && options.discoverDescriptors !== false ? new Map((0, import_node_fs2.readdirSync)("/proc/self/fd").map((fd) => {
|
|
644
|
+
try {
|
|
645
|
+
return [fd, (0, import_node_fs2.readlinkSync)(`/proc/self/fd/${fd}`)];
|
|
646
|
+
} catch {
|
|
647
|
+
return [fd, ""];
|
|
648
|
+
}
|
|
649
|
+
})) : null;
|
|
650
|
+
db = options.readonly ? opener(path, { readonly: true }) : opener(path);
|
|
651
|
+
const afterOpen = (0, import_node_fs2.lstatSync)(path);
|
|
652
|
+
if (!afterOpen.isFile() || afterOpen.dev !== identity.dev || afterOpen.ino !== identity.ino) {
|
|
653
|
+
throw new Error("The replica database changed while SQLite opened it.");
|
|
654
|
+
}
|
|
655
|
+
if (previousFds) db.get("SELECT count(*) FROM sqlite_master");
|
|
656
|
+
if (previousFds) {
|
|
657
|
+
const databases = (0, import_node_fs2.readdirSync)("/proc/self/fd").filter((fd) => {
|
|
658
|
+
try {
|
|
659
|
+
const target = (0, import_node_fs2.readlinkSync)(`/proc/self/fd/${fd}`);
|
|
660
|
+
return target.endsWith("/replica.db") && previousFds.get(fd) !== target;
|
|
661
|
+
} catch {
|
|
662
|
+
return false;
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
const sharedPager = options.readonly && databases.length === 0 && [...previousFds].some(([fd, target]) => {
|
|
666
|
+
if (Number(fd) === leaf.fd || !target.endsWith("/replica.db")) return false;
|
|
667
|
+
try {
|
|
668
|
+
const actual = (0, import_node_fs2.fstatSync)(Number(fd));
|
|
669
|
+
return actual.dev === identity.dev && actual.ino === identity.ino;
|
|
670
|
+
} catch {
|
|
671
|
+
return false;
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
if (!databases.length && !sharedPager || databases.some((fd) => {
|
|
675
|
+
try {
|
|
676
|
+
const actual = (0, import_node_fs2.fstatSync)(Number(fd));
|
|
677
|
+
return actual.dev !== identity.dev || actual.ino !== identity.ino;
|
|
678
|
+
} catch {
|
|
679
|
+
return true;
|
|
680
|
+
}
|
|
681
|
+
})) {
|
|
682
|
+
throw new Error("SQLite opened a different replica database.");
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
const after = (0, import_node_fs2.lstatSync)(path);
|
|
686
|
+
if (!after.isFile() || after.dev !== identity.dev || after.ino !== identity.ino) {
|
|
687
|
+
throw new Error("The replica database changed while SQLite opened it.");
|
|
688
|
+
}
|
|
689
|
+
const checked = db;
|
|
690
|
+
db = void 0;
|
|
691
|
+
return { db: checked, identity };
|
|
692
|
+
} finally {
|
|
693
|
+
try {
|
|
694
|
+
db?.close();
|
|
695
|
+
} finally {
|
|
696
|
+
await leaf.close();
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
var DURABILITY = "sqlite-wal-synchronous-full+fsynced-blobs";
|
|
315
701
|
function grantOf(cid, bytes, audience, issuer, nbf, exp) {
|
|
316
702
|
if (cid === null || bytes === null || audience === null || issuer === null) return null;
|
|
317
703
|
return { cid, bytes: new Uint8Array(bytes), audience, issuer, notBefore: nbf, expiresAt: exp };
|
|
@@ -340,9 +726,79 @@ var isHash = (name) => /^[0-9a-f]{64}$/.test(name);
|
|
|
340
726
|
function errnoOf(error) {
|
|
341
727
|
return typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
|
|
342
728
|
}
|
|
729
|
+
var isShard = (name) => /^[0-9a-f]{2}$/.test(name);
|
|
730
|
+
var isTempName = (name) => /^[0-9a-f]{64}\.[0-9a-f]{12}$/.test(name);
|
|
731
|
+
async function writerBlobFiles(root) {
|
|
732
|
+
const out = [];
|
|
733
|
+
let shards;
|
|
734
|
+
try {
|
|
735
|
+
shards = await (0, import_promises2.readdir)(root, { withFileTypes: true });
|
|
736
|
+
} catch (error) {
|
|
737
|
+
if (errnoOf(error) === "ENOENT" || errnoOf(error) === "ENOTDIR") return out;
|
|
738
|
+
throw error;
|
|
739
|
+
}
|
|
740
|
+
for (const shard of shards) {
|
|
741
|
+
const temp = shard.name === ".tmp";
|
|
742
|
+
if (!shard.isDirectory() || !(temp || isShard(shard.name))) continue;
|
|
743
|
+
const shardPath = (0, import_node_path2.join)(root, shard.name);
|
|
744
|
+
let files;
|
|
745
|
+
try {
|
|
746
|
+
files = await (0, import_promises2.readdir)(shardPath, { withFileTypes: true });
|
|
747
|
+
} catch (error) {
|
|
748
|
+
if (errnoOf(error) === "ENOENT" || errnoOf(error) === "ENOTDIR") continue;
|
|
749
|
+
throw error;
|
|
750
|
+
}
|
|
751
|
+
for (const file of files) {
|
|
752
|
+
if (!file.isFile()) continue;
|
|
753
|
+
const ok = temp ? isTempName(file.name) : isHash(file.name) && file.name.startsWith(shard.name);
|
|
754
|
+
if (!ok) continue;
|
|
755
|
+
const path = (0, import_node_path2.join)(shardPath, file.name);
|
|
756
|
+
const info = await (0, import_promises2.lstat)(path).catch(() => void 0);
|
|
757
|
+
if (!info?.isFile()) continue;
|
|
758
|
+
out.push({ path, hash: temp ? null : file.name, size: info.size });
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
return out;
|
|
762
|
+
}
|
|
763
|
+
async function unlinkWriterOrphans(root, keep) {
|
|
764
|
+
let removed = 0;
|
|
765
|
+
const touched = /* @__PURE__ */ new Set();
|
|
766
|
+
for (const file of await writerBlobFiles(root)) {
|
|
767
|
+
if (file.hash !== null && keep.has(file.hash)) continue;
|
|
768
|
+
await (0, import_promises2.rm)(file.path, { force: true });
|
|
769
|
+
touched.add((0, import_node_path2.dirname)(file.path));
|
|
770
|
+
removed += 1;
|
|
771
|
+
}
|
|
772
|
+
for (const dir of touched) if (!dir.endsWith(".tmp")) await (0, import_promises2.rmdir)(dir).catch(() => void 0);
|
|
773
|
+
return removed;
|
|
774
|
+
}
|
|
775
|
+
async function unlinkBlobsIn(root, keep) {
|
|
776
|
+
let removed = 0;
|
|
777
|
+
for (const shard of await entriesOf(root)) {
|
|
778
|
+
const shardPath = (0, import_node_path2.join)(root, shard);
|
|
779
|
+
if (shard === ".tmp") {
|
|
780
|
+
for (const name of await entriesOf(shardPath)) {
|
|
781
|
+
await (0, import_promises2.rm)((0, import_node_path2.join)(shardPath, name), { force: true });
|
|
782
|
+
removed += 1;
|
|
783
|
+
}
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
786
|
+
let kept = 0;
|
|
787
|
+
for (const name of await entriesOf(shardPath)) {
|
|
788
|
+
if (isHash(name) && keep.has(name)) {
|
|
789
|
+
kept += 1;
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
await (0, import_promises2.rm)((0, import_node_path2.join)(shardPath, name), { force: true });
|
|
793
|
+
removed += 1;
|
|
794
|
+
}
|
|
795
|
+
if (kept === 0) await (0, import_promises2.rmdir)(shardPath).catch(() => void 0);
|
|
796
|
+
}
|
|
797
|
+
return removed;
|
|
798
|
+
}
|
|
343
799
|
async function entriesOf(path) {
|
|
344
800
|
try {
|
|
345
|
-
return await (0,
|
|
801
|
+
return await (0, import_promises2.readdir)(path);
|
|
346
802
|
} catch (error) {
|
|
347
803
|
if (errnoOf(error) === "ENOENT") return [];
|
|
348
804
|
throw error;
|
|
@@ -351,33 +807,81 @@ async function entriesOf(path) {
|
|
|
351
807
|
var FAULTS = /* @__PURE__ */ Symbol("tinycloud.replica.faults");
|
|
352
808
|
var pinnedDirectories = new FinalizationRegistry((fd) => {
|
|
353
809
|
try {
|
|
354
|
-
(0,
|
|
810
|
+
(0, import_node_fs2.closeSync)(fd);
|
|
355
811
|
} catch {
|
|
356
812
|
}
|
|
357
813
|
});
|
|
814
|
+
var writableStores = /* @__PURE__ */ new Map();
|
|
815
|
+
function releaseWritable(shared) {
|
|
816
|
+
if (shared.references === 0 || --shared.references !== 0) return;
|
|
817
|
+
if (writableStores.get(shared.key) === shared) writableStores.delete(shared.key);
|
|
818
|
+
try {
|
|
819
|
+
shared.db.close();
|
|
820
|
+
} finally {
|
|
821
|
+
if (shared.directoryFd !== void 0) (0, import_node_fs2.closeSync)(shared.directoryFd);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
var sharedStores = new FinalizationRegistry((shared) => {
|
|
825
|
+
try {
|
|
826
|
+
releaseWritable(shared);
|
|
827
|
+
} catch {
|
|
828
|
+
}
|
|
829
|
+
});
|
|
830
|
+
async function acquireWritableMutation(shared) {
|
|
831
|
+
const previous = shared.mutationTail;
|
|
832
|
+
let release;
|
|
833
|
+
shared.mutationTail = new Promise((resolve3) => {
|
|
834
|
+
release = resolve3;
|
|
835
|
+
});
|
|
836
|
+
await previous;
|
|
837
|
+
return release;
|
|
838
|
+
}
|
|
839
|
+
var activeReadSection = new import_node_async_hooks.AsyncLocalStorage();
|
|
358
840
|
var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
359
841
|
dir;
|
|
360
|
-
#
|
|
842
|
+
#database;
|
|
843
|
+
get #db() {
|
|
844
|
+
if (this.#closed) throw new ReplicaError(ReplicaErrorCode.CLOSED, "The replica store is closed.");
|
|
845
|
+
return this.#database;
|
|
846
|
+
}
|
|
847
|
+
#closed = false;
|
|
848
|
+
#closing = false;
|
|
849
|
+
#closePromise;
|
|
850
|
+
#activeMutations = 0;
|
|
851
|
+
#drained;
|
|
852
|
+
#shared;
|
|
853
|
+
#readonlySnapshot;
|
|
361
854
|
#pinnedDir;
|
|
362
855
|
#directoryFd;
|
|
363
|
-
#holder
|
|
856
|
+
#holder;
|
|
364
857
|
#now;
|
|
365
858
|
#faults;
|
|
366
859
|
#guard;
|
|
367
860
|
/** Identity of the database file this store opened: a deleted-and-recreated replica is a different file. */
|
|
368
861
|
#ino;
|
|
862
|
+
#admit;
|
|
863
|
+
#partitionId;
|
|
369
864
|
#dev;
|
|
370
865
|
#namedAncestors;
|
|
371
|
-
constructor(dir, db, identity, options, directoryFd, namedAncestors = []) {
|
|
866
|
+
constructor(dir, db, identity, options, directoryFd, namedAncestors = [], shared, readonlySnapshot = false) {
|
|
372
867
|
this.dir = dir;
|
|
373
|
-
this.#pinnedDir = directoryFd !== void 0 && process.platform === "linux" ? `/proc/self/fd/${directoryFd}` : dir;
|
|
868
|
+
this.#pinnedDir = shared?.pinnedDir ?? (directoryFd !== void 0 && process.platform === "linux" ? `/proc/self/fd/${directoryFd}` : dir);
|
|
374
869
|
this.#directoryFd = directoryFd;
|
|
375
|
-
|
|
376
|
-
this.#
|
|
870
|
+
this.#shared = shared;
|
|
871
|
+
this.#readonlySnapshot = readonlySnapshot;
|
|
872
|
+
if (shared) {
|
|
873
|
+
shared.references++;
|
|
874
|
+
sharedStores.register(this, shared, this);
|
|
875
|
+
} else if (directoryFd !== void 0) {
|
|
876
|
+
pinnedDirectories.register(this, directoryFd, this);
|
|
877
|
+
}
|
|
878
|
+
this.#database = db;
|
|
377
879
|
this.#ino = identity.ino;
|
|
378
880
|
this.#dev = identity.dev;
|
|
379
881
|
this.#now = options.now ?? Date.now;
|
|
380
882
|
this.#faults = options[FAULTS] ?? {};
|
|
883
|
+
this.#admit = options.admit;
|
|
884
|
+
this.#partitionId = options.partitionId ?? "";
|
|
381
885
|
this.#guard = options.guard ?? ((section) => section());
|
|
382
886
|
this.#namedAncestors = namedAncestors;
|
|
383
887
|
}
|
|
@@ -391,10 +895,10 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
391
895
|
if (options.signal?.aborted) throw options.signal.reason ?? new ReplicaError(ReplicaErrorCode.CLOSED, "Replica status inspection was cancelled.");
|
|
392
896
|
};
|
|
393
897
|
checkAbort();
|
|
394
|
-
const dbPath = (0,
|
|
898
|
+
const dbPath = (0, import_node_path2.join)(dir, "replica.db");
|
|
395
899
|
let identity;
|
|
396
900
|
try {
|
|
397
|
-
identity = await (0,
|
|
901
|
+
identity = await (0, import_promises2.lstat)(dbPath);
|
|
398
902
|
if (!identity.isFile()) return null;
|
|
399
903
|
} catch (error) {
|
|
400
904
|
if (errnoOf(error) === "ENOENT") return null;
|
|
@@ -404,21 +908,30 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
404
908
|
const opener = await loadSqlite();
|
|
405
909
|
checkAbort();
|
|
406
910
|
let db;
|
|
911
|
+
let releaseOpen;
|
|
407
912
|
try {
|
|
408
|
-
|
|
913
|
+
releaseOpen = await acquireSqliteOpen();
|
|
914
|
+
const checked = await openVerifiedDatabase(dbPath, opener, { readonly: true, expected: identity });
|
|
915
|
+
db = checked.db;
|
|
916
|
+
releaseOpen();
|
|
917
|
+
releaseOpen = void 0;
|
|
409
918
|
db.exec("PRAGMA busy_timeout = 0");
|
|
410
919
|
checkAbort();
|
|
411
|
-
const store = new _SqliteReplicaStore(dir, db, identity, { create: false, ...options.now ? { now: options.now } : {} });
|
|
920
|
+
const store = new _SqliteReplicaStore(dir, db, checked.identity, { create: false, ...options.now ? { now: options.now } : {} });
|
|
412
921
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
413
922
|
const before = db.get("PRAGMA data_version").data_version;
|
|
414
923
|
const state = await store.open();
|
|
415
924
|
checkAbort();
|
|
416
925
|
if (state === null) return null;
|
|
417
926
|
const status = await store.status();
|
|
418
|
-
|
|
927
|
+
const latest = state.pendingGrant ?? state.grant;
|
|
928
|
+
const version = db.get("PRAGMA user_version")?.user_version ?? 0;
|
|
929
|
+
const grantInstall = latest === null || version < 2 ? null : await store.getGrantInstall(latest.cid);
|
|
930
|
+
const activeGrantInstall = state.grant === null || version < 2 ? null : state.grant === latest ? grantInstall : await store.getGrantInstall(state.grant.cid);
|
|
931
|
+
await new Promise((resolve3) => setImmediate(resolve3));
|
|
419
932
|
checkAbort();
|
|
420
933
|
const after = db.get("PRAGMA data_version").data_version;
|
|
421
|
-
if (before === after) return { state, status };
|
|
934
|
+
if (before === after) return { state, status, grantInstall, activeGrantInstall };
|
|
422
935
|
}
|
|
423
936
|
throw new ReplicaError(ReplicaErrorCode.BUSY, "The replica changed during status inspection.");
|
|
424
937
|
} catch (error) {
|
|
@@ -426,33 +939,89 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
426
939
|
throw new ReplicaError(ReplicaErrorCode.BUSY, "The replica is busy during status inspection.", void 0, { cause: error });
|
|
427
940
|
}
|
|
428
941
|
throw storageError(error, "Inspecting the replica");
|
|
942
|
+
} finally {
|
|
943
|
+
releaseOpen?.();
|
|
944
|
+
if (db) {
|
|
945
|
+
const releaseClose = await acquireSqliteOpen();
|
|
946
|
+
try {
|
|
947
|
+
db.close();
|
|
948
|
+
} finally {
|
|
949
|
+
releaseClose();
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Blob content bytes on disk in the replica at `dir` (0 when absent): the writer's blob files under `blobs/`,
|
|
956
|
+
* including writer temp files and blobs no entry references any more, so garbage a failed sync
|
|
957
|
+
* left behind still counts. Counts exactly what `collectOrphans` may delete: foreign entries
|
|
958
|
+
* (stray files, symlinks, unknown names) are neither counted nor touched. Only blob content is counted: not the database, its WAL, rows or the
|
|
959
|
+
* pending store. Filesystem-only: it opens no
|
|
960
|
+
* database, so it can sum a profile's replicas under the caller's own mutation hold.
|
|
961
|
+
*/
|
|
962
|
+
static async contentBytes(dir) {
|
|
963
|
+
try {
|
|
964
|
+
let bytes = 0;
|
|
965
|
+
for (const file of await writerBlobFiles((0, import_node_path2.join)(dir, "blobs"))) bytes += file.size;
|
|
966
|
+
return bytes;
|
|
967
|
+
} catch (error) {
|
|
968
|
+
throw storageError(error, "Measuring replica content");
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* Remove blobs and temp files in the replica at `dir` that no committed row references: what a
|
|
973
|
+
* crash between writing blobs and committing (or between committing and collecting) leaves.
|
|
974
|
+
* Returns the number of files removed; 0 when the replica is absent or unreadable.
|
|
975
|
+
*
|
|
976
|
+
* The caller MUST hold the profile mutation guard, which every writer of this replica holds
|
|
977
|
+
* across its blob write and commit; that is what makes an unreferenced blob safe to delete
|
|
978
|
+
* (it cannot belong to an in-flight write). Never call it without that hold. The database is
|
|
979
|
+
* read only, so nothing here can take a lease or change committed state.
|
|
980
|
+
*/
|
|
981
|
+
static async collectOrphans(dir) {
|
|
982
|
+
const dbPath = (0, import_node_path2.join)(dir, "replica.db");
|
|
983
|
+
const identity = await (0, import_promises2.lstat)(dbPath).catch(() => void 0);
|
|
984
|
+
if (!identity?.isFile()) return 0;
|
|
985
|
+
const opener = await loadSqlite();
|
|
986
|
+
let keep;
|
|
987
|
+
let db;
|
|
988
|
+
try {
|
|
989
|
+
db = opener(dbPath, { readonly: true });
|
|
990
|
+
db.exec("PRAGMA busy_timeout = 0");
|
|
991
|
+
keep = new Set(db.all("SELECT DISTINCT hash FROM entry WHERE content = 1").map((row) => row.hash));
|
|
992
|
+
} catch {
|
|
993
|
+
return 0;
|
|
429
994
|
} finally {
|
|
430
995
|
db?.close();
|
|
431
996
|
}
|
|
997
|
+
try {
|
|
998
|
+
return await unlinkWriterOrphans((0, import_node_path2.join)(dir, "blobs"), keep);
|
|
999
|
+
} catch (error) {
|
|
1000
|
+
throw storageError(error, "Collecting orphaned content");
|
|
1001
|
+
}
|
|
432
1002
|
}
|
|
433
1003
|
/**
|
|
434
1004
|
* Open the replica store in `dir`. With `create: false` a missing replica is
|
|
435
1005
|
* REPLICA_NOT_FOUND and nothing is written to disk.
|
|
436
1006
|
*/
|
|
437
1007
|
static async open(dir, options) {
|
|
438
|
-
const namedDbPath = (0,
|
|
1008
|
+
const namedDbPath = (0, import_node_path2.join)(dir, "replica.db");
|
|
439
1009
|
if (!options.create) {
|
|
440
|
-
const exists = await (0,
|
|
1010
|
+
const exists = await (0, import_promises2.lstat)(namedDbPath).then(
|
|
441
1011
|
(stats) => stats.isFile(),
|
|
442
1012
|
() => false
|
|
443
1013
|
);
|
|
444
1014
|
if (!exists) throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, `No regular replica database at ${dir}.`);
|
|
445
1015
|
}
|
|
446
|
-
const opener = options.sqlite ?? await loadSqlite();
|
|
447
1016
|
const releaseOpen = await acquireSqliteOpen();
|
|
448
1017
|
let store;
|
|
449
1018
|
let directoryFd;
|
|
450
1019
|
try {
|
|
451
|
-
if (options.create) await (0,
|
|
1020
|
+
if (options.create) await (0, import_promises2.mkdir)(dir, { recursive: true, mode: 448 });
|
|
452
1021
|
if (process.platform === "linux") {
|
|
453
|
-
directoryFd = (0,
|
|
1022
|
+
directoryFd = (0, import_node_fs2.openSync)(dir, import_node_fs2.constants.O_RDONLY | import_node_fs2.constants.O_DIRECTORY | import_node_fs2.constants.O_NOFOLLOW);
|
|
454
1023
|
}
|
|
455
|
-
const opened = directoryFd === void 0 ? (0,
|
|
1024
|
+
const opened = directoryFd === void 0 ? (0, import_node_fs2.lstatSync)(dir, { bigint: true }) : (0, import_node_fs2.fstatSync)(directoryFd, { bigint: true });
|
|
456
1025
|
if (!opened.isDirectory() || opened.isSymbolicLink() || options.expectedDirectory && (opened.dev !== BigInt(options.expectedDirectory.dev) || opened.ino !== BigInt(options.expectedDirectory.ino))) {
|
|
457
1026
|
throw new Error("The replica directory changed before SQLite opened it.");
|
|
458
1027
|
}
|
|
@@ -461,11 +1030,11 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
461
1030
|
throw new Error("The replica directory changed while checking its ancestry.");
|
|
462
1031
|
}
|
|
463
1032
|
const pinnedDir = directoryFd === void 0 ? dir : `/proc/self/fd/${directoryFd}`;
|
|
464
|
-
const dbPath = (0,
|
|
1033
|
+
const dbPath = (0, import_node_path2.join)(pinnedDir, "replica.db");
|
|
465
1034
|
if (options.create) {
|
|
466
|
-
await (0,
|
|
467
|
-
await Promise.all([(0,
|
|
468
|
-
const handle = await (0,
|
|
1035
|
+
await (0, import_promises2.mkdir)((0, import_node_path2.join)(pinnedDir, "blobs", ".tmp"), { recursive: true, mode: 448 });
|
|
1036
|
+
await Promise.all([(0, import_promises2.chmod)(pinnedDir, 448), (0, import_promises2.chmod)((0, import_node_path2.join)(pinnedDir, "blobs"), 448), (0, import_promises2.chmod)((0, import_node_path2.join)(pinnedDir, "blobs", ".tmp"), 448)]);
|
|
1037
|
+
const handle = await (0, import_promises2.open)(dbPath, import_node_fs2.constants.O_CREAT | import_node_fs2.constants.O_EXCL | import_node_fs2.constants.O_RDWR | import_node_fs2.constants.O_NOFOLLOW, 384).catch((error) => {
|
|
469
1038
|
if (errnoOf(error) === "EEXIST") return null;
|
|
470
1039
|
throw error;
|
|
471
1040
|
});
|
|
@@ -477,71 +1046,107 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
477
1046
|
}
|
|
478
1047
|
}
|
|
479
1048
|
}
|
|
480
|
-
const leaf = await (0,
|
|
1049
|
+
const leaf = await (0, import_promises2.open)(dbPath, import_node_fs2.constants.O_RDONLY | import_node_fs2.constants.O_NOFOLLOW);
|
|
481
1050
|
let db;
|
|
482
1051
|
try {
|
|
483
1052
|
const identity = await leaf.stat();
|
|
484
|
-
const named = await (0,
|
|
1053
|
+
const named = await (0, import_promises2.lstat)(dbPath);
|
|
485
1054
|
if (!identity.isFile() || !named.isFile() || named.dev !== identity.dev || named.ino !== identity.ino) {
|
|
486
1055
|
throw new Error("The replica database changed before SQLite opened it.");
|
|
487
1056
|
}
|
|
488
|
-
const
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
1057
|
+
const key = `${opened.dev}:${opened.ino}`;
|
|
1058
|
+
const active = writableStores.get(key);
|
|
1059
|
+
if (active && (active.dev !== identity.dev || active.ino !== identity.ino)) {
|
|
1060
|
+
throw new Error("The replica database changed while another controller holds it open.");
|
|
1061
|
+
}
|
|
1062
|
+
if (active && !options.sqlite) {
|
|
1063
|
+
const namedDirectory = (0, import_node_fs2.lstatSync)(dir, { bigint: true });
|
|
1064
|
+
if (!namedDirectory.isDirectory() || namedDirectory.dev !== opened.dev || namedDirectory.ino !== opened.ino) {
|
|
1065
|
+
throw new Error("The replica directory changed before its checked SQLite handle could be shared.");
|
|
1066
|
+
}
|
|
1067
|
+
const pinned = active.directoryFd === void 0 ? (0, import_node_fs2.lstatSync)(active.pinnedDir, { bigint: true }) : (0, import_node_fs2.fstatSync)(active.directoryFd, { bigint: true });
|
|
1068
|
+
const activeLeaf = (0, import_node_fs2.lstatSync)((0, import_node_path2.join)(active.pinnedDir, "replica.db"));
|
|
1069
|
+
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) {
|
|
1070
|
+
throw new Error("The live replica changed before its checked SQLite handle could be shared.");
|
|
493
1071
|
}
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
1072
|
+
if (directoryFd !== void 0) {
|
|
1073
|
+
(0, import_node_fs2.closeSync)(directoryFd);
|
|
1074
|
+
directoryFd = void 0;
|
|
1075
|
+
}
|
|
1076
|
+
store = new _SqliteReplicaStore(dir, active.db, identity, options, active.directoryFd, namedAncestors, active);
|
|
499
1077
|
}
|
|
500
|
-
if (
|
|
501
|
-
|
|
502
|
-
const
|
|
1078
|
+
if (!store) {
|
|
1079
|
+
const opener = options.sqlite ?? await loadSqlite();
|
|
1080
|
+
const previousFds = process.platform === "linux" && !options.sqlite ? new Map((0, import_node_fs2.readdirSync)("/proc/self/fd").map((fd) => {
|
|
503
1081
|
try {
|
|
504
|
-
|
|
505
|
-
return target.endsWith("/replica.db") && previousFds.get(fd) !== target;
|
|
1082
|
+
return [fd, (0, import_node_fs2.readlinkSync)(`/proc/self/fd/${fd}`)];
|
|
506
1083
|
} catch {
|
|
507
|
-
return
|
|
1084
|
+
return [fd, ""];
|
|
508
1085
|
}
|
|
509
|
-
});
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
1086
|
+
})) : null;
|
|
1087
|
+
db = opener(dbPath);
|
|
1088
|
+
const afterOpen = (0, import_node_fs2.lstatSync)(dbPath);
|
|
1089
|
+
if (!afterOpen.isFile() || afterOpen.dev !== identity.dev || afterOpen.ino !== identity.ino) {
|
|
1090
|
+
throw new Error("The replica database changed while SQLite opened it.");
|
|
1091
|
+
}
|
|
1092
|
+
if (previousFds) db.get("SELECT count(*) FROM sqlite_master");
|
|
1093
|
+
if (previousFds) {
|
|
1094
|
+
const databases = (0, import_node_fs2.readdirSync)("/proc/self/fd").filter((fd) => {
|
|
1095
|
+
try {
|
|
1096
|
+
const target = (0, import_node_fs2.readlinkSync)(`/proc/self/fd/${fd}`);
|
|
1097
|
+
return target.endsWith("/replica.db") && previousFds.get(fd) !== target;
|
|
1098
|
+
} catch {
|
|
1099
|
+
return false;
|
|
1100
|
+
}
|
|
1101
|
+
});
|
|
1102
|
+
if (databases.length === 0 || databases.some((fd) => {
|
|
1103
|
+
try {
|
|
1104
|
+
const actual = (0, import_node_fs2.fstatSync)(Number(fd));
|
|
1105
|
+
return actual.dev !== identity.dev || actual.ino !== identity.ino;
|
|
1106
|
+
} catch {
|
|
1107
|
+
return true;
|
|
1108
|
+
}
|
|
1109
|
+
})) {
|
|
1110
|
+
throw new Error("SQLite opened a different replica database.");
|
|
516
1111
|
}
|
|
517
|
-
})) {
|
|
518
|
-
throw new Error("SQLite opened a different replica database.");
|
|
519
1112
|
}
|
|
1113
|
+
const after = (0, import_node_fs2.lstatSync)(dbPath);
|
|
1114
|
+
if (!after.isFile() || after.dev !== identity.dev || after.ino !== identity.ino) {
|
|
1115
|
+
throw new Error("The replica database changed while SQLite opened it.");
|
|
1116
|
+
}
|
|
1117
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
1118
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
1119
|
+
db.exec("PRAGMA synchronous = FULL");
|
|
1120
|
+
db.exec("PRAGMA secure_delete = ON");
|
|
1121
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
1122
|
+
const version = db.get("PRAGMA user_version")?.user_version ?? 0;
|
|
1123
|
+
if (version > SCHEMA_VERSION) {
|
|
1124
|
+
throw new ReplicaError(
|
|
1125
|
+
ReplicaErrorCode.STORAGE_ERROR,
|
|
1126
|
+
`The replica at ${dir} was written by a newer tc (schema ${version}); upgrade tc to read it.`
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
1129
|
+
if (version < SCHEMA_VERSION) {
|
|
1130
|
+
db.exec("BEGIN IMMEDIATE");
|
|
1131
|
+
db.exec(SCHEMA);
|
|
1132
|
+
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
1133
|
+
db.exec("COMMIT");
|
|
1134
|
+
}
|
|
1135
|
+
const shared = {
|
|
1136
|
+
key,
|
|
1137
|
+
db,
|
|
1138
|
+
directoryFd,
|
|
1139
|
+
pinnedDir,
|
|
1140
|
+
namedPath: dir,
|
|
1141
|
+
dev: identity.dev,
|
|
1142
|
+
ino: identity.ino,
|
|
1143
|
+
references: 0,
|
|
1144
|
+
mutationTail: Promise.resolve()
|
|
1145
|
+
};
|
|
1146
|
+
store = new _SqliteReplicaStore(dir, db, identity, options, directoryFd, namedAncestors, shared);
|
|
1147
|
+
if (!options.sqlite) writableStores.set(key, shared);
|
|
1148
|
+
db = void 0;
|
|
520
1149
|
}
|
|
521
|
-
const after = (0, import_node_fs.lstatSync)(dbPath);
|
|
522
|
-
if (!after.isFile() || after.dev !== identity.dev || after.ino !== identity.ino) {
|
|
523
|
-
throw new Error("The replica database changed while SQLite opened it.");
|
|
524
|
-
}
|
|
525
|
-
db.exec("PRAGMA busy_timeout = 5000");
|
|
526
|
-
db.exec("PRAGMA journal_mode = WAL");
|
|
527
|
-
db.exec("PRAGMA synchronous = FULL");
|
|
528
|
-
db.exec("PRAGMA secure_delete = ON");
|
|
529
|
-
db.exec("PRAGMA foreign_keys = ON");
|
|
530
|
-
const version = db.get("PRAGMA user_version")?.user_version ?? 0;
|
|
531
|
-
if (version > SCHEMA_VERSION) {
|
|
532
|
-
throw new ReplicaError(
|
|
533
|
-
ReplicaErrorCode.STORAGE_ERROR,
|
|
534
|
-
`The replica at ${dir} was written by a newer tc (schema ${version}); upgrade tc to read it.`
|
|
535
|
-
);
|
|
536
|
-
}
|
|
537
|
-
if (version < SCHEMA_VERSION) {
|
|
538
|
-
db.exec("BEGIN IMMEDIATE");
|
|
539
|
-
db.exec(SCHEMA);
|
|
540
|
-
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
541
|
-
db.exec("COMMIT");
|
|
542
|
-
}
|
|
543
|
-
store = new _SqliteReplicaStore(dir, db, identity, options, directoryFd, namedAncestors);
|
|
544
|
-
db = void 0;
|
|
545
1150
|
} finally {
|
|
546
1151
|
try {
|
|
547
1152
|
db?.close();
|
|
@@ -551,7 +1156,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
551
1156
|
}
|
|
552
1157
|
} catch (error) {
|
|
553
1158
|
if (store) await store.close().catch(() => void 0);
|
|
554
|
-
else if (directoryFd !== void 0) (0,
|
|
1159
|
+
else if (directoryFd !== void 0) (0, import_node_fs2.closeSync)(directoryFd);
|
|
555
1160
|
throw storageError(error, `Opening the replica at ${dir}`);
|
|
556
1161
|
} finally {
|
|
557
1162
|
releaseOpen();
|
|
@@ -567,12 +1172,109 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
567
1172
|
throw error;
|
|
568
1173
|
}
|
|
569
1174
|
}
|
|
1175
|
+
/**
|
|
1176
|
+
* A gated read borrows this controller's already-verified writable handle.
|
|
1177
|
+
* Borrow before the first await: the parent may close in the callback, but
|
|
1178
|
+
* its pin and native handle remain live until the snapshot releases them.
|
|
1179
|
+
* Serialize BEGIN through COMMIT/ROLLBACK with writes on the shared handle.
|
|
1180
|
+
*/
|
|
1181
|
+
async readTransaction(generation, checkGate, section) {
|
|
1182
|
+
this.#assertAccepting();
|
|
1183
|
+
const shared = this.#shared;
|
|
1184
|
+
if (!shared) throw new ReplicaError(ReplicaErrorCode.CLOSED, "The replica handle is closed.");
|
|
1185
|
+
const snapshot = new _SqliteReplicaStore(
|
|
1186
|
+
this.dir,
|
|
1187
|
+
shared.db,
|
|
1188
|
+
{ dev: this.#dev, ino: this.#ino },
|
|
1189
|
+
{ create: false, now: this.#now },
|
|
1190
|
+
shared.directoryFd,
|
|
1191
|
+
this.#namedAncestors,
|
|
1192
|
+
shared,
|
|
1193
|
+
true
|
|
1194
|
+
);
|
|
1195
|
+
let release;
|
|
1196
|
+
let active = false;
|
|
1197
|
+
let callbackFailed = false;
|
|
1198
|
+
try {
|
|
1199
|
+
release = await acquireWritableMutation(shared);
|
|
1200
|
+
await snapshot.#assertReadIdentity();
|
|
1201
|
+
snapshot.#db.exec("BEGIN");
|
|
1202
|
+
active = true;
|
|
1203
|
+
const tombstone = snapshot.#db.get("SELECT generation FROM replica WHERE id = 1");
|
|
1204
|
+
try {
|
|
1205
|
+
await checkGate();
|
|
1206
|
+
} catch (error) {
|
|
1207
|
+
callbackFailed = true;
|
|
1208
|
+
throw error;
|
|
1209
|
+
}
|
|
1210
|
+
if (tombstone === void 0 || tombstone.generation !== generation) {
|
|
1211
|
+
throw new ReplicaError(ReplicaErrorCode.RESET_REQUIRED, "The replica generation changed; reopen it to continue.");
|
|
1212
|
+
}
|
|
1213
|
+
let result;
|
|
1214
|
+
const context = { controller: this, active: true };
|
|
1215
|
+
try {
|
|
1216
|
+
try {
|
|
1217
|
+
result = await activeReadSection.run(context, () => section(snapshot));
|
|
1218
|
+
} catch (error) {
|
|
1219
|
+
callbackFailed = true;
|
|
1220
|
+
throw error;
|
|
1221
|
+
}
|
|
1222
|
+
} finally {
|
|
1223
|
+
context.active = false;
|
|
1224
|
+
}
|
|
1225
|
+
try {
|
|
1226
|
+
await checkGate();
|
|
1227
|
+
} catch (error) {
|
|
1228
|
+
callbackFailed = true;
|
|
1229
|
+
throw error;
|
|
1230
|
+
}
|
|
1231
|
+
await snapshot.#assertReadIdentity();
|
|
1232
|
+
snapshot.#db.exec("COMMIT");
|
|
1233
|
+
active = false;
|
|
1234
|
+
return result;
|
|
1235
|
+
} catch (error) {
|
|
1236
|
+
if (callbackFailed) throw error;
|
|
1237
|
+
if (isReplicaError(error) || typeof error === "object" && error !== null && "code" in error && error.code === "REPLICA_GATED") throw error;
|
|
1238
|
+
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))) {
|
|
1239
|
+
throw new ReplicaError(ReplicaErrorCode.RESET_REQUIRED, "The replica was replaced; reopen it to continue.", void 0, { cause: error });
|
|
1240
|
+
}
|
|
1241
|
+
throw storageError(error, "Reading the replica snapshot");
|
|
1242
|
+
} finally {
|
|
1243
|
+
if (active) {
|
|
1244
|
+
try {
|
|
1245
|
+
snapshot.#db.exec("ROLLBACK");
|
|
1246
|
+
} catch {
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
try {
|
|
1250
|
+
await snapshot.close();
|
|
1251
|
+
} finally {
|
|
1252
|
+
release?.();
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
async #assertReadIdentity() {
|
|
1257
|
+
const shared = this.#shared;
|
|
1258
|
+
const pinned = shared.directoryFd === void 0 ? (0, import_node_fs2.lstatSync)(shared.pinnedDir) : (0, import_node_fs2.fstatSync)(shared.directoryFd);
|
|
1259
|
+
const namedDir = (0, import_node_fs2.lstatSync)(this.dir);
|
|
1260
|
+
const namedDb = (0, import_node_fs2.lstatSync)((0, import_node_path2.join)(this.dir, "replica.db"));
|
|
1261
|
+
const pinnedDb = (0, import_node_fs2.lstatSync)((0, import_node_path2.join)(shared.pinnedDir, "replica.db"));
|
|
1262
|
+
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) {
|
|
1263
|
+
throw new ReplicaError(ReplicaErrorCode.RESET_REQUIRED, "The replica was replaced during its read; reopen it to continue.");
|
|
1264
|
+
}
|
|
1265
|
+
if (this.#namedAncestors.length) {
|
|
1266
|
+
const ancestors = await captureNamedPath(this.dir);
|
|
1267
|
+
if (ancestors.length !== this.#namedAncestors.length || ancestors.some((entry, index) => entry.dev !== this.#namedAncestors[index].dev || entry.ino !== this.#namedAncestors[index].ino)) {
|
|
1268
|
+
throw new ReplicaError(ReplicaErrorCode.RESET_REQUIRED, "The replica directory was replaced; reopen it to continue.");
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
570
1272
|
#row() {
|
|
571
1273
|
return this.#db.get("SELECT * FROM replica WHERE id = 1");
|
|
572
1274
|
}
|
|
573
1275
|
/** Refuse to touch the filesystem for a replica that was deleted (or replaced) under us. */
|
|
574
1276
|
async #assertPresent() {
|
|
575
|
-
const identity = await (0,
|
|
1277
|
+
const identity = await (0, import_promises2.lstat)((0, import_node_path2.join)(this.#pinnedDir, "replica.db")).catch(() => null);
|
|
576
1278
|
if (!identity?.isFile() || identity.dev !== this.#dev || identity.ino !== this.#ino) {
|
|
577
1279
|
throw new ReplicaError(
|
|
578
1280
|
ReplicaErrorCode.NOT_FOUND,
|
|
@@ -580,30 +1282,63 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
580
1282
|
);
|
|
581
1283
|
}
|
|
582
1284
|
}
|
|
1285
|
+
/** New calls stop at close; accepted mutation sections may still finish their transactions. */
|
|
1286
|
+
#assertAccepting() {
|
|
1287
|
+
if (this.#closing || this.#closed) {
|
|
1288
|
+
throw new ReplicaError(ReplicaErrorCode.CLOSED, "The replica store is closed.");
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
583
1291
|
/** A short mutation section: guarded (profile lock) and checked against the database this store opened. */
|
|
584
1292
|
#mutate(section) {
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
}
|
|
1293
|
+
this.#assertAccepting();
|
|
1294
|
+
if (this.#readonlySnapshot) {
|
|
1295
|
+
throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "A read-only replica snapshot cannot mutate the shared database.");
|
|
1296
|
+
}
|
|
1297
|
+
const read = activeReadSection.getStore();
|
|
1298
|
+
if (read?.active && read.controller === this) {
|
|
1299
|
+
throw new ReplicaError(
|
|
1300
|
+
ReplicaErrorCode.STORAGE_ERROR,
|
|
1301
|
+
"mutation attempted on the same controller while its readTransaction section is active; this would deadlock"
|
|
1302
|
+
);
|
|
1303
|
+
}
|
|
1304
|
+
this.#activeMutations++;
|
|
1305
|
+
return (async () => {
|
|
1306
|
+
try {
|
|
1307
|
+
return await this.#guard(async () => {
|
|
1308
|
+
await this.#assertPresent();
|
|
1309
|
+
return section();
|
|
1310
|
+
});
|
|
1311
|
+
} finally {
|
|
1312
|
+
if (--this.#activeMutations === 0) {
|
|
1313
|
+
const drained = this.#drained;
|
|
1314
|
+
this.#drained = void 0;
|
|
1315
|
+
drained?.();
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
})();
|
|
589
1319
|
}
|
|
590
1320
|
/** Run `body` in a write transaction (BEGIN IMMEDIATE). Callers outside a mutation section use `#guardedWrite`. */
|
|
591
|
-
#write(action, body) {
|
|
1321
|
+
async #write(action, body) {
|
|
1322
|
+
const release = this.#shared ? await acquireWritableMutation(this.#shared) : void 0;
|
|
592
1323
|
try {
|
|
593
|
-
this.#db.exec("BEGIN IMMEDIATE");
|
|
594
|
-
} catch (error) {
|
|
595
|
-
throw storageError(error, action);
|
|
596
|
-
}
|
|
597
|
-
try {
|
|
598
|
-
const result = body();
|
|
599
|
-
this.#db.exec("COMMIT");
|
|
600
|
-
return result;
|
|
601
|
-
} catch (error) {
|
|
602
1324
|
try {
|
|
603
|
-
this.#db.exec("
|
|
604
|
-
} catch {
|
|
1325
|
+
this.#db.exec("BEGIN IMMEDIATE");
|
|
1326
|
+
} catch (error) {
|
|
1327
|
+
throw storageError(error, action);
|
|
1328
|
+
}
|
|
1329
|
+
try {
|
|
1330
|
+
const result = body();
|
|
1331
|
+
this.#db.exec("COMMIT");
|
|
1332
|
+
return result;
|
|
1333
|
+
} catch (error) {
|
|
1334
|
+
try {
|
|
1335
|
+
this.#db.exec("ROLLBACK");
|
|
1336
|
+
} catch {
|
|
1337
|
+
}
|
|
1338
|
+
throw storageError(error, action);
|
|
605
1339
|
}
|
|
606
|
-
|
|
1340
|
+
} finally {
|
|
1341
|
+
release?.();
|
|
607
1342
|
}
|
|
608
1343
|
}
|
|
609
1344
|
/** One write transaction as its own mutation section. */
|
|
@@ -630,6 +1365,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
630
1365
|
}
|
|
631
1366
|
}
|
|
632
1367
|
async open() {
|
|
1368
|
+
this.#assertAccepting();
|
|
633
1369
|
let row;
|
|
634
1370
|
try {
|
|
635
1371
|
row = this.#row();
|
|
@@ -708,6 +1444,110 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
708
1444
|
);
|
|
709
1445
|
});
|
|
710
1446
|
}
|
|
1447
|
+
/** Per-CID first-install anchor and policy bound share the pending-grant transaction. */
|
|
1448
|
+
#grantInstall(cid) {
|
|
1449
|
+
const row = this.#db.get(
|
|
1450
|
+
"SELECT installed_at, effective_expires_at, consent_deadline, consent_anchor_at, consent_anchor_source FROM grant_install WHERE cid = ?",
|
|
1451
|
+
cid
|
|
1452
|
+
);
|
|
1453
|
+
if (row === void 0) return null;
|
|
1454
|
+
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) {
|
|
1455
|
+
throw new ReplicaError(ReplicaErrorCode.GRANT_EXPIRED, "The stored grant expiry or consent anchor is invalid.");
|
|
1456
|
+
}
|
|
1457
|
+
return {
|
|
1458
|
+
installedAt: row.installed_at,
|
|
1459
|
+
effectiveExpiresAt: row.effective_expires_at,
|
|
1460
|
+
consentDeadline: row.consent_deadline,
|
|
1461
|
+
consentAnchor: { at: row.consent_anchor_at, source: row.consent_anchor_source }
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
1464
|
+
async getGrantInstall(cid) {
|
|
1465
|
+
return this.#grantInstall(cid);
|
|
1466
|
+
}
|
|
1467
|
+
/** Every installed CID, including superseded ones, for an origin-wide purge floor. */
|
|
1468
|
+
async grantInstallTimes() {
|
|
1469
|
+
const rows = this.#db.all(
|
|
1470
|
+
"SELECT cid, installed_at FROM grant_install"
|
|
1471
|
+
);
|
|
1472
|
+
return rows.map((row) => {
|
|
1473
|
+
if (typeof row.cid !== "string" || row.cid.length === 0 || !Number.isSafeInteger(row.installed_at) || row.installed_at < 0) {
|
|
1474
|
+
throw new ReplicaError(ReplicaErrorCode.GRANT_EXPIRED, "The stored grant install time is invalid.");
|
|
1475
|
+
}
|
|
1476
|
+
return { cid: row.cid, installedAt: row.installed_at };
|
|
1477
|
+
});
|
|
1478
|
+
}
|
|
1479
|
+
/** Phase 2 installed grants cannot commit a page after their shorter local TTL. */
|
|
1480
|
+
#assertInstalledGrantTime() {
|
|
1481
|
+
const row = this.#row();
|
|
1482
|
+
const cid = row?.pending_grant_cid ?? row?.grant_cid;
|
|
1483
|
+
if (cid === void 0 || cid === null) return;
|
|
1484
|
+
const installed = this.#grantInstall(cid);
|
|
1485
|
+
const now = this.#now();
|
|
1486
|
+
if (installed !== null && (now >= installed.effectiveExpiresAt || now >= installed.consentDeadline)) {
|
|
1487
|
+
throw new ReplicaError(ReplicaErrorCode.GRANT_EXPIRED, "The installed grant expired before this page committed.");
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
#storeGrantPolicy(g, policy, existing) {
|
|
1491
|
+
const installedAt = existing?.installedAt ?? policy.at;
|
|
1492
|
+
const keepExisting = existing !== null && existing.consentDeadline < policy.consentDeadline;
|
|
1493
|
+
const consentDeadline = keepExisting ? existing.consentDeadline : policy.consentDeadline;
|
|
1494
|
+
const consentAnchor = keepExisting ? existing.consentAnchor : policy.consentAnchor;
|
|
1495
|
+
const effectiveExpiresAt = Math.min(
|
|
1496
|
+
g.expiresAt === null ? Infinity : g.expiresAt * 1e3,
|
|
1497
|
+
installedAt + policy.ttlMs,
|
|
1498
|
+
consentDeadline
|
|
1499
|
+
);
|
|
1500
|
+
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) {
|
|
1501
|
+
throw new ReplicaError(ReplicaErrorCode.GRANT_EXPIRED, "The replica grant has no finite effective expiry.");
|
|
1502
|
+
}
|
|
1503
|
+
this.#db.run(
|
|
1504
|
+
`INSERT INTO grant_install (cid, installed_at, effective_expires_at, consent_deadline, consent_anchor_at, consent_anchor_source)
|
|
1505
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
1506
|
+
ON CONFLICT (cid) DO UPDATE SET effective_expires_at = excluded.effective_expires_at,
|
|
1507
|
+
consent_deadline = excluded.consent_deadline, consent_anchor_at = excluded.consent_anchor_at,
|
|
1508
|
+
consent_anchor_source = excluded.consent_anchor_source`,
|
|
1509
|
+
g.cid,
|
|
1510
|
+
installedAt,
|
|
1511
|
+
effectiveExpiresAt,
|
|
1512
|
+
consentDeadline,
|
|
1513
|
+
consentAnchor.at,
|
|
1514
|
+
consentAnchor.source
|
|
1515
|
+
);
|
|
1516
|
+
return { installedAt, effectiveExpiresAt, consentDeadline, consentAnchor };
|
|
1517
|
+
}
|
|
1518
|
+
async installGatedGrant(g, policy) {
|
|
1519
|
+
return this.#guardedWrite("Installing the gated grant", () => {
|
|
1520
|
+
const row = this.#row();
|
|
1521
|
+
if (row === void 0) throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica has not been created.");
|
|
1522
|
+
const previous = this.#grantInstall(g.cid);
|
|
1523
|
+
if (previous === null && (row.grant_cid === g.cid || row.pending_grant_cid === g.cid)) {
|
|
1524
|
+
throw new ReplicaError(ReplicaErrorCode.GRANT_EXPIRED, "The installed grant has no durable first-install time.");
|
|
1525
|
+
}
|
|
1526
|
+
const next = this.#storeGrantPolicy(g, policy, previous);
|
|
1527
|
+
if (next.effectiveExpiresAt <= this.#now()) {
|
|
1528
|
+
throw new ReplicaError(ReplicaErrorCode.GRANT_EXPIRED, "The replica grant expired under the current policy.");
|
|
1529
|
+
}
|
|
1530
|
+
if (row.grant_cid !== g.cid && row.pending_grant_cid !== g.cid) {
|
|
1531
|
+
this.#db.run(
|
|
1532
|
+
`UPDATE replica SET pending_grant_cid = ?, pending_grant_bytes = ?, pending_grant_audience = ?,
|
|
1533
|
+
pending_grant_issuer = ?, pending_grant_nbf = ?, pending_grant_exp = ?, pending_grant_error = NULL WHERE id = 1`,
|
|
1534
|
+
g.cid,
|
|
1535
|
+
g.bytes,
|
|
1536
|
+
g.audience,
|
|
1537
|
+
g.issuer,
|
|
1538
|
+
g.notBefore,
|
|
1539
|
+
g.expiresAt
|
|
1540
|
+
);
|
|
1541
|
+
}
|
|
1542
|
+
return next;
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
async recomputeGrantInstall(g, policy) {
|
|
1546
|
+
return this.#guardedWrite("Recomputing the gated grant", () => {
|
|
1547
|
+
const previous = this.#grantInstall(g.cid);
|
|
1548
|
+
return previous === null ? null : this.#storeGrantPolicy(g, policy, previous);
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
711
1551
|
async installGrant(g) {
|
|
712
1552
|
await this.#guardedWrite("Installing the grant", () => {
|
|
713
1553
|
const row = this.#row();
|
|
@@ -766,13 +1606,14 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
766
1606
|
const now = this.#now();
|
|
767
1607
|
if (row.lease_holder !== null && row.lease_expires_at !== null && row.lease_expires_at > now) return null;
|
|
768
1608
|
const token = row.lease_token + 1;
|
|
1609
|
+
const holder = this.#holder ??= `${process.pid}-${(0, import_node_crypto.randomBytes)(6).toString("hex")}`;
|
|
769
1610
|
this.#db.run(
|
|
770
1611
|
"UPDATE replica SET lease_token = ?, lease_holder = ?, lease_expires_at = ? WHERE id = 1",
|
|
771
1612
|
token,
|
|
772
|
-
|
|
1613
|
+
holder,
|
|
773
1614
|
now + ttlMs
|
|
774
1615
|
);
|
|
775
|
-
return { token, holder
|
|
1616
|
+
return { token, holder };
|
|
776
1617
|
});
|
|
777
1618
|
}
|
|
778
1619
|
async renewLease(t, ttlMs) {
|
|
@@ -791,6 +1632,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
791
1632
|
});
|
|
792
1633
|
}
|
|
793
1634
|
async hasContent(hashes) {
|
|
1635
|
+
this.#assertAccepting();
|
|
794
1636
|
const held = /* @__PURE__ */ new Set();
|
|
795
1637
|
for (let start = 0; start < hashes.length; start += 500) {
|
|
796
1638
|
const chunk = hashes.slice(start, start + 500);
|
|
@@ -803,7 +1645,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
803
1645
|
return held;
|
|
804
1646
|
}
|
|
805
1647
|
#blobPath(hash) {
|
|
806
|
-
return (0,
|
|
1648
|
+
return (0, import_node_path2.join)(this.#pinnedDir, "blobs", hash.slice(0, 2), hash);
|
|
807
1649
|
}
|
|
808
1650
|
/**
|
|
809
1651
|
* Make every blob durable before any row references it: write to a fresh
|
|
@@ -815,17 +1657,17 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
815
1657
|
const dirs = /* @__PURE__ */ new Set();
|
|
816
1658
|
for (const [hash, bytes] of blobs) {
|
|
817
1659
|
const final = this.#blobPath(hash);
|
|
818
|
-
const exists = await (0,
|
|
1660
|
+
const exists = await (0, import_promises2.stat)(final).then(
|
|
819
1661
|
() => true,
|
|
820
1662
|
() => false
|
|
821
1663
|
);
|
|
822
1664
|
if (exists) continue;
|
|
823
|
-
const shard = (0,
|
|
824
|
-
await (0,
|
|
1665
|
+
const shard = (0, import_node_path2.join)(this.#pinnedDir, "blobs", hash.slice(0, 2));
|
|
1666
|
+
await (0, import_promises2.mkdir)(shard, { mode: 448 }).catch((error) => {
|
|
825
1667
|
if (errnoOf(error) !== "EEXIST") throw error;
|
|
826
1668
|
});
|
|
827
|
-
const temp = (0,
|
|
828
|
-
const handle = await (0,
|
|
1669
|
+
const temp = (0, import_node_path2.join)(this.#pinnedDir, "blobs", ".tmp", `${hash}.${(0, import_node_crypto.randomBytes)(6).toString("hex")}`);
|
|
1670
|
+
const handle = await (0, import_promises2.open)(temp, import_node_fs2.constants.O_CREAT | import_node_fs2.constants.O_EXCL | import_node_fs2.constants.O_WRONLY, 384);
|
|
829
1671
|
try {
|
|
830
1672
|
await handle.writeFile(bytes);
|
|
831
1673
|
await handle.sync();
|
|
@@ -833,17 +1675,17 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
833
1675
|
await handle.close();
|
|
834
1676
|
}
|
|
835
1677
|
try {
|
|
836
|
-
await (0,
|
|
1678
|
+
await (0, import_promises2.link)(temp, final);
|
|
837
1679
|
} catch (error) {
|
|
838
1680
|
if (errnoOf(error) !== "EEXIST") throw error;
|
|
839
1681
|
} finally {
|
|
840
|
-
await (0,
|
|
1682
|
+
await (0, import_promises2.unlink)(temp);
|
|
841
1683
|
}
|
|
842
1684
|
dirs.add(shard);
|
|
843
|
-
dirs.add((0,
|
|
1685
|
+
dirs.add((0, import_node_path2.join)(this.#pinnedDir, "blobs"));
|
|
844
1686
|
}
|
|
845
1687
|
for (const dir of dirs) {
|
|
846
|
-
const handle = await (0,
|
|
1688
|
+
const handle = await (0, import_promises2.open)(dir, import_node_fs2.constants.O_RDONLY);
|
|
847
1689
|
try {
|
|
848
1690
|
await handle.sync();
|
|
849
1691
|
} finally {
|
|
@@ -854,6 +1696,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
854
1696
|
async applyPage(t, p) {
|
|
855
1697
|
await this.#mutate(async () => {
|
|
856
1698
|
this.#checkLease(t);
|
|
1699
|
+
this.#assertInstalledGrantTime();
|
|
857
1700
|
const reused = /* @__PURE__ */ new Map();
|
|
858
1701
|
for (const change of p.changes) {
|
|
859
1702
|
if (change.deleted || !change.content || p.blobs.has(change.hash) || reused.has(change.hash)) continue;
|
|
@@ -866,6 +1709,41 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
866
1709
|
}
|
|
867
1710
|
reused.set(change.hash, size);
|
|
868
1711
|
}
|
|
1712
|
+
if (this.#admit) {
|
|
1713
|
+
await this.#collectLocked(t);
|
|
1714
|
+
const changed = new Set(p.changes.map((change) => change.key));
|
|
1715
|
+
const kept = /* @__PURE__ */ new Set();
|
|
1716
|
+
for (const change of p.changes) if (!change.deleted && change.content) kept.add(change.hash);
|
|
1717
|
+
let blobBytes = 0;
|
|
1718
|
+
for (const [hash, bytes] of p.blobs) {
|
|
1719
|
+
const stored = this.#db.get(
|
|
1720
|
+
"SELECT 1 AS hit FROM entry WHERE hash = ? AND content = 1 AND deleted = 0 LIMIT 1",
|
|
1721
|
+
hash
|
|
1722
|
+
);
|
|
1723
|
+
if (stored === void 0) blobBytes += bytes.byteLength;
|
|
1724
|
+
}
|
|
1725
|
+
const released = /* @__PURE__ */ new Map();
|
|
1726
|
+
for (const key of changed) {
|
|
1727
|
+
const old = this.#db.get(
|
|
1728
|
+
"SELECT hash, size FROM entry WHERE key = ? AND deleted = 0 AND content = 1 AND hash IS NOT NULL AND size IS NOT NULL",
|
|
1729
|
+
key
|
|
1730
|
+
);
|
|
1731
|
+
if (old === void 0 || kept.has(old.hash) || released.has(old.hash)) continue;
|
|
1732
|
+
const holders = this.#db.all(
|
|
1733
|
+
"SELECT key FROM entry WHERE hash = ? AND content = 1 AND deleted = 0",
|
|
1734
|
+
old.hash
|
|
1735
|
+
);
|
|
1736
|
+
if (holders.every((holder) => changed.has(holder.key))) released.set(old.hash, old.size);
|
|
1737
|
+
}
|
|
1738
|
+
let freed = 0;
|
|
1739
|
+
for (const size of released.values()) freed += size;
|
|
1740
|
+
await this.#admit({
|
|
1741
|
+
partitionId: this.#partitionId,
|
|
1742
|
+
blobBytes: blobBytes - freed,
|
|
1743
|
+
entries: p.changes.length,
|
|
1744
|
+
freeBytes: null
|
|
1745
|
+
});
|
|
1746
|
+
}
|
|
869
1747
|
try {
|
|
870
1748
|
await this.#writeBlobs(p.blobs);
|
|
871
1749
|
} catch (error) {
|
|
@@ -873,8 +1751,9 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
873
1751
|
}
|
|
874
1752
|
await this.#faults.afterBlobs?.();
|
|
875
1753
|
try {
|
|
876
|
-
this.#write("Committing a sync page", () => {
|
|
1754
|
+
await this.#write("Committing a sync page", () => {
|
|
877
1755
|
this.#checkLease(t);
|
|
1756
|
+
this.#assertInstalledGrantTime();
|
|
878
1757
|
const now = this.#now();
|
|
879
1758
|
if (p.window.notBefore !== null && now < Date.parse(p.window.notBefore)) {
|
|
880
1759
|
throw new ReplicaError(ReplicaErrorCode.GRANT_NOT_YET_VALID, `The grant is not valid before ${p.window.notBefore}; nothing was committed.`);
|
|
@@ -940,72 +1819,59 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
940
1819
|
});
|
|
941
1820
|
} catch (error) {
|
|
942
1821
|
if (isReplicaError(error, ReplicaErrorCode.GRANT_REVOKED)) await this.#purgeContent().catch(() => void 0);
|
|
1822
|
+
else if (this.#admit) await this.#collectLocked(t).catch(() => void 0);
|
|
943
1823
|
throw error;
|
|
944
1824
|
}
|
|
945
1825
|
this.#faults.afterCommit?.();
|
|
1826
|
+
if (this.#admit) await this.#collectLocked(t);
|
|
946
1827
|
});
|
|
947
1828
|
}
|
|
948
1829
|
/** Unlink every blob not in `keep`, and every temp file. Only a missing directory counts as empty. */
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
const root = (0, import_node_path.join)(this.#pinnedDir, "blobs");
|
|
952
|
-
for (const shard of await entriesOf(root)) {
|
|
953
|
-
const shardPath = (0, import_node_path.join)(root, shard);
|
|
954
|
-
if (shard === ".tmp") {
|
|
955
|
-
for (const name of await entriesOf(shardPath)) {
|
|
956
|
-
await (0, import_promises.rm)((0, import_node_path.join)(shardPath, name), { force: true });
|
|
957
|
-
removed += 1;
|
|
958
|
-
}
|
|
959
|
-
continue;
|
|
960
|
-
}
|
|
961
|
-
let kept = 0;
|
|
962
|
-
for (const name of await entriesOf(shardPath)) {
|
|
963
|
-
if (isHash(name) && keep.has(name)) {
|
|
964
|
-
kept += 1;
|
|
965
|
-
continue;
|
|
966
|
-
}
|
|
967
|
-
await (0, import_promises.rm)((0, import_node_path.join)(shardPath, name), { force: true });
|
|
968
|
-
removed += 1;
|
|
969
|
-
}
|
|
970
|
-
if (kept === 0) await (0, import_promises.rmdir)(shardPath).catch(() => void 0);
|
|
971
|
-
}
|
|
972
|
-
return removed;
|
|
1830
|
+
#unlinkBlobs(keep) {
|
|
1831
|
+
return unlinkBlobsIn((0, import_node_path2.join)(this.#pinnedDir, "blobs"), keep);
|
|
973
1832
|
}
|
|
974
1833
|
/** Whether any content file (a blob or a temp file) is on disk. */
|
|
975
1834
|
async #holdsContentFiles() {
|
|
976
|
-
const root = (0,
|
|
1835
|
+
const root = (0, import_node_path2.join)(this.#pinnedDir, "blobs");
|
|
977
1836
|
for (const shard of await entriesOf(root)) {
|
|
978
|
-
if ((await entriesOf((0,
|
|
1837
|
+
if ((await entriesOf((0, import_node_path2.join)(root, shard))).length > 0) return true;
|
|
979
1838
|
}
|
|
980
1839
|
return false;
|
|
981
1840
|
}
|
|
982
1841
|
async collectGarbage(t) {
|
|
983
|
-
return this.#mutate(
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
1842
|
+
return this.#mutate(() => this.#collectLocked(t));
|
|
1843
|
+
}
|
|
1844
|
+
/** `collectGarbage` for callers already inside the mutation hold (it cannot re-enter `#mutate`). */
|
|
1845
|
+
async #collectLocked(t) {
|
|
1846
|
+
const release = this.#shared ? await acquireWritableMutation(this.#shared) : void 0;
|
|
1847
|
+
try {
|
|
1848
|
+
this.#db.exec("BEGIN IMMEDIATE");
|
|
1849
|
+
} catch (error) {
|
|
1850
|
+
release?.();
|
|
1851
|
+
throw storageError(error, "Collecting unused content");
|
|
1852
|
+
}
|
|
1853
|
+
try {
|
|
1854
|
+
this.#checkLease(t);
|
|
1855
|
+
await this.#faults.afterGarbageCollectionBegin?.();
|
|
1856
|
+
const keep = new Set(
|
|
1857
|
+
this.#db.all("SELECT DISTINCT hash FROM entry WHERE content = 1").map((row) => row.hash)
|
|
1858
|
+
);
|
|
1859
|
+
const removed = await this.#unlinkBlobs(keep);
|
|
1860
|
+
this.#db.exec("COMMIT");
|
|
1861
|
+
return removed;
|
|
1862
|
+
} catch (error) {
|
|
989
1863
|
try {
|
|
990
|
-
this.#
|
|
991
|
-
|
|
992
|
-
this.#db.all("SELECT DISTINCT hash FROM entry WHERE content = 1").map((row) => row.hash)
|
|
993
|
-
);
|
|
994
|
-
const removed = await this.#unlinkBlobs(keep);
|
|
995
|
-
this.#db.exec("COMMIT");
|
|
996
|
-
return removed;
|
|
997
|
-
} catch (error) {
|
|
998
|
-
try {
|
|
999
|
-
this.#db.exec("ROLLBACK");
|
|
1000
|
-
} catch {
|
|
1001
|
-
}
|
|
1002
|
-
throw storageError(error, "Collecting unused content");
|
|
1864
|
+
this.#db.exec("ROLLBACK");
|
|
1865
|
+
} catch {
|
|
1003
1866
|
}
|
|
1004
|
-
|
|
1867
|
+
throw storageError(error, "Collecting unused content");
|
|
1868
|
+
} finally {
|
|
1869
|
+
release?.();
|
|
1870
|
+
}
|
|
1005
1871
|
}
|
|
1006
1872
|
async reset(t, reason, options = {}) {
|
|
1007
1873
|
await this.#mutate(async () => {
|
|
1008
|
-
this.#write("Resetting the replica", () => {
|
|
1874
|
+
await this.#write("Resetting the replica", () => {
|
|
1009
1875
|
this.#checkLease(t);
|
|
1010
1876
|
this.#db.run("DELETE FROM entry");
|
|
1011
1877
|
this.#db.run(
|
|
@@ -1024,107 +1890,50 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
1024
1890
|
}
|
|
1025
1891
|
}
|
|
1026
1892
|
/**
|
|
1027
|
-
* Remove
|
|
1028
|
-
*
|
|
1029
|
-
* store opened, checked under the write lock, so a paused process can
|
|
1030
|
-
* never remove a replica another process took over or recreated. Allowed
|
|
1031
|
-
* on a revoked replica. The store is unusable afterwards; close it.
|
|
1893
|
+
* Remove the source-created mirror layout under a live lease. Unknown files
|
|
1894
|
+
* are removed only when their exact preflight identities were approved.
|
|
1032
1895
|
*/
|
|
1033
|
-
async destroy(t) {
|
|
1896
|
+
async destroy(t, options = {}) {
|
|
1034
1897
|
await this.#mutate(async () => {
|
|
1898
|
+
const release = this.#shared ? await acquireWritableMutation(this.#shared) : void 0;
|
|
1035
1899
|
try {
|
|
1036
1900
|
this.#db.exec("BEGIN IMMEDIATE");
|
|
1037
1901
|
} catch (error) {
|
|
1902
|
+
release?.();
|
|
1038
1903
|
throw storageError(error, "Removing the replica");
|
|
1039
1904
|
}
|
|
1040
1905
|
try {
|
|
1041
1906
|
this.#checkLease(t, { allowRevoked: true });
|
|
1042
|
-
const expected = this.#directoryFd === void 0 ? this.#namedAncestors.at(-1) : (0,
|
|
1907
|
+
const expected = this.#directoryFd === void 0 ? this.#namedAncestors.at(-1) : (0, import_node_fs2.fstatSync)(this.#directoryFd, { bigint: true });
|
|
1043
1908
|
if (!expected) {
|
|
1044
1909
|
throw new ReplicaError(ReplicaErrorCode.RUNTIME_UNSUPPORTED, "Replica removal requires an opened or identity-checked directory.");
|
|
1045
1910
|
}
|
|
1046
1911
|
const assertDirectory = async () => {
|
|
1047
|
-
const current = await (0,
|
|
1912
|
+
const current = await (0, import_promises2.lstat)(this.dir, { bigint: true });
|
|
1048
1913
|
if (!current.isDirectory() || current.dev !== expected.dev || current.ino !== expected.ino) {
|
|
1049
1914
|
throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica directory changed during removal.");
|
|
1050
1915
|
}
|
|
1051
1916
|
return current;
|
|
1052
1917
|
};
|
|
1053
1918
|
const pinned = await assertDirectory();
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
await assertDirectory();
|
|
1060
|
-
await (0, import_promises.rmdir)(this.dir);
|
|
1061
|
-
} else {
|
|
1062
|
-
const ancestors = this.#namedAncestors;
|
|
1063
|
-
const checkAncestors = async () => {
|
|
1064
|
-
for (const expected2 of ancestors) {
|
|
1065
|
-
const current = await (0, import_promises.lstat)(expected2.path, { bigint: true });
|
|
1066
|
-
if (current.dev !== expected2.dev || current.ino !== expected2.ino) {
|
|
1067
|
-
throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica ancestry changed during removal.");
|
|
1068
|
-
}
|
|
1919
|
+
const assertNamedPath = async () => {
|
|
1920
|
+
for (const ancestor of this.#namedAncestors) {
|
|
1921
|
+
const current = await (0, import_promises2.lstat)(ancestor.path, { bigint: true });
|
|
1922
|
+
if (current.dev !== ancestor.dev || current.ino !== ancestor.ino) {
|
|
1923
|
+
throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica ancestry changed during removal.");
|
|
1069
1924
|
}
|
|
1070
|
-
};
|
|
1071
|
-
const directories = /* @__PURE__ */ new Map([[this.dir, pinned]]);
|
|
1072
|
-
const files = /* @__PURE__ */ new Map();
|
|
1073
|
-
const check = async (path, expected2, directory) => {
|
|
1074
|
-
if (path !== this.dir) {
|
|
1075
|
-
const parent = (0, import_node_path.dirname)(path);
|
|
1076
|
-
await check(parent, directories.get(parent), true);
|
|
1077
|
-
}
|
|
1078
|
-
await checkAncestors();
|
|
1079
|
-
const current = await (0, import_promises.lstat)(path, { bigint: true });
|
|
1080
|
-
if ((directory ? !current.isDirectory() : !current.isFile()) || current.dev !== expected2.dev || current.ino !== expected2.ino) {
|
|
1081
|
-
throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica layout changed during removal.");
|
|
1082
|
-
}
|
|
1083
|
-
};
|
|
1084
|
-
const record = async (path, directory) => {
|
|
1085
|
-
const parent = (0, import_node_path.dirname)(path);
|
|
1086
|
-
await check(parent, directories.get(parent), true);
|
|
1087
|
-
const current = await (0, import_promises.lstat)(path, { bigint: true });
|
|
1088
|
-
if (!(directory ? current.isDirectory() : current.isFile())) {
|
|
1089
|
-
throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "Unexpected replica layout during removal.");
|
|
1090
|
-
}
|
|
1091
|
-
(directory ? directories : files).set(path, current);
|
|
1092
|
-
};
|
|
1093
|
-
const blobs = (0, import_node_path.join)(this.dir, "blobs");
|
|
1094
|
-
for (const name of await (0, import_promises.readdir)(this.dir)) {
|
|
1095
|
-
if (name === "blobs") await record(blobs, true);
|
|
1096
|
-
else if (["replica.db", "replica.db-wal", "replica.db-shm"].includes(name)) await record((0, import_node_path.join)(this.dir, name), false);
|
|
1097
|
-
else throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "Unexpected replica layout during removal.");
|
|
1098
|
-
}
|
|
1099
|
-
if (directories.has(blobs)) {
|
|
1100
|
-
await check(blobs, directories.get(blobs), true);
|
|
1101
|
-
for (const shard of await (0, import_promises.readdir)(blobs)) {
|
|
1102
|
-
if (shard !== ".tmp" && !/^[0-9a-f]{2}$/.test(shard)) {
|
|
1103
|
-
throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "Unexpected replica blob shard during removal.");
|
|
1104
|
-
}
|
|
1105
|
-
const shardPath = (0, import_node_path.join)(blobs, shard);
|
|
1106
|
-
await record(shardPath, true);
|
|
1107
|
-
await check(shardPath, directories.get(shardPath), true);
|
|
1108
|
-
for (const name of await (0, import_promises.readdir)(shardPath)) {
|
|
1109
|
-
if (shard === ".tmp" ? !/^[0-9a-f]{64}\.[0-9a-f]{12}$/.test(name) : !isHash(name) || !name.startsWith(shard)) {
|
|
1110
|
-
throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "Unexpected replica blob file during removal.");
|
|
1111
|
-
}
|
|
1112
|
-
await record((0, import_node_path.join)(shardPath, name), false);
|
|
1113
|
-
}
|
|
1114
|
-
}
|
|
1115
|
-
}
|
|
1116
|
-
for (const [path, identity] of files) {
|
|
1117
|
-
await check(path, identity, false);
|
|
1118
|
-
await (0, import_promises.unlink)(path);
|
|
1119
|
-
}
|
|
1120
|
-
for (const [path, identity] of [...directories].reverse()) {
|
|
1121
|
-
if (path === this.dir) continue;
|
|
1122
|
-
await check(path, identity, true);
|
|
1123
|
-
await (0, import_promises.rmdir)(path);
|
|
1124
1925
|
}
|
|
1125
1926
|
await assertDirectory();
|
|
1126
|
-
|
|
1127
|
-
|
|
1927
|
+
};
|
|
1928
|
+
await this.#assertPresent();
|
|
1929
|
+
try {
|
|
1930
|
+
await removeSqliteReplicaLayout(this.dir, {
|
|
1931
|
+
...options,
|
|
1932
|
+
expectedRoot: options.expectedRoot ?? { dev: pinned.dev.toString(), ino: pinned.ino.toString() }
|
|
1933
|
+
});
|
|
1934
|
+
} catch (error) {
|
|
1935
|
+
await assertNamedPath();
|
|
1936
|
+
throw error;
|
|
1128
1937
|
}
|
|
1129
1938
|
} catch (error) {
|
|
1130
1939
|
throw storageError(error, "Removing the replica");
|
|
@@ -1133,6 +1942,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
1133
1942
|
this.#db.exec("ROLLBACK");
|
|
1134
1943
|
} catch {
|
|
1135
1944
|
}
|
|
1945
|
+
release?.();
|
|
1136
1946
|
}
|
|
1137
1947
|
});
|
|
1138
1948
|
}
|
|
@@ -1176,7 +1986,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
1176
1986
|
* when both are done.
|
|
1177
1987
|
*/
|
|
1178
1988
|
async #purgeContent() {
|
|
1179
|
-
this.#write("Starting the revocation purge", () => {
|
|
1989
|
+
await this.#write("Starting the revocation purge", () => {
|
|
1180
1990
|
this.#db.run("UPDATE replica SET purge_pending = 1 WHERE id = 1 AND revoked_detail IS NOT NULL");
|
|
1181
1991
|
});
|
|
1182
1992
|
try {
|
|
@@ -1187,7 +1997,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
1187
1997
|
if (!this.#checkpoint()) {
|
|
1188
1998
|
throw new ReplicaError(ReplicaErrorCode.BUSY, "A reader holds the replica's write-ahead log; the purge finishes on the next open.");
|
|
1189
1999
|
}
|
|
1190
|
-
this.#write("Finishing the revocation purge", () => {
|
|
2000
|
+
await this.#write("Finishing the revocation purge", () => {
|
|
1191
2001
|
this.#db.run("UPDATE replica SET purge_pending = 0 WHERE id = 1 AND revoked_detail IS NOT NULL");
|
|
1192
2002
|
});
|
|
1193
2003
|
}
|
|
@@ -1197,6 +2007,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
1197
2007
|
});
|
|
1198
2008
|
}
|
|
1199
2009
|
async get(key) {
|
|
2010
|
+
this.#assertAccepting();
|
|
1200
2011
|
try {
|
|
1201
2012
|
const row = this.#db.get("SELECT key, deleted, etag, hash, metadata, content FROM entry WHERE key = ?", key);
|
|
1202
2013
|
return row === void 0 ? void 0 : entryOf(row);
|
|
@@ -1205,6 +2016,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
1205
2016
|
}
|
|
1206
2017
|
}
|
|
1207
2018
|
async list(o) {
|
|
2019
|
+
this.#assertAccepting();
|
|
1208
2020
|
const where = ["deleted = 0"];
|
|
1209
2021
|
const params = [];
|
|
1210
2022
|
if (o.prefix !== void 0 && o.prefix !== "") {
|
|
@@ -1234,7 +2046,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
1234
2046
|
async readContent(hash) {
|
|
1235
2047
|
if (!isHash(hash)) return void 0;
|
|
1236
2048
|
try {
|
|
1237
|
-
return new Uint8Array(await (0,
|
|
2049
|
+
return new Uint8Array(await (0, import_promises2.readFile)(this.#blobPath(hash)));
|
|
1238
2050
|
} catch (error) {
|
|
1239
2051
|
if (errnoOf(error) === "ENOENT") return void 0;
|
|
1240
2052
|
throw storageError(error, "Reading replica content");
|
|
@@ -1290,22 +2102,40 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
1290
2102
|
lastReset: state.lastReset
|
|
1291
2103
|
};
|
|
1292
2104
|
}
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
this.#
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
2105
|
+
close() {
|
|
2106
|
+
if (this.#closePromise) return this.#closePromise;
|
|
2107
|
+
this.#closing = true;
|
|
2108
|
+
this.#closePromise = (async () => {
|
|
2109
|
+
if (this.#activeMutations !== 0) {
|
|
2110
|
+
await new Promise((resolve3) => {
|
|
2111
|
+
this.#drained = resolve3;
|
|
2112
|
+
});
|
|
2113
|
+
}
|
|
2114
|
+
this.#closed = true;
|
|
2115
|
+
const fd = this.#directoryFd;
|
|
2116
|
+
this.#directoryFd = void 0;
|
|
2117
|
+
if (this.#shared) {
|
|
2118
|
+
sharedStores.unregister(this);
|
|
2119
|
+
releaseWritable(this.#shared);
|
|
2120
|
+
} else {
|
|
2121
|
+
if (fd !== void 0) pinnedDirectories.unregister(this);
|
|
2122
|
+
try {
|
|
2123
|
+
this.#database.close();
|
|
2124
|
+
} finally {
|
|
2125
|
+
if (fd !== void 0) (0, import_node_fs2.closeSync)(fd);
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
})();
|
|
2129
|
+
return this.#closePromise;
|
|
1302
2130
|
}
|
|
1303
2131
|
};
|
|
1304
2132
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1305
2133
|
0 && (module.exports = {
|
|
1306
2134
|
DURABILITY,
|
|
1307
2135
|
SqliteReplicaStore,
|
|
2136
|
+
inspectSqliteReplicaLayout,
|
|
1308
2137
|
loadSqlite,
|
|
1309
|
-
nodeSqliteSupported
|
|
2138
|
+
nodeSqliteSupported,
|
|
2139
|
+
removeSqliteReplicaLayout
|
|
1310
2140
|
});
|
|
1311
2141
|
//# sourceMappingURL=sqlite.cjs.map
|