@tinycloud/replica 0.2.4-beta.2 → 0.3.0-beta.4
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 +2587 -0
- package/dist/kv-storage.js.map +1 -0
- package/dist/kv-storage.worker.618d3a20b6f3f54ed78b68f1dc244dddc3daba46e91e9ae2b577685cc9339315.js +18891 -0
- package/dist/kv-storage.worker.d.ts +2 -0
- package/dist/kv-storage.worker.js +18891 -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 +444 -171
- package/dist/replica.worker.js.map +1 -1
- package/dist/sqlite.cjs +926 -239
- package/dist/sqlite.cjs.map +1 -1
- package/dist/sqlite.d.cts +77 -8
- package/dist/sqlite.d.ts +77 -8
- package/dist/sqlite.js +914 -229
- 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 +20 -3
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 };
|
|
@@ -313,7 +697,7 @@ function errnoOf(error) {
|
|
|
313
697
|
}
|
|
314
698
|
async function entriesOf(path) {
|
|
315
699
|
try {
|
|
316
|
-
return await
|
|
700
|
+
return await readdir2(path);
|
|
317
701
|
} catch (error) {
|
|
318
702
|
if (errnoOf(error) === "ENOENT") return [];
|
|
319
703
|
throw error;
|
|
@@ -326,12 +710,49 @@ var pinnedDirectories = new FinalizationRegistry((fd) => {
|
|
|
326
710
|
} catch {
|
|
327
711
|
}
|
|
328
712
|
});
|
|
713
|
+
var writableStores = /* @__PURE__ */ new Map();
|
|
714
|
+
function releaseWritable(shared) {
|
|
715
|
+
if (shared.references === 0 || --shared.references !== 0) return;
|
|
716
|
+
if (writableStores.get(shared.key) === shared) writableStores.delete(shared.key);
|
|
717
|
+
try {
|
|
718
|
+
shared.db.close();
|
|
719
|
+
} finally {
|
|
720
|
+
if (shared.directoryFd !== void 0) closeSync(shared.directoryFd);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
var sharedStores = new FinalizationRegistry((shared) => {
|
|
724
|
+
try {
|
|
725
|
+
releaseWritable(shared);
|
|
726
|
+
} catch {
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
async function acquireWritableMutation(shared) {
|
|
730
|
+
const previous = shared.mutationTail;
|
|
731
|
+
let release;
|
|
732
|
+
shared.mutationTail = new Promise((resolve3) => {
|
|
733
|
+
release = resolve3;
|
|
734
|
+
});
|
|
735
|
+
await previous;
|
|
736
|
+
return release;
|
|
737
|
+
}
|
|
738
|
+
var activeReadSection = new AsyncLocalStorage();
|
|
329
739
|
var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
330
740
|
dir;
|
|
331
|
-
#
|
|
741
|
+
#database;
|
|
742
|
+
get #db() {
|
|
743
|
+
if (this.#closed) throw new ReplicaError(ReplicaErrorCode.CLOSED, "The replica store is closed.");
|
|
744
|
+
return this.#database;
|
|
745
|
+
}
|
|
746
|
+
#closed = false;
|
|
747
|
+
#closing = false;
|
|
748
|
+
#closePromise;
|
|
749
|
+
#activeMutations = 0;
|
|
750
|
+
#drained;
|
|
751
|
+
#shared;
|
|
752
|
+
#readonlySnapshot;
|
|
332
753
|
#pinnedDir;
|
|
333
754
|
#directoryFd;
|
|
334
|
-
#holder
|
|
755
|
+
#holder;
|
|
335
756
|
#now;
|
|
336
757
|
#faults;
|
|
337
758
|
#guard;
|
|
@@ -339,12 +760,19 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
339
760
|
#ino;
|
|
340
761
|
#dev;
|
|
341
762
|
#namedAncestors;
|
|
342
|
-
constructor(dir, db, identity, options, directoryFd, namedAncestors = []) {
|
|
763
|
+
constructor(dir, db, identity, options, directoryFd, namedAncestors = [], shared, readonlySnapshot = false) {
|
|
343
764
|
this.dir = dir;
|
|
344
|
-
this.#pinnedDir = directoryFd !== void 0 && process.platform === "linux" ? `/proc/self/fd/${directoryFd}` : dir;
|
|
765
|
+
this.#pinnedDir = shared?.pinnedDir ?? (directoryFd !== void 0 && process.platform === "linux" ? `/proc/self/fd/${directoryFd}` : dir);
|
|
345
766
|
this.#directoryFd = directoryFd;
|
|
346
|
-
|
|
347
|
-
this.#
|
|
767
|
+
this.#shared = shared;
|
|
768
|
+
this.#readonlySnapshot = readonlySnapshot;
|
|
769
|
+
if (shared) {
|
|
770
|
+
shared.references++;
|
|
771
|
+
sharedStores.register(this, shared, this);
|
|
772
|
+
} else if (directoryFd !== void 0) {
|
|
773
|
+
pinnedDirectories.register(this, directoryFd, this);
|
|
774
|
+
}
|
|
775
|
+
this.#database = db;
|
|
348
776
|
this.#ino = identity.ino;
|
|
349
777
|
this.#dev = identity.dev;
|
|
350
778
|
this.#now = options.now ?? Date.now;
|
|
@@ -362,10 +790,10 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
362
790
|
if (options.signal?.aborted) throw options.signal.reason ?? new ReplicaError(ReplicaErrorCode.CLOSED, "Replica status inspection was cancelled.");
|
|
363
791
|
};
|
|
364
792
|
checkAbort();
|
|
365
|
-
const dbPath =
|
|
793
|
+
const dbPath = join2(dir, "replica.db");
|
|
366
794
|
let identity;
|
|
367
795
|
try {
|
|
368
|
-
identity = await
|
|
796
|
+
identity = await lstat2(dbPath);
|
|
369
797
|
if (!identity.isFile()) return null;
|
|
370
798
|
} catch (error) {
|
|
371
799
|
if (errnoOf(error) === "ENOENT") return null;
|
|
@@ -375,21 +803,30 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
375
803
|
const opener = await loadSqlite();
|
|
376
804
|
checkAbort();
|
|
377
805
|
let db;
|
|
806
|
+
let releaseOpen;
|
|
378
807
|
try {
|
|
379
|
-
|
|
808
|
+
releaseOpen = await acquireSqliteOpen();
|
|
809
|
+
const checked = await openVerifiedDatabase(dbPath, opener, { readonly: true, expected: identity });
|
|
810
|
+
db = checked.db;
|
|
811
|
+
releaseOpen();
|
|
812
|
+
releaseOpen = void 0;
|
|
380
813
|
db.exec("PRAGMA busy_timeout = 0");
|
|
381
814
|
checkAbort();
|
|
382
|
-
const store = new _SqliteReplicaStore(dir, db, identity, { create: false, ...options.now ? { now: options.now } : {} });
|
|
815
|
+
const store = new _SqliteReplicaStore(dir, db, checked.identity, { create: false, ...options.now ? { now: options.now } : {} });
|
|
383
816
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
384
817
|
const before = db.get("PRAGMA data_version").data_version;
|
|
385
818
|
const state = await store.open();
|
|
386
819
|
checkAbort();
|
|
387
820
|
if (state === null) return null;
|
|
388
821
|
const status = await store.status();
|
|
389
|
-
|
|
822
|
+
const latest = state.pendingGrant ?? state.grant;
|
|
823
|
+
const version = db.get("PRAGMA user_version")?.user_version ?? 0;
|
|
824
|
+
const grantInstall = latest === null || version < 2 ? null : await store.getGrantInstall(latest.cid);
|
|
825
|
+
const activeGrantInstall = state.grant === null || version < 2 ? null : state.grant === latest ? grantInstall : await store.getGrantInstall(state.grant.cid);
|
|
826
|
+
await new Promise((resolve3) => setImmediate(resolve3));
|
|
390
827
|
checkAbort();
|
|
391
828
|
const after = db.get("PRAGMA data_version").data_version;
|
|
392
|
-
if (before === after) return { state, status };
|
|
829
|
+
if (before === after) return { state, status, grantInstall, activeGrantInstall };
|
|
393
830
|
}
|
|
394
831
|
throw new ReplicaError(ReplicaErrorCode.BUSY, "The replica changed during status inspection.");
|
|
395
832
|
} catch (error) {
|
|
@@ -398,7 +835,15 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
398
835
|
}
|
|
399
836
|
throw storageError(error, "Inspecting the replica");
|
|
400
837
|
} finally {
|
|
401
|
-
|
|
838
|
+
releaseOpen?.();
|
|
839
|
+
if (db) {
|
|
840
|
+
const releaseClose = await acquireSqliteOpen();
|
|
841
|
+
try {
|
|
842
|
+
db.close();
|
|
843
|
+
} finally {
|
|
844
|
+
releaseClose();
|
|
845
|
+
}
|
|
846
|
+
}
|
|
402
847
|
}
|
|
403
848
|
}
|
|
404
849
|
/**
|
|
@@ -406,22 +851,21 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
406
851
|
* REPLICA_NOT_FOUND and nothing is written to disk.
|
|
407
852
|
*/
|
|
408
853
|
static async open(dir, options) {
|
|
409
|
-
const namedDbPath =
|
|
854
|
+
const namedDbPath = join2(dir, "replica.db");
|
|
410
855
|
if (!options.create) {
|
|
411
|
-
const exists = await
|
|
856
|
+
const exists = await lstat2(namedDbPath).then(
|
|
412
857
|
(stats) => stats.isFile(),
|
|
413
858
|
() => false
|
|
414
859
|
);
|
|
415
860
|
if (!exists) throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, `No regular replica database at ${dir}.`);
|
|
416
861
|
}
|
|
417
|
-
const opener = options.sqlite ?? await loadSqlite();
|
|
418
862
|
const releaseOpen = await acquireSqliteOpen();
|
|
419
863
|
let store;
|
|
420
864
|
let directoryFd;
|
|
421
865
|
try {
|
|
422
866
|
if (options.create) await mkdir(dir, { recursive: true, mode: 448 });
|
|
423
867
|
if (process.platform === "linux") {
|
|
424
|
-
directoryFd = openSync(dir,
|
|
868
|
+
directoryFd = openSync(dir, fsConstants2.O_RDONLY | fsConstants2.O_DIRECTORY | fsConstants2.O_NOFOLLOW);
|
|
425
869
|
}
|
|
426
870
|
const opened = directoryFd === void 0 ? lstatSync(dir, { bigint: true }) : fstatSync(directoryFd, { bigint: true });
|
|
427
871
|
if (!opened.isDirectory() || opened.isSymbolicLink() || options.expectedDirectory && (opened.dev !== BigInt(options.expectedDirectory.dev) || opened.ino !== BigInt(options.expectedDirectory.ino))) {
|
|
@@ -432,11 +876,11 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
432
876
|
throw new Error("The replica directory changed while checking its ancestry.");
|
|
433
877
|
}
|
|
434
878
|
const pinnedDir = directoryFd === void 0 ? dir : `/proc/self/fd/${directoryFd}`;
|
|
435
|
-
const dbPath =
|
|
879
|
+
const dbPath = join2(pinnedDir, "replica.db");
|
|
436
880
|
if (options.create) {
|
|
437
|
-
await mkdir(
|
|
438
|
-
await Promise.all([chmod(pinnedDir, 448), chmod(
|
|
439
|
-
const handle = await
|
|
881
|
+
await mkdir(join2(pinnedDir, "blobs", ".tmp"), { recursive: true, mode: 448 });
|
|
882
|
+
await Promise.all([chmod(pinnedDir, 448), chmod(join2(pinnedDir, "blobs"), 448), chmod(join2(pinnedDir, "blobs", ".tmp"), 448)]);
|
|
883
|
+
const handle = await open2(dbPath, fsConstants2.O_CREAT | fsConstants2.O_EXCL | fsConstants2.O_RDWR | fsConstants2.O_NOFOLLOW, 384).catch((error) => {
|
|
440
884
|
if (errnoOf(error) === "EEXIST") return null;
|
|
441
885
|
throw error;
|
|
442
886
|
});
|
|
@@ -448,71 +892,107 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
448
892
|
}
|
|
449
893
|
}
|
|
450
894
|
}
|
|
451
|
-
const leaf = await
|
|
895
|
+
const leaf = await open2(dbPath, fsConstants2.O_RDONLY | fsConstants2.O_NOFOLLOW);
|
|
452
896
|
let db;
|
|
453
897
|
try {
|
|
454
898
|
const identity = await leaf.stat();
|
|
455
|
-
const named = await
|
|
899
|
+
const named = await lstat2(dbPath);
|
|
456
900
|
if (!identity.isFile() || !named.isFile() || named.dev !== identity.dev || named.ino !== identity.ino) {
|
|
457
901
|
throw new Error("The replica database changed before SQLite opened it.");
|
|
458
902
|
}
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
903
|
+
const key = `${opened.dev}:${opened.ino}`;
|
|
904
|
+
const active = writableStores.get(key);
|
|
905
|
+
if (active && (active.dev !== identity.dev || active.ino !== identity.ino)) {
|
|
906
|
+
throw new Error("The replica database changed while another controller holds it open.");
|
|
907
|
+
}
|
|
908
|
+
if (active && !options.sqlite) {
|
|
909
|
+
const namedDirectory = lstatSync(dir, { bigint: true });
|
|
910
|
+
if (!namedDirectory.isDirectory() || namedDirectory.dev !== opened.dev || namedDirectory.ino !== opened.ino) {
|
|
911
|
+
throw new Error("The replica directory changed before its checked SQLite handle could be shared.");
|
|
912
|
+
}
|
|
913
|
+
const pinned = active.directoryFd === void 0 ? lstatSync(active.pinnedDir, { bigint: true }) : fstatSync(active.directoryFd, { bigint: true });
|
|
914
|
+
const activeLeaf = lstatSync(join2(active.pinnedDir, "replica.db"));
|
|
915
|
+
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) {
|
|
916
|
+
throw new Error("The live replica changed before its checked SQLite handle could be shared.");
|
|
917
|
+
}
|
|
918
|
+
if (directoryFd !== void 0) {
|
|
919
|
+
closeSync(directoryFd);
|
|
920
|
+
directoryFd = void 0;
|
|
464
921
|
}
|
|
465
|
-
|
|
466
|
-
db = opener(dbPath);
|
|
467
|
-
const afterOpen = lstatSync(dbPath);
|
|
468
|
-
if (!afterOpen.isFile() || afterOpen.dev !== identity.dev || afterOpen.ino !== identity.ino) {
|
|
469
|
-
throw new Error("The replica database changed while SQLite opened it.");
|
|
922
|
+
store = new _SqliteReplicaStore(dir, active.db, identity, options, active.directoryFd, namedAncestors, active);
|
|
470
923
|
}
|
|
471
|
-
if (
|
|
472
|
-
|
|
473
|
-
const
|
|
924
|
+
if (!store) {
|
|
925
|
+
const opener = options.sqlite ?? await loadSqlite();
|
|
926
|
+
const previousFds = process.platform === "linux" && !options.sqlite ? new Map(readdirSync("/proc/self/fd").map((fd) => {
|
|
474
927
|
try {
|
|
475
|
-
|
|
476
|
-
return target.endsWith("/replica.db") && previousFds.get(fd) !== target;
|
|
928
|
+
return [fd, readlinkSync(`/proc/self/fd/${fd}`)];
|
|
477
929
|
} catch {
|
|
478
|
-
return
|
|
930
|
+
return [fd, ""];
|
|
479
931
|
}
|
|
480
|
-
});
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
932
|
+
})) : null;
|
|
933
|
+
db = opener(dbPath);
|
|
934
|
+
const afterOpen = lstatSync(dbPath);
|
|
935
|
+
if (!afterOpen.isFile() || afterOpen.dev !== identity.dev || afterOpen.ino !== identity.ino) {
|
|
936
|
+
throw new Error("The replica database changed while SQLite opened it.");
|
|
937
|
+
}
|
|
938
|
+
if (previousFds) db.get("SELECT count(*) FROM sqlite_master");
|
|
939
|
+
if (previousFds) {
|
|
940
|
+
const databases = readdirSync("/proc/self/fd").filter((fd) => {
|
|
941
|
+
try {
|
|
942
|
+
const target = readlinkSync(`/proc/self/fd/${fd}`);
|
|
943
|
+
return target.endsWith("/replica.db") && previousFds.get(fd) !== target;
|
|
944
|
+
} catch {
|
|
945
|
+
return false;
|
|
946
|
+
}
|
|
947
|
+
});
|
|
948
|
+
if (databases.length === 0 || databases.some((fd) => {
|
|
949
|
+
try {
|
|
950
|
+
const actual = fstatSync(Number(fd));
|
|
951
|
+
return actual.dev !== identity.dev || actual.ino !== identity.ino;
|
|
952
|
+
} catch {
|
|
953
|
+
return true;
|
|
954
|
+
}
|
|
955
|
+
})) {
|
|
956
|
+
throw new Error("SQLite opened a different replica database.");
|
|
487
957
|
}
|
|
488
|
-
})) {
|
|
489
|
-
throw new Error("SQLite opened a different replica database.");
|
|
490
958
|
}
|
|
959
|
+
const after = lstatSync(dbPath);
|
|
960
|
+
if (!after.isFile() || after.dev !== identity.dev || after.ino !== identity.ino) {
|
|
961
|
+
throw new Error("The replica database changed while SQLite opened it.");
|
|
962
|
+
}
|
|
963
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
964
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
965
|
+
db.exec("PRAGMA synchronous = FULL");
|
|
966
|
+
db.exec("PRAGMA secure_delete = ON");
|
|
967
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
968
|
+
const version = db.get("PRAGMA user_version")?.user_version ?? 0;
|
|
969
|
+
if (version > SCHEMA_VERSION) {
|
|
970
|
+
throw new ReplicaError(
|
|
971
|
+
ReplicaErrorCode.STORAGE_ERROR,
|
|
972
|
+
`The replica at ${dir} was written by a newer tc (schema ${version}); upgrade tc to read it.`
|
|
973
|
+
);
|
|
974
|
+
}
|
|
975
|
+
if (version < SCHEMA_VERSION) {
|
|
976
|
+
db.exec("BEGIN IMMEDIATE");
|
|
977
|
+
db.exec(SCHEMA);
|
|
978
|
+
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
979
|
+
db.exec("COMMIT");
|
|
980
|
+
}
|
|
981
|
+
const shared = {
|
|
982
|
+
key,
|
|
983
|
+
db,
|
|
984
|
+
directoryFd,
|
|
985
|
+
pinnedDir,
|
|
986
|
+
namedPath: dir,
|
|
987
|
+
dev: identity.dev,
|
|
988
|
+
ino: identity.ino,
|
|
989
|
+
references: 0,
|
|
990
|
+
mutationTail: Promise.resolve()
|
|
991
|
+
};
|
|
992
|
+
store = new _SqliteReplicaStore(dir, db, identity, options, directoryFd, namedAncestors, shared);
|
|
993
|
+
if (!options.sqlite) writableStores.set(key, shared);
|
|
994
|
+
db = void 0;
|
|
491
995
|
}
|
|
492
|
-
const after = lstatSync(dbPath);
|
|
493
|
-
if (!after.isFile() || after.dev !== identity.dev || after.ino !== identity.ino) {
|
|
494
|
-
throw new Error("The replica database changed while SQLite opened it.");
|
|
495
|
-
}
|
|
496
|
-
db.exec("PRAGMA busy_timeout = 5000");
|
|
497
|
-
db.exec("PRAGMA journal_mode = WAL");
|
|
498
|
-
db.exec("PRAGMA synchronous = FULL");
|
|
499
|
-
db.exec("PRAGMA secure_delete = ON");
|
|
500
|
-
db.exec("PRAGMA foreign_keys = ON");
|
|
501
|
-
const version = db.get("PRAGMA user_version")?.user_version ?? 0;
|
|
502
|
-
if (version > SCHEMA_VERSION) {
|
|
503
|
-
throw new ReplicaError(
|
|
504
|
-
ReplicaErrorCode.STORAGE_ERROR,
|
|
505
|
-
`The replica at ${dir} was written by a newer tc (schema ${version}); upgrade tc to read it.`
|
|
506
|
-
);
|
|
507
|
-
}
|
|
508
|
-
if (version < SCHEMA_VERSION) {
|
|
509
|
-
db.exec("BEGIN IMMEDIATE");
|
|
510
|
-
db.exec(SCHEMA);
|
|
511
|
-
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
512
|
-
db.exec("COMMIT");
|
|
513
|
-
}
|
|
514
|
-
store = new _SqliteReplicaStore(dir, db, identity, options, directoryFd, namedAncestors);
|
|
515
|
-
db = void 0;
|
|
516
996
|
} finally {
|
|
517
997
|
try {
|
|
518
998
|
db?.close();
|
|
@@ -538,12 +1018,109 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
538
1018
|
throw error;
|
|
539
1019
|
}
|
|
540
1020
|
}
|
|
1021
|
+
/**
|
|
1022
|
+
* A gated read borrows this controller's already-verified writable handle.
|
|
1023
|
+
* Borrow before the first await: the parent may close in the callback, but
|
|
1024
|
+
* its pin and native handle remain live until the snapshot releases them.
|
|
1025
|
+
* Serialize BEGIN through COMMIT/ROLLBACK with writes on the shared handle.
|
|
1026
|
+
*/
|
|
1027
|
+
async readTransaction(generation, checkGate, section) {
|
|
1028
|
+
this.#assertAccepting();
|
|
1029
|
+
const shared = this.#shared;
|
|
1030
|
+
if (!shared) throw new ReplicaError(ReplicaErrorCode.CLOSED, "The replica handle is closed.");
|
|
1031
|
+
const snapshot = new _SqliteReplicaStore(
|
|
1032
|
+
this.dir,
|
|
1033
|
+
shared.db,
|
|
1034
|
+
{ dev: this.#dev, ino: this.#ino },
|
|
1035
|
+
{ create: false, now: this.#now },
|
|
1036
|
+
shared.directoryFd,
|
|
1037
|
+
this.#namedAncestors,
|
|
1038
|
+
shared,
|
|
1039
|
+
true
|
|
1040
|
+
);
|
|
1041
|
+
let release;
|
|
1042
|
+
let active = false;
|
|
1043
|
+
let callbackFailed = false;
|
|
1044
|
+
try {
|
|
1045
|
+
release = await acquireWritableMutation(shared);
|
|
1046
|
+
await snapshot.#assertReadIdentity();
|
|
1047
|
+
snapshot.#db.exec("BEGIN");
|
|
1048
|
+
active = true;
|
|
1049
|
+
const tombstone = snapshot.#db.get("SELECT generation FROM replica WHERE id = 1");
|
|
1050
|
+
try {
|
|
1051
|
+
await checkGate();
|
|
1052
|
+
} catch (error) {
|
|
1053
|
+
callbackFailed = true;
|
|
1054
|
+
throw error;
|
|
1055
|
+
}
|
|
1056
|
+
if (tombstone === void 0 || tombstone.generation !== generation) {
|
|
1057
|
+
throw new ReplicaError(ReplicaErrorCode.RESET_REQUIRED, "The replica generation changed; reopen it to continue.");
|
|
1058
|
+
}
|
|
1059
|
+
let result;
|
|
1060
|
+
const context = { controller: this, active: true };
|
|
1061
|
+
try {
|
|
1062
|
+
try {
|
|
1063
|
+
result = await activeReadSection.run(context, () => section(snapshot));
|
|
1064
|
+
} catch (error) {
|
|
1065
|
+
callbackFailed = true;
|
|
1066
|
+
throw error;
|
|
1067
|
+
}
|
|
1068
|
+
} finally {
|
|
1069
|
+
context.active = false;
|
|
1070
|
+
}
|
|
1071
|
+
try {
|
|
1072
|
+
await checkGate();
|
|
1073
|
+
} catch (error) {
|
|
1074
|
+
callbackFailed = true;
|
|
1075
|
+
throw error;
|
|
1076
|
+
}
|
|
1077
|
+
await snapshot.#assertReadIdentity();
|
|
1078
|
+
snapshot.#db.exec("COMMIT");
|
|
1079
|
+
active = false;
|
|
1080
|
+
return result;
|
|
1081
|
+
} catch (error) {
|
|
1082
|
+
if (callbackFailed) throw error;
|
|
1083
|
+
if (isReplicaError(error) || typeof error === "object" && error !== null && "code" in error && error.code === "REPLICA_GATED") throw error;
|
|
1084
|
+
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))) {
|
|
1085
|
+
throw new ReplicaError(ReplicaErrorCode.RESET_REQUIRED, "The replica was replaced; reopen it to continue.", void 0, { cause: error });
|
|
1086
|
+
}
|
|
1087
|
+
throw storageError(error, "Reading the replica snapshot");
|
|
1088
|
+
} finally {
|
|
1089
|
+
if (active) {
|
|
1090
|
+
try {
|
|
1091
|
+
snapshot.#db.exec("ROLLBACK");
|
|
1092
|
+
} catch {
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
try {
|
|
1096
|
+
await snapshot.close();
|
|
1097
|
+
} finally {
|
|
1098
|
+
release?.();
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
async #assertReadIdentity() {
|
|
1103
|
+
const shared = this.#shared;
|
|
1104
|
+
const pinned = shared.directoryFd === void 0 ? lstatSync(shared.pinnedDir) : fstatSync(shared.directoryFd);
|
|
1105
|
+
const namedDir = lstatSync(this.dir);
|
|
1106
|
+
const namedDb = lstatSync(join2(this.dir, "replica.db"));
|
|
1107
|
+
const pinnedDb = lstatSync(join2(shared.pinnedDir, "replica.db"));
|
|
1108
|
+
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) {
|
|
1109
|
+
throw new ReplicaError(ReplicaErrorCode.RESET_REQUIRED, "The replica was replaced during its read; reopen it to continue.");
|
|
1110
|
+
}
|
|
1111
|
+
if (this.#namedAncestors.length) {
|
|
1112
|
+
const ancestors = await captureNamedPath(this.dir);
|
|
1113
|
+
if (ancestors.length !== this.#namedAncestors.length || ancestors.some((entry, index) => entry.dev !== this.#namedAncestors[index].dev || entry.ino !== this.#namedAncestors[index].ino)) {
|
|
1114
|
+
throw new ReplicaError(ReplicaErrorCode.RESET_REQUIRED, "The replica directory was replaced; reopen it to continue.");
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
541
1118
|
#row() {
|
|
542
1119
|
return this.#db.get("SELECT * FROM replica WHERE id = 1");
|
|
543
1120
|
}
|
|
544
1121
|
/** Refuse to touch the filesystem for a replica that was deleted (or replaced) under us. */
|
|
545
1122
|
async #assertPresent() {
|
|
546
|
-
const identity = await
|
|
1123
|
+
const identity = await lstat2(join2(this.#pinnedDir, "replica.db")).catch(() => null);
|
|
547
1124
|
if (!identity?.isFile() || identity.dev !== this.#dev || identity.ino !== this.#ino) {
|
|
548
1125
|
throw new ReplicaError(
|
|
549
1126
|
ReplicaErrorCode.NOT_FOUND,
|
|
@@ -551,30 +1128,63 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
551
1128
|
);
|
|
552
1129
|
}
|
|
553
1130
|
}
|
|
1131
|
+
/** New calls stop at close; accepted mutation sections may still finish their transactions. */
|
|
1132
|
+
#assertAccepting() {
|
|
1133
|
+
if (this.#closing || this.#closed) {
|
|
1134
|
+
throw new ReplicaError(ReplicaErrorCode.CLOSED, "The replica store is closed.");
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
554
1137
|
/** A short mutation section: guarded (profile lock) and checked against the database this store opened. */
|
|
555
1138
|
#mutate(section) {
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
}
|
|
1139
|
+
this.#assertAccepting();
|
|
1140
|
+
if (this.#readonlySnapshot) {
|
|
1141
|
+
throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "A read-only replica snapshot cannot mutate the shared database.");
|
|
1142
|
+
}
|
|
1143
|
+
const read = activeReadSection.getStore();
|
|
1144
|
+
if (read?.active && read.controller === this) {
|
|
1145
|
+
throw new ReplicaError(
|
|
1146
|
+
ReplicaErrorCode.STORAGE_ERROR,
|
|
1147
|
+
"mutation attempted on the same controller while its readTransaction section is active; this would deadlock"
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
this.#activeMutations++;
|
|
1151
|
+
return (async () => {
|
|
1152
|
+
try {
|
|
1153
|
+
return await this.#guard(async () => {
|
|
1154
|
+
await this.#assertPresent();
|
|
1155
|
+
return section();
|
|
1156
|
+
});
|
|
1157
|
+
} finally {
|
|
1158
|
+
if (--this.#activeMutations === 0) {
|
|
1159
|
+
const drained = this.#drained;
|
|
1160
|
+
this.#drained = void 0;
|
|
1161
|
+
drained?.();
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
})();
|
|
560
1165
|
}
|
|
561
1166
|
/** Run `body` in a write transaction (BEGIN IMMEDIATE). Callers outside a mutation section use `#guardedWrite`. */
|
|
562
|
-
#write(action, body) {
|
|
1167
|
+
async #write(action, body) {
|
|
1168
|
+
const release = this.#shared ? await acquireWritableMutation(this.#shared) : void 0;
|
|
563
1169
|
try {
|
|
564
|
-
this.#db.exec("BEGIN IMMEDIATE");
|
|
565
|
-
} catch (error) {
|
|
566
|
-
throw storageError(error, action);
|
|
567
|
-
}
|
|
568
|
-
try {
|
|
569
|
-
const result = body();
|
|
570
|
-
this.#db.exec("COMMIT");
|
|
571
|
-
return result;
|
|
572
|
-
} catch (error) {
|
|
573
1170
|
try {
|
|
574
|
-
this.#db.exec("
|
|
575
|
-
} catch {
|
|
1171
|
+
this.#db.exec("BEGIN IMMEDIATE");
|
|
1172
|
+
} catch (error) {
|
|
1173
|
+
throw storageError(error, action);
|
|
1174
|
+
}
|
|
1175
|
+
try {
|
|
1176
|
+
const result = body();
|
|
1177
|
+
this.#db.exec("COMMIT");
|
|
1178
|
+
return result;
|
|
1179
|
+
} catch (error) {
|
|
1180
|
+
try {
|
|
1181
|
+
this.#db.exec("ROLLBACK");
|
|
1182
|
+
} catch {
|
|
1183
|
+
}
|
|
1184
|
+
throw storageError(error, action);
|
|
576
1185
|
}
|
|
577
|
-
|
|
1186
|
+
} finally {
|
|
1187
|
+
release?.();
|
|
578
1188
|
}
|
|
579
1189
|
}
|
|
580
1190
|
/** One write transaction as its own mutation section. */
|
|
@@ -601,6 +1211,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
601
1211
|
}
|
|
602
1212
|
}
|
|
603
1213
|
async open() {
|
|
1214
|
+
this.#assertAccepting();
|
|
604
1215
|
let row;
|
|
605
1216
|
try {
|
|
606
1217
|
row = this.#row();
|
|
@@ -679,6 +1290,107 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
679
1290
|
);
|
|
680
1291
|
});
|
|
681
1292
|
}
|
|
1293
|
+
/** Per-CID first-install anchor and policy bound share the pending-grant transaction. */
|
|
1294
|
+
#grantInstall(cid) {
|
|
1295
|
+
const row = this.#db.get(
|
|
1296
|
+
"SELECT installed_at, effective_expires_at, consent_deadline, consent_anchor_at, consent_anchor_source FROM grant_install WHERE cid = ?",
|
|
1297
|
+
cid
|
|
1298
|
+
);
|
|
1299
|
+
if (row === void 0) return null;
|
|
1300
|
+
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) {
|
|
1301
|
+
throw new ReplicaError(ReplicaErrorCode.GRANT_EXPIRED, "The stored grant expiry or consent anchor is invalid.");
|
|
1302
|
+
}
|
|
1303
|
+
return {
|
|
1304
|
+
installedAt: row.installed_at,
|
|
1305
|
+
effectiveExpiresAt: row.effective_expires_at,
|
|
1306
|
+
consentDeadline: row.consent_deadline,
|
|
1307
|
+
consentAnchor: { at: row.consent_anchor_at, source: row.consent_anchor_source }
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
async getGrantInstall(cid) {
|
|
1311
|
+
return this.#grantInstall(cid);
|
|
1312
|
+
}
|
|
1313
|
+
/** Every installed CID, including superseded ones, for an origin-wide purge floor. */
|
|
1314
|
+
async grantInstallTimes() {
|
|
1315
|
+
const rows = this.#db.all(
|
|
1316
|
+
"SELECT cid, installed_at FROM grant_install"
|
|
1317
|
+
);
|
|
1318
|
+
return rows.map((row) => {
|
|
1319
|
+
if (typeof row.cid !== "string" || row.cid.length === 0 || !Number.isSafeInteger(row.installed_at) || row.installed_at < 0) {
|
|
1320
|
+
throw new ReplicaError(ReplicaErrorCode.GRANT_EXPIRED, "The stored grant install time is invalid.");
|
|
1321
|
+
}
|
|
1322
|
+
return { cid: row.cid, installedAt: row.installed_at };
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
/** Phase 2 installed grants cannot commit a page after their shorter local TTL. */
|
|
1326
|
+
#assertInstalledGrantTime() {
|
|
1327
|
+
const row = this.#row();
|
|
1328
|
+
const cid = row?.pending_grant_cid ?? row?.grant_cid;
|
|
1329
|
+
if (cid === void 0 || cid === null) return;
|
|
1330
|
+
const installed = this.#grantInstall(cid);
|
|
1331
|
+
const now = this.#now();
|
|
1332
|
+
if (installed !== null && (now >= installed.effectiveExpiresAt || now >= installed.consentDeadline)) {
|
|
1333
|
+
throw new ReplicaError(ReplicaErrorCode.GRANT_EXPIRED, "The installed grant expired before this page committed.");
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
#storeGrantPolicy(g, policy, existing) {
|
|
1337
|
+
const installedAt = existing?.installedAt ?? policy.at;
|
|
1338
|
+
const effectiveExpiresAt = Math.min(
|
|
1339
|
+
g.expiresAt === null ? Infinity : g.expiresAt * 1e3,
|
|
1340
|
+
installedAt + policy.ttlMs,
|
|
1341
|
+
policy.consentDeadline
|
|
1342
|
+
);
|
|
1343
|
+
if (!Number.isSafeInteger(installedAt) || installedAt < 0 || !Number.isSafeInteger(effectiveExpiresAt) || effectiveExpiresAt < 0 || !Number.isSafeInteger(policy.consentDeadline) || policy.consentDeadline < 0 || !Number.isSafeInteger(policy.consentAnchor.at) || policy.consentAnchor.at < 0 || !Number.isSafeInteger(policy.ttlMs) || policy.ttlMs <= 0) {
|
|
1344
|
+
throw new ReplicaError(ReplicaErrorCode.GRANT_EXPIRED, "The replica grant has no finite effective expiry.");
|
|
1345
|
+
}
|
|
1346
|
+
this.#db.run(
|
|
1347
|
+
`INSERT INTO grant_install (cid, installed_at, effective_expires_at, consent_deadline, consent_anchor_at, consent_anchor_source)
|
|
1348
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
1349
|
+
ON CONFLICT (cid) DO UPDATE SET effective_expires_at = excluded.effective_expires_at,
|
|
1350
|
+
consent_deadline = excluded.consent_deadline, consent_anchor_at = excluded.consent_anchor_at,
|
|
1351
|
+
consent_anchor_source = excluded.consent_anchor_source`,
|
|
1352
|
+
g.cid,
|
|
1353
|
+
installedAt,
|
|
1354
|
+
effectiveExpiresAt,
|
|
1355
|
+
policy.consentDeadline,
|
|
1356
|
+
policy.consentAnchor.at,
|
|
1357
|
+
policy.consentAnchor.source
|
|
1358
|
+
);
|
|
1359
|
+
return { installedAt, effectiveExpiresAt, consentDeadline: policy.consentDeadline, consentAnchor: policy.consentAnchor };
|
|
1360
|
+
}
|
|
1361
|
+
async installGatedGrant(g, policy) {
|
|
1362
|
+
return this.#guardedWrite("Installing the gated grant", () => {
|
|
1363
|
+
const row = this.#row();
|
|
1364
|
+
if (row === void 0) throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica has not been created.");
|
|
1365
|
+
const previous = this.#grantInstall(g.cid);
|
|
1366
|
+
if (previous === null && (row.grant_cid === g.cid || row.pending_grant_cid === g.cid)) {
|
|
1367
|
+
throw new ReplicaError(ReplicaErrorCode.GRANT_EXPIRED, "The installed grant has no durable first-install time.");
|
|
1368
|
+
}
|
|
1369
|
+
const next = this.#storeGrantPolicy(g, policy, previous);
|
|
1370
|
+
if (next.effectiveExpiresAt <= this.#now()) {
|
|
1371
|
+
throw new ReplicaError(ReplicaErrorCode.GRANT_EXPIRED, "The replica grant expired under the current policy.");
|
|
1372
|
+
}
|
|
1373
|
+
if (row.grant_cid !== g.cid && row.pending_grant_cid !== g.cid) {
|
|
1374
|
+
this.#db.run(
|
|
1375
|
+
`UPDATE replica SET pending_grant_cid = ?, pending_grant_bytes = ?, pending_grant_audience = ?,
|
|
1376
|
+
pending_grant_issuer = ?, pending_grant_nbf = ?, pending_grant_exp = ?, pending_grant_error = NULL WHERE id = 1`,
|
|
1377
|
+
g.cid,
|
|
1378
|
+
g.bytes,
|
|
1379
|
+
g.audience,
|
|
1380
|
+
g.issuer,
|
|
1381
|
+
g.notBefore,
|
|
1382
|
+
g.expiresAt
|
|
1383
|
+
);
|
|
1384
|
+
}
|
|
1385
|
+
return next;
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
async recomputeGrantInstall(g, policy) {
|
|
1389
|
+
return this.#guardedWrite("Recomputing the gated grant", () => {
|
|
1390
|
+
const previous = this.#grantInstall(g.cid);
|
|
1391
|
+
return previous === null ? null : this.#storeGrantPolicy(g, policy, previous);
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
682
1394
|
async installGrant(g) {
|
|
683
1395
|
await this.#guardedWrite("Installing the grant", () => {
|
|
684
1396
|
const row = this.#row();
|
|
@@ -737,13 +1449,14 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
737
1449
|
const now = this.#now();
|
|
738
1450
|
if (row.lease_holder !== null && row.lease_expires_at !== null && row.lease_expires_at > now) return null;
|
|
739
1451
|
const token = row.lease_token + 1;
|
|
1452
|
+
const holder = this.#holder ??= `${process.pid}-${randomBytes(6).toString("hex")}`;
|
|
740
1453
|
this.#db.run(
|
|
741
1454
|
"UPDATE replica SET lease_token = ?, lease_holder = ?, lease_expires_at = ? WHERE id = 1",
|
|
742
1455
|
token,
|
|
743
|
-
|
|
1456
|
+
holder,
|
|
744
1457
|
now + ttlMs
|
|
745
1458
|
);
|
|
746
|
-
return { token, holder
|
|
1459
|
+
return { token, holder };
|
|
747
1460
|
});
|
|
748
1461
|
}
|
|
749
1462
|
async renewLease(t, ttlMs) {
|
|
@@ -762,6 +1475,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
762
1475
|
});
|
|
763
1476
|
}
|
|
764
1477
|
async hasContent(hashes) {
|
|
1478
|
+
this.#assertAccepting();
|
|
765
1479
|
const held = /* @__PURE__ */ new Set();
|
|
766
1480
|
for (let start = 0; start < hashes.length; start += 500) {
|
|
767
1481
|
const chunk = hashes.slice(start, start + 500);
|
|
@@ -774,7 +1488,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
774
1488
|
return held;
|
|
775
1489
|
}
|
|
776
1490
|
#blobPath(hash) {
|
|
777
|
-
return
|
|
1491
|
+
return join2(this.#pinnedDir, "blobs", hash.slice(0, 2), hash);
|
|
778
1492
|
}
|
|
779
1493
|
/**
|
|
780
1494
|
* Make every blob durable before any row references it: write to a fresh
|
|
@@ -791,12 +1505,12 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
791
1505
|
() => false
|
|
792
1506
|
);
|
|
793
1507
|
if (exists) continue;
|
|
794
|
-
const shard =
|
|
1508
|
+
const shard = join2(this.#pinnedDir, "blobs", hash.slice(0, 2));
|
|
795
1509
|
await mkdir(shard, { mode: 448 }).catch((error) => {
|
|
796
1510
|
if (errnoOf(error) !== "EEXIST") throw error;
|
|
797
1511
|
});
|
|
798
|
-
const temp =
|
|
799
|
-
const handle = await
|
|
1512
|
+
const temp = join2(this.#pinnedDir, "blobs", ".tmp", `${hash}.${randomBytes(6).toString("hex")}`);
|
|
1513
|
+
const handle = await open2(temp, fsConstants2.O_CREAT | fsConstants2.O_EXCL | fsConstants2.O_WRONLY, 384);
|
|
800
1514
|
try {
|
|
801
1515
|
await handle.writeFile(bytes);
|
|
802
1516
|
await handle.sync();
|
|
@@ -808,13 +1522,13 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
808
1522
|
} catch (error) {
|
|
809
1523
|
if (errnoOf(error) !== "EEXIST") throw error;
|
|
810
1524
|
} finally {
|
|
811
|
-
await
|
|
1525
|
+
await unlink2(temp);
|
|
812
1526
|
}
|
|
813
1527
|
dirs.add(shard);
|
|
814
|
-
dirs.add(
|
|
1528
|
+
dirs.add(join2(this.#pinnedDir, "blobs"));
|
|
815
1529
|
}
|
|
816
1530
|
for (const dir of dirs) {
|
|
817
|
-
const handle = await
|
|
1531
|
+
const handle = await open2(dir, fsConstants2.O_RDONLY);
|
|
818
1532
|
try {
|
|
819
1533
|
await handle.sync();
|
|
820
1534
|
} finally {
|
|
@@ -825,6 +1539,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
825
1539
|
async applyPage(t, p) {
|
|
826
1540
|
await this.#mutate(async () => {
|
|
827
1541
|
this.#checkLease(t);
|
|
1542
|
+
this.#assertInstalledGrantTime();
|
|
828
1543
|
const reused = /* @__PURE__ */ new Map();
|
|
829
1544
|
for (const change of p.changes) {
|
|
830
1545
|
if (change.deleted || !change.content || p.blobs.has(change.hash) || reused.has(change.hash)) continue;
|
|
@@ -844,8 +1559,9 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
844
1559
|
}
|
|
845
1560
|
await this.#faults.afterBlobs?.();
|
|
846
1561
|
try {
|
|
847
|
-
this.#write("Committing a sync page", () => {
|
|
1562
|
+
await this.#write("Committing a sync page", () => {
|
|
848
1563
|
this.#checkLease(t);
|
|
1564
|
+
this.#assertInstalledGrantTime();
|
|
849
1565
|
const now = this.#now();
|
|
850
1566
|
if (p.window.notBefore !== null && now < Date.parse(p.window.notBefore)) {
|
|
851
1567
|
throw new ReplicaError(ReplicaErrorCode.GRANT_NOT_YET_VALID, `The grant is not valid before ${p.window.notBefore}; nothing was committed.`);
|
|
@@ -919,12 +1635,12 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
919
1635
|
/** Unlink every blob not in `keep`, and every temp file. Only a missing directory counts as empty. */
|
|
920
1636
|
async #unlinkBlobs(keep) {
|
|
921
1637
|
let removed = 0;
|
|
922
|
-
const root =
|
|
1638
|
+
const root = join2(this.#pinnedDir, "blobs");
|
|
923
1639
|
for (const shard of await entriesOf(root)) {
|
|
924
|
-
const shardPath =
|
|
1640
|
+
const shardPath = join2(root, shard);
|
|
925
1641
|
if (shard === ".tmp") {
|
|
926
1642
|
for (const name of await entriesOf(shardPath)) {
|
|
927
|
-
await rm(
|
|
1643
|
+
await rm(join2(shardPath, name), { force: true });
|
|
928
1644
|
removed += 1;
|
|
929
1645
|
}
|
|
930
1646
|
continue;
|
|
@@ -935,30 +1651,33 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
935
1651
|
kept += 1;
|
|
936
1652
|
continue;
|
|
937
1653
|
}
|
|
938
|
-
await rm(
|
|
1654
|
+
await rm(join2(shardPath, name), { force: true });
|
|
939
1655
|
removed += 1;
|
|
940
1656
|
}
|
|
941
|
-
if (kept === 0) await
|
|
1657
|
+
if (kept === 0) await rmdir2(shardPath).catch(() => void 0);
|
|
942
1658
|
}
|
|
943
1659
|
return removed;
|
|
944
1660
|
}
|
|
945
1661
|
/** Whether any content file (a blob or a temp file) is on disk. */
|
|
946
1662
|
async #holdsContentFiles() {
|
|
947
|
-
const root =
|
|
1663
|
+
const root = join2(this.#pinnedDir, "blobs");
|
|
948
1664
|
for (const shard of await entriesOf(root)) {
|
|
949
|
-
if ((await entriesOf(
|
|
1665
|
+
if ((await entriesOf(join2(root, shard))).length > 0) return true;
|
|
950
1666
|
}
|
|
951
1667
|
return false;
|
|
952
1668
|
}
|
|
953
1669
|
async collectGarbage(t) {
|
|
954
1670
|
return this.#mutate(async () => {
|
|
1671
|
+
const release = this.#shared ? await acquireWritableMutation(this.#shared) : void 0;
|
|
955
1672
|
try {
|
|
956
1673
|
this.#db.exec("BEGIN IMMEDIATE");
|
|
957
1674
|
} catch (error) {
|
|
1675
|
+
release?.();
|
|
958
1676
|
throw storageError(error, "Collecting unused content");
|
|
959
1677
|
}
|
|
960
1678
|
try {
|
|
961
1679
|
this.#checkLease(t);
|
|
1680
|
+
await this.#faults.afterGarbageCollectionBegin?.();
|
|
962
1681
|
const keep = new Set(
|
|
963
1682
|
this.#db.all("SELECT DISTINCT hash FROM entry WHERE content = 1").map((row) => row.hash)
|
|
964
1683
|
);
|
|
@@ -971,12 +1690,14 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
971
1690
|
} catch {
|
|
972
1691
|
}
|
|
973
1692
|
throw storageError(error, "Collecting unused content");
|
|
1693
|
+
} finally {
|
|
1694
|
+
release?.();
|
|
974
1695
|
}
|
|
975
1696
|
});
|
|
976
1697
|
}
|
|
977
1698
|
async reset(t, reason, options = {}) {
|
|
978
1699
|
await this.#mutate(async () => {
|
|
979
|
-
this.#write("Resetting the replica", () => {
|
|
1700
|
+
await this.#write("Resetting the replica", () => {
|
|
980
1701
|
this.#checkLease(t);
|
|
981
1702
|
this.#db.run("DELETE FROM entry");
|
|
982
1703
|
this.#db.run(
|
|
@@ -995,17 +1716,16 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
995
1716
|
}
|
|
996
1717
|
}
|
|
997
1718
|
/**
|
|
998
|
-
* Remove
|
|
999
|
-
*
|
|
1000
|
-
* store opened, checked under the write lock, so a paused process can
|
|
1001
|
-
* never remove a replica another process took over or recreated. Allowed
|
|
1002
|
-
* on a revoked replica. The store is unusable afterwards; close it.
|
|
1719
|
+
* Remove the source-created mirror layout under a live lease. Unknown files
|
|
1720
|
+
* are removed only when their exact preflight identities were approved.
|
|
1003
1721
|
*/
|
|
1004
|
-
async destroy(t) {
|
|
1722
|
+
async destroy(t, options = {}) {
|
|
1005
1723
|
await this.#mutate(async () => {
|
|
1724
|
+
const release = this.#shared ? await acquireWritableMutation(this.#shared) : void 0;
|
|
1006
1725
|
try {
|
|
1007
1726
|
this.#db.exec("BEGIN IMMEDIATE");
|
|
1008
1727
|
} catch (error) {
|
|
1728
|
+
release?.();
|
|
1009
1729
|
throw storageError(error, "Removing the replica");
|
|
1010
1730
|
}
|
|
1011
1731
|
try {
|
|
@@ -1015,87 +1735,31 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
1015
1735
|
throw new ReplicaError(ReplicaErrorCode.RUNTIME_UNSUPPORTED, "Replica removal requires an opened or identity-checked directory.");
|
|
1016
1736
|
}
|
|
1017
1737
|
const assertDirectory = async () => {
|
|
1018
|
-
const current = await
|
|
1738
|
+
const current = await lstat2(this.dir, { bigint: true });
|
|
1019
1739
|
if (!current.isDirectory() || current.dev !== expected.dev || current.ino !== expected.ino) {
|
|
1020
1740
|
throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica directory changed during removal.");
|
|
1021
1741
|
}
|
|
1022
1742
|
return current;
|
|
1023
1743
|
};
|
|
1024
1744
|
const pinned = await assertDirectory();
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
await assertDirectory();
|
|
1031
|
-
await rmdir(this.dir);
|
|
1032
|
-
} else {
|
|
1033
|
-
const ancestors = this.#namedAncestors;
|
|
1034
|
-
const checkAncestors = async () => {
|
|
1035
|
-
for (const expected2 of ancestors) {
|
|
1036
|
-
const current = await lstat(expected2.path, { bigint: true });
|
|
1037
|
-
if (current.dev !== expected2.dev || current.ino !== expected2.ino) {
|
|
1038
|
-
throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica ancestry changed during removal.");
|
|
1039
|
-
}
|
|
1040
|
-
}
|
|
1041
|
-
};
|
|
1042
|
-
const directories = /* @__PURE__ */ new Map([[this.dir, pinned]]);
|
|
1043
|
-
const files = /* @__PURE__ */ new Map();
|
|
1044
|
-
const check = async (path, expected2, directory) => {
|
|
1045
|
-
if (path !== this.dir) {
|
|
1046
|
-
const parent = dirname(path);
|
|
1047
|
-
await check(parent, directories.get(parent), true);
|
|
1048
|
-
}
|
|
1049
|
-
await checkAncestors();
|
|
1050
|
-
const current = await lstat(path, { bigint: true });
|
|
1051
|
-
if ((directory ? !current.isDirectory() : !current.isFile()) || current.dev !== expected2.dev || current.ino !== expected2.ino) {
|
|
1052
|
-
throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica layout changed during removal.");
|
|
1053
|
-
}
|
|
1054
|
-
};
|
|
1055
|
-
const record = async (path, directory) => {
|
|
1056
|
-
const parent = dirname(path);
|
|
1057
|
-
await check(parent, directories.get(parent), true);
|
|
1058
|
-
const current = await lstat(path, { bigint: true });
|
|
1059
|
-
if (!(directory ? current.isDirectory() : current.isFile())) {
|
|
1060
|
-
throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "Unexpected replica layout during removal.");
|
|
1061
|
-
}
|
|
1062
|
-
(directory ? directories : files).set(path, current);
|
|
1063
|
-
};
|
|
1064
|
-
const blobs = join(this.dir, "blobs");
|
|
1065
|
-
for (const name of await readdir(this.dir)) {
|
|
1066
|
-
if (name === "blobs") await record(blobs, true);
|
|
1067
|
-
else if (["replica.db", "replica.db-wal", "replica.db-shm"].includes(name)) await record(join(this.dir, name), false);
|
|
1068
|
-
else throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "Unexpected replica layout during removal.");
|
|
1069
|
-
}
|
|
1070
|
-
if (directories.has(blobs)) {
|
|
1071
|
-
await check(blobs, directories.get(blobs), true);
|
|
1072
|
-
for (const shard of await readdir(blobs)) {
|
|
1073
|
-
if (shard !== ".tmp" && !/^[0-9a-f]{2}$/.test(shard)) {
|
|
1074
|
-
throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "Unexpected replica blob shard during removal.");
|
|
1075
|
-
}
|
|
1076
|
-
const shardPath = join(blobs, shard);
|
|
1077
|
-
await record(shardPath, true);
|
|
1078
|
-
await check(shardPath, directories.get(shardPath), true);
|
|
1079
|
-
for (const name of await readdir(shardPath)) {
|
|
1080
|
-
if (shard === ".tmp" ? !/^[0-9a-f]{64}\.[0-9a-f]{12}$/.test(name) : !isHash(name) || !name.startsWith(shard)) {
|
|
1081
|
-
throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "Unexpected replica blob file during removal.");
|
|
1082
|
-
}
|
|
1083
|
-
await record(join(shardPath, name), false);
|
|
1084
|
-
}
|
|
1745
|
+
const assertNamedPath = async () => {
|
|
1746
|
+
for (const ancestor of this.#namedAncestors) {
|
|
1747
|
+
const current = await lstat2(ancestor.path, { bigint: true });
|
|
1748
|
+
if (current.dev !== ancestor.dev || current.ino !== ancestor.ino) {
|
|
1749
|
+
throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica ancestry changed during removal.");
|
|
1085
1750
|
}
|
|
1086
1751
|
}
|
|
1087
|
-
for (const [path, identity] of files) {
|
|
1088
|
-
await check(path, identity, false);
|
|
1089
|
-
await unlink(path);
|
|
1090
|
-
}
|
|
1091
|
-
for (const [path, identity] of [...directories].reverse()) {
|
|
1092
|
-
if (path === this.dir) continue;
|
|
1093
|
-
await check(path, identity, true);
|
|
1094
|
-
await rmdir(path);
|
|
1095
|
-
}
|
|
1096
1752
|
await assertDirectory();
|
|
1097
|
-
|
|
1098
|
-
|
|
1753
|
+
};
|
|
1754
|
+
await this.#assertPresent();
|
|
1755
|
+
try {
|
|
1756
|
+
await removeSqliteReplicaLayout(this.dir, {
|
|
1757
|
+
...options,
|
|
1758
|
+
expectedRoot: options.expectedRoot ?? { dev: pinned.dev.toString(), ino: pinned.ino.toString() }
|
|
1759
|
+
});
|
|
1760
|
+
} catch (error) {
|
|
1761
|
+
await assertNamedPath();
|
|
1762
|
+
throw error;
|
|
1099
1763
|
}
|
|
1100
1764
|
} catch (error) {
|
|
1101
1765
|
throw storageError(error, "Removing the replica");
|
|
@@ -1104,6 +1768,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
1104
1768
|
this.#db.exec("ROLLBACK");
|
|
1105
1769
|
} catch {
|
|
1106
1770
|
}
|
|
1771
|
+
release?.();
|
|
1107
1772
|
}
|
|
1108
1773
|
});
|
|
1109
1774
|
}
|
|
@@ -1147,7 +1812,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
1147
1812
|
* when both are done.
|
|
1148
1813
|
*/
|
|
1149
1814
|
async #purgeContent() {
|
|
1150
|
-
this.#write("Starting the revocation purge", () => {
|
|
1815
|
+
await this.#write("Starting the revocation purge", () => {
|
|
1151
1816
|
this.#db.run("UPDATE replica SET purge_pending = 1 WHERE id = 1 AND revoked_detail IS NOT NULL");
|
|
1152
1817
|
});
|
|
1153
1818
|
try {
|
|
@@ -1158,7 +1823,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
1158
1823
|
if (!this.#checkpoint()) {
|
|
1159
1824
|
throw new ReplicaError(ReplicaErrorCode.BUSY, "A reader holds the replica's write-ahead log; the purge finishes on the next open.");
|
|
1160
1825
|
}
|
|
1161
|
-
this.#write("Finishing the revocation purge", () => {
|
|
1826
|
+
await this.#write("Finishing the revocation purge", () => {
|
|
1162
1827
|
this.#db.run("UPDATE replica SET purge_pending = 0 WHERE id = 1 AND revoked_detail IS NOT NULL");
|
|
1163
1828
|
});
|
|
1164
1829
|
}
|
|
@@ -1168,6 +1833,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
1168
1833
|
});
|
|
1169
1834
|
}
|
|
1170
1835
|
async get(key) {
|
|
1836
|
+
this.#assertAccepting();
|
|
1171
1837
|
try {
|
|
1172
1838
|
const row = this.#db.get("SELECT key, deleted, etag, hash, metadata, content FROM entry WHERE key = ?", key);
|
|
1173
1839
|
return row === void 0 ? void 0 : entryOf(row);
|
|
@@ -1176,6 +1842,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
1176
1842
|
}
|
|
1177
1843
|
}
|
|
1178
1844
|
async list(o) {
|
|
1845
|
+
this.#assertAccepting();
|
|
1179
1846
|
const where = ["deleted = 0"];
|
|
1180
1847
|
const params = [];
|
|
1181
1848
|
if (o.prefix !== void 0 && o.prefix !== "") {
|
|
@@ -1261,21 +1928,39 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
1261
1928
|
lastReset: state.lastReset
|
|
1262
1929
|
};
|
|
1263
1930
|
}
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
this.#
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1931
|
+
close() {
|
|
1932
|
+
if (this.#closePromise) return this.#closePromise;
|
|
1933
|
+
this.#closing = true;
|
|
1934
|
+
this.#closePromise = (async () => {
|
|
1935
|
+
if (this.#activeMutations !== 0) {
|
|
1936
|
+
await new Promise((resolve3) => {
|
|
1937
|
+
this.#drained = resolve3;
|
|
1938
|
+
});
|
|
1939
|
+
}
|
|
1940
|
+
this.#closed = true;
|
|
1941
|
+
const fd = this.#directoryFd;
|
|
1942
|
+
this.#directoryFd = void 0;
|
|
1943
|
+
if (this.#shared) {
|
|
1944
|
+
sharedStores.unregister(this);
|
|
1945
|
+
releaseWritable(this.#shared);
|
|
1946
|
+
} else {
|
|
1947
|
+
if (fd !== void 0) pinnedDirectories.unregister(this);
|
|
1948
|
+
try {
|
|
1949
|
+
this.#database.close();
|
|
1950
|
+
} finally {
|
|
1951
|
+
if (fd !== void 0) closeSync(fd);
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
})();
|
|
1955
|
+
return this.#closePromise;
|
|
1273
1956
|
}
|
|
1274
1957
|
};
|
|
1275
1958
|
export {
|
|
1276
1959
|
DURABILITY,
|
|
1277
1960
|
SqliteReplicaStore,
|
|
1961
|
+
inspectSqliteReplicaLayout,
|
|
1278
1962
|
loadSqlite,
|
|
1279
|
-
nodeSqliteSupported
|
|
1963
|
+
nodeSqliteSupported,
|
|
1964
|
+
removeSqliteReplicaLayout
|
|
1280
1965
|
};
|
|
1281
1966
|
//# sourceMappingURL=sqlite.js.map
|