@haverstack/record-adapter-sqlite 0.21.0 → 0.23.0

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/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { existsSync, writeFileSync, readFileSync, unlinkSync } from 'fs';
2
- import { StackVersionConflictError, StackNotFoundError, StackConflictError, applyMergePatch, StackQueryError } from '@haverstack/core';
1
+ import { existsSync, writeFileSync, unlinkSync, readFileSync } from 'fs';
2
+ import { StackNotFoundError, StackConflictError, applyMergePatch, StackVersionConflictError, StackQueryError, NATIVE_SORT_FIELDS } from '@haverstack/core';
3
3
  import { contentSortEntry, parseContentFilterKey, contentSortKey } from '@haverstack/core/adapter';
4
4
  import { randomBytes, createHash } from 'crypto';
5
5
 
@@ -151,7 +151,13 @@ var FTS5_SCHEMA_SQL = `
151
151
  `;
152
152
  var PRAGMA_FOREIGN_KEYS_ON = `PRAGMA foreign_keys = ON;`;
153
153
  var PRAGMA_JOURNAL_MODE_WAL = `PRAGMA journal_mode = WAL;`;
154
- var SORT_FIELDS = ["createdAt", "updatedAt", "version"];
154
+ var applyRecordSchema = (exec, opts) => {
155
+ exec.exec(PRAGMA_FOREIGN_KEYS_ON);
156
+ exec.exec(PRAGMA_JOURNAL_MODE_WAL);
157
+ exec.exec(RECORD_SCHEMA_SQL);
158
+ exec.exec(FTS5_SCHEMA_SQL);
159
+ };
160
+ var SORT_FIELDS = NATIVE_SORT_FIELDS;
155
161
  var toBase64 = (text) => {
156
162
  const bytes = new TextEncoder().encode(text);
157
163
  let binary = "";
@@ -367,17 +373,30 @@ var recordConditions = (query) => {
367
373
  const conditions = ["r.id != '_config'"];
368
374
  const params = [];
369
375
  const f = query.filter ?? {};
376
+ const inFilter = (column, value) => {
377
+ if (value === void 0)
378
+ return;
379
+ const values = Array.isArray(value) ? value : [value];
380
+ conditions.push(`r.${column} IN (${values.map(() => "?").join(",")})`);
381
+ params.push(...values);
382
+ };
383
+ const rangeFilter = (column, range) => {
384
+ if (range?.after) {
385
+ conditions.push(`r.${column} > ?`);
386
+ params.push(range.after.getTime());
387
+ }
388
+ if (range?.before) {
389
+ conditions.push(`r.${column} < ?`);
390
+ params.push(range.before.getTime());
391
+ }
392
+ };
370
393
  if (!f.includeDeleted) {
371
394
  conditions.push("r.deleted_at IS NULL");
372
395
  }
373
396
  if (!f.includeUnlisted) {
374
397
  conditions.push("r.unlisted_at IS NULL");
375
398
  }
376
- if (f.typeId !== void 0) {
377
- const ids = Array.isArray(f.typeId) ? f.typeId : [f.typeId];
378
- conditions.push(`r.type_id IN (${ids.map(() => "?").join(",")})`);
379
- params.push(...ids);
380
- }
399
+ inFilter("type_id", f.typeId);
381
400
  if (f.parentId !== void 0) {
382
401
  if (f.parentId === null) {
383
402
  conditions.push("r.parent_id IS NULL");
@@ -386,49 +405,24 @@ var recordConditions = (query) => {
386
405
  params.push(f.parentId);
387
406
  }
388
407
  }
389
- if (f.appId !== void 0) {
390
- const ids = Array.isArray(f.appId) ? f.appId : [f.appId];
391
- conditions.push(`r.app_id IN (${ids.map(() => "?").join(",")})`);
392
- params.push(...ids);
393
- }
394
- if (f.entityId !== void 0) {
395
- const ids = Array.isArray(f.entityId) ? f.entityId : [f.entityId];
396
- conditions.push(`r.entity_id IN (${ids.map(() => "?").join(",")})`);
397
- params.push(...ids);
398
- }
399
- if (f.principalId !== void 0) {
400
- const ids = Array.isArray(f.principalId) ? f.principalId : [f.principalId];
401
- conditions.push(`r.principal_id IN (${ids.map(() => "?").join(",")})`);
402
- params.push(...ids);
403
- }
404
- if (f.createdAt?.after) {
405
- conditions.push("r.created_at > ?");
406
- params.push(f.createdAt.after.getTime());
407
- }
408
- if (f.createdAt?.before) {
409
- conditions.push("r.created_at < ?");
410
- params.push(f.createdAt.before.getTime());
411
- }
412
- if (f.updatedAt?.after) {
413
- conditions.push("r.updated_at > ?");
414
- params.push(f.updatedAt.after.getTime());
415
- }
416
- if (f.updatedAt?.before) {
417
- conditions.push("r.updated_at < ?");
418
- params.push(f.updatedAt.before.getTime());
419
- }
408
+ inFilter("app_id", f.appId);
409
+ inFilter("entity_id", f.entityId);
410
+ inFilter("principal_id", f.principalId);
411
+ rangeFilter("created_at", f.createdAt);
412
+ rangeFilter("updated_at", f.updatedAt);
413
+ const hasAssociation = (kind, clauses) => `r.id IN (SELECT a.record_id FROM associations a WHERE a.kind = '${kind}'` + clauses.map((c) => ` AND ${c}`).join("") + `)`;
420
414
  if (f.tags?.length) {
421
415
  for (const tag of f.tags) {
422
- conditions.push(`r.id IN (SELECT a.record_id FROM associations a WHERE a.kind = 'tag' AND a.label = ?)`);
416
+ conditions.push(hasAssociation("tag", ["a.label = ?"]));
423
417
  params.push(tag);
424
418
  }
425
419
  }
426
420
  if (f.hasAttachment) {
427
- conditions.push(`r.id IN (SELECT a.record_id FROM associations a WHERE a.kind = 'attachment' AND a.label = ?)`);
421
+ conditions.push(hasAssociation("attachment", ["a.label = ?"]));
428
422
  params.push(f.hasAttachment);
429
423
  }
430
424
  if (f.attachmentFileId) {
431
- conditions.push(`(r.id IN (SELECT a.record_id FROM associations a WHERE a.kind = 'attachment' AND a.file_id = ?)
425
+ conditions.push(`(${hasAssociation("attachment", ["a.file_id = ?"])}
432
426
  OR r.id IN (SELECT ci.record_id FROM content_index ci WHERE ci.file_id = ?))`);
433
427
  params.push(f.attachmentFileId, f.attachmentFileId);
434
428
  }
@@ -457,7 +451,7 @@ var recordConditions = (query) => {
457
451
  }
458
452
  }
459
453
  }
460
- conditions.push(`r.id IN (SELECT a.record_id FROM associations a WHERE a.kind = 'relationship'` + clauses.map((c) => ` AND ${c}`).join("") + `)`);
454
+ conditions.push(hasAssociation("relationship", clauses));
461
455
  }
462
456
  if (f.content) {
463
457
  let aliasSeq = 0;
@@ -656,64 +650,32 @@ var rowToVersion = (row) => {
656
650
  v.permissions = JSON.parse(row.permissions);
657
651
  return v;
658
652
  };
659
- var lockPathFor = (dbPath) => `${dbPath}.lock`;
660
- var isProcessAlive = (pid) => {
661
- try {
662
- process.kill(pid, 0);
663
- return true;
664
- } catch (err) {
665
- return err.code !== "ESRCH";
666
- }
667
- };
668
- var acquireLock = (dbPath, force) => {
669
- const lockPath = lockPathFor(dbPath);
670
- const info = existsSync(lockPath) ? readLock(lockPath) : null;
671
- if (info) {
672
- const ownedBySelf = info.pid === process.pid;
673
- if (!ownedBySelf && !force && isProcessAlive(info.pid)) {
674
- throw new Error(`Stack database at "${dbPath}" is in use by another process (pid ${info.pid}). Connect via its server instead, or pass { force: true } to override. If no such process is running, remove "${lockPath}".`);
675
- }
676
- }
677
- writeFileSync(lockPath, JSON.stringify({ pid: process.pid }));
678
- };
679
- var readLock = (lockPath) => {
680
- try {
681
- const info = JSON.parse(readFileSync(lockPath, "utf-8"));
682
- return typeof info?.pid === "number" ? info : null;
683
- } catch {
684
- return null;
685
- }
686
- };
687
- var releaseLock = (dbPath) => {
688
- const lockPath = lockPathFor(dbPath);
689
- if (!existsSync(lockPath))
690
- return;
691
- try {
692
- const info = JSON.parse(readFileSync(lockPath, "utf-8"));
693
- if (info.pid === process.pid)
694
- unlinkSync(lockPath);
695
- } catch {
696
- unlinkSync(lockPath);
697
- }
698
- };
699
653
 
700
654
  // ../sqlite-shared/dist/executor.js
701
655
  var isForeignKeyViolation = (err) => err instanceof Error && err.message.includes("FOREIGN KEY constraint failed");
702
656
  var isUniqueConstraintViolation = (err) => err instanceof Error && err.message.includes("UNIQUE constraint failed");
703
657
 
704
658
  // ../sqlite-shared/dist/config.js
659
+ var CONFIG_ROW_SQL = `SELECT content FROM records WHERE id = '_config'`;
705
660
  var insertConfigRecord = (exec, entityId, timezone) => {
706
661
  const now = Date.now();
707
662
  exec.run(`INSERT INTO records (id, type_id, created_at, updated_at, content, version)
708
663
  VALUES ('_config', '_config@1', ?, ?, ?, 1)`, [now, now, JSON.stringify({ entityId, timezone })]);
664
+ return { entityId, timezone };
709
665
  };
710
- var readStackConfig = (exec) => {
711
- const row = exec.get(`SELECT content FROM records WHERE id = '_config'`);
666
+ var tryReadStackConfig = (exec) => {
667
+ const row = exec.get(CONFIG_ROW_SQL);
712
668
  if (!row)
713
- throw new Error("Stack database is missing its config record.");
669
+ return null;
714
670
  const content = JSON.parse(row.content);
715
671
  return { entityId: content.entityId, timezone: content.timezone };
716
672
  };
673
+ var readStackConfig = (exec) => {
674
+ const config = tryReadStackConfig(exec);
675
+ if (!config)
676
+ throw new Error("Stack database is missing its config record.");
677
+ return config;
678
+ };
717
679
  var asStackQueryError = (query, run) => {
718
680
  try {
719
681
  return run();
@@ -723,6 +685,29 @@ var asStackQueryError = (query, run) => {
723
685
  throw new StackQueryError(`Search text could not be parsed: ${err instanceof Error ? err.message : String(err)}`);
724
686
  }
725
687
  };
688
+ var versionConflict = (id, expected, actual) => new StackVersionConflictError(`Record "${id}" is at version ${actual}, expected ${expected}`, id, expected, actual);
689
+ var VERSION_COLUMNS = [
690
+ "type_id",
691
+ "content",
692
+ "updated_at",
693
+ "entity_id",
694
+ "updated_by",
695
+ "updated_via",
696
+ "parent_id",
697
+ "associations",
698
+ "permissions"
699
+ ];
700
+ var versionRowValues = (version) => [
701
+ version.typeId,
702
+ JSON.stringify(version.content),
703
+ toMs(version.updatedAt),
704
+ version.entityId ?? null,
705
+ version.updatedBy ?? null,
706
+ version.updatedVia ?? null,
707
+ version.parentId ?? null,
708
+ version.associations ? JSON.stringify(version.associations) : null,
709
+ version.permissions ? JSON.stringify(version.permissions) : null
710
+ ];
726
711
  var indexedFieldKinds = (schema) => {
727
712
  const kinds = /* @__PURE__ */ new Map();
728
713
  for (const [field, def] of Object.entries(schema)) {
@@ -748,26 +733,22 @@ var SharedSqlRecordLogic = class {
748
733
  return this.deps.exec;
749
734
  }
750
735
  /**
751
- * SQL fragment (plus its bind params) that gates a records-table
752
- * UPDATE/DELETE on the opt-in `expectedVersion` precondition. Empty when
753
- * expectedVersion is omitted, keeping today's unconditional behavior.
754
- */
755
- versionGuard(expectedVersion) {
756
- return expectedVersion === void 0 ? { clause: "", params: [] } : { clause: " AND version = ?", params: [expectedVersion] };
757
- }
758
- /**
759
- * Precondition check for mutations that can't fold expectedVersion into
760
- * their primary UPDATE's WHERE clause: mutateRecord/restoreVersion need
761
- * fts.remove() to run *before* the records-table content changes, so
762
- * the check happens first, standalone.
736
+ * Precondition check for a mutation that has already read the record —
737
+ * either because it can't fold the check into its own statement
738
+ * (hardDeleteRecord: children reference the row, so the DELETE can't
739
+ * carry it), or because it has to settle the precondition *before*
740
+ * fts5Strategy.remove() runs, which needs the records row still holding
741
+ * the old content. In the latter case versionedUpdate() re-checks it
742
+ * inside the statement, and that is the enforcement; this is what lets
743
+ * the work before it be skipped.
763
744
  */
764
745
  checkExpectedVersion(record, expectedVersion) {
765
746
  if (expectedVersion === void 0 || record.version === expectedVersion)
766
747
  return;
767
- throw new StackVersionConflictError(`Record "${record.id}" is at version ${record.version}, expected ${expectedVersion}`, record.id, expectedVersion, record.version);
748
+ throw versionConflict(record.id, expectedVersion, record.version);
768
749
  }
769
750
  /**
770
- * Called after a versionGuard()-gated statement affected zero rows.
751
+ * Called after a version-gated statement affected zero rows.
771
752
  * Distinguishes "record doesn't exist" (StackNotFoundError) from "it
772
753
  * exists but isn't at expectedVersion" (StackVersionConflictError, with
773
754
  * the actual current version for the caller to act on).
@@ -778,7 +759,48 @@ var SharedSqlRecordLogic = class {
778
759
  ]);
779
760
  if (!row)
780
761
  throw new StackNotFoundError(`Record not found: "${id}"`);
781
- throw new StackVersionConflictError(`Record "${id}" is at version ${row.version}, expected ${expectedVersion}`, id, expectedVersion, row.version);
762
+ throw versionConflict(id, expectedVersion, row.version);
763
+ }
764
+ /**
765
+ * The one shape every mutating UPDATE in this class has: whatever columns
766
+ * the caller is changing, then the version bump and the actor/timestamp
767
+ * stamp that every mutation owes, gated on the opt-in `expectedVersion`
768
+ * precondition and failing loudly when nothing matched.
769
+ *
770
+ * The guard rides in the WHERE clause even where the caller has already
771
+ * checked the version against a record it read: that read is outside this
772
+ * statement, so a writer slipping in between the two is caught here
773
+ * rather than silently overwritten.
774
+ */
775
+ versionedUpdate(id, sets, values, opts, now = toMs(/* @__PURE__ */ new Date())) {
776
+ const guarded = opts.expectedVersion !== void 0;
777
+ const changed = this.exec.run(`UPDATE records SET ${[...sets, "version = version + 1", "updated_at = ?", "updated_by = ?", "updated_via = ?"].join(", ")} WHERE id = ?${guarded ? " AND version = ?" : ""}`, [
778
+ ...values,
779
+ now,
780
+ opts.updatedBy ?? null,
781
+ opts.updatedVia ?? null,
782
+ id,
783
+ ...guarded ? [opts.expectedVersion] : []
784
+ ]);
785
+ if (changed === 0)
786
+ this.throwVersionConflict(id, opts.expectedVersion);
787
+ }
788
+ /**
789
+ * Read a record back after its own mutation committed, for the caller to
790
+ * return. Its absence is not a "not found" for the caller to handle — the
791
+ * mutation just succeeded against it — so it fails as the invariant
792
+ * breach it would be.
793
+ *
794
+ * Synchronous, via readRecord() rather than getRecord(), so the read
795
+ * cannot be separated from the commit by anything: an async caller
796
+ * returning this still hands back a record read in the same tick its
797
+ * transaction committed. See readRecord() for why that matters.
798
+ */
799
+ reread(id, verb) {
800
+ const updated = this.readRecord(id);
801
+ if (!updated)
802
+ throw new Error(`Record not found after ${verb}: "${id}"`);
803
+ return updated;
782
804
  }
783
805
  // -------------------------------------------------------
784
806
  // Records
@@ -835,11 +857,11 @@ var SharedSqlRecordLogic = class {
835
857
  * reason (see SqlExecutor.transaction).
836
858
  *
837
859
  * The same synchrony is what lets the post-commit reads below report the
838
- * version their own mutation produced: `getRecord()` runs its body before
839
- * the `await` yields, so nothing interleaves between the commit and the
840
- * read. A backend that made these reads genuinely asynchronous would open
841
- * that window, and a concurrent mutation could be reported under this
842
- * one's verb.
860
+ * version their own mutation produced: reread() calls this directly, so
861
+ * nothing interleaves between the commit and the read even though the
862
+ * method returning it is async. A backend that made these reads genuinely
863
+ * asynchronous would open that window, and a concurrent mutation could be
864
+ * reported under this one's verb.
843
865
  */
844
866
  readRecord(id) {
845
867
  const row = this.exec.get("SELECT * FROM records WHERE id = ?", [id]);
@@ -890,49 +912,28 @@ var SharedSqlRecordLogic = class {
890
912
  this.snapshotBeforeMutation(id, opts.snapshot);
891
913
  if (merged !== void 0)
892
914
  fts5Strategy.remove(this.exec, id);
893
- const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
894
- const changed = this.exec.run(`UPDATE records SET ${sets.concat(["version = version + 1", "updated_at = ?", "updated_by = ?", "updated_via = ?"]).join(", ")} WHERE id = ?${clause}`, [...values, now, opts.updatedBy ?? null, opts.updatedVia ?? null, id, ...verParams]);
895
- if (changed === 0)
896
- this.throwVersionConflict(id, opts.expectedVersion);
915
+ this.versionedUpdate(id, sets, values, opts, now);
897
916
  if (changes.associations !== void 0) {
898
- this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
899
- if (changes.associations.length)
900
- this.insertAssociations(id, changes.associations);
917
+ this.replaceAssociations(id, changes.associations);
901
918
  }
902
919
  if (merged !== void 0) {
903
920
  fts5Strategy.insert(this.exec, id, JSON.stringify(merged));
904
921
  this.syncContentIndex(id, existing.typeId, merged);
905
922
  }
906
923
  });
907
- const updated = await this.getRecord(id);
908
- if (!updated)
909
- throw new Error(`Record not found after mutateRecord: "${id}"`);
910
- return updated;
924
+ return this.reread(id, "mutateRecord");
911
925
  }
912
926
  async deleteRecord(id, opts = {}) {
913
927
  if (opts.hard) {
914
928
  return this.exec.transaction(() => this.hardDeleteRecord(id, opts.expectedVersion));
915
- } else {
916
- this.exec.transaction(() => {
917
- if (opts.snapshot)
918
- this.snapshotBeforeMutation(id, opts.snapshot);
919
- const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
920
- const changed = this.exec.run(`UPDATE records SET deleted_at = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?${clause}`, [
921
- toMs(/* @__PURE__ */ new Date()),
922
- toMs(/* @__PURE__ */ new Date()),
923
- opts.updatedBy ?? null,
924
- opts.updatedVia ?? null,
925
- id,
926
- ...verParams
927
- ]);
928
- if (changed === 0)
929
- this.throwVersionConflict(id, opts.expectedVersion);
930
- });
931
929
  }
932
- const updated = await this.getRecord(id);
933
- if (!updated)
934
- throw new Error(`Record not found after deleteRecord: "${id}"`);
935
- return updated;
930
+ const now = toMs(/* @__PURE__ */ new Date());
931
+ this.exec.transaction(() => {
932
+ if (opts.snapshot)
933
+ this.snapshotBeforeMutation(id, opts.snapshot);
934
+ this.versionedUpdate(id, ["deleted_at = ?"], [now], opts, now);
935
+ });
936
+ return this.reread(id, "deleteRecord");
936
937
  }
937
938
  /**
938
939
  * Deletes a record's FTS entry, associations, versions, file-refs, sort
@@ -945,15 +946,12 @@ var SharedSqlRecordLogic = class {
945
946
  */
946
947
  hardDeleteRecord(id, expectedVersion) {
947
948
  const purged = this.readRecord(id);
948
- if (expectedVersion !== void 0) {
949
- if (!purged)
949
+ if (!purged) {
950
+ if (expectedVersion !== void 0)
950
951
  throw new StackNotFoundError(`Record not found: "${id}"`);
951
- if (purged.version !== expectedVersion) {
952
- throw new StackVersionConflictError(`Record "${id}" is at version ${purged.version}, expected ${expectedVersion}`, id, expectedVersion, purged.version);
953
- }
954
- }
955
- if (!purged)
956
952
  return null;
953
+ }
954
+ this.checkExpectedVersion(purged, expectedVersion);
957
955
  fts5Strategy.remove(this.exec, id);
958
956
  this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
959
957
  this.exec.run("DELETE FROM versions WHERE record_id = ?", [id]);
@@ -965,15 +963,9 @@ var SharedSqlRecordLogic = class {
965
963
  this.exec.transaction(() => {
966
964
  if (opts.snapshot)
967
965
  this.snapshotBeforeMutation(id, opts.snapshot);
968
- const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
969
- const changed = this.exec.run(`UPDATE records SET deleted_at = NULL, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?${clause}`, [toMs(/* @__PURE__ */ new Date()), opts.updatedBy ?? null, opts.updatedVia ?? null, id, ...verParams]);
970
- if (changed === 0)
971
- this.throwVersionConflict(id, opts.expectedVersion);
966
+ this.versionedUpdate(id, ["deleted_at = NULL"], [], opts);
972
967
  });
973
- const updated = await this.getRecord(id);
974
- if (!updated)
975
- throw new Error(`Record not found after undelete: "${id}"`);
976
- return updated;
968
+ return this.reread(id, "undelete");
977
969
  }
978
970
  async restoreVersion(id, version, opts = {}) {
979
971
  const existing = await this.getRecord(id);
@@ -986,29 +978,12 @@ var SharedSqlRecordLogic = class {
986
978
  this.exec.transaction(() => {
987
979
  if (opts.snapshot)
988
980
  this.snapshotBeforeMutation(id, opts.snapshot);
989
- fts5Strategy.remove(this.exec, id);
990
- this.exec.run(`UPDATE records SET type_id = ?, content = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ?, parent_id = ? WHERE id = ?`, [
991
- target.typeId,
992
- JSON.stringify(target.content),
993
- toMs(/* @__PURE__ */ new Date()),
994
- opts.updatedBy ?? null,
995
- opts.updatedVia ?? null,
996
- target.parentId ?? null,
997
- id
998
- ]);
981
+ this.rewriteContent(id, target.typeId, target.content, opts, ["parent_id = ?"], [target.parentId ?? null]);
999
982
  const applied = opts.restoreAssociations === false ? void 0 : target.associations;
1000
- if (applied !== void 0) {
1001
- this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
1002
- if (applied.length)
1003
- this.insertAssociations(id, applied);
1004
- }
1005
- fts5Strategy.insert(this.exec, id, JSON.stringify(target.content));
1006
- this.syncContentIndex(id, target.typeId, target.content);
983
+ if (applied !== void 0)
984
+ this.replaceAssociations(id, applied);
1007
985
  });
1008
- const updated = await this.getRecord(id);
1009
- if (!updated)
1010
- throw new Error(`Record not found after restoreVersion: "${id}"`);
1011
- return updated;
986
+ return this.reread(id, "restoreVersion");
1012
987
  }
1013
988
  async commitMigration(id, toTypeId, content, opts = {}) {
1014
989
  const existing = await this.getRecord(id);
@@ -1018,22 +993,27 @@ var SharedSqlRecordLogic = class {
1018
993
  this.exec.transaction(() => {
1019
994
  if (opts.snapshot)
1020
995
  this.snapshotBeforeMutation(id, opts.snapshot);
1021
- fts5Strategy.remove(this.exec, id);
1022
- this.exec.run(`UPDATE records SET type_id = ?, content = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?`, [
1023
- toTypeId,
1024
- JSON.stringify(content),
1025
- toMs(/* @__PURE__ */ new Date()),
1026
- opts.updatedBy ?? null,
1027
- opts.updatedVia ?? null,
1028
- id
1029
- ]);
1030
- fts5Strategy.insert(this.exec, id, JSON.stringify(content));
1031
- this.syncContentIndex(id, toTypeId, content);
996
+ this.rewriteContent(id, toTypeId, content, opts);
1032
997
  });
1033
- const updated = await this.getRecord(id);
1034
- if (!updated)
1035
- throw new Error(`Record not found after commitMigration: "${id}"`);
1036
- return updated;
998
+ return this.reread(id, "commitMigration");
999
+ }
1000
+ /**
1001
+ * Replace a record's type and content wholesale — what restoreVersion and
1002
+ * commitMigration each do, differing only in where the content came from
1003
+ * and whether the container moves with it.
1004
+ *
1005
+ * The order is the constraint: fts5Strategy.remove() has to read the old
1006
+ * content off the records row, so it runs before the UPDATE, and the
1007
+ * re-index runs after. Every caller must already sit inside
1008
+ * exec.transaction() — a failure between the two halves would otherwise
1009
+ * leave the record unindexed.
1010
+ */
1011
+ rewriteContent(id, typeId, content, opts, extraSets = [], extraValues = []) {
1012
+ const json = JSON.stringify(content);
1013
+ fts5Strategy.remove(this.exec, id);
1014
+ this.versionedUpdate(id, ["type_id = ?", "content = ?", ...extraSets], [typeId, json, ...extraValues], opts);
1015
+ fts5Strategy.insert(this.exec, id, json);
1016
+ this.syncContentIndex(id, typeId, content);
1037
1017
  }
1038
1018
  async queryRecords(query) {
1039
1019
  const limit = query.limit ?? 50;
@@ -1094,8 +1074,8 @@ var SharedSqlRecordLogic = class {
1094
1074
  return rows.map(rowToVersion);
1095
1075
  }
1096
1076
  async getVersion(id, version) {
1097
- const rows = this.exec.all("SELECT * FROM versions WHERE record_id = ? AND version = ?", [id, version]);
1098
- return rows.length ? rowToVersion(rows[0]) : null;
1077
+ const row = this.exec.get("SELECT * FROM versions WHERE record_id = ? AND version = ?", [id, version]);
1078
+ return row ? rowToVersion(row) : null;
1099
1079
  }
1100
1080
  /**
1101
1081
  * A (record_id, version) collision is rejected loudly via the UNIQUE
@@ -1106,21 +1086,8 @@ var SharedSqlRecordLogic = class {
1106
1086
  insertVersionRow(id, version) {
1107
1087
  try {
1108
1088
  this.exec.run(`INSERT INTO versions
1109
- (record_id, version, type_id, content, updated_at, entity_id,
1110
- updated_by, updated_via, parent_id, associations, permissions)
1111
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
1112
- id,
1113
- version.version,
1114
- version.typeId,
1115
- JSON.stringify(version.content),
1116
- toMs(version.updatedAt),
1117
- version.entityId ?? null,
1118
- version.updatedBy ?? null,
1119
- version.updatedVia ?? null,
1120
- version.parentId ?? null,
1121
- version.associations ? JSON.stringify(version.associations) : null,
1122
- version.permissions ? JSON.stringify(version.permissions) : null
1123
- ]);
1089
+ (record_id, version, ${VERSION_COLUMNS.join(", ")})
1090
+ VALUES (?, ?, ${VERSION_COLUMNS.map(() => "?").join(", ")})`, [id, version.version, ...versionRowValues(version)]);
1124
1091
  } catch (err) {
1125
1092
  if (isUniqueConstraintViolation(err)) {
1126
1093
  throw new StackConflictError(`Version ${version.version} already exists for record "${id}" \u2014 a concurrent writer raced past this version. Use ifVersion to detect this before it happens.`);
@@ -1129,28 +1096,15 @@ var SharedSqlRecordLogic = class {
1129
1096
  }
1130
1097
  }
1131
1098
  /**
1132
- * Replaces every column insertVersionRow writes, so an overwritten row is
1133
- * indistinguishable from a freshly inserted one — a column left out here
1134
- * would keep the replaced row's value and surface later as a restore
1135
- * putting back something the snapshot never said.
1099
+ * Replaces every column insertVersionRow writes — structurally, off the
1100
+ * same VERSION_COLUMNS list, because a column left out here would keep
1101
+ * the replaced row's value and surface later as a restore putting back
1102
+ * something the snapshot never said.
1136
1103
  */
1137
1104
  overwriteVersionRow(id, version) {
1138
1105
  this.exec.run(`UPDATE versions
1139
- SET type_id = ?, content = ?, updated_at = ?, entity_id = ?,
1140
- updated_by = ?, updated_via = ?, parent_id = ?, associations = ?, permissions = ?
1141
- WHERE record_id = ? AND version = ?`, [
1142
- version.typeId,
1143
- JSON.stringify(version.content),
1144
- toMs(version.updatedAt),
1145
- version.entityId ?? null,
1146
- version.updatedBy ?? null,
1147
- version.updatedVia ?? null,
1148
- version.parentId ?? null,
1149
- version.associations ? JSON.stringify(version.associations) : null,
1150
- version.permissions ? JSON.stringify(version.permissions) : null,
1151
- id,
1152
- version.version
1153
- ]);
1106
+ SET ${VERSION_COLUMNS.map((c) => `${c} = ?`).join(", ")}
1107
+ WHERE record_id = ? AND version = ?`, [...versionRowValues(version), id, version.version]);
1154
1108
  }
1155
1109
  /**
1156
1110
  * Standalone snapshot write for tooling and tests — loud on any
@@ -1201,8 +1155,8 @@ var SharedSqlRecordLogic = class {
1201
1155
  this.indexedFieldsByType.set(type.id, indexedFieldKinds(schema));
1202
1156
  }
1203
1157
  async getType(id) {
1204
- const rows = this.exec.all("SELECT * FROM types WHERE id = ?", [id]);
1205
- return rows.length ? rowToType(rows[0]) : null;
1158
+ const row = this.exec.get("SELECT * FROM types WHERE id = ?", [id]);
1159
+ return row ? rowToType(row) : null;
1206
1160
  }
1207
1161
  async listTypes() {
1208
1162
  const rows = this.exec.all("SELECT * FROM types ORDER BY base_id, version");
@@ -1215,19 +1169,16 @@ var SharedSqlRecordLogic = class {
1215
1169
  this.exec.transaction(() => {
1216
1170
  if (opts.snapshot)
1217
1171
  this.snapshotBeforeMutation(recordId, opts.snapshot);
1218
- this.bumpVersion(recordId, opts);
1172
+ this.versionedUpdate(recordId, [], [], opts);
1219
1173
  this.insertAssociations(recordId, [association]);
1220
1174
  });
1221
- const updated = await this.getRecord(recordId);
1222
- if (!updated)
1223
- throw new Error(`Record not found after associate: "${recordId}"`);
1224
- return updated;
1175
+ return this.reread(recordId, "associate");
1225
1176
  }
1226
1177
  async dissociate(recordId, association, opts = {}) {
1227
1178
  this.exec.transaction(() => {
1228
1179
  if (opts.snapshot)
1229
1180
  this.snapshotBeforeMutation(recordId, opts.snapshot);
1230
- this.bumpVersion(recordId, opts);
1181
+ this.versionedUpdate(recordId, [], [], opts);
1231
1182
  this.exec.run(`DELETE FROM associations
1232
1183
  WHERE record_id = ?
1233
1184
  AND kind = ?
@@ -1238,16 +1189,18 @@ var SharedSqlRecordLogic = class {
1238
1189
  AND related_ns = ?
1239
1190
  AND related_stack = ?`, [recordId, association.kind, association.label, ...associationKeyColumns(association)]);
1240
1191
  });
1241
- const updated = await this.getRecord(recordId);
1242
- if (!updated)
1243
- throw new Error(`Record not found after dissociate: "${recordId}"`);
1244
- return updated;
1192
+ return this.reread(recordId, "dissociate");
1245
1193
  }
1246
- bumpVersion(id, opts = {}) {
1247
- const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
1248
- const changed = this.exec.run(`UPDATE records SET version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?${clause}`, [toMs(/* @__PURE__ */ new Date()), opts.updatedBy ?? null, opts.updatedVia ?? null, id, ...verParams]);
1249
- if (changed === 0)
1250
- this.throwVersionConflict(id, opts.expectedVersion);
1194
+ /**
1195
+ * Swap a record's association set for `associations` wholesale — the
1196
+ * "replace, don't merge" semantics a change set's association list and a
1197
+ * version snapshot's both carry. Caller-transactional, like every write
1198
+ * below.
1199
+ */
1200
+ replaceAssociations(recordId, associations) {
1201
+ this.exec.run("DELETE FROM associations WHERE record_id = ?", [recordId]);
1202
+ if (associations.length)
1203
+ this.insertAssociations(recordId, associations);
1251
1204
  }
