@tinycloud/replica 0.2.4-beta.1 → 0.2.4-beta.3
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/dist/browser.d.ts +336 -0
- package/dist/replica.worker.js +1 -1
- package/dist/replica.worker.js.map +1 -1
- package/dist/sqlite.cjs +416 -78
- package/dist/sqlite.cjs.map +1 -1
- package/dist/sqlite.d.cts +5 -0
- package/dist/sqlite.d.ts +5 -0
- package/dist/sqlite.js +419 -81
- package/dist/sqlite.js.map +1 -1
- package/package.json +1 -1
package/dist/sqlite.cjs
CHANGED
|
@@ -228,6 +228,29 @@ async function loadSqlite() {
|
|
|
228
228
|
|
|
229
229
|
// src/sqlite/store.ts
|
|
230
230
|
var SCHEMA_VERSION = 1;
|
|
231
|
+
var sqliteOpenQueue = Promise.resolve();
|
|
232
|
+
async function acquireSqliteOpen() {
|
|
233
|
+
const previous = sqliteOpenQueue;
|
|
234
|
+
let release;
|
|
235
|
+
sqliteOpenQueue = new Promise((resolve2) => {
|
|
236
|
+
release = resolve2;
|
|
237
|
+
});
|
|
238
|
+
await previous;
|
|
239
|
+
return release;
|
|
240
|
+
}
|
|
241
|
+
async function captureNamedPath(path) {
|
|
242
|
+
const names = [];
|
|
243
|
+
for (let current = (0, import_node_path.resolve)(path); ; current = (0, import_node_path.dirname)(current)) {
|
|
244
|
+
names.push(current);
|
|
245
|
+
if ((0, import_node_path.dirname)(current) === current) break;
|
|
246
|
+
}
|
|
247
|
+
const identities = [];
|
|
248
|
+
for (const name of names.reverse()) {
|
|
249
|
+
const stats = await (0, import_promises.lstat)(name, { bigint: true });
|
|
250
|
+
identities.push({ path: name, dev: stats.dev, ino: stats.ino });
|
|
251
|
+
}
|
|
252
|
+
return identities;
|
|
253
|
+
}
|
|
231
254
|
var DURABILITY = "sqlite-wal-synchronous-full+fsynced-blobs";
|
|
232
255
|
var SCHEMA = `
|
|
233
256
|
CREATE TABLE IF NOT EXISTS replica (
|
|
@@ -326,22 +349,78 @@ async function entriesOf(path) {
|
|
|
326
349
|
}
|
|
327
350
|
}
|
|
328
351
|
var FAULTS = /* @__PURE__ */ Symbol("tinycloud.replica.faults");
|
|
352
|
+
var pinnedDirectories = new FinalizationRegistry((fd) => {
|
|
353
|
+
try {
|
|
354
|
+
(0, import_node_fs.closeSync)(fd);
|
|
355
|
+
} catch {
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
var writableStores = /* @__PURE__ */ new Map();
|
|
359
|
+
function releaseWritable(shared) {
|
|
360
|
+
if (shared.references === 0 || --shared.references !== 0) return;
|
|
361
|
+
if (writableStores.get(shared.key) === shared) writableStores.delete(shared.key);
|
|
362
|
+
try {
|
|
363
|
+
shared.db.close();
|
|
364
|
+
} finally {
|
|
365
|
+
if (shared.directoryFd !== void 0) (0, import_node_fs.closeSync)(shared.directoryFd);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
var sharedStores = new FinalizationRegistry((shared) => {
|
|
369
|
+
try {
|
|
370
|
+
releaseWritable(shared);
|
|
371
|
+
} catch {
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
async function acquireWritableMutation(shared) {
|
|
375
|
+
const previous = shared.mutationTail;
|
|
376
|
+
let release;
|
|
377
|
+
shared.mutationTail = new Promise((resolve2) => {
|
|
378
|
+
release = resolve2;
|
|
379
|
+
});
|
|
380
|
+
await previous;
|
|
381
|
+
return release;
|
|
382
|
+
}
|
|
329
383
|
var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
330
384
|
dir;
|
|
331
|
-
#
|
|
385
|
+
#database;
|
|
386
|
+
get #db() {
|
|
387
|
+
if (this.#closed) throw new ReplicaError(ReplicaErrorCode.CLOSED, "The replica store is closed.");
|
|
388
|
+
return this.#database;
|
|
389
|
+
}
|
|
390
|
+
#closed = false;
|
|
391
|
+
#closing = false;
|
|
392
|
+
#closePromise;
|
|
393
|
+
#activeMutations = 0;
|
|
394
|
+
#drained;
|
|
395
|
+
#shared;
|
|
396
|
+
#pinnedDir;
|
|
397
|
+
#directoryFd;
|
|
332
398
|
#holder = `${process.pid}-${(0, import_node_crypto.randomBytes)(6).toString("hex")}`;
|
|
333
399
|
#now;
|
|
334
400
|
#faults;
|
|
335
401
|
#guard;
|
|
336
402
|
/** Identity of the database file this store opened: a deleted-and-recreated replica is a different file. */
|
|
337
403
|
#ino;
|
|
338
|
-
|
|
404
|
+
#dev;
|
|
405
|
+
#namedAncestors;
|
|
406
|
+
constructor(dir, db, identity, options, directoryFd, namedAncestors = [], shared) {
|
|
339
407
|
this.dir = dir;
|
|
340
|
-
this.#
|
|
341
|
-
this.#
|
|
408
|
+
this.#pinnedDir = shared?.pinnedDir ?? (directoryFd !== void 0 && process.platform === "linux" ? `/proc/self/fd/${directoryFd}` : dir);
|
|
409
|
+
this.#directoryFd = directoryFd;
|
|
410
|
+
this.#shared = shared;
|
|
411
|
+
if (shared) {
|
|
412
|
+
shared.references++;
|
|
413
|
+
sharedStores.register(this, shared, this);
|
|
414
|
+
} else if (directoryFd !== void 0) {
|
|
415
|
+
pinnedDirectories.register(this, directoryFd, this);
|
|
416
|
+
}
|
|
417
|
+
this.#database = db;
|
|
418
|
+
this.#ino = identity.ino;
|
|
419
|
+
this.#dev = identity.dev;
|
|
342
420
|
this.#now = options.now ?? Date.now;
|
|
343
421
|
this.#faults = options[FAULTS] ?? {};
|
|
344
422
|
this.#guard = options.guard ?? ((section) => section());
|
|
423
|
+
this.#namedAncestors = namedAncestors;
|
|
345
424
|
}
|
|
346
425
|
/**
|
|
347
426
|
* Read-only status snapshot; does not create a replica, migrate, recover
|
|
@@ -354,9 +433,10 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
354
433
|
};
|
|
355
434
|
checkAbort();
|
|
356
435
|
const dbPath = (0, import_node_path.join)(dir, "replica.db");
|
|
357
|
-
let
|
|
436
|
+
let identity;
|
|
358
437
|
try {
|
|
359
|
-
|
|
438
|
+
identity = await (0, import_promises.lstat)(dbPath);
|
|
439
|
+
if (!identity.isFile()) return null;
|
|
360
440
|
} catch (error) {
|
|
361
441
|
if (errnoOf(error) === "ENOENT") return null;
|
|
362
442
|
throw storageError(error, "Inspecting the replica");
|
|
@@ -369,14 +449,14 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
369
449
|
db = opener(dbPath, { readonly: true });
|
|
370
450
|
db.exec("PRAGMA busy_timeout = 0");
|
|
371
451
|
checkAbort();
|
|
372
|
-
const store = new _SqliteReplicaStore(dir, db,
|
|
452
|
+
const store = new _SqliteReplicaStore(dir, db, identity, { create: false, ...options.now ? { now: options.now } : {} });
|
|
373
453
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
374
454
|
const before = db.get("PRAGMA data_version").data_version;
|
|
375
455
|
const state = await store.open();
|
|
376
456
|
checkAbort();
|
|
377
457
|
if (state === null) return null;
|
|
378
458
|
const status = await store.status();
|
|
379
|
-
await new Promise((
|
|
459
|
+
await new Promise((resolve2) => setImmediate(resolve2));
|
|
380
460
|
checkAbort();
|
|
381
461
|
const after = db.get("PRAGMA data_version").data_version;
|
|
382
462
|
if (before === after) return { state, status };
|
|
@@ -396,95 +476,233 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
396
476
|
* REPLICA_NOT_FOUND and nothing is written to disk.
|
|
397
477
|
*/
|
|
398
478
|
static async open(dir, options) {
|
|
399
|
-
const
|
|
479
|
+
const namedDbPath = (0, import_node_path.join)(dir, "replica.db");
|
|
400
480
|
if (!options.create) {
|
|
401
|
-
const exists = await (0, import_promises.
|
|
402
|
-
() =>
|
|
481
|
+
const exists = await (0, import_promises.lstat)(namedDbPath).then(
|
|
482
|
+
(stats) => stats.isFile(),
|
|
403
483
|
() => false
|
|
404
484
|
);
|
|
405
|
-
if (!exists) throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, `No replica at ${dir}.`);
|
|
485
|
+
if (!exists) throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, `No regular replica database at ${dir}.`);
|
|
406
486
|
}
|
|
407
|
-
const
|
|
487
|
+
const releaseOpen = await acquireSqliteOpen();
|
|
408
488
|
let store;
|
|
489
|
+
let directoryFd;
|
|
409
490
|
try {
|
|
410
|
-
if (options.create) {
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
const handle = await (0, import_promises.open)(dbPath, import_node_fs.constants.O_CREAT | import_node_fs.constants.O_RDWR, 384);
|
|
414
|
-
await handle.close();
|
|
415
|
-
await (0, import_promises.chmod)(dbPath, 384);
|
|
491
|
+
if (options.create) await (0, import_promises.mkdir)(dir, { recursive: true, mode: 448 });
|
|
492
|
+
if (process.platform === "linux") {
|
|
493
|
+
directoryFd = (0, import_node_fs.openSync)(dir, import_node_fs.constants.O_RDONLY | import_node_fs.constants.O_DIRECTORY | import_node_fs.constants.O_NOFOLLOW);
|
|
416
494
|
}
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
db.exec("PRAGMA journal_mode = WAL");
|
|
421
|
-
db.exec("PRAGMA synchronous = FULL");
|
|
422
|
-
db.exec("PRAGMA secure_delete = ON");
|
|
423
|
-
db.exec("PRAGMA foreign_keys = ON");
|
|
424
|
-
const version = db.get("PRAGMA user_version")?.user_version ?? 0;
|
|
425
|
-
if (version > SCHEMA_VERSION) {
|
|
426
|
-
db.close();
|
|
427
|
-
throw new ReplicaError(
|
|
428
|
-
ReplicaErrorCode.STORAGE_ERROR,
|
|
429
|
-
`The replica at ${dir} was written by a newer tc (schema ${version}); upgrade tc to read it.`
|
|
430
|
-
);
|
|
495
|
+
const opened = directoryFd === void 0 ? (0, import_node_fs.lstatSync)(dir, { bigint: true }) : (0, import_node_fs.fstatSync)(directoryFd, { bigint: true });
|
|
496
|
+
if (!opened.isDirectory() || opened.isSymbolicLink() || options.expectedDirectory && (opened.dev !== BigInt(options.expectedDirectory.dev) || opened.ino !== BigInt(options.expectedDirectory.ino))) {
|
|
497
|
+
throw new Error("The replica directory changed before SQLite opened it.");
|
|
431
498
|
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
499
|
+
const namedAncestors = process.platform === "linux" ? [] : await captureNamedPath(dir);
|
|
500
|
+
if (namedAncestors.length && (namedAncestors[namedAncestors.length - 1].dev !== opened.dev || namedAncestors[namedAncestors.length - 1].ino !== opened.ino)) {
|
|
501
|
+
throw new Error("The replica directory changed while checking its ancestry.");
|
|
502
|
+
}
|
|
503
|
+
const pinnedDir = directoryFd === void 0 ? dir : `/proc/self/fd/${directoryFd}`;
|
|
504
|
+
const dbPath = (0, import_node_path.join)(pinnedDir, "replica.db");
|
|
505
|
+
if (options.create) {
|
|
506
|
+
await (0, import_promises.mkdir)((0, import_node_path.join)(pinnedDir, "blobs", ".tmp"), { recursive: true, mode: 448 });
|
|
507
|
+
await Promise.all([(0, import_promises.chmod)(pinnedDir, 448), (0, import_promises.chmod)((0, import_node_path.join)(pinnedDir, "blobs"), 448), (0, import_promises.chmod)((0, import_node_path.join)(pinnedDir, "blobs", ".tmp"), 448)]);
|
|
508
|
+
const handle = await (0, import_promises.open)(dbPath, import_node_fs.constants.O_CREAT | import_node_fs.constants.O_EXCL | import_node_fs.constants.O_RDWR | import_node_fs.constants.O_NOFOLLOW, 384).catch((error) => {
|
|
509
|
+
if (errnoOf(error) === "EEXIST") return null;
|
|
510
|
+
throw error;
|
|
511
|
+
});
|
|
512
|
+
if (handle) {
|
|
513
|
+
try {
|
|
514
|
+
await handle.chmod(384);
|
|
515
|
+
} finally {
|
|
516
|
+
await handle.close();
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
const leaf = await (0, import_promises.open)(dbPath, import_node_fs.constants.O_RDONLY | import_node_fs.constants.O_NOFOLLOW);
|
|
521
|
+
let db;
|
|
522
|
+
try {
|
|
523
|
+
const identity = await leaf.stat();
|
|
524
|
+
const named = await (0, import_promises.lstat)(dbPath);
|
|
525
|
+
if (!identity.isFile() || !named.isFile() || named.dev !== identity.dev || named.ino !== identity.ino) {
|
|
526
|
+
throw new Error("The replica database changed before SQLite opened it.");
|
|
527
|
+
}
|
|
528
|
+
const key = `${opened.dev}:${opened.ino}`;
|
|
529
|
+
const active = writableStores.get(key);
|
|
530
|
+
if (active && (active.dev !== identity.dev || active.ino !== identity.ino)) {
|
|
531
|
+
throw new Error("The replica database changed while another controller holds it open.");
|
|
532
|
+
}
|
|
533
|
+
if (active && !options.sqlite) {
|
|
534
|
+
const namedDirectory = (0, import_node_fs.lstatSync)(dir, { bigint: true });
|
|
535
|
+
if (!namedDirectory.isDirectory() || namedDirectory.dev !== opened.dev || namedDirectory.ino !== opened.ino) {
|
|
536
|
+
throw new Error("The replica directory changed before its checked SQLite handle could be shared.");
|
|
537
|
+
}
|
|
538
|
+
const pinned = active.directoryFd === void 0 ? (0, import_node_fs.lstatSync)(active.pinnedDir, { bigint: true }) : (0, import_node_fs.fstatSync)(active.directoryFd, { bigint: true });
|
|
539
|
+
const activeLeaf = (0, import_node_fs.lstatSync)((0, import_node_path.join)(active.pinnedDir, "replica.db"));
|
|
540
|
+
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) {
|
|
541
|
+
throw new Error("The live replica changed before its checked SQLite handle could be shared.");
|
|
542
|
+
}
|
|
543
|
+
if (directoryFd !== void 0) {
|
|
544
|
+
(0, import_node_fs.closeSync)(directoryFd);
|
|
545
|
+
directoryFd = void 0;
|
|
546
|
+
}
|
|
547
|
+
store = new _SqliteReplicaStore(dir, active.db, identity, options, active.directoryFd, namedAncestors, active);
|
|
548
|
+
}
|
|
549
|
+
if (!store) {
|
|
550
|
+
const opener = options.sqlite ?? await loadSqlite();
|
|
551
|
+
const previousFds = process.platform === "linux" && !options.sqlite ? new Map((0, import_node_fs.readdirSync)("/proc/self/fd").map((fd) => {
|
|
552
|
+
try {
|
|
553
|
+
return [fd, (0, import_node_fs.readlinkSync)(`/proc/self/fd/${fd}`)];
|
|
554
|
+
} catch {
|
|
555
|
+
return [fd, ""];
|
|
556
|
+
}
|
|
557
|
+
})) : null;
|
|
558
|
+
db = opener(dbPath);
|
|
559
|
+
const afterOpen = (0, import_node_fs.lstatSync)(dbPath);
|
|
560
|
+
if (!afterOpen.isFile() || afterOpen.dev !== identity.dev || afterOpen.ino !== identity.ino) {
|
|
561
|
+
throw new Error("The replica database changed while SQLite opened it.");
|
|
562
|
+
}
|
|
563
|
+
if (previousFds) db.get("SELECT count(*) FROM sqlite_master");
|
|
564
|
+
if (previousFds) {
|
|
565
|
+
const databases = (0, import_node_fs.readdirSync)("/proc/self/fd").filter((fd) => {
|
|
566
|
+
try {
|
|
567
|
+
const target = (0, import_node_fs.readlinkSync)(`/proc/self/fd/${fd}`);
|
|
568
|
+
return target.endsWith("/replica.db") && previousFds.get(fd) !== target;
|
|
569
|
+
} catch {
|
|
570
|
+
return false;
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
if (databases.length === 0 || databases.some((fd) => {
|
|
574
|
+
try {
|
|
575
|
+
const actual = (0, import_node_fs.fstatSync)(Number(fd));
|
|
576
|
+
return actual.dev !== identity.dev || actual.ino !== identity.ino;
|
|
577
|
+
} catch {
|
|
578
|
+
return true;
|
|
579
|
+
}
|
|
580
|
+
})) {
|
|
581
|
+
throw new Error("SQLite opened a different replica database.");
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
const after = (0, import_node_fs.lstatSync)(dbPath);
|
|
585
|
+
if (!after.isFile() || after.dev !== identity.dev || after.ino !== identity.ino) {
|
|
586
|
+
throw new Error("The replica database changed while SQLite opened it.");
|
|
587
|
+
}
|
|
588
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
589
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
590
|
+
db.exec("PRAGMA synchronous = FULL");
|
|
591
|
+
db.exec("PRAGMA secure_delete = ON");
|
|
592
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
593
|
+
const version = db.get("PRAGMA user_version")?.user_version ?? 0;
|
|
594
|
+
if (version > SCHEMA_VERSION) {
|
|
595
|
+
throw new ReplicaError(
|
|
596
|
+
ReplicaErrorCode.STORAGE_ERROR,
|
|
597
|
+
`The replica at ${dir} was written by a newer tc (schema ${version}); upgrade tc to read it.`
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
if (version < SCHEMA_VERSION) {
|
|
601
|
+
db.exec("BEGIN IMMEDIATE");
|
|
602
|
+
db.exec(SCHEMA);
|
|
603
|
+
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
604
|
+
db.exec("COMMIT");
|
|
605
|
+
}
|
|
606
|
+
const shared = options.sqlite ? void 0 : {
|
|
607
|
+
key,
|
|
608
|
+
db,
|
|
609
|
+
directoryFd,
|
|
610
|
+
pinnedDir,
|
|
611
|
+
namedPath: dir,
|
|
612
|
+
dev: identity.dev,
|
|
613
|
+
ino: identity.ino,
|
|
614
|
+
references: 0,
|
|
615
|
+
mutationTail: Promise.resolve()
|
|
616
|
+
};
|
|
617
|
+
store = new _SqliteReplicaStore(dir, db, identity, options, directoryFd, namedAncestors, shared);
|
|
618
|
+
if (shared) writableStores.set(key, shared);
|
|
619
|
+
db = void 0;
|
|
620
|
+
}
|
|
621
|
+
} finally {
|
|
622
|
+
try {
|
|
623
|
+
db?.close();
|
|
624
|
+
} finally {
|
|
625
|
+
await leaf.close();
|
|
626
|
+
}
|
|
437
627
|
}
|
|
438
|
-
store = new _SqliteReplicaStore(dir, db, ino, options);
|
|
439
628
|
} catch (error) {
|
|
629
|
+
if (store) await store.close().catch(() => void 0);
|
|
630
|
+
else if (directoryFd !== void 0) (0, import_node_fs.closeSync)(directoryFd);
|
|
440
631
|
throw storageError(error, `Opening the replica at ${dir}`);
|
|
632
|
+
} finally {
|
|
633
|
+
releaseOpen();
|
|
441
634
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
await store.#
|
|
635
|
+
try {
|
|
636
|
+
const row = store.#row();
|
|
637
|
+
if (row?.revoked_detail != null && (row.purge_pending === 1 || await store.#holdsContentFiles().catch(() => true))) {
|
|
638
|
+
await store.#finishRevocationPurge().catch(() => void 0);
|
|
639
|
+
}
|
|
640
|
+
return store;
|
|
641
|
+
} catch (error) {
|
|
642
|
+
await store.close().catch(() => void 0);
|
|
643
|
+
throw error;
|
|
445
644
|
}
|
|
446
|
-
return store;
|
|
447
645
|
}
|
|
448
646
|
#row() {
|
|
449
647
|
return this.#db.get("SELECT * FROM replica WHERE id = 1");
|
|
450
648
|
}
|
|
451
649
|
/** Refuse to touch the filesystem for a replica that was deleted (or replaced) under us. */
|
|
452
650
|
async #assertPresent() {
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
() => void 0
|
|
456
|
-
);
|
|
457
|
-
if (ino !== this.#ino) {
|
|
651
|
+
const identity = await (0, import_promises.lstat)((0, import_node_path.join)(this.#pinnedDir, "replica.db")).catch(() => null);
|
|
652
|
+
if (!identity?.isFile() || identity.dev !== this.#dev || identity.ino !== this.#ino) {
|
|
458
653
|
throw new ReplicaError(
|
|
459
654
|
ReplicaErrorCode.NOT_FOUND,
|
|
460
655
|
`The replica at ${this.dir} was deleted while this command ran (its profile was deleted or the replica purged); nothing more was written.`
|
|
461
656
|
);
|
|
462
657
|
}
|
|
463
658
|
}
|
|
659
|
+
/** New calls stop at close; accepted mutation sections may still finish their transactions. */
|
|
660
|
+
#assertAccepting() {
|
|
661
|
+
if (this.#closing || this.#closed) {
|
|
662
|
+
throw new ReplicaError(ReplicaErrorCode.CLOSED, "The replica store is closed.");
|
|
663
|
+
}
|
|
664
|
+
}
|
|
464
665
|
/** A short mutation section: guarded (profile lock) and checked against the database this store opened. */
|
|
465
666
|
#mutate(section) {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
667
|
+
this.#assertAccepting();
|
|
668
|
+
this.#activeMutations++;
|
|
669
|
+
return (async () => {
|
|
670
|
+
try {
|
|
671
|
+
return await this.#guard(async () => {
|
|
672
|
+
await this.#assertPresent();
|
|
673
|
+
return section();
|
|
674
|
+
});
|
|
675
|
+
} finally {
|
|
676
|
+
if (--this.#activeMutations === 0) {
|
|
677
|
+
const drained = this.#drained;
|
|
678
|
+
this.#drained = void 0;
|
|
679
|
+
drained?.();
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
})();
|
|
470
683
|
}
|
|
471
684
|
/** Run `body` in a write transaction (BEGIN IMMEDIATE). Callers outside a mutation section use `#guardedWrite`. */
|
|
472
|
-
#write(action, body) {
|
|
473
|
-
|
|
474
|
-
this.#db.exec("BEGIN IMMEDIATE");
|
|
475
|
-
} catch (error) {
|
|
476
|
-
throw storageError(error, action);
|
|
477
|
-
}
|
|
685
|
+
async #write(action, body) {
|
|
686
|
+
const release = this.#shared ? await acquireWritableMutation(this.#shared) : void 0;
|
|
478
687
|
try {
|
|
479
|
-
const result = body();
|
|
480
|
-
this.#db.exec("COMMIT");
|
|
481
|
-
return result;
|
|
482
|
-
} catch (error) {
|
|
483
688
|
try {
|
|
484
|
-
this.#db.exec("
|
|
485
|
-
} catch {
|
|
689
|
+
this.#db.exec("BEGIN IMMEDIATE");
|
|
690
|
+
} catch (error) {
|
|
691
|
+
throw storageError(error, action);
|
|
692
|
+
}
|
|
693
|
+
try {
|
|
694
|
+
const result = body();
|
|
695
|
+
this.#db.exec("COMMIT");
|
|
696
|
+
return result;
|
|
697
|
+
} catch (error) {
|
|
698
|
+
try {
|
|
699
|
+
this.#db.exec("ROLLBACK");
|
|
700
|
+
} catch {
|
|
701
|
+
}
|
|
702
|
+
throw storageError(error, action);
|
|
486
703
|
}
|
|
487
|
-
|
|
704
|
+
} finally {
|
|
705
|
+
release?.();
|
|
488
706
|
}
|
|
489
707
|
}
|
|
490
708
|
/** One write transaction as its own mutation section. */
|
|
@@ -511,6 +729,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
511
729
|
}
|
|
512
730
|
}
|
|
513
731
|
async open() {
|
|
732
|
+
this.#assertAccepting();
|
|
514
733
|
let row;
|
|
515
734
|
try {
|
|
516
735
|
row = this.#row();
|
|
@@ -672,6 +891,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
672
891
|
});
|
|
673
892
|
}
|
|
674
893
|
async hasContent(hashes) {
|
|
894
|
+
this.#assertAccepting();
|
|
675
895
|
const held = /* @__PURE__ */ new Set();
|
|
676
896
|
for (let start = 0; start < hashes.length; start += 500) {
|
|
677
897
|
const chunk = hashes.slice(start, start + 500);
|
|
@@ -684,7 +904,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
684
904
|
return held;
|
|
685
905
|
}
|
|
686
906
|
#blobPath(hash) {
|
|
687
|
-
return (0, import_node_path.join)(this
|
|
907
|
+
return (0, import_node_path.join)(this.#pinnedDir, "blobs", hash.slice(0, 2), hash);
|
|
688
908
|
}
|
|
689
909
|
/**
|
|
690
910
|
* Make every blob durable before any row references it: write to a fresh
|
|
@@ -701,11 +921,11 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
701
921
|
() => false
|
|
702
922
|
);
|
|
703
923
|
if (exists) continue;
|
|
704
|
-
const shard = (0, import_node_path.join)(this
|
|
924
|
+
const shard = (0, import_node_path.join)(this.#pinnedDir, "blobs", hash.slice(0, 2));
|
|
705
925
|
await (0, import_promises.mkdir)(shard, { mode: 448 }).catch((error) => {
|
|
706
926
|
if (errnoOf(error) !== "EEXIST") throw error;
|
|
707
927
|
});
|
|
708
|
-
const temp = (0, import_node_path.join)(this
|
|
928
|
+
const temp = (0, import_node_path.join)(this.#pinnedDir, "blobs", ".tmp", `${hash}.${(0, import_node_crypto.randomBytes)(6).toString("hex")}`);
|
|
709
929
|
const handle = await (0, import_promises.open)(temp, import_node_fs.constants.O_CREAT | import_node_fs.constants.O_EXCL | import_node_fs.constants.O_WRONLY, 384);
|
|
710
930
|
try {
|
|
711
931
|
await handle.writeFile(bytes);
|
|
@@ -721,7 +941,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
721
941
|
await (0, import_promises.unlink)(temp);
|
|
722
942
|
}
|
|
723
943
|
dirs.add(shard);
|
|
724
|
-
dirs.add((0, import_node_path.join)(this
|
|
944
|
+
dirs.add((0, import_node_path.join)(this.#pinnedDir, "blobs"));
|
|
725
945
|
}
|
|
726
946
|
for (const dir of dirs) {
|
|
727
947
|
const handle = await (0, import_promises.open)(dir, import_node_fs.constants.O_RDONLY);
|
|
@@ -754,7 +974,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
754
974
|
}
|
|
755
975
|
await this.#faults.afterBlobs?.();
|
|
756
976
|
try {
|
|
757
|
-
this.#write("Committing a sync page", () => {
|
|
977
|
+
await this.#write("Committing a sync page", () => {
|
|
758
978
|
this.#checkLease(t);
|
|
759
979
|
const now = this.#now();
|
|
760
980
|
if (p.window.notBefore !== null && now < Date.parse(p.window.notBefore)) {
|
|
@@ -829,7 +1049,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
829
1049
|
/** Unlink every blob not in `keep`, and every temp file. Only a missing directory counts as empty. */
|
|
830
1050
|
async #unlinkBlobs(keep) {
|
|
831
1051
|
let removed = 0;
|
|
832
|
-
const root = (0, import_node_path.join)(this
|
|
1052
|
+
const root = (0, import_node_path.join)(this.#pinnedDir, "blobs");
|
|
833
1053
|
for (const shard of await entriesOf(root)) {
|
|
834
1054
|
const shardPath = (0, import_node_path.join)(root, shard);
|
|
835
1055
|
if (shard === ".tmp") {
|
|
@@ -854,7 +1074,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
854
1074
|
}
|
|
855
1075
|
/** Whether any content file (a blob or a temp file) is on disk. */
|
|
856
1076
|
async #holdsContentFiles() {
|
|
857
|
-
const root = (0, import_node_path.join)(this
|
|
1077
|
+
const root = (0, import_node_path.join)(this.#pinnedDir, "blobs");
|
|
858
1078
|
for (const shard of await entriesOf(root)) {
|
|
859
1079
|
if ((await entriesOf((0, import_node_path.join)(root, shard))).length > 0) return true;
|
|
860
1080
|
}
|
|
@@ -862,13 +1082,16 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
862
1082
|
}
|
|
863
1083
|
async collectGarbage(t) {
|
|
864
1084
|
return this.#mutate(async () => {
|
|
1085
|
+
const release = this.#shared ? await acquireWritableMutation(this.#shared) : void 0;
|
|
865
1086
|
try {
|
|
866
1087
|
this.#db.exec("BEGIN IMMEDIATE");
|
|
867
1088
|
} catch (error) {
|
|
1089
|
+
release?.();
|
|
868
1090
|
throw storageError(error, "Collecting unused content");
|
|
869
1091
|
}
|
|
870
1092
|
try {
|
|
871
1093
|
this.#checkLease(t);
|
|
1094
|
+
await this.#faults.afterGarbageCollectionBegin?.();
|
|
872
1095
|
const keep = new Set(
|
|
873
1096
|
this.#db.all("SELECT DISTINCT hash FROM entry WHERE content = 1").map((row) => row.hash)
|
|
874
1097
|
);
|
|
@@ -881,12 +1104,14 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
881
1104
|
} catch {
|
|
882
1105
|
}
|
|
883
1106
|
throw storageError(error, "Collecting unused content");
|
|
1107
|
+
} finally {
|
|
1108
|
+
release?.();
|
|
884
1109
|
}
|
|
885
1110
|
});
|
|
886
1111
|
}
|
|
887
1112
|
async reset(t, reason, options = {}) {
|
|
888
1113
|
await this.#mutate(async () => {
|
|
889
|
-
this.#write("Resetting the replica", () => {
|
|
1114
|
+
await this.#write("Resetting the replica", () => {
|
|
890
1115
|
this.#checkLease(t);
|
|
891
1116
|
this.#db.run("DELETE FROM entry");
|
|
892
1117
|
this.#db.run(
|
|
@@ -913,15 +1138,102 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
913
1138
|
*/
|
|
914
1139
|
async destroy(t) {
|
|
915
1140
|
await this.#mutate(async () => {
|
|
1141
|
+
const release = this.#shared ? await acquireWritableMutation(this.#shared) : void 0;
|
|
916
1142
|
try {
|
|
917
1143
|
this.#db.exec("BEGIN IMMEDIATE");
|
|
918
1144
|
} catch (error) {
|
|
1145
|
+
release?.();
|
|
919
1146
|
throw storageError(error, "Removing the replica");
|
|
920
1147
|
}
|
|
921
1148
|
try {
|
|
922
1149
|
this.#checkLease(t, { allowRevoked: true });
|
|
1150
|
+
const expected = this.#directoryFd === void 0 ? this.#namedAncestors.at(-1) : (0, import_node_fs.fstatSync)(this.#directoryFd, { bigint: true });
|
|
1151
|
+
if (!expected) {
|
|
1152
|
+
throw new ReplicaError(ReplicaErrorCode.RUNTIME_UNSUPPORTED, "Replica removal requires an opened or identity-checked directory.");
|
|
1153
|
+
}
|
|
1154
|
+
const assertDirectory = async () => {
|
|
1155
|
+
const current = await (0, import_promises.lstat)(this.dir, { bigint: true });
|
|
1156
|
+
if (!current.isDirectory() || current.dev !== expected.dev || current.ino !== expected.ino) {
|
|
1157
|
+
throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica directory changed during removal.");
|
|
1158
|
+
}
|
|
1159
|
+
return current;
|
|
1160
|
+
};
|
|
1161
|
+
const pinned = await assertDirectory();
|
|
923
1162
|
await this.#assertPresent();
|
|
924
|
-
|
|
1163
|
+
if (process.platform === "linux") {
|
|
1164
|
+
for (const entry of await (0, import_promises.readdir)(this.#pinnedDir)) {
|
|
1165
|
+
await (0, import_promises.rm)((0, import_node_path.join)(this.#pinnedDir, entry), { recursive: true, force: true });
|
|
1166
|
+
}
|
|
1167
|
+
await assertDirectory();
|
|
1168
|
+
await (0, import_promises.rmdir)(this.dir);
|
|
1169
|
+
} else {
|
|
1170
|
+
const ancestors = this.#namedAncestors;
|
|
1171
|
+
const checkAncestors = async () => {
|
|
1172
|
+
for (const expected2 of ancestors) {
|
|
1173
|
+
const current = await (0, import_promises.lstat)(expected2.path, { bigint: true });
|
|
1174
|
+
if (current.dev !== expected2.dev || current.ino !== expected2.ino) {
|
|
1175
|
+
throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica ancestry changed during removal.");
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
};
|
|
1179
|
+
const directories = /* @__PURE__ */ new Map([[this.dir, pinned]]);
|
|
1180
|
+
const files = /* @__PURE__ */ new Map();
|
|
1181
|
+
const check = async (path, expected2, directory) => {
|
|
1182
|
+
if (path !== this.dir) {
|
|
1183
|
+
const parent = (0, import_node_path.dirname)(path);
|
|
1184
|
+
await check(parent, directories.get(parent), true);
|
|
1185
|
+
}
|
|
1186
|
+
await checkAncestors();
|
|
1187
|
+
const current = await (0, import_promises.lstat)(path, { bigint: true });
|
|
1188
|
+
if ((directory ? !current.isDirectory() : !current.isFile()) || current.dev !== expected2.dev || current.ino !== expected2.ino) {
|
|
1189
|
+
throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica layout changed during removal.");
|
|
1190
|
+
}
|
|
1191
|
+
};
|
|
1192
|
+
const record = async (path, directory) => {
|
|
1193
|
+
const parent = (0, import_node_path.dirname)(path);
|
|
1194
|
+
await check(parent, directories.get(parent), true);
|
|
1195
|
+
const current = await (0, import_promises.lstat)(path, { bigint: true });
|
|
1196
|
+
if (!(directory ? current.isDirectory() : current.isFile())) {
|
|
1197
|
+
throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "Unexpected replica layout during removal.");
|
|
1198
|
+
}
|
|
1199
|
+
(directory ? directories : files).set(path, current);
|
|
1200
|
+
};
|
|
1201
|
+
const blobs = (0, import_node_path.join)(this.dir, "blobs");
|
|
1202
|
+
for (const name of await (0, import_promises.readdir)(this.dir)) {
|
|
1203
|
+
if (name === "blobs") await record(blobs, true);
|
|
1204
|
+
else if (["replica.db", "replica.db-wal", "replica.db-shm"].includes(name)) await record((0, import_node_path.join)(this.dir, name), false);
|
|
1205
|
+
else throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "Unexpected replica layout during removal.");
|
|
1206
|
+
}
|
|
1207
|
+
if (directories.has(blobs)) {
|
|
1208
|
+
await check(blobs, directories.get(blobs), true);
|
|
1209
|
+
for (const shard of await (0, import_promises.readdir)(blobs)) {
|
|
1210
|
+
if (shard !== ".tmp" && !/^[0-9a-f]{2}$/.test(shard)) {
|
|
1211
|
+
throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "Unexpected replica blob shard during removal.");
|
|
1212
|
+
}
|
|
1213
|
+
const shardPath = (0, import_node_path.join)(blobs, shard);
|
|
1214
|
+
await record(shardPath, true);
|
|
1215
|
+
await check(shardPath, directories.get(shardPath), true);
|
|
1216
|
+
for (const name of await (0, import_promises.readdir)(shardPath)) {
|
|
1217
|
+
if (shard === ".tmp" ? !/^[0-9a-f]{64}\.[0-9a-f]{12}$/.test(name) : !isHash(name) || !name.startsWith(shard)) {
|
|
1218
|
+
throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "Unexpected replica blob file during removal.");
|
|
1219
|
+
}
|
|
1220
|
+
await record((0, import_node_path.join)(shardPath, name), false);
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
for (const [path, identity] of files) {
|
|
1225
|
+
await check(path, identity, false);
|
|
1226
|
+
await (0, import_promises.unlink)(path);
|
|
1227
|
+
}
|
|
1228
|
+
for (const [path, identity] of [...directories].reverse()) {
|
|
1229
|
+
if (path === this.dir) continue;
|
|
1230
|
+
await check(path, identity, true);
|
|
1231
|
+
await (0, import_promises.rmdir)(path);
|
|
1232
|
+
}
|
|
1233
|
+
await assertDirectory();
|
|
1234
|
+
await checkAncestors();
|
|
1235
|
+
await (0, import_promises.rmdir)(this.dir);
|
|
1236
|
+
}
|
|
925
1237
|
} catch (error) {
|
|
926
1238
|
throw storageError(error, "Removing the replica");
|
|
927
1239
|
} finally {
|
|
@@ -929,6 +1241,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
929
1241
|
this.#db.exec("ROLLBACK");
|
|
930
1242
|
} catch {
|
|
931
1243
|
}
|
|
1244
|
+
release?.();
|
|
932
1245
|
}
|
|
933
1246
|
});
|
|
934
1247
|
}
|
|
@@ -972,7 +1285,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
972
1285
|
* when both are done.
|
|
973
1286
|
*/
|
|
974
1287
|
async #purgeContent() {
|
|
975
|
-
this.#write("Starting the revocation purge", () => {
|
|
1288
|
+
await this.#write("Starting the revocation purge", () => {
|
|
976
1289
|
this.#db.run("UPDATE replica SET purge_pending = 1 WHERE id = 1 AND revoked_detail IS NOT NULL");
|
|
977
1290
|
});
|
|
978
1291
|
try {
|
|
@@ -983,7 +1296,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
983
1296
|
if (!this.#checkpoint()) {
|
|
984
1297
|
throw new ReplicaError(ReplicaErrorCode.BUSY, "A reader holds the replica's write-ahead log; the purge finishes on the next open.");
|
|
985
1298
|
}
|
|
986
|
-
this.#write("Finishing the revocation purge", () => {
|
|
1299
|
+
await this.#write("Finishing the revocation purge", () => {
|
|
987
1300
|
this.#db.run("UPDATE replica SET purge_pending = 0 WHERE id = 1 AND revoked_detail IS NOT NULL");
|
|
988
1301
|
});
|
|
989
1302
|
}
|
|
@@ -993,6 +1306,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
993
1306
|
});
|
|
994
1307
|
}
|
|
995
1308
|
async get(key) {
|
|
1309
|
+
this.#assertAccepting();
|
|
996
1310
|
try {
|
|
997
1311
|
const row = this.#db.get("SELECT key, deleted, etag, hash, metadata, content FROM entry WHERE key = ?", key);
|
|
998
1312
|
return row === void 0 ? void 0 : entryOf(row);
|
|
@@ -1001,6 +1315,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
1001
1315
|
}
|
|
1002
1316
|
}
|
|
1003
1317
|
async list(o) {
|
|
1318
|
+
this.#assertAccepting();
|
|
1004
1319
|
const where = ["deleted = 0"];
|
|
1005
1320
|
const params = [];
|
|
1006
1321
|
if (o.prefix !== void 0 && o.prefix !== "") {
|
|
@@ -1086,8 +1401,31 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
1086
1401
|
lastReset: state.lastReset
|
|
1087
1402
|
};
|
|
1088
1403
|
}
|
|
1089
|
-
|
|
1090
|
-
this.#
|
|
1404
|
+
close() {
|
|
1405
|
+
if (this.#closePromise) return this.#closePromise;
|
|
1406
|
+
this.#closing = true;
|
|
1407
|
+
this.#closePromise = (async () => {
|
|
1408
|
+
if (this.#activeMutations !== 0) {
|
|
1409
|
+
await new Promise((resolve2) => {
|
|
1410
|
+
this.#drained = resolve2;
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
this.#closed = true;
|
|
1414
|
+
const fd = this.#directoryFd;
|
|
1415
|
+
this.#directoryFd = void 0;
|
|
1416
|
+
if (this.#shared) {
|
|
1417
|
+
sharedStores.unregister(this);
|
|
1418
|
+
releaseWritable(this.#shared);
|
|
1419
|
+
} else {
|
|
1420
|
+
if (fd !== void 0) pinnedDirectories.unregister(this);
|
|
1421
|
+
try {
|
|
1422
|
+
this.#database.close();
|
|
1423
|
+
} finally {
|
|
1424
|
+
if (fd !== void 0) (0, import_node_fs.closeSync)(fd);
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
})();
|
|
1428
|
+
return this.#closePromise;
|
|
1091
1429
|
}
|
|
1092
1430
|
};
|
|
1093
1431
|
// Annotate the CommonJS export names for ESM import in node:
|