@haverstack/record-adapter-sqlite 0.20.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: patchContent/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]);
@@ -848,55 +856,70 @@ var SharedSqlRecordLogic = class {
848
856
  const associations = this.getAssociationsForRecord(id);
849
857
  return rowToRecord(row, associations);
850
858
  }
851
- async patchContent(id, patch, opts = {}) {
859
+ /**
860
+ * Apply a change set in one transaction: one UPDATE over the record row
861
+ * with only the columns the change set names, the association set
862
+ * replaced when it carries one, and the snapshot written alongside — so
863
+ * one call is one version whatever it moved.
864
+ *
865
+ * The expectedVersion check is standalone rather than folded into the
866
+ * UPDATE alone, because a content change needs fts.remove() to run
867
+ * *before* the records-table content changes. The guard is still in the
868
+ * UPDATE's WHERE, so a writer that slipped in between the read and the
869
+ * write is caught there rather than overwriting.
870
+ */
871
+ async mutateRecord(id, changes, opts = {}) {
852
872
  const existing = await this.getRecord(id);
853
873
  if (!existing)
854
- throw new Error(`Record not found: "${id}"`);
874
+ throw new StackNotFoundError(`Record not found: "${id}"`);
855
875
  this.checkExpectedVersion(existing, opts.expectedVersion);
856
- const merged = applyMergePatch(existing.content, patch);
876
+ const merged = changes.contentPatch ? applyMergePatch(existing.content, changes.contentPatch) : void 0;
877
+ const sets = [];
878
+ const values = [];
879
+ if (merged !== void 0) {
880
+ sets.push("content = ?");
881
+ values.push(JSON.stringify(merged));
882
+ }
883
+ if (changes.parentId !== void 0) {
884
+ sets.push("parent_id = ?");
885
+ values.push(changes.parentId);
886
+ }
887
+ if (changes.permissions !== void 0) {
888
+ sets.push("permissions = ?");
889
+ values.push(changes.permissions.length ? JSON.stringify(changes.permissions) : null);
890
+ }
891
+ const now = toMs(/* @__PURE__ */ new Date());
892
+ if (changes.unlisted !== void 0) {
893
+ sets.push("unlisted_at = ?");
894
+ values.push(changes.unlisted ? now : null);
895
+ }
857
896
  this.exec.transaction(() => {
858
897
  if (opts.snapshot)
859
898
  this.snapshotBeforeMutation(id, opts.snapshot);
860
- fts5Strategy.remove(this.exec, id);
861
- this.exec.run(`UPDATE records SET content = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?`, [
862
- JSON.stringify(merged),
863
- toMs(/* @__PURE__ */ new Date()),
864
- opts.updatedBy ?? null,
865
- opts.updatedVia ?? null,
866
- id
867
- ]);
868
- fts5Strategy.insert(this.exec, id, JSON.stringify(merged));
869
- this.syncContentIndex(id, existing.typeId, merged);
899
+ if (merged !== void 0)
900
+ fts5Strategy.remove(this.exec, id);
901
+ this.versionedUpdate(id, sets, values, opts, now);
902
+ if (changes.associations !== void 0) {
903
+ this.replaceAssociations(id, changes.associations);
904
+ }
905
+ if (merged !== void 0) {
906
+ fts5Strategy.insert(this.exec, id, JSON.stringify(merged));
907
+ this.syncContentIndex(id, existing.typeId, merged);
908
+ }
870
909
  });
871
- const updated = await this.getRecord(id);
872
- if (!updated)
873
- throw new Error(`Record not found after patchContent: "${id}"`);
874
- return updated;
910
+ return this.reread(id, "mutateRecord");
875
911
  }
876
912
  async deleteRecord(id, opts = {}) {
877
913
  if (opts.hard) {
878
914
  return this.exec.transaction(() => this.hardDeleteRecord(id, opts.expectedVersion));
879
- } else {
880
- this.exec.transaction(() => {
881
- if (opts.snapshot)
882
- this.snapshotBeforeMutation(id, opts.snapshot);
883
- const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
884
- const changed = this.exec.run(`UPDATE records SET deleted_at = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?${clause}`, [
885
- toMs(/* @__PURE__ */ new Date()),
886
- toMs(/* @__PURE__ */ new Date()),
887
- opts.updatedBy ?? null,
888
- opts.updatedVia ?? null,
889
- id,
890
- ...verParams
891
- ]);
892
- if (changed === 0)
893
- this.throwVersionConflict(id, opts.expectedVersion);
894
- });
895
915
  }
896
- const updated = await this.getRecord(id);
897
- if (!updated)
898
- throw new Error(`Record not found after deleteRecord: "${id}"`);
899
- 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");
900
923
  }
901
924
  /**
902
925
  * Deletes a record's FTS entry, associations, versions, file-refs, sort
@@ -909,15 +932,12 @@ var SharedSqlRecordLogic = class {
909
932
  */
910
933
  hardDeleteRecord(id, expectedVersion) {
911
934
  const purged = this.readRecord(id);
912
- if (expectedVersion !== void 0) {
913
- if (!purged)
935
+ if (!purged) {
936
+ if (expectedVersion !== void 0)
914
937
  throw new StackNotFoundError(`Record not found: "${id}"`);
915
- if (purged.version !== expectedVersion) {
916
- throw new StackVersionConflictError(`Record "${id}" is at version ${purged.version}, expected ${expectedVersion}`, id, expectedVersion, purged.version);
917
- }
918
- }
919
- if (!purged)
920
938
  return null;
939
+ }
940
+ this.checkExpectedVersion(purged, expectedVersion);
921
941
  fts5Strategy.remove(this.exec, id);
922
942
  this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
923
943
  this.exec.run("DELETE FROM versions WHERE record_id = ?", [id]);
@@ -929,79 +949,9 @@ var SharedSqlRecordLogic = class {
929
949
  this.exec.transaction(() => {
930
950
  if (opts.snapshot)
931
951
  this.snapshotBeforeMutation(id, opts.snapshot);
932
- const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
933
- 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]);
934
- if (changed === 0)
935
- this.throwVersionConflict(id, opts.expectedVersion);
936
- });
937
- const updated = await this.getRecord(id);
938
- if (!updated)
939
- throw new Error(`Record not found after undelete: "${id}"`);
940
- return updated;
941
- }
942
- async setPermissions(id, permissions, opts = {}) {
943
- this.exec.transaction(() => {
944
- if (opts.snapshot)
945
- this.snapshotBeforeMutation(id, opts.snapshot);
946
- const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
947
- const changed = this.exec.run(`UPDATE records SET permissions = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?${clause}`, [
948
- permissions.length ? JSON.stringify(permissions) : null,
949
- toMs(/* @__PURE__ */ new Date()),
950
- opts.updatedBy ?? null,
951
- opts.updatedVia ?? null,
952
- id,
953
- ...verParams
954
- ]);
955
- if (changed === 0)
956
- this.throwVersionConflict(id, opts.expectedVersion);
952
+ this.versionedUpdate(id, ["deleted_at = NULL"], [], opts);
957
953
  });
958
- const updated = await this.getRecord(id);
959
- if (!updated)
960
- throw new Error(`Record not found after setPermissions: "${id}"`);
961
- return updated;
962
- }
963
- async setUnlisted(id, unlisted, opts = {}) {
964
- this.exec.transaction(() => {
965
- if (opts.snapshot)
966
- this.snapshotBeforeMutation(id, opts.snapshot);
967
- const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
968
- const now = toMs(/* @__PURE__ */ new Date());
969
- const changed = this.exec.run(`UPDATE records SET unlisted_at = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?${clause}`, [
970
- unlisted ? now : null,
971
- now,
972
- opts.updatedBy ?? null,
973
- opts.updatedVia ?? null,
974
- id,
975
- ...verParams
976
- ]);
977
- if (changed === 0)
978
- this.throwVersionConflict(id, opts.expectedVersion);
979
- });
980
- const updated = await this.getRecord(id);
981
- if (!updated)
982
- throw new Error(`Record not found after setUnlisted: "${id}"`);
983
- return updated;
984
- }
985
- async setParent(id, parentId, opts = {}) {
986
- this.exec.transaction(() => {
987
- if (opts.snapshot)
988
- this.snapshotBeforeMutation(id, opts.snapshot);
989
- const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
990
- const changed = this.exec.run(`UPDATE records SET parent_id = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?${clause}`, [
991
- parentId,
992
- toMs(/* @__PURE__ */ new Date()),
993
- opts.updatedBy ?? null,
994
- opts.updatedVia ?? null,
995
- id,
996
- ...verParams
997
- ]);
998
- if (changed === 0)
999
- this.throwVersionConflict(id, opts.expectedVersion);
1000
- });
1001
- const updated = await this.getRecord(id);
1002
- if (!updated)
1003
- throw new Error(`Record not found after setParent: "${id}"`);
1004
- return updated;
954
+ return this.reread(id, "undelete");
1005
955
  }