1252
1205
  /**
1253
1206
  * FK enforcement (PRAGMA_FOREIGN_KEYS_ON) means inserting an
@@ -1319,6 +1272,134 @@ var SharedSqlRecordLogic = class {
1319
1272
  return this.getIndexedFields(record.typeId).get(field);
1320
1273
  }
1321
1274
  };
1275
+
1276
+ // ../sqlite-shared/dist/record-adapter.js
1277
+ var SQLITE_RECORD_CAPABILITIES = {
1278
+ filter: {
1279
+ content: "path",
1280
+ contentPresent: true,
1281
+ search: true
1282
+ },
1283
+ sort: {
1284
+ fields: ["createdAt", "updatedAt", "version"],
1285
+ contentField: true
1286
+ },
1287
+ limits: {
1288
+ attachmentBytes: null,
1289
+ contentBytes: null
1290
+ }
1291
+ };
1292
+ var SharedSqlRecordAdapter = class {
1293
+ capabilities = SQLITE_RECORD_CAPABILITIES;
1294
+ ownerEntityId;
1295
+ timezone;
1296
+ record;
1297
+ constructor(exec, config) {
1298
+ this.record = new SharedSqlRecordLogic({ exec });
1299
+ this.ownerEntityId = config.entityId;
1300
+ this.timezone = config.timezone;
1301
+ }
1302
+ // -------------------------------------------------------
1303
+ // Records
1304
+ // -------------------------------------------------------
1305
+ createRecord(record) {
1306
+ return this.record.createRecord(record);
1307
+ }
1308
+ getRecord(id) {
1309
+ return this.record.getRecord(id);
1310
+ }
1311
+ mutateRecord(id, changes, opts) {
1312
+ return this.record.mutateRecord(id, changes, opts);
1313
+ }
1314
+ deleteRecord(id, opts) {
1315
+ return this.record.deleteRecord(id, opts);
1316
+ }
1317
+ undeleteRecord(id, opts) {
1318
+ return this.record.undeleteRecord(id, opts);
1319
+ }
1320
+ restoreVersion(id, version, opts) {
1321
+ return this.record.restoreVersion(id, version, opts);
1322
+ }
1323
+ commitMigration(id, toTypeId, content, opts) {
1324
+ return this.record.commitMigration(id, toTypeId, content, opts);
1325
+ }
1326
+ queryRecords(query) {
1327
+ return this.record.queryRecords(query);
1328
+ }
1329
+ deleteUnreferencedAttachmentRecords(fileId, metadataTypeIds) {
1330
+ return this.record.deleteUnreferencedAttachmentRecords(fileId, metadataTypeIds);
1331
+ }
1332
+ // -------------------------------------------------------
1333
+ // Versions
1334
+ // -------------------------------------------------------
1335
+ getVersions(id) {
1336
+ return this.record.getVersions(id);
1337
+ }
1338
+ getVersion(id, version) {
1339
+ return this.record.getVersion(id, version);
1340
+ }
1341
+ saveVersion(id, version) {
1342
+ return this.record.saveVersion(id, version);
1343
+ }
1344
+ // -------------------------------------------------------
1345
+ // Types
1346
+ // -------------------------------------------------------
1347
+ saveType(type) {
1348
+ return this.record.saveType(type);
1349
+ }
1350
+ getType(id) {
1351
+ return this.record.getType(id);
1352
+ }
1353
+ listTypes() {
1354
+ return this.record.listTypes();
1355
+ }
1356
+ // -------------------------------------------------------
1357
+ // Associations
1358
+ // -------------------------------------------------------
1359
+ associate(recordId, association, opts) {
1360
+ return this.record.associate(recordId, association, opts);
1361
+ }
1362
+ dissociate(recordId, association, opts) {
1363
+ return this.record.dissociate(recordId, association, opts);
1364
+ }
1365
+ };
1366
+ var lockPathFor = (dbPath) => `${dbPath}.lock`;
1367
+ var isProcessAlive = (pid) => {
1368
+ try {
1369
+ process.kill(pid, 0);
1370
+ return true;
1371
+ } catch (err) {
1372
+ return err.code !== "ESRCH";
1373
+ }
1374
+ };
1375
+ var acquireLock = (dbPath, force) => {
1376
+ const lockPath = lockPathFor(dbPath);
1377
+ const info = existsSync(lockPath) ? readLock(lockPath) : null;
1378
+ if (info) {
1379
+ const ownedBySelf = info.pid === process.pid;
1380
+ if (!ownedBySelf && !force && isProcessAlive(info.pid)) {
1381
+ throw new Error(`Stack database at "${dbPath}" is in use by another process (pid ${info.pid}). Connect via its server instead, or pass { force: true } to override. If no such process is running, remove "${lockPath}".`);
1382
+ }
1383
+ }
1384
+ writeFileSync(lockPath, JSON.stringify({ pid: process.pid }));
1385
+ };
1386
+ var readLock = (lockPath) => {
1387
+ try {
1388
+ const info = JSON.parse(readFileSync(lockPath, "utf-8"));
1389
+ return typeof info?.pid === "number" ? info : null;
1390
+ } catch {
1391
+ return null;
1392
+ }
1393
+ };
1394
+ var releaseLock = (dbPath) => {
1395
+ const lockPath = lockPathFor(dbPath);
1396
+ if (!existsSync(lockPath))
1397
+ return;
1398
+ const info = readLock(lockPath);
1399
+ if (!info || info.pid === process.pid)
1400
+ unlinkSync(lockPath);
1401
+ };
1402
+ var hashToken = (token) => createHash("sha256").update(token).digest("hex");
1322
1403
  var SharedTokenLogic = class {
1323
1404
  deps;
1324
1405
  constructor(deps) {
@@ -1330,7 +1411,7 @@ var SharedTokenLogic = class {
1330
1411
  async createToken(principalId, opts = {}) {
1331
1412
  const id = randomBytes(8).toString("hex");
1332
1413
  const token = randomBytes(32).toString("hex");
1333
- const tokenHash = createHash("sha256").update(token).digest("hex");
1414
+ const tokenHash = hashToken(token);
1334
1415
  this.exec.run("INSERT INTO tokens (id, token_hash, principal_id, subject_id, label, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)", [
1335
1416
  id,
1336
1417
  tokenHash,
@@ -1345,7 +1426,7 @@ var SharedTokenLogic = class {
1345
1426
  return { id, token };
1346
1427
  }
1347
1428
  async lookupToken(token) {
1348
- const hash = createHash("sha256").update(token).digest("hex");
1429
+ const hash = hashToken(token);
1349
1430
  const row = this.exec.get("SELECT principal_id, subject_id, expires_at FROM tokens WHERE token_hash = ?", [hash]);
1350
1431
  if (!row)
1351
1432
  return null;
@@ -1439,33 +1520,26 @@ var NativeTokenStore = class _NativeTokenStore {
1439
1520
  };
1440
1521
 
1441
1522
  // src/index.ts
1442
- var NativeSQLiteRecordAdapter = class _NativeSQLiteRecordAdapter {
1443
- constructor(path) {
1523
+ var NativeSQLiteRecordAdapter = class _NativeSQLiteRecordAdapter extends SharedSqlRecordAdapter {
1524
+ constructor(path, db, exec, config) {
1525
+ super(exec, config);
1444
1526
  this.path = path;
1527
+ this.db = db;
1445
1528
  }
1446
1529
  path;
1447
- capabilities = {
1448
- filter: {
1449
- content: "path",
1450
- contentPresent: true,
1451
- search: true
1452
- },
1453
- sort: {
1454
- fields: ["createdAt", "updatedAt", "version"],
1455
- contentField: true
1456
- },
1457
- limits: {
1458
- attachmentBytes: null,
1459
- contentBytes: null
1460
- }
1461
- };
1462
- ownerEntityId;
1463
- timezone;
1464
1530
  db;
1465
- record;
1466
- wire() {
1467
- const exec = new NativeSqliteExecutor(this.db);
1468
- this.record = new SharedSqlRecordLogic({ exec });
1531
+ /**
1532
+ * Takes the storage-ownership lock, opens the file, and brings the
1533
+ * schema up. Shared by initialize() and open(), which differ only in
1534
+ * whether the file may already exist and in where the config record
1535
+ * comes from.
1536
+ */
1537
+ static attach(path, force) {
1538
+ acquireLock(path, force);
1539
+ const db = new DatabaseSync(path);
1540
+ const exec = new NativeSqliteExecutor(db);
1541
+ applyRecordSchema(exec);
1542
+ return [db, exec];
1469
1543
  }
