@haverstack/record-adapter-sqlite 0.21.0 → 0.22.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,7 @@ 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 SORT_FIELDS = NATIVE_SORT_FIELDS;
155
155
  var toBase64 = (text) => {
156
156
  const bytes = new TextEncoder().encode(text);
157
157
  let binary = "";
@@ -367,17 +367,30 @@ var recordConditions = (query) => {
367
367
  const conditions = ["r.id != '_config'"];
368
368
  const params = [];
369
369
  const f = query.filter ?? {};
370
+ const inFilter = (column, value) => {
371
+ if (value === void 0)
372
+ return;
373
+ const values = Array.isArray(value) ? value : [value];
374
+ conditions.push(`r.${column} IN (${values.map(() => "?").join(",")})`);
375
+ params.push(...values);
376
+ };
377
+ const rangeFilter = (column, range) => {
378
+ if (range?.after) {
379
+ conditions.push(`r.${column} > ?`);
380
+ params.push(range.after.getTime());
381
+ }
382
+ if (range?.before) {
383
+ conditions.push(`r.${column} < ?`);
384
+ params.push(range.before.getTime());
385
+ }
386
+ };
370
387
  if (!f.includeDeleted) {
371
388
  conditions.push("r.deleted_at IS NULL");
372
389
  }
373
390
  if (!f.includeUnlisted) {
374
391
  conditions.push("r.unlisted_at IS NULL");
375
392
  }
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
- }
393
+ inFilter("type_id", f.typeId);
381
394
  if (f.parentId !== void 0) {
382
395
  if (f.parentId === null) {
383
396
  conditions.push("r.parent_id IS NULL");
@@ -386,49 +399,24 @@ var recordConditions = (query) => {
386
399
  params.push(f.parentId);
387
400
  }
388
401
  }
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
- }
402
+ inFilter("app_id", f.appId);
403
+ inFilter("entity_id", f.entityId);
404
+ inFilter("principal_id", f.principalId);
405
+ rangeFilter("created_at", f.createdAt);
406
+ rangeFilter("updated_at", f.updatedAt);
407
+ 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
408
  if (f.tags?.length) {
421
409
  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 = ?)`);
410
+ conditions.push(hasAssociation("tag", ["a.label = ?"]));
423
411
  params.push(tag);
424
412
  }
425
413
  }
426
414
  if (f.hasAttachment) {
427
- conditions.push(`r.id IN (SELECT a.record_id FROM associations a WHERE a.kind = 'attachment' AND a.label = ?)`);
415
+ conditions.push(hasAssociation("attachment", ["a.label = ?"]));
428
416
  params.push(f.hasAttachment);
429
417
  }
430
418
  if (f.attachmentFileId) {
431
- conditions.push(`(r.id IN (SELECT a.record_id FROM associations a WHERE a.kind = 'attachment' AND a.file_id = ?)
419
+ conditions.push(`(${hasAssociation("attachment", ["a.file_id = ?"])}
432
420
  OR r.id IN (SELECT ci.record_id FROM content_index ci WHERE ci.file_id = ?))`);
433
421
  params.push(f.attachmentFileId, f.attachmentFileId);
434
422
  }
@@ -457,7 +445,7 @@ var recordConditions = (query) => {
457
445
  }
458
446
  }
459
447
  }
460
- conditions.push(`r.id IN (SELECT a.record_id FROM associations a WHERE a.kind = 'relationship'` + clauses.map((c) => ` AND ${c}`).join("") + `)`);
448
+ conditions.push(hasAssociation("relationship", clauses));
461
449
  }
462
450
  if (f.content) {
463
451
  let aliasSeq = 0;
@@ -656,46 +644,6 @@ var rowToVersion = (row) => {
656
644
  v.permissions = JSON.parse(row.permissions);
657
645
  return v;
658
646
  };
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
647
 
700
648
  // ../sqlite-shared/dist/executor.js
701
649
  var isForeignKeyViolation = (err) => err instanceof Error && err.message.includes("FOREIGN KEY constraint failed");
@@ -723,6 +671,29 @@ var asStackQueryError = (query, run) => {
723
671
  throw new StackQueryError(`Search text could not be parsed: ${err instanceof Error ? err.message : String(err)}`);
724
672
  }
725
673
  };