1006
956
  async restoreVersion(id, version, opts = {}) {
1007
957
  const existing = await this.getRecord(id);
@@ -1014,28 +964,12 @@ var SharedSqlRecordLogic = class {
1014
964
  this.exec.transaction(() => {
1015
965
  if (opts.snapshot)
1016
966
  this.snapshotBeforeMutation(id, opts.snapshot);
1017
- fts5Strategy.remove(this.exec, id);
1018
- this.exec.run(`UPDATE records SET type_id = ?, content = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ?, parent_id = ? WHERE id = ?`, [
1019
- target.typeId,
1020
- JSON.stringify(target.content),
1021
- toMs(/* @__PURE__ */ new Date()),
1022
- opts.updatedBy ?? null,
1023
- opts.updatedVia ?? null,
1024
- target.parentId ?? null,
1025
- id
1026
- ]);
1027
- if (target.associations !== void 0) {
1028
- this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
1029
- if (target.associations.length)
1030
- this.insertAssociations(id, target.associations);
1031
- }
1032
- fts5Strategy.insert(this.exec, id, JSON.stringify(target.content));
1033
- this.syncContentIndex(id, target.typeId, target.content);
967
+ this.rewriteContent(id, target.typeId, target.content, opts, ["parent_id = ?"], [target.parentId ?? null]);
968
+ const applied = opts.restoreAssociations === false ? void 0 : target.associations;
969
+ if (applied !== void 0)
970
+ this.replaceAssociations(id, applied);
1034
971
  });
1035
- const updated = await this.getRecord(id);
1036
- if (!updated)
1037
- throw new Error(`Record not found after restoreVersion: "${id}"`);
1038
- return updated;
972
+ return this.reread(id, "restoreVersion");
1039
973
  }
1040
974
  async commitMigration(id, toTypeId, content, opts = {}) {
1041
975
  const existing = await this.getRecord(id);
@@ -1045,22 +979,27 @@ var SharedSqlRecordLogic = class {
1045
979
  this.exec.transaction(() => {
1046
980
  if (opts.snapshot)
1047
981
  this.snapshotBeforeMutation(id, opts.snapshot);
1048
- fts5Strategy.remove(this.exec, id);
1049
- this.exec.run(`UPDATE records SET type_id = ?, content = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?`, [
1050
- toTypeId,
1051
- JSON.stringify(content),
1052
- toMs(/* @__PURE__ */ new Date()),
1053
- opts.updatedBy ?? null,
1054
- opts.updatedVia ?? null,
1055
- id
1056
- ]);
1057
- fts5Strategy.insert(this.exec, id, JSON.stringify(content));
1058
- this.syncContentIndex(id, toTypeId, content);
982
+ this.rewriteContent(id, toTypeId, content, opts);
1059
983
  });
1060
- const updated = await this.getRecord(id);
1061
- if (!updated)
1062
- throw new Error(`Record not found after commitMigration: "${id}"`);
1063
- 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);
1064
1003
  }
1065
1004
  async queryRecords(query) {
1066
1005
  const limit = query.limit ?? 50;
@@ -1121,8 +1060,8 @@ var SharedSqlRecordLogic = class {
1121
1060
  return rows.map(rowToVersion);
1122
1061
  }
1123
1062
  async getVersion(id, version) {
1124
- const rows = this.exec.all("SELECT * FROM versions WHERE record_id = ? AND version = ?", [id, version]);
1125
- 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;
1126
1065
  }
1127
1066
  /**
1128
1067
  * A (record_id, version) collision is rejected loudly via the UNIQUE
@@ -1133,21 +1072,8 @@ var SharedSqlRecordLogic = class {
1133
1072
  insertVersionRow(id, version) {
1134
1073
  try {
1135
1074
  this.exec.run(`INSERT INTO versions
1136
- (record_id, version, type_id, content, updated_at, entity_id,
1137
- updated_by, updated_via, parent_id, associations, permissions)
1138
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
1139
- id,
1140
- version.version,
1141
- version.typeId,
1142
- JSON.stringify(version.content),
1143
- toMs(version.updatedAt),
1144
- version.entityId ?? null,
1145
- version.updatedBy ?? null,
1146
- version.updatedVia ?? null,
1147
- version.parentId ?? null,
1148
- version.associations ? JSON.stringify(version.associations) : null,
1149
- version.permissions ? JSON.stringify(version.permissions) : null
1150
- ]);
1075
+ (record_id, version, ${VERSION_COLUMNS.join(", ")})
1076
+ VALUES (?, ?, ${VERSION_COLUMNS.map(() => "?").join(", ")})`, [id, version.version, ...versionRowValues(version)]);
1151
1077
  } catch (err) {
1152
1078
  if (isUniqueConstraintViolation(err)) {
1153
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.`);
@@ -1156,28 +1082,15 @@ var SharedSqlRecordLogic = class {
1156
1082
  }
1157
1083
  }
1158
1084
  /**
1159
- * Replaces every column insertVersionRow writes, so an overwritten row is
1160
- * indistinguishable from a freshly inserted one — a column left out here
1161
- * would keep the replaced row's value and surface later as a restore
1162
- * 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.
1163
1089
  */
1164
1090
  overwriteVersionRow(id, version) {
1165
1091
  this.exec.run(`UPDATE versions
1166
- SET type_id = ?, content = ?, updated_at = ?, entity_id = ?,
1167
- updated_by = ?, updated_via = ?, parent_id = ?, associations = ?, permissions = ?
1168
- WHERE record_id = ? AND version = ?`, [
1169
- version.typeId,
1170
- JSON.stringify(version.content),
1171
- toMs(version.updatedAt),
1172
- version.entityId ?? null,
1173
- version.updatedBy ?? null,
1174
- version.updatedVia ?? null,
1175
- version.parentId ?? null,
1176
- version.associations ? JSON.stringify(version.associations) : null,
1177
- version.permissions ? JSON.stringify(version.permissions) : null,
1178
- id,
1179
- version.version
1180
- ]);
1092
+ SET ${VERSION_COLUMNS.map((c) => `${c} = ?`).join(", ")}
1093
+ WHERE record_id = ? AND version = ?`, [...versionRowValues(version), id, version.version]);
1181
1094
  }
1182
1095
  /**
1183
1096
  * Standalone snapshot write for tooling and tests — loud on any
@@ -1228,8 +1141,8 @@ var SharedSqlRecordLogic = class {
1228
1141
  this.indexedFieldsByType.set(type.id, indexedFieldKinds(schema));
1229
1142
  }
1230
1143
  async getType(id) {
1231
- const rows = this.exec.all("SELECT * FROM types WHERE id = ?", [id]);
1232
- 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;
1233
1146
  }
1234
1147
  async listTypes() {
1235
1148
  const rows = this.exec.all("SELECT * FROM types ORDER BY base_id, version");
@@ -1242,19 +1155,16 @@ var SharedSqlRecordLogic = class {
1242
1155
  this.exec.transaction(() => {
1243
1156
  if (opts.snapshot)
1244
1157
  this.snapshotBeforeMutation(recordId, opts.snapshot);
1245
- this.bumpVersion(recordId, opts);
1158
+ this.versionedUpdate(recordId, [], [], opts);
1246
1159
  this.insertAssociations(recordId, [association]);
1247
1160
  });
1248
- const updated = await this.getRecord(recordId);
1249
- if (!updated)
1250
- throw new Error(`Record not found after associate: "${recordId}"`);
1251
- return updated;
1161
+ return this.reread(recordId, "associate");
1252
1162
  }
1253
1163
  async dissociate(recordId, association, opts = {}) {
1254
1164
  this.exec.transaction(() => {
1255
1165
  if (opts.snapshot)
1256
1166
  this.snapshotBeforeMutation(recordId, opts.snapshot);
1257
- this.bumpVersion(recordId, opts);
1167
+ this.versionedUpdate(recordId, [], [], opts);
1258
1168
  this.exec.run(`DELETE FROM associations
1259
1169
  WHERE record_id = ?
1260
1170
  AND kind = ?
@@ -1265,16 +1175,18 @@ var SharedSqlRecordLogic = class {
1265
1175
  AND related_ns = ?
1266
1176
  AND related_stack = ?`, [recordId, association.kind, association.label, ...associationKeyColumns(association)]);
1267
1177
  });
1268
- const updated = await this.getRecord(recordId);
1269
- if (!updated)
1270
- throw new Error(`Record not found after dissociate: "${recordId}"`);
1271
- return updated;
1178
+ return this.reread(recordId, "dissociate");
1272
1179
  }
1273
- bumpVersion(id, opts = {}) {
1274
- const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
1275
- 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]);
1276
- if (changed === 0)
1277
- 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);
1278
1190
  }
1279
1191
  /**
1280
1192
  * FK enforcement (PRAGMA_FOREIGN_KEYS_ON) means inserting an
@@ -1346,6 +1258,43 @@ var SharedSqlRecordLogic = class {
1346
1258
  return this.getIndexedFields(record.typeId).get(field);
1347
1259
  }
1348
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");
1349
1298
  var SharedTokenLogic = class {
1350
1299
  deps;
1351
1300
  constructor(deps) {
@@ -1357,7 +1306,7 @@ var SharedTokenLogic = class {
1357
1306
  async createToken(principalId, opts = {}) {
1358
1307
  const id = randomBytes(8).toString("hex");
1359
1308
  const token = randomBytes(32).toString("hex");
1360
- const tokenHash = createHash("sha256").update(token).digest("hex");
1309
+ const tokenHash = hashToken(token);
1361
1310
  this.exec.run("INSERT INTO tokens (id, token_hash, principal_id, subject_id, label, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)", [
1362
1311
  id,
1363
1312
  tokenHash,
@@ -1372,7 +1321,7 @@ var SharedTokenLogic = class {
1372
1321
  return { id, token };
1373
1322
  }
1374
1323
  async lookupToken(token) {
1375
- const hash = createHash("sha256").update(token).digest("hex");
1324
+ const hash = hashToken(token);
1376
1325
  const row = this.exec.get("SELECT principal_id, subject_id, expires_at FROM tokens WHERE token_hash = ?", [hash]);
1377
1326
  if (!row)
1378
1327
  return null;
@@ -1549,8 +1498,8 @@ var NativeSQLiteRecordAdapter = class _NativeSQLiteRecordAdapter {
1549
1498
  getRecord(id) {
1550
1499
  return this.record.getRecord(id);
1551
1500
  }
1552
- patchContent(id, patch, opts) {
1553
- return this.record.patchContent(id, patch, opts);
1501
+ mutateRecord(id, changes, opts) {
1502
+ return this.record.mutateRecord(id, changes, opts);
1554
1503
  }
1555
1504
  deleteRecord(id, opts) {
1556
1505
  return this.record.deleteRecord(id, opts);
@@ -1558,15 +1507,6 @@ var NativeSQLiteRecordAdapter = class _NativeSQLiteRecordAdapter {
1558
1507
  undeleteRecord(id, opts) {
1559
1508
  return this.record.undeleteRecord(id, opts);
1560
1509
  }
1561
- setPermissions(id, permissions, opts) {
1562
- return this.record.setPermissions(id, permissions, opts);
1563
- }
1564
- setUnlisted(id, unlisted, opts) {
1565
- return this.record.setUnlisted(id, unlisted, opts);
1566
- }
1567
- setParent(id, parentId, opts) {
1568
- return this.record.setParent(id, parentId, opts);
1569
- }
1570
1510
  restoreVersion(id, version, opts) {
1571
1511
  return this.record.restoreVersion(id, version, opts);
1572
1512
  }