@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.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// src/sqlite/store.ts
|
|
2
2
|
import { randomBytes } from "crypto";
|
|
3
|
-
import { constants as fsConstants } from "fs";
|
|
4
|
-
import { chmod, link, mkdir, open, readFile, readdir, rm, rmdir, stat, unlink } from "fs/promises";
|
|
5
|
-
import { join } from "path";
|
|
3
|
+
import { closeSync, constants as fsConstants, fstatSync, lstatSync, openSync, readdirSync, readlinkSync } from "fs";
|
|
4
|
+
import { chmod, link, lstat, mkdir, open, readFile, readdir, rm, rmdir, stat, unlink } from "fs/promises";
|
|
5
|
+
import { dirname, join, resolve } from "path";
|
|
6
6
|
|
|
7
7
|
// src/errors.ts
|
|
8
8
|
var ReplicaErrorCode = {
|
|
@@ -199,6 +199,29 @@ async function loadSqlite() {
|
|
|
199
199
|
|
|
200
200
|
// src/sqlite/store.ts
|
|
201
201
|
var SCHEMA_VERSION = 1;
|
|
202
|
+
var sqliteOpenQueue = Promise.resolve();
|
|
203
|
+
async function acquireSqliteOpen() {
|
|
204
|
+
const previous = sqliteOpenQueue;
|
|
205
|
+
let release;
|
|
206
|
+
sqliteOpenQueue = new Promise((resolve2) => {
|
|
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
|
+
}
|
|
202
225
|
var DURABILITY = "sqlite-wal-synchronous-full+fsynced-blobs";
|
|
203
226
|
var SCHEMA = `
|
|
204
227
|
CREATE TABLE IF NOT EXISTS replica (
|
|
@@ -297,22 +320,78 @@ async function entriesOf(path) {
|
|
|
297
320
|
}
|
|
298
321
|
}
|
|
299
322
|
var FAULTS = /* @__PURE__ */ Symbol("tinycloud.replica.faults");
|
|
323
|
+
var pinnedDirectories = new FinalizationRegistry((fd) => {
|
|
324
|
+
try {
|
|
325
|
+
closeSync(fd);
|
|
326
|
+
} catch {
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
var writableStores = /* @__PURE__ */ new Map();
|
|
330
|
+
function releaseWritable(shared) {
|
|
331
|
+
if (shared.references === 0 || --shared.references !== 0) return;
|
|
332
|
+
if (writableStores.get(shared.key) === shared) writableStores.delete(shared.key);
|
|
333
|
+
try {
|
|
334
|
+
shared.db.close();
|
|
335
|
+
} finally {
|
|
336
|
+
if (shared.directoryFd !== void 0) closeSync(shared.directoryFd);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
var sharedStores = new FinalizationRegistry((shared) => {
|
|
340
|
+
try {
|
|
341
|
+
releaseWritable(shared);
|
|
342
|
+
} catch {
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
async function acquireWritableMutation(shared) {
|
|
346
|
+
const previous = shared.mutationTail;
|
|
347
|
+
let release;
|
|
348
|
+
shared.mutationTail = new Promise((resolve2) => {
|
|
349
|
+
release = resolve2;
|
|
350
|
+
});
|
|
351
|
+
await previous;
|
|
352
|
+
return release;
|
|
353
|
+
}
|
|
300
354
|
var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
301
355
|
dir;
|
|
302
|
-
#
|
|
356
|
+
#database;
|
|
357
|
+
get #db() {
|
|
358
|
+
if (this.#closed) throw new ReplicaError(ReplicaErrorCode.CLOSED, "The replica store is closed.");
|
|
359
|
+
return this.#database;
|
|
360
|
+
}
|
|
361
|
+
#closed = false;
|
|
362
|
+
#closing = false;
|
|
363
|
+
#closePromise;
|
|
364
|
+
#activeMutations = 0;
|
|
365
|
+
#drained;
|
|
366
|
+
#shared;
|
|
367
|
+
#pinnedDir;
|
|
368
|
+
#directoryFd;
|
|
303
369
|
#holder = `${process.pid}-${randomBytes(6).toString("hex")}`;
|
|
304
370
|
#now;
|
|
305
371
|
#faults;
|
|
306
372
|
#guard;
|
|
307
373
|
/** Identity of the database file this store opened: a deleted-and-recreated replica is a different file. */
|
|
308
374
|
#ino;
|
|
309
|
-
|
|
375
|
+
#dev;
|
|
376
|
+
#namedAncestors;
|
|
377
|
+
constructor(dir, db, identity, options, directoryFd, namedAncestors = [], shared) {
|
|
310
378
|
this.dir = dir;
|
|
311
|
-
this.#
|
|
312
|
-
this.#
|
|
379
|
+
this.#pinnedDir = shared?.pinnedDir ?? (directoryFd !== void 0 && process.platform === "linux" ? `/proc/self/fd/${directoryFd}` : dir);
|
|
380
|
+
this.#directoryFd = directoryFd;
|
|
381
|
+
this.#shared = shared;
|
|
382
|
+
if (shared) {
|
|
383
|
+
shared.references++;
|
|
384
|
+
sharedStores.register(this, shared, this);
|
|
385
|
+
} else if (directoryFd !== void 0) {
|
|
386
|
+
pinnedDirectories.register(this, directoryFd, this);
|
|
387
|
+
}
|
|
388
|
+
this.#database = db;
|
|
389
|
+
this.#ino = identity.ino;
|
|
390
|
+
this.#dev = identity.dev;
|
|
313
391
|
this.#now = options.now ?? Date.now;
|
|
314
392
|
this.#faults = options[FAULTS] ?? {};
|
|
315
393
|
this.#guard = options.guard ?? ((section) => section());
|
|
394
|
+
this.#namedAncestors = namedAncestors;
|
|
316
395
|
}
|
|
317
396
|
/**
|
|
318
397
|
* Read-only status snapshot; does not create a replica, migrate, recover
|
|
@@ -325,9 +404,10 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
325
404
|
};
|
|
326
405
|
checkAbort();
|
|
327
406
|
const dbPath = join(dir, "replica.db");
|
|
328
|
-
let
|
|
407
|
+
let identity;
|
|
329
408
|
try {
|
|
330
|
-
|
|
409
|
+
identity = await lstat(dbPath);
|
|
410
|
+
if (!identity.isFile()) return null;
|
|
331
411
|
} catch (error) {
|
|
332
412
|
if (errnoOf(error) === "ENOENT") return null;
|
|
333
413
|
throw storageError(error, "Inspecting the replica");
|
|
@@ -340,14 +420,14 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
340
420
|
db = opener(dbPath, { readonly: true });
|
|
341
421
|
db.exec("PRAGMA busy_timeout = 0");
|
|
342
422
|
checkAbort();
|
|
343
|
-
const store = new _SqliteReplicaStore(dir, db,
|
|
423
|
+
const store = new _SqliteReplicaStore(dir, db, identity, { create: false, ...options.now ? { now: options.now } : {} });
|
|
344
424
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
345
425
|
const before = db.get("PRAGMA data_version").data_version;
|
|
346
426
|
const state = await store.open();
|
|
347
427
|
checkAbort();
|
|
348
428
|
if (state === null) return null;
|
|
349
429
|
const status = await store.status();
|
|
350
|
-
await new Promise((
|
|
430
|
+
await new Promise((resolve2) => setImmediate(resolve2));
|
|
351
431
|
checkAbort();
|
|
352
432
|
const after = db.get("PRAGMA data_version").data_version;
|
|
353
433
|
if (before === after) return { state, status };
|
|
@@ -367,95 +447,233 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
367
447
|
* REPLICA_NOT_FOUND and nothing is written to disk.
|
|
368
448
|
*/
|
|
369
449
|
static async open(dir, options) {
|
|
370
|
-
const
|
|
450
|
+
const namedDbPath = join(dir, "replica.db");
|
|
371
451
|
if (!options.create) {
|
|
372
|
-
const exists = await
|
|
373
|
-
() =>
|
|
452
|
+
const exists = await lstat(namedDbPath).then(
|
|
453
|
+
(stats) => stats.isFile(),
|
|
374
454
|
() => false
|
|
375
455
|
);
|
|
376
|
-
if (!exists) throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, `No replica at ${dir}.`);
|
|
456
|
+
if (!exists) throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, `No regular replica database at ${dir}.`);
|
|
377
457
|
}
|
|
378
|
-
const
|
|
458
|
+
const releaseOpen = await acquireSqliteOpen();
|
|
379
459
|
let store;
|
|
460
|
+
let directoryFd;
|
|
380
461
|
try {
|
|
381
|
-
if (options.create) {
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
const handle = await open(dbPath, fsConstants.O_CREAT | fsConstants.O_RDWR, 384);
|
|
385
|
-
await handle.close();
|
|
386
|
-
await chmod(dbPath, 384);
|
|
462
|
+
if (options.create) await mkdir(dir, { recursive: true, mode: 448 });
|
|
463
|
+
if (process.platform === "linux") {
|
|
464
|
+
directoryFd = openSync(dir, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW);
|
|
387
465
|
}
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
)
|
|
466
|
+
const opened = directoryFd === void 0 ? lstatSync(dir, { bigint: true }) : fstatSync(directoryFd, { bigint: true });
|
|
467
|
+
if (!opened.isDirectory() || opened.isSymbolicLink() || options.expectedDirectory && (opened.dev !== BigInt(options.expectedDirectory.dev) || opened.ino !== BigInt(options.expectedDirectory.ino))) {
|
|
468
|
+
throw new Error("The replica directory changed before SQLite opened it.");
|
|
469
|
+
}
|
|
470
|
+
const namedAncestors = process.platform === "linux" ? [] : await captureNamedPath(dir);
|
|
471
|
+
if (namedAncestors.length && (namedAncestors[namedAncestors.length - 1].dev !== opened.dev || namedAncestors[namedAncestors.length - 1].ino !== opened.ino)) {
|
|
472
|
+
throw new Error("The replica directory changed while checking its ancestry.");
|
|
473
|
+
}
|
|
474
|
+
const pinnedDir = directoryFd === void 0 ? dir : `/proc/self/fd/${directoryFd}`;
|
|
475
|
+
const dbPath = join(pinnedDir, "replica.db");
|
|
476
|
+
if (options.create) {
|
|
477
|
+
await mkdir(join(pinnedDir, "blobs", ".tmp"), { recursive: true, mode: 448 });
|
|
478
|
+
await Promise.all([chmod(pinnedDir, 448), chmod(join(pinnedDir, "blobs"), 448), chmod(join(pinnedDir, "blobs", ".tmp"), 448)]);
|
|
479
|
+
const handle = await open(dbPath, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_RDWR | fsConstants.O_NOFOLLOW, 384).catch((error) => {
|
|
480
|
+
if (errnoOf(error) === "EEXIST") return null;
|
|
481
|
+
throw error;
|
|
482
|
+
});
|
|
483
|
+
if (handle) {
|
|
484
|
+
try {
|
|
485
|
+
await handle.chmod(384);
|
|
486
|
+
} finally {
|
|
487
|
+
await handle.close();
|
|
488
|
+
}
|
|
489
|
+
}
|
|
402
490
|
}
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
491
|
+
const leaf = await open(dbPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
492
|
+
let db;
|
|
493
|
+
try {
|
|
494
|
+
const identity = await leaf.stat();
|
|
495
|
+
const named = await lstat(dbPath);
|
|
496
|
+
if (!identity.isFile() || !named.isFile() || named.dev !== identity.dev || named.ino !== identity.ino) {
|
|
497
|
+
throw new Error("The replica database changed before SQLite opened it.");
|
|
498
|
+
}
|
|
499
|
+
const key = `${opened.dev}:${opened.ino}`;
|
|
500
|
+
const active = writableStores.get(key);
|
|
501
|
+
if (active && (active.dev !== identity.dev || active.ino !== identity.ino)) {
|
|
502
|
+
throw new Error("The replica database changed while another controller holds it open.");
|
|
503
|
+
}
|
|
504
|
+
if (active && !options.sqlite) {
|
|
505
|
+
const namedDirectory = lstatSync(dir, { bigint: true });
|
|
506
|
+
if (!namedDirectory.isDirectory() || namedDirectory.dev !== opened.dev || namedDirectory.ino !== opened.ino) {
|
|
507
|
+
throw new Error("The replica directory changed before its checked SQLite handle could be shared.");
|
|
508
|
+
}
|
|
509
|
+
const pinned = active.directoryFd === void 0 ? lstatSync(active.pinnedDir, { bigint: true }) : fstatSync(active.directoryFd, { bigint: true });
|
|
510
|
+
const activeLeaf = lstatSync(join(active.pinnedDir, "replica.db"));
|
|
511
|
+
if (!pinned.isDirectory() || pinned.dev !== opened.dev || pinned.ino !== opened.ino || !activeLeaf.isFile() || activeLeaf.dev !== identity.dev || activeLeaf.ino !== identity.ino || active.directoryFd === void 0 && active.namedPath !== dir) {
|
|
512
|
+
throw new Error("The live replica changed before its checked SQLite handle could be shared.");
|
|
513
|
+
}
|
|
514
|
+
if (directoryFd !== void 0) {
|
|
515
|
+
closeSync(directoryFd);
|
|
516
|
+
directoryFd = void 0;
|
|
517
|
+
}
|
|
518
|
+
store = new _SqliteReplicaStore(dir, active.db, identity, options, active.directoryFd, namedAncestors, active);
|
|
519
|
+
}
|
|
520
|
+
if (!store) {
|
|
521
|
+
const opener = options.sqlite ?? await loadSqlite();
|
|
522
|
+
const previousFds = process.platform === "linux" && !options.sqlite ? new Map(readdirSync("/proc/self/fd").map((fd) => {
|
|
523
|
+
try {
|
|
524
|
+
return [fd, readlinkSync(`/proc/self/fd/${fd}`)];
|
|
525
|
+
} catch {
|
|
526
|
+
return [fd, ""];
|
|
527
|
+
}
|
|
528
|
+
})) : null;
|
|
529
|
+
db = opener(dbPath);
|
|
530
|
+
const afterOpen = lstatSync(dbPath);
|
|
531
|
+
if (!afterOpen.isFile() || afterOpen.dev !== identity.dev || afterOpen.ino !== identity.ino) {
|
|
532
|
+
throw new Error("The replica database changed while SQLite opened it.");
|
|
533
|
+
}
|
|
534
|
+
if (previousFds) db.get("SELECT count(*) FROM sqlite_master");
|
|
535
|
+
if (previousFds) {
|
|
536
|
+
const databases = readdirSync("/proc/self/fd").filter((fd) => {
|
|
537
|
+
try {
|
|
538
|
+
const target = readlinkSync(`/proc/self/fd/${fd}`);
|
|
539
|
+
return target.endsWith("/replica.db") && previousFds.get(fd) !== target;
|
|
540
|
+
} catch {
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
if (databases.length === 0 || databases.some((fd) => {
|
|
545
|
+
try {
|
|
546
|
+
const actual = fstatSync(Number(fd));
|
|
547
|
+
return actual.dev !== identity.dev || actual.ino !== identity.ino;
|
|
548
|
+
} catch {
|
|
549
|
+
return true;
|
|
550
|
+
}
|
|
551
|
+
})) {
|
|
552
|
+
throw new Error("SQLite opened a different replica database.");
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
const after = lstatSync(dbPath);
|
|
556
|
+
if (!after.isFile() || after.dev !== identity.dev || after.ino !== identity.ino) {
|
|
557
|
+
throw new Error("The replica database changed while SQLite opened it.");
|
|
558
|
+
}
|
|
559
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
560
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
561
|
+
db.exec("PRAGMA synchronous = FULL");
|
|
562
|
+
db.exec("PRAGMA secure_delete = ON");
|
|
563
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
564
|
+
const version = db.get("PRAGMA user_version")?.user_version ?? 0;
|
|
565
|
+
if (version > SCHEMA_VERSION) {
|
|
566
|
+
throw new ReplicaError(
|
|
567
|
+
ReplicaErrorCode.STORAGE_ERROR,
|
|
568
|
+
`The replica at ${dir} was written by a newer tc (schema ${version}); upgrade tc to read it.`
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
if (version < SCHEMA_VERSION) {
|
|
572
|
+
db.exec("BEGIN IMMEDIATE");
|
|
573
|
+
db.exec(SCHEMA);
|
|
574
|
+
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
575
|
+
db.exec("COMMIT");
|
|
576
|
+
}
|
|
577
|
+
const shared = options.sqlite ? void 0 : {
|
|
578
|
+
key,
|
|
579
|
+
db,
|
|
580
|
+
directoryFd,
|
|
581
|
+
pinnedDir,
|
|
582
|
+
namedPath: dir,
|
|
583
|
+
dev: identity.dev,
|
|
584
|
+
ino: identity.ino,
|
|
585
|
+
references: 0,
|
|
586
|
+
mutationTail: Promise.resolve()
|
|
587
|
+
};
|
|
588
|
+
store = new _SqliteReplicaStore(dir, db, identity, options, directoryFd, namedAncestors, shared);
|
|
589
|
+
if (shared) writableStores.set(key, shared);
|
|
590
|
+
db = void 0;
|
|
591
|
+
}
|
|
592
|
+
} finally {
|
|
593
|
+
try {
|
|
594
|
+
db?.close();
|
|
595
|
+
} finally {
|
|
596
|
+
await leaf.close();
|
|
597
|
+
}
|
|
408
598
|
}
|
|
409
|
-
store = new _SqliteReplicaStore(dir, db, ino, options);
|
|
410
599
|
} catch (error) {
|
|
600
|
+
if (store) await store.close().catch(() => void 0);
|
|
601
|
+
else if (directoryFd !== void 0) closeSync(directoryFd);
|
|
411
602
|
throw storageError(error, `Opening the replica at ${dir}`);
|
|
603
|
+
} finally {
|
|
604
|
+
releaseOpen();
|
|
412
605
|
}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
await store.#
|
|
606
|
+
try {
|
|
607
|
+
const row = store.#row();
|
|
608
|
+
if (row?.revoked_detail != null && (row.purge_pending === 1 || await store.#holdsContentFiles().catch(() => true))) {
|
|
609
|
+
await store.#finishRevocationPurge().catch(() => void 0);
|
|
610
|
+
}
|
|
611
|
+
return store;
|
|
612
|
+
} catch (error) {
|
|
613
|
+
await store.close().catch(() => void 0);
|
|
614
|
+
throw error;
|
|
416
615
|
}
|
|
417
|
-
return store;
|
|
418
616
|
}
|
|
419
617
|
#row() {
|
|
420
618
|
return this.#db.get("SELECT * FROM replica WHERE id = 1");
|
|
421
619
|
}
|
|
422
620
|
/** Refuse to touch the filesystem for a replica that was deleted (or replaced) under us. */
|
|
423
621
|
async #assertPresent() {
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
() => void 0
|
|
427
|
-
);
|
|
428
|
-
if (ino !== this.#ino) {
|
|
622
|
+
const identity = await lstat(join(this.#pinnedDir, "replica.db")).catch(() => null);
|
|
623
|
+
if (!identity?.isFile() || identity.dev !== this.#dev || identity.ino !== this.#ino) {
|
|
429
624
|
throw new ReplicaError(
|
|
430
625
|
ReplicaErrorCode.NOT_FOUND,
|
|
431
626
|
`The replica at ${this.dir} was deleted while this command ran (its profile was deleted or the replica purged); nothing more was written.`
|
|
432
627
|
);
|
|
433
628
|
}
|
|
434
629
|
}
|
|
630
|
+
/** New calls stop at close; accepted mutation sections may still finish their transactions. */
|
|
631
|
+
#assertAccepting() {
|
|
632
|
+
if (this.#closing || this.#closed) {
|
|
633
|
+
throw new ReplicaError(ReplicaErrorCode.CLOSED, "The replica store is closed.");
|
|
634
|
+
}
|
|
635
|
+
}
|
|
435
636
|
/** A short mutation section: guarded (profile lock) and checked against the database this store opened. */
|
|
436
637
|
#mutate(section) {
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
638
|
+
this.#assertAccepting();
|
|
639
|
+
this.#activeMutations++;
|
|
640
|
+
return (async () => {
|
|
641
|
+
try {
|
|
642
|
+
return await this.#guard(async () => {
|
|
643
|
+
await this.#assertPresent();
|
|
644
|
+
return section();
|
|
645
|
+
});
|
|
646
|
+
} finally {
|
|
647
|
+
if (--this.#activeMutations === 0) {
|
|
648
|
+
const drained = this.#drained;
|
|
649
|
+
this.#drained = void 0;
|
|
650
|
+
drained?.();
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
})();
|
|
441
654
|
}
|
|
442
655
|
/** Run `body` in a write transaction (BEGIN IMMEDIATE). Callers outside a mutation section use `#guardedWrite`. */
|
|
443
|
-
#write(action, body) {
|
|
444
|
-
|
|
445
|
-
this.#db.exec("BEGIN IMMEDIATE");
|
|
446
|
-
} catch (error) {
|
|
447
|
-
throw storageError(error, action);
|
|
448
|
-
}
|
|
656
|
+
async #write(action, body) {
|
|
657
|
+
const release = this.#shared ? await acquireWritableMutation(this.#shared) : void 0;
|
|
449
658
|
try {
|
|
450
|
-
const result = body();
|
|
451
|
-
this.#db.exec("COMMIT");
|
|
452
|
-
return result;
|
|
453
|
-
} catch (error) {
|
|
454
659
|
try {
|
|
455
|
-
this.#db.exec("
|
|
456
|
-
} catch {
|
|
660
|
+
this.#db.exec("BEGIN IMMEDIATE");
|
|
661
|
+
} catch (error) {
|
|
662
|
+
throw storageError(error, action);
|
|
457
663
|
}
|
|
458
|
-
|
|
664
|
+
try {
|
|
665
|
+
const result = body();
|
|
666
|
+
this.#db.exec("COMMIT");
|
|
667
|
+
return result;
|
|
668
|
+
} catch (error) {
|
|
669
|
+
try {
|
|
670
|
+
this.#db.exec("ROLLBACK");
|
|
671
|
+
} catch {
|
|
672
|
+
}
|
|
673
|
+
throw storageError(error, action);
|
|
674
|
+
}
|
|
675
|
+
} finally {
|
|
676
|
+
release?.();
|
|
459
677
|
}
|
|
460
678
|
}
|
|
461
679
|
/** One write transaction as its own mutation section. */
|
|
@@ -482,6 +700,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
482
700
|
}
|
|
483
701
|
}
|
|
484
702
|
async open() {
|
|
703
|
+
this.#assertAccepting();
|
|
485
704
|
let row;
|
|
486
705
|
try {
|
|
487
706
|
row = this.#row();
|
|
@@ -643,6 +862,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
643
862
|
});
|
|
644
863
|
}
|
|
645
864
|
async hasContent(hashes) {
|
|
865
|
+
this.#assertAccepting();
|
|
646
866
|
const held = /* @__PURE__ */ new Set();
|
|
647
867
|
for (let start = 0; start < hashes.length; start += 500) {
|
|
648
868
|
const chunk = hashes.slice(start, start + 500);
|
|
@@ -655,7 +875,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
655
875
|
return held;
|
|
656
876
|
}
|
|
657
877
|
#blobPath(hash) {
|
|
658
|
-
return join(this
|
|
878
|
+
return join(this.#pinnedDir, "blobs", hash.slice(0, 2), hash);
|
|
659
879
|
}
|
|
660
880
|
/**
|
|
661
881
|
* Make every blob durable before any row references it: write to a fresh
|
|
@@ -672,11 +892,11 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
672
892
|
() => false
|
|
673
893
|
);
|
|
674
894
|
if (exists) continue;
|
|
675
|
-
const shard = join(this
|
|
895
|
+
const shard = join(this.#pinnedDir, "blobs", hash.slice(0, 2));
|
|
676
896
|
await mkdir(shard, { mode: 448 }).catch((error) => {
|
|
677
897
|
if (errnoOf(error) !== "EEXIST") throw error;
|
|
678
898
|
});
|
|
679
|
-
const temp = join(this
|
|
899
|
+
const temp = join(this.#pinnedDir, "blobs", ".tmp", `${hash}.${randomBytes(6).toString("hex")}`);
|
|
680
900
|
const handle = await open(temp, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, 384);
|
|
681
901
|
try {
|
|
682
902
|
await handle.writeFile(bytes);
|
|
@@ -692,7 +912,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
692
912
|
await unlink(temp);
|
|
693
913
|
}
|
|
694
914
|
dirs.add(shard);
|
|
695
|
-
dirs.add(join(this
|
|
915
|
+
dirs.add(join(this.#pinnedDir, "blobs"));
|
|
696
916
|
}
|
|
697
917
|
for (const dir of dirs) {
|
|
698
918
|
const handle = await open(dir, fsConstants.O_RDONLY);
|
|
@@ -725,7 +945,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
725
945
|
}
|
|
726
946
|
await this.#faults.afterBlobs?.();
|
|
727
947
|
try {
|
|
728
|
-
this.#write("Committing a sync page", () => {
|
|
948
|
+
await this.#write("Committing a sync page", () => {
|
|
729
949
|
this.#checkLease(t);
|
|
730
950
|
const now = this.#now();
|
|
731
951
|
if (p.window.notBefore !== null && now < Date.parse(p.window.notBefore)) {
|
|
@@ -800,7 +1020,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
800
1020
|
/** Unlink every blob not in `keep`, and every temp file. Only a missing directory counts as empty. */
|
|
801
1021
|
async #unlinkBlobs(keep) {
|
|
802
1022
|
let removed = 0;
|
|
803
|
-
const root = join(this
|
|
1023
|
+
const root = join(this.#pinnedDir, "blobs");
|
|
804
1024
|
for (const shard of await entriesOf(root)) {
|
|
805
1025
|
const shardPath = join(root, shard);
|
|
806
1026
|
if (shard === ".tmp") {
|
|
@@ -825,7 +1045,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
825
1045
|
}
|
|
826
1046
|
/** Whether any content file (a blob or a temp file) is on disk. */
|
|
827
1047
|
async #holdsContentFiles() {
|
|
828
|
-
const root = join(this
|
|
1048
|
+
const root = join(this.#pinnedDir, "blobs");
|
|
829
1049
|
for (const shard of await entriesOf(root)) {
|
|
830
1050
|
if ((await entriesOf(join(root, shard))).length > 0) return true;
|
|
831
1051
|
}
|
|
@@ -833,13 +1053,16 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
833
1053
|
}
|
|
834
1054
|
async collectGarbage(t) {
|
|
835
1055
|
return this.#mutate(async () => {
|
|
1056
|
+
const release = this.#shared ? await acquireWritableMutation(this.#shared) : void 0;
|
|
836
1057
|
try {
|
|
837
1058
|
this.#db.exec("BEGIN IMMEDIATE");
|
|
838
1059
|
} catch (error) {
|
|
1060
|
+
release?.();
|
|
839
1061
|
throw storageError(error, "Collecting unused content");
|
|
840
1062
|
}
|
|
841
1063
|
try {
|
|
842
1064
|
this.#checkLease(t);
|
|
1065
|
+
await this.#faults.afterGarbageCollectionBegin?.();
|
|
843
1066
|
const keep = new Set(
|
|
844
1067
|
this.#db.all("SELECT DISTINCT hash FROM entry WHERE content = 1").map((row) => row.hash)
|
|
845
1068
|
);
|
|
@@ -852,12 +1075,14 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
852
1075
|
} catch {
|
|
853
1076
|
}
|
|
854
1077
|
throw storageError(error, "Collecting unused content");
|
|
1078
|
+
} finally {
|
|
1079
|
+
release?.();
|
|
855
1080
|
}
|
|
856
1081
|
});
|
|
857
1082
|
}
|
|
858
1083
|
async reset(t, reason, options = {}) {
|
|
859
1084
|
await this.#mutate(async () => {
|
|
860
|
-
this.#write("Resetting the replica", () => {
|
|
1085
|
+
await this.#write("Resetting the replica", () => {
|
|
861
1086
|
this.#checkLease(t);
|
|
862
1087
|
this.#db.run("DELETE FROM entry");
|
|
863
1088
|
this.#db.run(
|
|
@@ -884,15 +1109,102 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
884
1109
|
*/
|
|
885
1110
|
async destroy(t) {
|
|
886
1111
|
await this.#mutate(async () => {
|
|
1112
|
+
const release = this.#shared ? await acquireWritableMutation(this.#shared) : void 0;
|
|
887
1113
|
try {
|
|
888
1114
|
this.#db.exec("BEGIN IMMEDIATE");
|
|
889
1115
|
} catch (error) {
|
|
1116
|
+
release?.();
|
|
890
1117
|
throw storageError(error, "Removing the replica");
|
|
891
1118
|
}
|
|
892
1119
|
try {
|
|
893
1120
|
this.#checkLease(t, { allowRevoked: true });
|
|
1121
|
+
const expected = this.#directoryFd === void 0 ? this.#namedAncestors.at(-1) : fstatSync(this.#directoryFd, { bigint: true });
|
|
1122
|
+
if (!expected) {
|
|
1123
|
+
throw new ReplicaError(ReplicaErrorCode.RUNTIME_UNSUPPORTED, "Replica removal requires an opened or identity-checked directory.");
|
|
1124
|
+
}
|
|
1125
|
+
const assertDirectory = async () => {
|
|
1126
|
+
const current = await lstat(this.dir, { bigint: true });
|
|
1127
|
+
if (!current.isDirectory() || current.dev !== expected.dev || current.ino !== expected.ino) {
|
|
1128
|
+
throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica directory changed during removal.");
|
|
1129
|
+
}
|
|
1130
|
+
return current;
|
|
1131
|
+
};
|
|
1132
|
+
const pinned = await assertDirectory();
|
|
894
1133
|
await this.#assertPresent();
|
|
895
|
-
|
|
1134
|
+
if (process.platform === "linux") {
|
|
1135
|
+
for (const entry of await readdir(this.#pinnedDir)) {
|
|
1136
|
+
await rm(join(this.#pinnedDir, entry), { recursive: true, force: true });
|
|
1137
|
+
}
|
|
1138
|
+
await assertDirectory();
|
|
1139
|
+
await rmdir(this.dir);
|
|
1140
|
+
} else {
|
|
1141
|
+
const ancestors = this.#namedAncestors;
|
|
1142
|
+
const checkAncestors = async () => {
|
|
1143
|
+
for (const expected2 of ancestors) {
|
|
1144
|
+
const current = await lstat(expected2.path, { bigint: true });
|
|
1145
|
+
if (current.dev !== expected2.dev || current.ino !== expected2.ino) {
|
|
1146
|
+
throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica ancestry changed during removal.");
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
};
|
|
1150
|
+
const directories = /* @__PURE__ */ new Map([[this.dir, pinned]]);
|
|
1151
|
+
const files = /* @__PURE__ */ new Map();
|
|
1152
|
+
const check = async (path, expected2, directory) => {
|
|
1153
|
+
if (path !== this.dir) {
|
|
1154
|
+
const parent = dirname(path);
|
|
1155
|
+
await check(parent, directories.get(parent), true);
|
|
1156
|
+
}
|
|
1157
|
+
await checkAncestors();
|
|
1158
|
+
const current = await lstat(path, { bigint: true });
|
|
1159
|
+
if ((directory ? !current.isDirectory() : !current.isFile()) || current.dev !== expected2.dev || current.ino !== expected2.ino) {
|
|
1160
|
+
throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica layout changed during removal.");
|
|
1161
|
+
}
|
|
1162
|
+
};
|
|
1163
|
+
const record = async (path, directory) => {
|
|
1164
|
+
const parent = dirname(path);
|
|
1165
|
+
await check(parent, directories.get(parent), true);
|
|
1166
|
+
const current = await lstat(path, { bigint: true });
|
|
1167
|
+
if (!(directory ? current.isDirectory() : current.isFile())) {
|
|
1168
|
+
throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "Unexpected replica layout during removal.");
|
|
1169
|
+
}
|
|
1170
|
+
(directory ? directories : files).set(path, current);
|
|
1171
|
+
};
|
|
1172
|
+
const blobs = join(this.dir, "blobs");
|
|
1173
|
+
for (const name of await readdir(this.dir)) {
|
|
1174
|
+
if (name === "blobs") await record(blobs, true);
|
|
1175
|
+
else if (["replica.db", "replica.db-wal", "replica.db-shm"].includes(name)) await record(join(this.dir, name), false);
|
|
1176
|
+
else throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "Unexpected replica layout during removal.");
|
|
1177
|
+
}
|
|
1178
|
+
if (directories.has(blobs)) {
|
|
1179
|
+
await check(blobs, directories.get(blobs), true);
|
|
1180
|
+
for (const shard of await readdir(blobs)) {
|
|
1181
|
+
if (shard !== ".tmp" && !/^[0-9a-f]{2}$/.test(shard)) {
|
|
1182
|
+
throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "Unexpected replica blob shard during removal.");
|
|
1183
|
+
}
|
|
1184
|
+
const shardPath = join(blobs, shard);
|
|
1185
|
+
await record(shardPath, true);
|
|
1186
|
+
await check(shardPath, directories.get(shardPath), true);
|
|
1187
|
+
for (const name of await readdir(shardPath)) {
|
|
1188
|
+
if (shard === ".tmp" ? !/^[0-9a-f]{64}\.[0-9a-f]{12}$/.test(name) : !isHash(name) || !name.startsWith(shard)) {
|
|
1189
|
+
throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "Unexpected replica blob file during removal.");
|
|
1190
|
+
}
|
|
1191
|
+
await record(join(shardPath, name), false);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
for (const [path, identity] of files) {
|
|
1196
|
+
await check(path, identity, false);
|
|
1197
|
+
await unlink(path);
|
|
1198
|
+
}
|
|
1199
|
+
for (const [path, identity] of [...directories].reverse()) {
|
|
1200
|
+
if (path === this.dir) continue;
|
|
1201
|
+
await check(path, identity, true);
|
|
1202
|
+
await rmdir(path);
|
|
1203
|
+
}
|
|
1204
|
+
await assertDirectory();
|
|
1205
|
+
await checkAncestors();
|
|
1206
|
+
await rmdir(this.dir);
|
|
1207
|
+
}
|
|
896
1208
|
} catch (error) {
|
|
897
1209
|
throw storageError(error, "Removing the replica");
|
|
898
1210
|
} finally {
|
|
@@ -900,6 +1212,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
900
1212
|
this.#db.exec("ROLLBACK");
|
|
901
1213
|
} catch {
|
|
902
1214
|
}
|
|
1215
|
+
release?.();
|
|
903
1216
|
}
|
|
904
1217
|
});
|
|
905
1218
|
}
|
|
@@ -943,7 +1256,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
943
1256
|
* when both are done.
|
|
944
1257
|
*/
|
|
945
1258
|
async #purgeContent() {
|
|
946
|
-
this.#write("Starting the revocation purge", () => {
|
|
1259
|
+
await this.#write("Starting the revocation purge", () => {
|
|
947
1260
|
this.#db.run("UPDATE replica SET purge_pending = 1 WHERE id = 1 AND revoked_detail IS NOT NULL");
|
|
948
1261
|
});
|
|
949
1262
|
try {
|
|
@@ -954,7 +1267,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
954
1267
|
if (!this.#checkpoint()) {
|
|
955
1268
|
throw new ReplicaError(ReplicaErrorCode.BUSY, "A reader holds the replica's write-ahead log; the purge finishes on the next open.");
|
|
956
1269
|
}
|
|
957
|
-
this.#write("Finishing the revocation purge", () => {
|
|
1270
|
+
await this.#write("Finishing the revocation purge", () => {
|
|
958
1271
|
this.#db.run("UPDATE replica SET purge_pending = 0 WHERE id = 1 AND revoked_detail IS NOT NULL");
|
|
959
1272
|
});
|
|
960
1273
|
}
|
|
@@ -964,6 +1277,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
964
1277
|
});
|
|
965
1278
|
}
|
|
966
1279
|
async get(key) {
|
|
1280
|
+
this.#assertAccepting();
|
|
967
1281
|
try {
|
|
968
1282
|
const row = this.#db.get("SELECT key, deleted, etag, hash, metadata, content FROM entry WHERE key = ?", key);
|
|
969
1283
|
return row === void 0 ? void 0 : entryOf(row);
|
|
@@ -972,6 +1286,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
972
1286
|
}
|
|
973
1287
|
}
|
|
974
1288
|
async list(o) {
|
|
1289
|
+
this.#assertAccepting();
|
|
975
1290
|
const where = ["deleted = 0"];
|
|
976
1291
|
const params = [];
|
|
977
1292
|
if (o.prefix !== void 0 && o.prefix !== "") {
|
|
@@ -1057,8 +1372,31 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
1057
1372
|
lastReset: state.lastReset
|
|
1058
1373
|
};
|
|
1059
1374
|
}
|
|
1060
|
-
|
|
1061
|
-
this.#
|
|
1375
|
+
close() {
|
|
1376
|
+
if (this.#closePromise) return this.#closePromise;
|
|
1377
|
+
this.#closing = true;
|
|
1378
|
+
this.#closePromise = (async () => {
|
|
1379
|
+
if (this.#activeMutations !== 0) {
|
|
1380
|
+
await new Promise((resolve2) => {
|
|
1381
|
+
this.#drained = resolve2;
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
this.#closed = true;
|
|
1385
|
+
const fd = this.#directoryFd;
|
|
1386
|
+
this.#directoryFd = void 0;
|
|
1387
|
+
if (this.#shared) {
|
|
1388
|
+
sharedStores.unregister(this);
|
|
1389
|
+
releaseWritable(this.#shared);
|
|
1390
|
+
} else {
|
|
1391
|
+
if (fd !== void 0) pinnedDirectories.unregister(this);
|
|
1392
|
+
try {
|
|
1393
|
+
this.#database.close();
|
|
1394
|
+
} finally {
|
|
1395
|
+
if (fd !== void 0) closeSync(fd);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
})();
|
|
1399
|
+
return this.#closePromise;
|
|
1062
1400
|
}
|
|
1063
1401
|
};
|
|
1064
1402
|
export {
|