674
+ var versionConflict = (id, expected, actual) => new StackVersionConflictError(`Record "${id}" is at version ${actual}, expected ${expected}`, id, expected, actual);
675
+ var VERSION_COLUMNS = [
676
+ "type_id",
677
+ "content",
678
+ "updated_at",
679
+ "entity_id",
680
+ "updated_by",
681
+ "updated_via",
682
+ "parent_id",
683
+ "associations",
684
+ "permissions"
685
+ ];
686
+ var versionRowValues = (version) => [
687
+ version.typeId,
688
+ JSON.stringify(version.content),
689
+ toMs(version.updatedAt),
690
+ version.entityId ?? null,
691
+ version.updatedBy ?? null,
692
+ version.updatedVia ?? null,
693
+ version.parentId ?? null,
694
+ version.associations ? JSON.stringify(version.associations) : null,
695
+ version.permissions ? JSON.stringify(version.permissions) : null
696
+ ];
726
697
  var indexedFieldKinds = (schema) => {
727
698
  const kinds = /* @__PURE__ */ new Map();
728
699
  for (const [field, def] of Object.entries(schema)) {
@@ -748,26 +719,22 @@ var SharedSqlRecordLogic = class {
748
719
  return this.deps.exec;
749
720
  }
750
721
  /**
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.
722
+ * Precondition check for a mutation that has already read the record —
723
+ * either because it can't fold the check into its own statement
724
+ * (hardDeleteRecord: children reference the row, so the DELETE can't
725
+ * carry it), or because it has to settle the precondition *before*
726
+ * fts5Strategy.remove() runs, which needs the records row still holding
727
+ * the old content. In the latter case versionedUpdate() re-checks it
728
+ * inside the statement, and that is the enforcement; this is what lets
729
+ * the work before it be skipped.
763
730
  */
764
731
  checkExpectedVersion(record, expectedVersion) {
765
732
  if (expectedVersion === void 0 || record.version === expectedVersion)
766
733
  return;
767
- throw new StackVersionConflictError(`Record "${record.id}" is at version ${record.version}, expected ${expectedVersion}`, record.id, expectedVersion, record.version);
734
+ throw versionConflict(record.id, expectedVersion, record.version);
768
735
  }
769
736
  /**
770
- * Called after a versionGuard()-gated statement affected zero rows.
737
+ * Called after a version-gated statement affected zero rows.
771
738
  * Distinguishes "record doesn't exist" (StackNotFoundError) from "it
772
739
  * exists but isn't at expectedVersion" (StackVersionConflictError, with
773
740
  * the actual current version for the caller to act on).
@@ -778,7 +745,48 @@ var SharedSqlRecordLogic = class {
778
745
  ]);
779
746
  if (!row)
780
747
  throw new StackNotFoundError(`Record not found: "${id}"`);
781
- throw new StackVersionConflictError(`Record "${id}" is at version ${row.version}, expected ${expectedVersion}`, id, expectedVersion, row.version);
748
+ throw versionConflict(id, expectedVersion, row.version);
749
+ }
750
+ /**
751
+ * The one shape every mutating UPDATE in this class has: whatever columns
752
+ * the caller is changing, then the version bump and the actor/timestamp
753
+ * stamp that every mutation owes, gated on the opt-in `expectedVersion`
754
+ * precondition and failing loudly when nothing matched.
755
+ *
756
+ * The guard rides in the WHERE clause even where the caller has already
757
+ * checked the version against a record it read: that read is outside this
758
+ * statement, so a writer slipping in between the two is caught here
759
+ * rather than silently overwritten.
760
+ */
761
+ versionedUpdate(id, sets, values, opts, now = toMs(/* @__PURE__ */ new Date())) {
762
+ const guarded = opts.expectedVersion !== void 0;
763
+ const changed = this.exec.run(`UPDATE records SET ${[...sets, "version = version + 1", "updated_at = ?", "updated_by = ?", "updated_via = ?"].join(", ")} WHERE id = ?${guarded ? " AND version = ?" : ""}`, [
764
+ ...values,
765
+ now,
766
+ opts.updatedBy ?? null,
767
+ opts.updatedVia ?? null,
768
+ id,
769
+ ...guarded ? [opts.expectedVersion] : []
770
+ ]);
771
+ if (changed === 0)
772
+ this.throwVersionConflict(id, opts.expectedVersion);
773
+ }
774
+ /**
775
+ * Read a record back after its own mutation committed, for the caller to
776
+ * return. Its absence is not a "not found" for the caller to handle — the
777
+ * mutation just succeeded against it — so it fails as the invariant
778
+ * breach it would be.
779
+ *
780
+ * Synchronous, via readRecord() rather than getRecord(), so the read
781
+ * cannot be separated from the commit by anything: an async caller
782
+ * returning this still hands back a record read in the same tick its
783
+ * transaction committed. See readRecord() for why that matters.
784
+ */
785
+ reread(id, verb) {
786
+ const updated = this.readRecord(id);
787
+ if (!updated)
788
+ throw new Error(`Record not found after ${verb}: "${id}"`);
789
+ return updated;
782
790
  }
783
791
  // -------------------------------------------------------
784
792
  // Records
@@ -835,11 +843,11 @@ var SharedSqlRecordLogic = class {
835
843
  * reason (see SqlExecutor.transaction).
836
844
  *
837
845
  * 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.
846
+ * version their own mutation produced: reread() calls this directly, so
847
+ * nothing interleaves between the commit and the read even though the
848
+ * method returning it is async. A backend that made these reads genuinely
849
+ * asynchronous would open that window, and a concurrent mutation could be
850
+ * reported under this one's verb.
843
851
  */
844
852
  readRecord(id) {
845
853
  const row = this.exec.get("SELECT * FROM records WHERE id = ?", [id]);
@@ -890,49 +898,28 @@ var SharedSqlRecordLogic = class {
890
898
  this.snapshotBeforeMutation(id, opts.snapshot);
891
899
  if (merged !== void 0)
892
900
  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);
901
+ this.versionedUpdate(id, sets, values, opts, now);
897
902
  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);
903
+ this.replaceAssociations(id, changes.associations);
901
904
  }
902
905
  if (merged !== void 0) {
903
906
  fts5Strategy.insert(this.exec, id, JSON.stringify(merged));
904
907
  this.syncContentIndex(id, existing.typeId, merged);
905
908
  }
906
909
  });
