@tinycloud/replica 0.2.4-beta.1 → 0.2.4-beta.2

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/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,37 @@ 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
+ });
300
329
  var SqliteReplicaStore = class _SqliteReplicaStore {
301
330
  dir;
302
331
  #db;
332
+ #pinnedDir;
333
+ #directoryFd;
303
334
  #holder = `${process.pid}-${randomBytes(6).toString("hex")}`;
304
335
  #now;
305
336
  #faults;
306
337
  #guard;
307
338
  /** Identity of the database file this store opened: a deleted-and-recreated replica is a different file. */
308
339
  #ino;
309
- constructor(dir, db, ino, options) {
340
+ #dev;
341
+ #namedAncestors;
342
+ constructor(dir, db, identity, options, directoryFd, namedAncestors = []) {
310
343
  this.dir = dir;
344
+ this.#pinnedDir = directoryFd !== void 0 && process.platform === "linux" ? `/proc/self/fd/${directoryFd}` : dir;
345
+ this.#directoryFd = directoryFd;
346
+ if (directoryFd !== void 0) pinnedDirectories.register(this, directoryFd, this);
311
347
  this.#db = db;
312
- this.#ino = ino;
348
+ this.#ino = identity.ino;
349
+ this.#dev = identity.dev;
313
350
  this.#now = options.now ?? Date.now;
314
351
  this.#faults = options[FAULTS] ?? {};
315
352
  this.#guard = options.guard ?? ((section) => section());
353
+ this.#namedAncestors = namedAncestors;
316
354
  }
317
355
  /**
318
356
  * Read-only status snapshot; does not create a replica, migrate, recover
@@ -325,9 +363,10 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
325
363
  };
326
364
  checkAbort();
327
365
  const dbPath = join(dir, "replica.db");
328
- let ino;
366
+ let identity;
329
367
  try {
330
- ino = (await stat(dbPath)).ino;
368
+ identity = await lstat(dbPath);
369
+ if (!identity.isFile()) return null;
331
370
  } catch (error) {
332
371
  if (errnoOf(error) === "ENOENT") return null;
333
372
  throw storageError(error, "Inspecting the replica");
@@ -340,14 +379,14 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
340
379
  db = opener(dbPath, { readonly: true });
341
380
  db.exec("PRAGMA busy_timeout = 0");
342
381
  checkAbort();
343
- const store = new _SqliteReplicaStore(dir, db, ino, { create: false, ...options.now ? { now: options.now } : {} });
382
+ const store = new _SqliteReplicaStore(dir, db, identity, { create: false, ...options.now ? { now: options.now } : {} });
344
383
  for (let attempt = 0; attempt < 2; attempt++) {
345
384
  const before = db.get("PRAGMA data_version").data_version;
346
385
  const state = await store.open();
347
386
  checkAbort();
348
387
  if (state === null) return null;
349
388
  const status = await store.status();
350
- await new Promise((resolve) => setImmediate(resolve));
389
+ await new Promise((resolve2) => setImmediate(resolve2));
351
390
  checkAbort();
352
391
  const after = db.get("PRAGMA data_version").data_version;
353
392
  if (before === after) return { state, status };
@@ -367,65 +406,145 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
367
406
  * REPLICA_NOT_FOUND and nothing is written to disk.
368
407
  */
369
408
  static async open(dir, options) {
370
- const dbPath = join(dir, "replica.db");
409
+ const namedDbPath = join(dir, "replica.db");
371
410
  if (!options.create) {
372
- const exists = await stat(dbPath).then(
373
- () => true,
411
+ const exists = await lstat(namedDbPath).then(
412
+ (stats) => stats.isFile(),
374
413
  () => false
375
414
  );
376
- if (!exists) throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, `No replica at ${dir}.`);
415
+ if (!exists) throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, `No regular replica database at ${dir}.`);
377
416
  }
378
417
  const opener = options.sqlite ?? await loadSqlite();
418
+ const releaseOpen = await acquireSqliteOpen();
379
419
  let store;
420
+ let directoryFd;
380
421
  try {
381
- if (options.create) {
382
- await mkdir(join(dir, "blobs", ".tmp"), { recursive: true, mode: 448 });
383
- await Promise.all([chmod(dir, 448), chmod(join(dir, "blobs"), 448), chmod(join(dir, "blobs", ".tmp"), 448)]);
384
- const handle = await open(dbPath, fsConstants.O_CREAT | fsConstants.O_RDWR, 384);
385
- await handle.close();
386
- await chmod(dbPath, 384);
422
+ if (options.create) await mkdir(dir, { recursive: true, mode: 448 });
423
+ if (process.platform === "linux") {
424
+ directoryFd = openSync(dir, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW);
387
425
  }
388
- const ino = (await stat(dbPath)).ino;
389
- const db = opener(dbPath);
390
- db.exec("PRAGMA busy_timeout = 5000");
391
- db.exec("PRAGMA journal_mode = WAL");
392
- db.exec("PRAGMA synchronous = FULL");
393
- db.exec("PRAGMA secure_delete = ON");
394
- db.exec("PRAGMA foreign_keys = ON");
395
- const version = db.get("PRAGMA user_version")?.user_version ?? 0;
396
- if (version > SCHEMA_VERSION) {
397
- db.close();
398
- throw new ReplicaError(
399
- ReplicaErrorCode.STORAGE_ERROR,
400
- `The replica at ${dir} was written by a newer tc (schema ${version}); upgrade tc to read it.`
401
- );
426
+ const opened = directoryFd === void 0 ? lstatSync(dir, { bigint: true }) : fstatSync(directoryFd, { bigint: true });
427
+ if (!opened.isDirectory() || opened.isSymbolicLink() || options.expectedDirectory && (opened.dev !== BigInt(options.expectedDirectory.dev) || opened.ino !== BigInt(options.expectedDirectory.ino))) {
428
+ throw new Error("The replica directory changed before SQLite opened it.");
429
+ }
430
+ const namedAncestors = process.platform === "linux" ? [] : await captureNamedPath(dir);
431
+ if (namedAncestors.length && (namedAncestors[namedAncestors.length - 1].dev !== opened.dev || namedAncestors[namedAncestors.length - 1].ino !== opened.ino)) {
432
+ throw new Error("The replica directory changed while checking its ancestry.");
402
433
  }
403
- if (version < SCHEMA_VERSION) {
404
- db.exec("BEGIN IMMEDIATE");
405
- db.exec(SCHEMA);
406
- db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
407
- db.exec("COMMIT");
434
+ const pinnedDir = directoryFd === void 0 ? dir : `/proc/self/fd/${directoryFd}`;
435
+ const dbPath = join(pinnedDir, "replica.db");
436
+ if (options.create) {
437
+ await mkdir(join(pinnedDir, "blobs", ".tmp"), { recursive: true, mode: 448 });
438
+ await Promise.all([chmod(pinnedDir, 448), chmod(join(pinnedDir, "blobs"), 448), chmod(join(pinnedDir, "blobs", ".tmp"), 448)]);
439
+ const handle = await open(dbPath, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_RDWR | fsConstants.O_NOFOLLOW, 384).catch((error) => {
440
+ if (errnoOf(error) === "EEXIST") return null;
441
+ throw error;
442
+ });
443
+ if (handle) {
444
+ try {
445
+ await handle.chmod(384);
446
+ } finally {
447
+ await handle.close();
448
+ }
449
+ }
450
+ }
451
+ const leaf = await open(dbPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
452
+ let db;
453
+ try {
454
+ const identity = await leaf.stat();
455
+ const named = await lstat(dbPath);
456
+ if (!identity.isFile() || !named.isFile() || named.dev !== identity.dev || named.ino !== identity.ino) {
457
+ throw new Error("The replica database changed before SQLite opened it.");
458
+ }
459
+ const previousFds = process.platform === "linux" && !options.sqlite ? new Map(readdirSync("/proc/self/fd").map((fd) => {
460
+ try {
461
+ return [fd, readlinkSync(`/proc/self/fd/${fd}`)];
462
+ } catch {
463
+ return [fd, ""];
464
+ }
465
+ })) : null;
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.");
470
+ }
471
+ if (previousFds) db.get("SELECT count(*) FROM sqlite_master");
472
+ if (previousFds) {
473
+ const databases = readdirSync("/proc/self/fd").filter((fd) => {
474
+ try {
475
+ const target = readlinkSync(`/proc/self/fd/${fd}`);
476
+ return target.endsWith("/replica.db") && previousFds.get(fd) !== target;
477
+ } catch {
478
+ return false;
479
+ }
480
+ });
481
+ if (databases.length === 0 || databases.some((fd) => {
482
+ try {
483
+ const actual = fstatSync(Number(fd));
484
+ return actual.dev !== identity.dev || actual.ino !== identity.ino;
485
+ } catch {
486
+ return true;
487
+ }
488
+ })) {
489
+ throw new Error("SQLite opened a different replica database.");
490
+ }
491
+ }
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
+ } finally {
517
+ try {
518
+ db?.close();
519
+ } finally {
520
+ await leaf.close();
521
+ }
408
522
  }
409
- store = new _SqliteReplicaStore(dir, db, ino, options);
410
523
  } catch (error) {
524
+ if (store) await store.close().catch(() => void 0);
525
+ else if (directoryFd !== void 0) closeSync(directoryFd);
411
526
  throw storageError(error, `Opening the replica at ${dir}`);
527
+ } finally {
528
+ releaseOpen();
412
529
  }
413
- const row = store.#row();
414
- if (row?.revoked_detail != null && (row.purge_pending === 1 || await store.#holdsContentFiles().catch(() => true))) {
415
- await store.#finishRevocationPurge().catch(() => void 0);
530
+ try {
531
+ const row = store.#row();
532
+ if (row?.revoked_detail != null && (row.purge_pending === 1 || await store.#holdsContentFiles().catch(() => true))) {
533
+ await store.#finishRevocationPurge().catch(() => void 0);
534
+ }
535
+ return store;
536
+ } catch (error) {
537
+ await store.close().catch(() => void 0);
538
+ throw error;
416
539
  }
417
- return store;
418
540
  }
419
541
  #row() {
420
542
  return this.#db.get("SELECT * FROM replica WHERE id = 1");
421
543
  }
422
544
  /** Refuse to touch the filesystem for a replica that was deleted (or replaced) under us. */
423
545
  async #assertPresent() {
424
- const ino = await stat(join(this.dir, "replica.db")).then(
425
- (stats) => stats.ino,
426
- () => void 0
427
- );
428
- if (ino !== this.#ino) {
546
+ const identity = await lstat(join(this.#pinnedDir, "replica.db")).catch(() => null);
547
+ if (!identity?.isFile() || identity.dev !== this.#dev || identity.ino !== this.#ino) {
429
548
  throw new ReplicaError(
430
549
  ReplicaErrorCode.NOT_FOUND,
431
550
  `The replica at ${this.dir} was deleted while this command ran (its profile was deleted or the replica purged); nothing more was written.`
@@ -655,7 +774,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
655
774
  return held;
656
775
  }
657
776
  #blobPath(hash) {
658
- return join(this.dir, "blobs", hash.slice(0, 2), hash);
777
+ return join(this.#pinnedDir, "blobs", hash.slice(0, 2), hash);
659
778
  }
660
779
  /**
661
780
  * Make every blob durable before any row references it: write to a fresh
@@ -672,11 +791,11 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
672
791
  () => false
673
792
  );
674
793
  if (exists) continue;
675
- const shard = join(this.dir, "blobs", hash.slice(0, 2));
794
+ const shard = join(this.#pinnedDir, "blobs", hash.slice(0, 2));
676
795
  await mkdir(shard, { mode: 448 }).catch((error) => {
677
796
  if (errnoOf(error) !== "EEXIST") throw error;
678
797
  });
679
- const temp = join(this.dir, "blobs", ".tmp", `${hash}.${randomBytes(6).toString("hex")}`);
798
+ const temp = join(this.#pinnedDir, "blobs", ".tmp", `${hash}.${randomBytes(6).toString("hex")}`);
680
799
  const handle = await open(temp, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, 384);
681
800
  try {
682
801
  await handle.writeFile(bytes);
@@ -692,7 +811,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
692
811
  await unlink(temp);
693
812
  }
694
813
  dirs.add(shard);
695
- dirs.add(join(this.dir, "blobs"));
814
+ dirs.add(join(this.#pinnedDir, "blobs"));
696
815
  }
697
816
  for (const dir of dirs) {
698
817
  const handle = await open(dir, fsConstants.O_RDONLY);
@@ -800,7 +919,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
800
919
  /** Unlink every blob not in `keep`, and every temp file. Only a missing directory counts as empty. */
801
920
  async #unlinkBlobs(keep) {
802
921
  let removed = 0;
803
- const root = join(this.dir, "blobs");
922
+ const root = join(this.#pinnedDir, "blobs");
804
923
  for (const shard of await entriesOf(root)) {
805
924
  const shardPath = join(root, shard);
806
925
  if (shard === ".tmp") {
@@ -825,7 +944,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
825
944
  }
826
945
  /** Whether any content file (a blob or a temp file) is on disk. */
827
946
  async #holdsContentFiles() {
828
- const root = join(this.dir, "blobs");
947
+ const root = join(this.#pinnedDir, "blobs");
829
948
  for (const shard of await entriesOf(root)) {
830
949
  if ((await entriesOf(join(root, shard))).length > 0) return true;
831
950
  }
@@ -891,8 +1010,93 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
891
1010
  }
892
1011
  try {
893
1012
  this.#checkLease(t, { allowRevoked: true });
1013
+ const expected = this.#directoryFd === void 0 ? this.#namedAncestors.at(-1) : fstatSync(this.#directoryFd, { bigint: true });
1014
+ if (!expected) {
1015
+ throw new ReplicaError(ReplicaErrorCode.RUNTIME_UNSUPPORTED, "Replica removal requires an opened or identity-checked directory.");
1016
+ }
1017
+ const assertDirectory = async () => {
1018
+ const current = await lstat(this.dir, { bigint: true });
1019
+ if (!current.isDirectory() || current.dev !== expected.dev || current.ino !== expected.ino) {
1020
+ throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica directory changed during removal.");
1021
+ }
1022
+ return current;
1023
+ };
1024
+ const pinned = await assertDirectory();
894
1025
  await this.#assertPresent();
895
- await rm(this.dir, { recursive: true, force: true });
1026
+ if (process.platform === "linux") {
1027
+ for (const entry of await readdir(this.#pinnedDir)) {
1028
+ await rm(join(this.#pinnedDir, entry), { recursive: true, force: true });
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
+ }
1085
+ }
1086
+ }
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
+ await assertDirectory();
1097
+ await checkAncestors();
1098
+ await rmdir(this.dir);
1099
+ }
896
1100
  } catch (error) {
897
1101
  throw storageError(error, "Removing the replica");
898
1102
  } finally {
@@ -1058,7 +1262,14 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
1058
1262
  };
1059
1263
  }
1060
1264
  async close() {
1061
- this.#db.close();
1265
+ const fd = this.#directoryFd;
1266
+ this.#directoryFd = void 0;
1267
+ if (fd !== void 0) pinnedDirectories.unregister(this);
1268
+ try {
1269
+ this.#db.close();
1270
+ } finally {
1271
+ if (fd !== void 0) closeSync(fd);
1272
+ }
1062
1273
  }
1063
1274
  };
1064
1275
  export {