1470
1544
  /**
1471
1545
  * Initialize a new stack database. Fails if the file already exists —
@@ -1477,18 +1551,9 @@ var NativeSQLiteRecordAdapter = class _NativeSQLiteRecordAdapter {
1477
1551
  `Cannot initialize: database already exists at "${opts.path}". Use NativeSQLiteRecordAdapter.open() instead.`
1478
1552
  );
1479
1553
  }
1480
- acquireLock(opts.path, opts.force);
1481
- const adapter = new _NativeSQLiteRecordAdapter(opts.path);
1482
- adapter.db = new DatabaseSync(opts.path);
1483
- adapter.db.exec(PRAGMA_FOREIGN_KEYS_ON);
1484
- adapter.db.exec(PRAGMA_JOURNAL_MODE_WAL);
1485
- adapter.db.exec(RECORD_SCHEMA_SQL);
1486
- adapter.db.exec(FTS5_SCHEMA_SQL);
1487
- adapter.wire();
1488
- insertConfigRecord(new NativeSqliteExecutor(adapter.db), opts.entityId, opts.timezone);
1489
- adapter.ownerEntityId = opts.entityId;
1490
- adapter.timezone = opts.timezone;
1491
- return adapter;
1554
+ const [db, exec] = _NativeSQLiteRecordAdapter.attach(opts.path, opts.force);
1555
+ const config = insertConfigRecord(exec, opts.entityId, opts.timezone);
1556
+ return new _NativeSQLiteRecordAdapter(opts.path, db, exec, config);
1492
1557
  }
1493
1558
  /**
1494
1559
  * Open an existing stack database. Fails if the file does not exist —
@@ -1500,81 +1565,8 @@ var NativeSQLiteRecordAdapter = class _NativeSQLiteRecordAdapter {
1500
1565
  `Cannot open: no database found at "${opts.path}". Use NativeSQLiteRecordAdapter.initialize() to create one.`
1501
1566
  );
1502
1567
  }
1503
- acquireLock(opts.path, opts.force);
1504
- const adapter = new _NativeSQLiteRecordAdapter(opts.path);
1505
- adapter.db = new DatabaseSync(opts.path);
1506
- adapter.db.exec(PRAGMA_FOREIGN_KEYS_ON);
1507
- adapter.db.exec(PRAGMA_JOURNAL_MODE_WAL);
1508
- adapter.db.exec(RECORD_SCHEMA_SQL);
1509
- adapter.db.exec(FTS5_SCHEMA_SQL);
1510
- adapter.wire();
1511
- const config = readStackConfig(new NativeSqliteExecutor(adapter.db));
1512
- adapter.ownerEntityId = config.entityId;
1513
- adapter.timezone = config.timezone;
1514
- return adapter;
1515
- }
1516
- // -------------------------------------------------------
1517
- // Records
1518
- // -------------------------------------------------------
1519
- createRecord(record) {
1520
- return this.record.createRecord(record);
1521
- }
1522
- getRecord(id) {
1523
- return this.record.getRecord(id);
1524
- }
1525
- mutateRecord(id, changes, opts) {
1526
- return this.record.mutateRecord(id, changes, opts);
1527
- }
1528
- deleteRecord(id, opts) {
1529
- return this.record.deleteRecord(id, opts);
1530
- }
1531
- undeleteRecord(id, opts) {
1532
- return this.record.undeleteRecord(id, opts);
1533
- }
1534
- restoreVersion(id, version, opts) {
1535
- return this.record.restoreVersion(id, version, opts);
1536
- }
1537
- commitMigration(id, toTypeId, content, opts) {
1538
- return this.record.commitMigration(id, toTypeId, content, opts);
1539
- }
1540
- queryRecords(query) {
1541
- return this.record.queryRecords(query);
1542
- }
1543
- deleteUnreferencedAttachmentRecords(fileId, metadataTypeIds) {
1544
- return this.record.deleteUnreferencedAttachmentRecords(fileId, metadataTypeIds);
1545
- }
1546
- // -------------------------------------------------------
1547
- // Versions
1548
- // -------------------------------------------------------
1549
- getVersions(id) {
1550
- return this.record.getVersions(id);
1551
- }
1552
- getVersion(id, version) {
1553
- return this.record.getVersion(id, version);
1554
- }
1555
- saveVersion(id, version) {
1556
- return this.record.saveVersion(id, version);
1557
- }
1558
- // -------------------------------------------------------
1559
- // Types
1560
- // -------------------------------------------------------
1561
- saveType(type) {
1562
- return this.record.saveType(type);
1563
- }
1564
- getType(id) {
1565
- return this.record.getType(id);
1566
- }
1567
- listTypes() {
1568
- return this.record.listTypes();
1569
- }
1570
- // -------------------------------------------------------
1571
- // Associations
1572
- // -------------------------------------------------------
1573
- associate(recordId, association, opts) {
1574
- return this.record.associate(recordId, association, opts);
1575
- }
1576
- dissociate(recordId, association, opts) {
1577
- return this.record.dissociate(recordId, association, opts);
1568
+ const [db, exec] = _NativeSQLiteRecordAdapter.attach(opts.path, opts.force);
1569
+ return new _NativeSQLiteRecordAdapter(opts.path, db, exec, readStackConfig(exec));
1578
1570
  }
1579
1571
  // -------------------------------------------------------
1580
1572
  // Lifecycle