907
- const updated = await this.getRecord(id);
908
- if (!updated)
909
- throw new Error(`Record not found after mutateRecord: "${id}"`);
910
- return updated;
910
+ return this.reread(id, "mutateRecord");
911
911
  }
912
912
  async deleteRecord(id, opts = {}) {
913
913
  if (opts.hard) {
914
914
  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
915
  }
932
- const updated = await this.getRecord(id);
933
- if (!updated)
934
- throw new Error(`Record not found after deleteRecord: "${id}"`);
935
- return updated;
916
+ const now = toMs(/* @__PURE__ */ new Date());
917
+ this.exec.transaction(() => {
918
+ if (opts.snapshot)
919
+ this.snapshotBeforeMutation(id, opts.snapshot);
920
+ this.versionedUpdate(id, ["deleted_at = ?"], [now], opts, now);
921
+ });
922
+ return this.reread(id, "deleteRecord");
936
923
  }
937
924
  /**
938
925
  * Deletes a record's FTS entry, associations, versions, file-refs, sort
@@ -945,15 +932,12 @@ var SharedSqlRecordLogic = class {
945
932
  */
946
933
  hardDeleteRecord(id, expectedVersion) {
947
934
  const purged = this.readRecord(id);
948
- if (expectedVersion !== void 0) {
949
- if (!purged)
935
+ if (!purged) {
936
+ if (expectedVersion !== void 0)
950
937
  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
938
  return null;
939
+ }
940
+ this.checkExpectedVersion(purged, expectedVersion);
957
941
  fts5Strategy.remove(this.exec, id);
958
942
  this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
959
943
  this.exec.run("DELETE FROM versions WHERE record_id = ?", [id]);
@@ -965,15 +949,9 @@ var SharedSqlRecordLogic = class {
965
949
  this.exec.transaction(() => {
966
950
  if (opts.snapshot)
967
951
  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);
952
+ this.versionedUpdate(id, ["deleted_at = NULL"], [], opts);
972
953
  });
973
- const updated = await this.getRecord(id);
974
- if (!updated)
975
- throw new Error(`Record not found after undelete: "${id}"`);
976
- return updated;
954
+ return this.reread(id, "undelete");
977
955
  }
978
956
  async restoreVersion(id, version, opts = {}) {
979
957
  const existing = await this.getRecord(id);
@@ -986,29 +964,12 @@ var SharedSqlRecordLogic = class {
986
964
  this.exec.transaction(() => {
987
965
  if (opts.snapshot)
988
966
  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
- ]);
967
+ this.rewriteContent(id, target.typeId, target.content, opts, ["parent_id = ?"], [target.parentId ?? null]);
999
968
  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);
969
+ if (applied !== void 0)
970
+ this.replaceAssociations(id, applied);
1007
971
  });
