@tinycloud/replica 0.2.4-beta.3 → 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 +920 -217
- 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 +896 -195
- 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;
|
|
@@ -345,12 +799,13 @@ var sharedStores = new FinalizationRegistry((shared) => {
|
|
|
345
799
|
async function acquireWritableMutation(shared) {
|
|
346
800
|
const previous = shared.mutationTail;
|
|
347
801
|
let release;
|
|
348
|
-
shared.mutationTail = new Promise((
|
|
349
|
-
release =
|
|
802
|
+
shared.mutationTail = new Promise((resolve3) => {
|
|
803
|
+
release = resolve3;
|
|
350
804
|
});
|
|
351
805
|
await previous;
|
|
352
806
|
return release;
|
|
353
807
|
}
|
|
808
|
+
var activeReadSection = new AsyncLocalStorage();
|
|
354
809
|
var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
355
810
|
dir;
|
|
356
811
|
#database;
|
|
@@ -364,21 +819,25 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
364
819
|
#activeMutations = 0;
|
|
365
820
|
#drained;
|
|
366
821
|
#shared;
|
|
822
|
+
#readonlySnapshot;
|
|
367
823
|
#pinnedDir;
|
|
368
824
|
#directoryFd;
|
|
369
|
-
#holder
|
|
825
|
+
#holder;
|
|
370
826
|
#now;
|
|
371
827
|
#faults;
|
|
372
828
|
#guard;
|
|
373
829
|
/** Identity of the database file this store opened: a deleted-and-recreated replica is a different file. */
|
|
374
830
|
#ino;
|
|
831
|
+
#admit;
|
|
832
|
+
#partitionId;
|
|
375
833
|
#dev;
|
|
376
834
|
#namedAncestors;
|
|
377
|
-
constructor(dir, db, identity, options, directoryFd, namedAncestors = [], shared) {
|
|
835
|
+
constructor(dir, db, identity, options, directoryFd, namedAncestors = [], shared, readonlySnapshot = false) {
|
|
378
836
|
this.dir = dir;
|
|
379
837
|
this.#pinnedDir = shared?.pinnedDir ?? (directoryFd !== void 0 && process.platform === "linux" ? `/proc/self/fd/${directoryFd}` : dir);
|
|
380
838
|
this.#directoryFd = directoryFd;
|
|
381
839
|
this.#shared = shared;
|
|
840
|
+
this.#readonlySnapshot = readonlySnapshot;
|
|
382
841
|
if (shared) {
|
|
383
842
|
shared.references++;
|
|
384
843
|
sharedStores.register(this, shared, this);
|
|
@@ -390,6 +849,8 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
390
849
|
this.#dev = identity.dev;
|
|
391
850
|
this.#now = options.now ?? Date.now;
|
|
392
851
|
this.#faults = options[FAULTS] ?? {};
|
|
852
|
+
this.#admit = options.admit;
|
|
853
|
+
this.#partitionId = options.partitionId ?? "";
|
|
393
854
|
this.#guard = options.guard ?? ((section) => section());
|
|
394
855
|
this.#namedAncestors = namedAncestors;
|
|
395
856
|
}
|
|
@@ -403,10 +864,10 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
403
864
|
if (options.signal?.aborted) throw options.signal.reason ?? new ReplicaError(ReplicaErrorCode.CLOSED, "Replica status inspection was cancelled.");
|
|
404
865
|
};
|
|
405
866
|
checkAbort();
|
|
406
|
-
const dbPath =
|
|
867
|
+
const dbPath = join2(dir, "replica.db");
|
|
407
868
|
let identity;
|
|
408
869
|
try {
|
|
409
|
-
identity = await
|
|
870
|
+
identity = await lstat2(dbPath);
|
|
410
871
|
if (!identity.isFile()) return null;
|
|
411
872
|
} catch (error) {
|
|
412
873
|
if (errnoOf(error) === "ENOENT") return null;
|
|
@@ -416,21 +877,30 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
416
877
|
const opener = await loadSqlite();
|
|
417
878
|
checkAbort();
|
|
418
879
|
let db;
|
|
880
|
+
let releaseOpen;
|
|
419
881
|
try {
|
|
420
|
-
|
|
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;
|
|
421
887
|
db.exec("PRAGMA busy_timeout = 0");
|
|
422
888
|
checkAbort();
|
|
423
|
-
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 } : {} });
|
|
424
890
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
425
891
|
const before = db.get("PRAGMA data_version").data_version;
|
|
426
892
|
const state = await store.open();
|
|
427
893
|
checkAbort();
|
|
428
894
|
if (state === null) return null;
|
|
429
895
|
const status = await store.status();
|
|
430
|
-
|
|
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));
|
|
431
901
|
checkAbort();
|
|
432
902
|
const after = db.get("PRAGMA data_version").data_version;
|
|
433
|
-
if (before === after) return { state, status };
|
|
903
|
+
if (before === after) return { state, status, grantInstall, activeGrantInstall };
|
|
434
904
|
}
|
|
435
905
|
throw new ReplicaError(ReplicaErrorCode.BUSY, "The replica changed during status inspection.");
|
|
436
906
|
} catch (error) {
|
|
@@ -438,18 +908,75 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
438
908
|
throw new ReplicaError(ReplicaErrorCode.BUSY, "The replica is busy during status inspection.", void 0, { cause: error });
|
|
439
909
|
}
|
|
440
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;
|
|
441
963
|
} finally {
|
|
442
964
|
db?.close();
|
|
443
965
|
}
|
|
966
|
+
try {
|
|
967
|
+
return await unlinkWriterOrphans(join2(dir, "blobs"), keep);
|
|
968
|
+
} catch (error) {
|
|
969
|
+
throw storageError(error, "Collecting orphaned content");
|
|
970
|
+
}
|
|
444
971
|
}
|
|
445
972
|
/**
|
|
446
973
|
* Open the replica store in `dir`. With `create: false` a missing replica is
|
|
447
974
|
* REPLICA_NOT_FOUND and nothing is written to disk.
|
|
448
975
|
*/
|
|
449
976
|
static async open(dir, options) {
|
|
450
|
-
const namedDbPath =
|
|
977
|
+
const namedDbPath = join2(dir, "replica.db");
|
|
451
978
|
if (!options.create) {
|
|
452
|
-
const exists = await
|
|
979
|
+
const exists = await lstat2(namedDbPath).then(
|
|
453
980
|
(stats) => stats.isFile(),
|
|
454
981
|
() => false
|
|
455
982
|
);
|
|
@@ -461,7 +988,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
461
988
|
try {
|
|
462
989
|
if (options.create) await mkdir(dir, { recursive: true, mode: 448 });
|
|
463
990
|
if (process.platform === "linux") {
|
|
464
|
-
directoryFd = openSync(dir,
|
|
991
|
+
directoryFd = openSync(dir, fsConstants2.O_RDONLY | fsConstants2.O_DIRECTORY | fsConstants2.O_NOFOLLOW);
|
|
465
992
|
}
|
|
466
993
|
const opened = directoryFd === void 0 ? lstatSync(dir, { bigint: true }) : fstatSync(directoryFd, { bigint: true });
|
|
467
994
|
if (!opened.isDirectory() || opened.isSymbolicLink() || options.expectedDirectory && (opened.dev !== BigInt(options.expectedDirectory.dev) || opened.ino !== BigInt(options.expectedDirectory.ino))) {
|
|
@@ -472,11 +999,11 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
472
999
|
throw new Error("The replica directory changed while checking its ancestry.");
|
|
473
1000
|
}
|
|
474
1001
|
const pinnedDir = directoryFd === void 0 ? dir : `/proc/self/fd/${directoryFd}`;
|
|
475
|
-
const dbPath =
|
|
1002
|
+
const dbPath = join2(pinnedDir, "replica.db");
|
|
476
1003
|
if (options.create) {
|
|
477
|
-
await mkdir(
|
|
478
|
-
await Promise.all([chmod(pinnedDir, 448), chmod(
|
|
479
|
-
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) => {
|
|
480
1007
|
if (errnoOf(error) === "EEXIST") return null;
|
|
481
1008
|
throw error;
|
|
482
1009
|
});
|
|
@@ -488,11 +1015,11 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
488
1015
|
}
|
|
489
1016
|
}
|
|
490
1017
|
}
|
|
491
|
-
const leaf = await
|
|
1018
|
+
const leaf = await open2(dbPath, fsConstants2.O_RDONLY | fsConstants2.O_NOFOLLOW);
|
|
492
1019
|
let db;
|
|
493
1020
|
try {
|
|
494
1021
|
const identity = await leaf.stat();
|
|
495
|
-
const named = await
|
|
1022
|
+
const named = await lstat2(dbPath);
|
|
496
1023
|
if (!identity.isFile() || !named.isFile() || named.dev !== identity.dev || named.ino !== identity.ino) {
|
|
497
1024
|
throw new Error("The replica database changed before SQLite opened it.");
|
|
498
1025
|
}
|
|
@@ -507,7 +1034,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
507
1034
|
throw new Error("The replica directory changed before its checked SQLite handle could be shared.");
|
|
508
1035
|
}
|
|
509
1036
|
const pinned = active.directoryFd === void 0 ? lstatSync(active.pinnedDir, { bigint: true }) : fstatSync(active.directoryFd, { bigint: true });
|
|
510
|
-
const activeLeaf = lstatSync(
|
|
1037
|
+
const activeLeaf = lstatSync(join2(active.pinnedDir, "replica.db"));
|
|
511
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) {
|
|
512
1039
|
throw new Error("The live replica changed before its checked SQLite handle could be shared.");
|
|
513
1040
|
}
|
|
@@ -574,7 +1101,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
574
1101
|
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
575
1102
|
db.exec("COMMIT");
|
|
576
1103
|
}
|
|
577
|
-
const shared =
|
|
1104
|
+
const shared = {
|
|
578
1105
|
key,
|
|
579
1106
|
db,
|
|
580
1107
|
directoryFd,
|
|
@@ -586,7 +1113,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
586
1113
|
mutationTail: Promise.resolve()
|
|
587
1114
|
};
|
|
588
1115
|
store = new _SqliteReplicaStore(dir, db, identity, options, directoryFd, namedAncestors, shared);
|
|
589
|
-
if (
|
|
1116
|
+
if (!options.sqlite) writableStores.set(key, shared);
|
|
590
1117
|
db = void 0;
|
|
591
1118
|
}
|
|
592
1119
|
} finally {
|
|
@@ -614,12 +1141,109 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
614
1141
|
throw error;
|
|
615
1142
|
}
|
|
616
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
|
+
}
|
|
617
1241
|
#row() {
|
|
618
1242
|
return this.#db.get("SELECT * FROM replica WHERE id = 1");
|
|
619
1243
|
}
|
|
620
1244
|
/** Refuse to touch the filesystem for a replica that was deleted (or replaced) under us. */
|
|
621
1245
|
async #assertPresent() {
|
|
622
|
-
const identity = await
|
|
1246
|
+
const identity = await lstat2(join2(this.#pinnedDir, "replica.db")).catch(() => null);
|
|
623
1247
|
if (!identity?.isFile() || identity.dev !== this.#dev || identity.ino !== this.#ino) {
|
|
624
1248
|
throw new ReplicaError(
|
|
625
1249
|
ReplicaErrorCode.NOT_FOUND,
|
|
@@ -636,6 +1260,16 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
636
1260
|
/** A short mutation section: guarded (profile lock) and checked against the database this store opened. */
|
|
637
1261
|
#mutate(section) {
|
|
638
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
|
+
}
|
|
639
1273
|
this.#activeMutations++;
|
|
640
1274
|
return (async () => {
|
|
641
1275
|
try {
|
|
@@ -779,6 +1413,110 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
779
1413
|
);
|
|
780
1414
|
});
|
|
781
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
|
+
}
|
|
782
1520
|
async installGrant(g) {
|
|
783
1521
|
await this.#guardedWrite("Installing the grant", () => {
|
|
784
1522
|
const row = this.#row();
|
|
@@ -837,13 +1575,14 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
837
1575
|
const now = this.#now();
|
|
838
1576
|
if (row.lease_holder !== null && row.lease_expires_at !== null && row.lease_expires_at > now) return null;
|
|
839
1577
|
const token = row.lease_token + 1;
|
|
1578
|
+
const holder = this.#holder ??= `${process.pid}-${randomBytes(6).toString("hex")}`;
|
|
840
1579
|
this.#db.run(
|
|
841
1580
|
"UPDATE replica SET lease_token = ?, lease_holder = ?, lease_expires_at = ? WHERE id = 1",
|
|
842
1581
|
token,
|
|
843
|
-
|
|
1582
|
+
holder,
|
|
844
1583
|
now + ttlMs
|
|
845
1584
|
);
|
|
846
|
-
return { token, holder
|
|
1585
|
+
return { token, holder };
|
|
847
1586
|
});
|
|
848
1587
|
}
|
|
849
1588
|
async renewLease(t, ttlMs) {
|
|
@@ -875,7 +1614,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
875
1614
|
return held;
|
|
876
1615
|
}
|
|
877
1616
|
#blobPath(hash) {
|
|
878
|
-
return
|
|
1617
|
+
return join2(this.#pinnedDir, "blobs", hash.slice(0, 2), hash);
|
|
879
1618
|
}
|
|
880
1619
|
/**
|
|
881
1620
|
* Make every blob durable before any row references it: write to a fresh
|
|
@@ -892,12 +1631,12 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
892
1631
|
() => false
|
|
893
1632
|
);
|
|
894
1633
|
if (exists) continue;
|
|
895
|
-
const shard =
|
|
1634
|
+
const shard = join2(this.#pinnedDir, "blobs", hash.slice(0, 2));
|
|
896
1635
|
await mkdir(shard, { mode: 448 }).catch((error) => {
|
|
897
1636
|
if (errnoOf(error) !== "EEXIST") throw error;
|
|
898
1637
|
});
|
|
899
|
-
const temp =
|
|
900
|
-
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);
|
|
901
1640
|
try {
|
|
902
1641
|
await handle.writeFile(bytes);
|
|
903
1642
|
await handle.sync();
|
|
@@ -909,13 +1648,13 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
909
1648
|
} catch (error) {
|
|
910
1649
|
if (errnoOf(error) !== "EEXIST") throw error;
|
|
911
1650
|
} finally {
|
|
912
|
-
await
|
|
1651
|
+
await unlink2(temp);
|
|
913
1652
|
}
|
|
914
1653
|
dirs.add(shard);
|
|
915
|
-
dirs.add(
|
|
1654
|
+
dirs.add(join2(this.#pinnedDir, "blobs"));
|
|
916
1655
|
}
|
|
917
1656
|
for (const dir of dirs) {
|
|
918
|
-
const handle = await
|
|
1657
|
+
const handle = await open2(dir, fsConstants2.O_RDONLY);
|
|
919
1658
|
try {
|
|
920
1659
|
await handle.sync();
|
|
921
1660
|
} finally {
|
|
@@ -926,6 +1665,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
926
1665
|
async applyPage(t, p) {
|
|
927
1666
|
await this.#mutate(async () => {
|
|
928
1667
|
this.#checkLease(t);
|
|
1668
|
+
this.#assertInstalledGrantTime();
|
|
929
1669
|
const reused = /* @__PURE__ */ new Map();
|
|
930
1670
|
for (const change of p.changes) {
|
|
931
1671
|
if (change.deleted || !change.content || p.blobs.has(change.hash) || reused.has(change.hash)) continue;
|
|
@@ -938,6 +1678,41 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
938
1678
|
}
|
|
939
1679
|
reused.set(change.hash, size);
|
|
940
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
|
+
}
|
|
941
1716
|
try {
|
|
942
1717
|
await this.#writeBlobs(p.blobs);
|
|
943
1718
|
} catch (error) {
|
|
@@ -947,6 +1722,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
947
1722
|
try {
|
|
948
1723
|
await this.#write("Committing a sync page", () => {
|
|
949
1724
|
this.#checkLease(t);
|
|
1725
|
+
this.#assertInstalledGrantTime();
|
|
950
1726
|
const now = this.#now();
|
|
951
1727
|
if (p.window.notBefore !== null && now < Date.parse(p.window.notBefore)) {
|
|
952
1728
|
throw new ReplicaError(ReplicaErrorCode.GRANT_NOT_YET_VALID, `The grant is not valid before ${p.window.notBefore}; nothing was committed.`);
|
|
@@ -1012,73 +1788,55 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
1012
1788
|
});
|
|
1013
1789
|
} catch (error) {
|
|
1014
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);
|
|
1015
1792
|
throw error;
|
|
1016
1793
|
}
|
|
1017
1794
|
this.#faults.afterCommit?.();
|
|
1795
|
+
if (this.#admit) await this.#collectLocked(t);
|
|
1018
1796
|
});
|
|
1019
1797
|
}
|
|
1020
1798
|
/** Unlink every blob not in `keep`, and every temp file. Only a missing directory counts as empty. */
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
const root = join(this.#pinnedDir, "blobs");
|
|
1024
|
-
for (const shard of await entriesOf(root)) {
|
|
1025
|
-
const shardPath = join(root, shard);
|
|
1026
|
-
if (shard === ".tmp") {
|
|
1027
|
-
for (const name of await entriesOf(shardPath)) {
|
|
1028
|
-
await rm(join(shardPath, name), { force: true });
|
|
1029
|
-
removed += 1;
|
|
1030
|
-
}
|
|
1031
|
-
continue;
|
|
1032
|
-
}
|
|
1033
|
-
let kept = 0;
|
|
1034
|
-
for (const name of await entriesOf(shardPath)) {
|
|
1035
|
-
if (isHash(name) && keep.has(name)) {
|
|
1036
|
-
kept += 1;
|
|
1037
|
-
continue;
|
|
1038
|
-
}
|
|
1039
|
-
await rm(join(shardPath, name), { force: true });
|
|
1040
|
-
removed += 1;
|
|
1041
|
-
}
|
|
1042
|
-
if (kept === 0) await rmdir(shardPath).catch(() => void 0);
|
|
1043
|
-
}
|
|
1044
|
-
return removed;
|
|
1799
|
+
#unlinkBlobs(keep) {
|
|
1800
|
+
return unlinkBlobsIn(join2(this.#pinnedDir, "blobs"), keep);
|
|
1045
1801
|
}
|
|
1046
1802
|
/** Whether any content file (a blob or a temp file) is on disk. */
|
|
1047
1803
|
async #holdsContentFiles() {
|
|
1048
|
-
const root =
|
|
1804
|
+
const root = join2(this.#pinnedDir, "blobs");
|
|
1049
1805
|
for (const shard of await entriesOf(root)) {
|
|
1050
|
-
if ((await entriesOf(
|
|
1806
|
+
if ((await entriesOf(join2(root, shard))).length > 0) return true;
|
|
1051
1807
|
}
|
|
1052
1808
|
return false;
|
|
1053
1809
|
}
|
|
1054
1810
|
async collectGarbage(t) {
|
|
1055
|
-
return this.#mutate(
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
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) {
|
|
1063
1832
|
try {
|
|
1064
|
-
this.#
|
|
1065
|
-
|
|
1066
|
-
const keep = new Set(
|
|
1067
|
-
this.#db.all("SELECT DISTINCT hash FROM entry WHERE content = 1").map((row) => row.hash)
|
|
1068
|
-
);
|
|
1069
|
-
const removed = await this.#unlinkBlobs(keep);
|
|
1070
|
-
this.#db.exec("COMMIT");
|
|
1071
|
-
return removed;
|
|
1072
|
-
} catch (error) {
|
|
1073
|
-
try {
|
|
1074
|
-
this.#db.exec("ROLLBACK");
|
|
1075
|
-
} catch {
|
|
1076
|
-
}
|
|
1077
|
-
throw storageError(error, "Collecting unused content");
|
|
1078
|
-
} finally {
|
|
1079
|
-
release?.();
|
|
1833
|
+
this.#db.exec("ROLLBACK");
|
|
1834
|
+
} catch {
|
|
1080
1835
|
}
|
|
1081
|
-
|
|
1836
|
+
throw storageError(error, "Collecting unused content");
|
|
1837
|
+
} finally {
|
|
1838
|
+
release?.();
|
|
1839
|
+
}
|
|
1082
1840
|
}
|
|
1083
1841
|
async reset(t, reason, options = {}) {
|
|
1084
1842
|
await this.#mutate(async () => {
|
|
@@ -1101,13 +1859,10 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
1101
1859
|
}
|
|
1102
1860
|
}
|
|
1103
1861
|
/**
|
|
1104
|
-
* Remove
|
|
1105
|
-
*
|
|
1106
|
-
* store opened, checked under the write lock, so a paused process can
|
|
1107
|
-
* never remove a replica another process took over or recreated. Allowed
|
|
1108
|
-
* 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.
|
|
1109
1864
|
*/
|
|
1110
|
-
async destroy(t) {
|
|
1865
|
+
async destroy(t, options = {}) {
|
|
1111
1866
|
await this.#mutate(async () => {
|
|
1112
1867
|
const release = this.#shared ? await acquireWritableMutation(this.#shared) : void 0;
|
|
1113
1868
|
try {
|
|
@@ -1123,87 +1878,31 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
1123
1878
|
throw new ReplicaError(ReplicaErrorCode.RUNTIME_UNSUPPORTED, "Replica removal requires an opened or identity-checked directory.");
|
|
1124
1879
|
}
|
|
1125
1880
|
const assertDirectory = async () => {
|
|
1126
|
-
const current = await
|
|
1881
|
+
const current = await lstat2(this.dir, { bigint: true });
|
|
1127
1882
|
if (!current.isDirectory() || current.dev !== expected.dev || current.ino !== expected.ino) {
|
|
1128
1883
|
throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica directory changed during removal.");
|
|
1129
1884
|
}
|
|
1130
1885
|
return current;
|
|
1131
1886
|
};
|
|
1132
1887
|
const pinned = await assertDirectory();
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
await assertDirectory();
|
|
1139
|
-
await rmdir(this.dir);
|
|
1140
|
-
} else {
|
|
1141
|
-
const ancestors = this.#namedAncestors;
|
|
1142
|
-
const checkAncestors = async () => {
|
|
1143
|
-
for (const expected2 of ancestors) {
|
|
1144
|
-
const current = await lstat(expected2.path, { bigint: true });
|
|
1145
|
-
if (current.dev !== expected2.dev || current.ino !== expected2.ino) {
|
|
1146
|
-
throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica ancestry changed during removal.");
|
|
1147
|
-
}
|
|
1148
|
-
}
|
|
1149
|
-
};
|
|
1150
|
-
const directories = /* @__PURE__ */ new Map([[this.dir, pinned]]);
|
|
1151
|
-
const files = /* @__PURE__ */ new Map();
|
|
1152
|
-
const check = async (path, expected2, directory) => {
|
|
1153
|
-
if (path !== this.dir) {
|
|
1154
|
-
const parent = dirname(path);
|
|
1155
|
-
await check(parent, directories.get(parent), true);
|
|
1156
|
-
}
|
|
1157
|
-
await checkAncestors();
|
|
1158
|
-
const current = await lstat(path, { bigint: true });
|
|
1159
|
-
if ((directory ? !current.isDirectory() : !current.isFile()) || current.dev !== expected2.dev || current.ino !== expected2.ino) {
|
|
1160
|
-
throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica layout changed during removal.");
|
|
1161
|
-
}
|
|
1162
|
-
};
|
|
1163
|
-
const record = async (path, directory) => {
|
|
1164
|
-
const parent = dirname(path);
|
|
1165
|
-
await check(parent, directories.get(parent), true);
|
|
1166
|
-
const current = await lstat(path, { bigint: true });
|
|
1167
|
-
if (!(directory ? current.isDirectory() : current.isFile())) {
|
|
1168
|
-
throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "Unexpected replica layout during removal.");
|
|
1169
|
-
}
|
|
1170
|
-
(directory ? directories : files).set(path, current);
|
|
1171
|
-
};
|
|
1172
|
-
const blobs = join(this.dir, "blobs");
|
|
1173
|
-
for (const name of await readdir(this.dir)) {
|
|
1174
|
-
if (name === "blobs") await record(blobs, true);
|
|
1175
|
-
else if (["replica.db", "replica.db-wal", "replica.db-shm"].includes(name)) await record(join(this.dir, name), false);
|
|
1176
|
-
else throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "Unexpected replica layout during removal.");
|
|
1177
|
-
}
|
|
1178
|
-
if (directories.has(blobs)) {
|
|
1179
|
-
await check(blobs, directories.get(blobs), true);
|
|
1180
|
-
for (const shard of await readdir(blobs)) {
|
|
1181
|
-
if (shard !== ".tmp" && !/^[0-9a-f]{2}$/.test(shard)) {
|
|
1182
|
-
throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "Unexpected replica blob shard during removal.");
|
|
1183
|
-
}
|
|
1184
|
-
const shardPath = join(blobs, shard);
|
|
1185
|
-
await record(shardPath, true);
|
|
1186
|
-
await check(shardPath, directories.get(shardPath), true);
|
|
1187
|
-
for (const name of await readdir(shardPath)) {
|
|
1188
|
-
if (shard === ".tmp" ? !/^[0-9a-f]{64}\.[0-9a-f]{12}$/.test(name) : !isHash(name) || !name.startsWith(shard)) {
|
|
1189
|
-
throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "Unexpected replica blob file during removal.");
|
|
1190
|
-
}
|
|
1191
|
-
await record(join(shardPath, name), false);
|
|
1192
|
-
}
|
|
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.");
|
|
1193
1893
|
}
|
|
1194
1894
|
}
|
|
1195
|
-
for (const [path, identity] of files) {
|
|
1196
|
-
await check(path, identity, false);
|
|
1197
|
-
await unlink(path);
|
|
1198
|
-
}
|
|
1199
|
-
for (const [path, identity] of [...directories].reverse()) {
|
|
1200
|
-
if (path === this.dir) continue;
|
|
1201
|
-
await check(path, identity, true);
|
|
1202
|
-
await rmdir(path);
|
|
1203
|
-
}
|
|
1204
1895
|
await assertDirectory();
|
|
1205
|
-
|
|
1206
|
-
|
|
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;
|
|
1207
1906
|
}
|
|
1208
1907
|
} catch (error) {
|
|
1209
1908
|
throw storageError(error, "Removing the replica");
|
|
@@ -1377,8 +2076,8 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
1377
2076
|
this.#closing = true;
|
|
1378
2077
|
this.#closePromise = (async () => {
|
|
1379
2078
|
if (this.#activeMutations !== 0) {
|
|
1380
|
-
await new Promise((
|
|
1381
|
-
this.#drained =
|
|
2079
|
+
await new Promise((resolve3) => {
|
|
2080
|
+
this.#drained = resolve3;
|
|
1382
2081
|
});
|
|
1383
2082
|
}
|
|
1384
2083
|
this.#closed = true;
|
|
@@ -1402,7 +2101,9 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
1402
2101
|
export {
|
|
1403
2102
|
DURABILITY,
|
|
1404
2103
|
SqliteReplicaStore,
|
|
2104
|
+
inspectSqliteReplicaLayout,
|
|
1405
2105
|
loadSqlite,
|
|
1406
|
-
nodeSqliteSupported
|
|
2106
|
+
nodeSqliteSupported,
|
|
2107
|
+
removeSqliteReplicaLayout
|
|
1407
2108
|
};
|
|
1408
2109
|
//# sourceMappingURL=sqlite.js.map
|