1008
- const updated = await this.getRecord(id);
1009
- if (!updated)
1010
- throw new Error(`Record not found after restoreVersion: "${id}"`);
1011
- return updated;
972
+ return this.reread(id, "restoreVersion");
1012
973
  }
1013
974
  async commitMigration(id, toTypeId, content, opts = {}) {
1014
975
  const existing = await this.getRecord(id);
@@ -1018,22 +979,27 @@ var SharedSqlRecordLogic = class {
1018
979
  this.exec.transaction(() => {
1019
980
  if (opts.snapshot)
1020
981
  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);
982
+ this.rewriteContent(id, toTypeId, content, opts);
1032
983
  });
1033
- const updated = await this.getRecord(id);
1034
- if (!updated)
1035
- throw new Error(`Record not found after commitMigration: "${id}"`);
1036
- return updated;
984
+ return this.reread(id, "commitMigration");
985
+ }
986
+ /**
987
+ * Replace a record's type and content wholesale — what restoreVersion and
988
+ * commitMigration each do, differing only in where the content came from
989
+ * and whether the container moves with it.
990
+ *
991
+ * The order is the constraint: fts5Strategy.remove() has to read the old
992
+ * content off the records row, so it runs before the UPDATE, and the
993
+ * re-index runs after. Every caller must already sit inside
994
+ * exec.transaction() — a failure between the two halves would otherwise
995
+ * leave the record unindexed.
996
+ */
997
+ rewriteContent(id, typeId, content, opts, extraSets = [], extraValues = []) {
998
+ const json = JSON.stringify(content);
999
+ fts5Strategy.remove(this.exec, id);
1000
+ this.versionedUpdate(id, ["type_id = ?", "content = ?", ...extraSets], [typeId, json, ...extraValues], opts);
1001
+ fts5Strategy.insert(this.exec, id, json);
1002
+ this.syncContentIndex(id, typeId, content);
1037
1003
  }
1038
1004
  async queryRecords(query) {
1039
1005
  const limit = query.limit ?? 50;
@@ -1094,8 +1060,8 @@ var SharedSqlRecordLogic = class {
1094
1060
  return rows.map(rowToVersion);
1095
1061
  }
1096
1062
  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;
1063
+ const row = this.exec.get("SELECT * FROM versions WHERE record_id = ? AND version = ?", [id, version]);
1064
+ return row ? rowToVersion(row) : null;
1099
1065
  }
1100
1066
  /**
1101
1067
  * A (record_id, version) collision is rejected loudly via the UNIQUE
@@ -1106,21 +1072,8 @@ var SharedSqlRecordLogic = class {
1106
1072
  insertVersionRow(id, version) {
1107
1073
  try {
1108
1074
  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
- ]);
1075
+ (record_id, version, ${VERSION_COLUMNS.join(", ")})
1076
+ VALUES (?, ?, ${VERSION_COLUMNS.map(() => "?").join(", ")})`, [id, version.version, ...versionRowValues(version)]);
1124
1077
  } catch (err) {
1125
1078
  if (isUniqueConstraintViolation(err)) {
1126
1079
  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 +1082,15 @@ var SharedSqlRecordLogic = class {
1129
1082
  }
1130
1083
  }
1131
1084
  /**
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.
1085
+ * Replaces every column insertVersionRow writes — structurally, off the
1086
+ * same VERSION_COLUMNS list, because a column left out here would keep
1087
+ * the replaced row's value and surface later as a restore putting back
1088
+ * something the snapshot never said.
1136
1089
  */
1137
1090
  overwriteVersionRow(id, version) {
1138
1091
  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
- ]);
1092
+ SET ${VERSION_COLUMNS.map((c) => `${c} = ?`).join(", ")}
1093
+ WHERE record_id = ? AND version = ?`, [...versionRowValues(version), id, version.version]);
1154
1094
  }
1155
1095
  /**
1156
1096
  * Standalone snapshot write for tooling and tests — loud on any
@@ -1201,8 +1141,8 @@ var SharedSqlRecordLogic = class {
1201
1141
  this.indexedFieldsByType.set(type.id, indexedFieldKinds(schema));
1202
1142
  }
1203
1143
  async getType(id) {
1204
- const rows = this.exec.all("SELECT * FROM types WHERE id = ?", [id]);
1205
- return rows.length ? rowToType(rows[0]) : null;
1144
+ const row = this.exec.get("SELECT * FROM types WHERE id = ?", [id]);
1145
+ return row ? rowToType(row) : null;
1206
1146
  }
1207
1147
  async listTypes() {
1208
1148
  const rows = this.exec.all("SELECT * FROM types ORDER BY base_id, version");
@@ -1215,19 +1155,16 @@ var SharedSqlRecordLogic = class {
1215
1155
  this.exec.transaction(() => {
1216
1156
  if (opts.snapshot)
1217
1157
  this.snapshotBeforeMutation(recordId, opts.snapshot);
1218
- this.bumpVersion(recordId, opts);
1158
+ this.versionedUpdate(recordId, [], [], opts);
1219
1159
  this.insertAssociations(recordId, [association]);
1220
1160
  });
1221
- const updated = await this.getRecord(recordId);
1222
- if (!updated)
1223
- throw new Error(`Record not found after associate: "${recordId}"`);
1224
- return updated;
1161
+ return this.reread(recordId, "associate");
1225
1162
  }
1226
1163
  async dissociate(recordId, association, opts = {}) {
1227
1164
  this.exec.transaction(() => {
1228
1165
  if (opts.snapshot)
1229
1166
  this.snapshotBeforeMutation(recordId, opts.snapshot);
1230
- this.bumpVersion(recordId, opts);
1167
+ this.versionedUpdate(recordId, [], [], opts);
1231
1168
  this.exec.run(`DELETE FROM associations
1232
1169
  WHERE record_id = ?
1233
1170
  AND kind = ?
@@ -1238,16 +1175,18 @@ var SharedSqlRecordLogic = class {
1238
1175
  AND related_ns = ?
1239
1176
  AND related_stack = ?`, [recordId, association.kind, association.label, ...associationKeyColumns(association)]);
1240
1177
  });
1241
- const updated = await this.getRecord(recordId);
1242
- if (!updated)
1243
- throw new Error(`Record not found after dissociate: "${recordId}"`);
1244
- return updated;
1178
+ return this.reread(recordId, "dissociate");
1245
1179
  }
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);
1180
+ /**
1181
+ * Swap a record's association set for `associations` wholesale — the
1182
+ * "replace, don't merge" semantics a change set's association list and a
1183
+ * version snapshot's both carry. Caller-transactional, like every write
1184
+ * below.
1185
+ */
1186
+ replaceAssociations(recordId, associations) {
1187
+ this.exec.run("DELETE FROM associations WHERE record_id = ?", [recordId]);
1188
+ if (associations.length)
1189
+ this.insertAssociations(recordId, associations);
1251
1190
  }
1252
1191
  /**
1253
1192
  * FK enforcement (PRAGMA_FOREIGN_KEYS_ON) means inserting an
@@ -1319,6 +1258,43 @@ var SharedSqlRecordLogic = class {
1319
1258
  return this.getIndexedFields(record.typeId).get(field);
1320
1259
  }
1321
1260
  };
1261
+ var lockPathFor = (dbPath) => `${dbPath}.lock`;
1262
+ var isProcessAlive = (pid) => {
1263
+ try {
1264
+ process.kill(pid, 0);
1265
+ return true;
1266
+ } catch (err) {
1267
+ return err.code !== "ESRCH";
1268
+ }
1269
+ };
1270
+ var acquireLock = (dbPath, force) => {
1271
+ const lockPath = lockPathFor(dbPath);
1272
+ const info = existsSync(lockPath) ? readLock(lockPath) : null;
1273
+ if (info) {
1274
+ const ownedBySelf = info.pid === process.pid;
1275
+ if (!ownedBySelf && !force && isProcessAlive(info.pid)) {
1276
+ 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}".`);
1277
+ }
1278
+ }
1279
+ writeFileSync(lockPath, JSON.stringify({ pid: process.pid }));
1280
+ };
1281
+ var readLock = (lockPath) => {
1282
+ try {
1283
+ const info = JSON.parse(readFileSync(lockPath, "utf-8"));
1284
+ return typeof info?.pid === "number" ? info : null;
1285
+ } catch {
1286
+ return null;
1287
+ }
1288
+ };
1289
+ var releaseLock = (dbPath) => {
1290
+ const lockPath = lockPathFor(dbPath);
1291
+ if (!existsSync(lockPath))
1292
+ return;
1293
+ const info = readLock(lockPath);
1294
+ if (!info || info.pid === process.pid)
1295
+ unlinkSync(lockPath);
1296
+ };
1297
+ var hashToken = (token) => createHash("sha256").update(token).digest("hex");
1322
1298
  var SharedTokenLogic = class {
1323
1299
  deps;
1324
1300
  constructor(deps) {
@@ -1330,7 +1306,7 @@ var SharedTokenLogic = class {
1330
1306
  async createToken(principalId, opts = {}) {
1331
1307
  const id = randomBytes(8).toString("hex");
1332
1308
  const token = randomBytes(32).toString("hex");
1333
- const tokenHash = createHash("sha256").update(token).digest("hex");
1309
+ const tokenHash = hashToken(token);
1334
1310
  this.exec.run("INSERT INTO tokens (id, token_hash, principal_id, subject_id, label, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)", [
1335
1311
  id,
1336
1312
  tokenHash,
@@ -1345,7 +1321,7 @@ var SharedTokenLogic = class {
1345
1321
  return { id, token };
1346
1322
  }
1347
1323
  async lookupToken(token) {
1348
- const hash = createHash("sha256").update(token).digest("hex");
1324
+ const hash = hashToken(token);
1349
1325
  const row = this.exec.get("SELECT principal_id, subject_id, expires_at FROM tokens WHERE token_hash = ?", [hash]);
1350
1326
  if (!row)
1351
1327
  return null;