@haverstack/record-adapter-do-sqlite 0.13.0 → 0.15.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,4 +1,4 @@
1
- import { StackVersionConflictError, StackNotFoundError, StackConflictError, applyMergePatch, StackQueryError } from '@haverstack/core';
1
+ import { StackNotFoundError, StackConflictError, applyMergePatch, StackVersionConflictError, StackQueryError, NATIVE_SORT_FIELDS } from '@haverstack/core';
2
2
  import { contentSortEntry, parseContentFilterKey, contentSortKey } from '@haverstack/core/adapter';
3
3
 
4
4
  // ../sqlite-shared/dist/schema.js
@@ -132,7 +132,7 @@ var FTS5_SCHEMA_SQL = `
132
132
  );
133
133
  `;
134
134
  var PRAGMA_FOREIGN_KEYS_ON = `PRAGMA foreign_keys = ON;`;
135
- var SORT_FIELDS = ["createdAt", "updatedAt", "version"];
135
+ var SORT_FIELDS = NATIVE_SORT_FIELDS;
136
136
  var toBase64 = (text) => {
137
137
  const bytes = new TextEncoder().encode(text);
138
138
  let binary = "";
@@ -348,17 +348,30 @@ var recordConditions = (query) => {
348
348
  const conditions = ["r.id != '_config'"];
349
349
  const params = [];
350
350
  const f = query.filter ?? {};
351
+ const inFilter = (column, value) => {
352
+ if (value === void 0)
353
+ return;
354
+ const values = Array.isArray(value) ? value : [value];
355
+ conditions.push(`r.${column} IN (${values.map(() => "?").join(",")})`);
356
+ params.push(...values);
357
+ };
358
+ const rangeFilter = (column, range) => {
359
+ if (range?.after) {
360
+ conditions.push(`r.${column} > ?`);
361
+ params.push(range.after.getTime());
362
+ }
363
+ if (range?.before) {
364
+ conditions.push(`r.${column} < ?`);
365
+ params.push(range.before.getTime());
366
+ }
367
+ };
351
368
  if (!f.includeDeleted) {
352
369
  conditions.push("r.deleted_at IS NULL");
353
370
  }
354
371
  if (!f.includeUnlisted) {
355
372
  conditions.push("r.unlisted_at IS NULL");
356
373
  }
357
- if (f.typeId !== void 0) {
358
- const ids = Array.isArray(f.typeId) ? f.typeId : [f.typeId];
359
- conditions.push(`r.type_id IN (${ids.map(() => "?").join(",")})`);
360
- params.push(...ids);
361
- }
374
+ inFilter("type_id", f.typeId);
362
375
  if (f.parentId !== void 0) {
363
376
  if (f.parentId === null) {
364
377
  conditions.push("r.parent_id IS NULL");
@@ -367,49 +380,24 @@ var recordConditions = (query) => {
367
380
  params.push(f.parentId);
368
381
  }
369
382
  }
370
- if (f.appId !== void 0) {
371
- const ids = Array.isArray(f.appId) ? f.appId : [f.appId];
372
- conditions.push(`r.app_id IN (${ids.map(() => "?").join(",")})`);
373
- params.push(...ids);
374
- }
375
- if (f.entityId !== void 0) {
376
- const ids = Array.isArray(f.entityId) ? f.entityId : [f.entityId];
377
- conditions.push(`r.entity_id IN (${ids.map(() => "?").join(",")})`);
378
- params.push(...ids);
379
- }
380
- if (f.principalId !== void 0) {
381
- const ids = Array.isArray(f.principalId) ? f.principalId : [f.principalId];
382
- conditions.push(`r.principal_id IN (${ids.map(() => "?").join(",")})`);
383
- params.push(...ids);
384
- }
385
- if (f.createdAt?.after) {
386
- conditions.push("r.created_at > ?");
387
- params.push(f.createdAt.after.getTime());
388
- }
389
- if (f.createdAt?.before) {
390
- conditions.push("r.created_at < ?");
391
- params.push(f.createdAt.before.getTime());
392
- }
393
- if (f.updatedAt?.after) {
394
- conditions.push("r.updated_at > ?");
395
- params.push(f.updatedAt.after.getTime());
396
- }
397
- if (f.updatedAt?.before) {
398
- conditions.push("r.updated_at < ?");
399
- params.push(f.updatedAt.before.getTime());
400
- }
383
+ inFilter("app_id", f.appId);
384
+ inFilter("entity_id", f.entityId);
385
+ inFilter("principal_id", f.principalId);
386
+ rangeFilter("created_at", f.createdAt);
387
+ rangeFilter("updated_at", f.updatedAt);
388
+ const hasAssociation = (kind, clauses) => `r.id IN (SELECT a.record_id FROM associations a WHERE a.kind = '${kind}'` + clauses.map((c) => ` AND ${c}`).join("") + `)`;
401
389
  if (f.tags?.length) {
402
390
  for (const tag of f.tags) {
403
- conditions.push(`r.id IN (SELECT a.record_id FROM associations a WHERE a.kind = 'tag' AND a.label = ?)`);
391
+ conditions.push(hasAssociation("tag", ["a.label = ?"]));
404
392
  params.push(tag);
405
393
  }
406
394
  }
407
395
  if (f.hasAttachment) {
408
- conditions.push(`r.id IN (SELECT a.record_id FROM associations a WHERE a.kind = 'attachment' AND a.label = ?)`);
396
+ conditions.push(hasAssociation("attachment", ["a.label = ?"]));
409
397
  params.push(f.hasAttachment);
410
398
  }
411
399
  if (f.attachmentFileId) {
412
- conditions.push(`(r.id IN (SELECT a.record_id FROM associations a WHERE a.kind = 'attachment' AND a.file_id = ?)
400
+ conditions.push(`(${hasAssociation("attachment", ["a.file_id = ?"])}
413
401
  OR r.id IN (SELECT ci.record_id FROM content_index ci WHERE ci.file_id = ?))`);
414
402
  params.push(f.attachmentFileId, f.attachmentFileId);
415
403
  }
@@ -438,7 +426,7 @@ var recordConditions = (query) => {
438
426
  }
439
427
  }
440
428
  }
441
- conditions.push(`r.id IN (SELECT a.record_id FROM associations a WHERE a.kind = 'relationship'` + clauses.map((c) => ` AND ${c}`).join("") + `)`);
429
+ conditions.push(hasAssociation("relationship", clauses));
442
430
  }
443
431
  if (f.content) {
444
432
  let aliasSeq = 0;
@@ -664,6 +652,29 @@ var asStackQueryError = (query, run) => {
664
652
  throw new StackQueryError(`Search text could not be parsed: ${err instanceof Error ? err.message : String(err)}`);
665
653
  }
666
654
  };
655
+ var versionConflict = (id, expected, actual) => new StackVersionConflictError(`Record "${id}" is at version ${actual}, expected ${expected}`, id, expected, actual);
656
+ var VERSION_COLUMNS = [
657
+ "type_id",
658
+ "content",
659
+ "updated_at",
660
+ "entity_id",
661
+ "updated_by",
662
+ "updated_via",
663
+ "parent_id",
664
+ "associations",
665
+ "permissions"
666
+ ];
667
+ var versionRowValues = (version) => [
668
+ version.typeId,
669
+ JSON.stringify(version.content),
670
+ toMs(version.updatedAt),
671
+ version.entityId ?? null,
672
+ version.updatedBy ?? null,
673
+ version.updatedVia ?? null,
674
+ version.parentId ?? null,
675
+ version.associations ? JSON.stringify(version.associations) : null,
676
+ version.permissions ? JSON.stringify(version.permissions) : null
677
+ ];
667
678
  var indexedFieldKinds = (schema) => {
668
679
  const kinds = /* @__PURE__ */ new Map();
669
680
  for (const [field, def] of Object.entries(schema)) {
@@ -689,26 +700,22 @@ var SharedSqlRecordLogic = class {
689
700
  return this.deps.exec;
690
701
  }
691
702
  /**
692
- * SQL fragment (plus its bind params) that gates a records-table
693
- * UPDATE/DELETE on the opt-in `expectedVersion` precondition. Empty when
694
- * expectedVersion is omitted, keeping today's unconditional behavior.
695
- */
696
- versionGuard(expectedVersion) {
697
- return expectedVersion === void 0 ? { clause: "", params: [] } : { clause: " AND version = ?", params: [expectedVersion] };
698
- }
699
- /**
700
- * Precondition check for mutations that can't fold expectedVersion into
701
- * their primary UPDATE's WHERE clause: patchContent/restoreVersion need
702
- * fts.remove() to run *before* the records-table content changes, so
703
- * the check happens first, standalone.
703
+ * Precondition check for a mutation that has already read the record —
704
+ * either because it can't fold the check into its own statement
705
+ * (hardDeleteRecord: children reference the row, so the DELETE can't
706
+ * carry it), or because it has to settle the precondition *before*
707
+ * fts5Strategy.remove() runs, which needs the records row still holding
708
+ * the old content. In the latter case versionedUpdate() re-checks it
709
+ * inside the statement, and that is the enforcement; this is what lets
710
+ * the work before it be skipped.
704
711
  */
705
712
  checkExpectedVersion(record, expectedVersion) {
706
713
  if (expectedVersion === void 0 || record.version === expectedVersion)
707
714
  return;
708
- throw new StackVersionConflictError(`Record "${record.id}" is at version ${record.version}, expected ${expectedVersion}`, record.id, expectedVersion, record.version);
715
+ throw versionConflict(record.id, expectedVersion, record.version);
709
716
  }
710
717
  /**
711
- * Called after a versionGuard()-gated statement affected zero rows.
718
+ * Called after a version-gated statement affected zero rows.
712
719
  * Distinguishes "record doesn't exist" (StackNotFoundError) from "it
713
720
  * exists but isn't at expectedVersion" (StackVersionConflictError, with
714
721
  * the actual current version for the caller to act on).
@@ -719,7 +726,48 @@ var SharedSqlRecordLogic = class {
719
726
  ]);
720
727
  if (!row)
721
728
  throw new StackNotFoundError(`Record not found: "${id}"`);
722
- throw new StackVersionConflictError(`Record "${id}" is at version ${row.version}, expected ${expectedVersion}`, id, expectedVersion, row.version);
729
+ throw versionConflict(id, expectedVersion, row.version);
730
+ }
731
+ /**
732
+ * The one shape every mutating UPDATE in this class has: whatever columns
733
+ * the caller is changing, then the version bump and the actor/timestamp
734
+ * stamp that every mutation owes, gated on the opt-in `expectedVersion`
735
+ * precondition and failing loudly when nothing matched.
736
+ *
737
+ * The guard rides in the WHERE clause even where the caller has already
738
+ * checked the version against a record it read: that read is outside this
739
+ * statement, so a writer slipping in between the two is caught here
740
+ * rather than silently overwritten.
741
+ */
742
+ versionedUpdate(id, sets, values, opts, now = toMs(/* @__PURE__ */ new Date())) {
743
+ const guarded = opts.expectedVersion !== void 0;
744
+ const changed = this.exec.run(`UPDATE records SET ${[...sets, "version = version + 1", "updated_at = ?", "updated_by = ?", "updated_via = ?"].join(", ")} WHERE id = ?${guarded ? " AND version = ?" : ""}`, [
745
+ ...values,
746
+ now,
747
+ opts.updatedBy ?? null,
748
+ opts.updatedVia ?? null,
749
+ id,
750
+ ...guarded ? [opts.expectedVersion] : []
751
+ ]);
752
+ if (changed === 0)
753
+ this.throwVersionConflict(id, opts.expectedVersion);
754
+ }
755
+ /**
756
+ * Read a record back after its own mutation committed, for the caller to
757
+ * return. Its absence is not a "not found" for the caller to handle — the
758
+ * mutation just succeeded against it — so it fails as the invariant
759
+ * breach it would be.
760
+ *
761
+ * Synchronous, via readRecord() rather than getRecord(), so the read
762
+ * cannot be separated from the commit by anything: an async caller
763
+ * returning this still hands back a record read in the same tick its
764
+ * transaction committed. See readRecord() for why that matters.
765
+ */
766
+ reread(id, verb) {
767
+ const updated = this.readRecord(id);
768
+ if (!updated)
769
+ throw new Error(`Record not found after ${verb}: "${id}"`);
770
+ return updated;
723
771
  }
724
772
  // -------------------------------------------------------
725
773
  // Records
@@ -776,11 +824,11 @@ var SharedSqlRecordLogic = class {
776
824
  * reason (see SqlExecutor.transaction).
777
825
  *
778
826
  * The same synchrony is what lets the post-commit reads below report the
779
- * version their own mutation produced: `getRecord()` runs its body before
780
- * the `await` yields, so nothing interleaves between the commit and the
781
- * read. A backend that made these reads genuinely asynchronous would open
782
- * that window, and a concurrent mutation could be reported under this
783
- * one's verb.
827
+ * version their own mutation produced: reread() calls this directly, so
828
+ * nothing interleaves between the commit and the read even though the
829
+ * method returning it is async. A backend that made these reads genuinely
830
+ * asynchronous would open that window, and a concurrent mutation could be
831
+ * reported under this one's verb.
784
832
  */
785
833
  readRecord(id) {
786
834
  const row = this.exec.get("SELECT * FROM records WHERE id = ?", [id]);
@@ -789,55 +837,70 @@ var SharedSqlRecordLogic = class {
789
837
  const associations = this.getAssociationsForRecord(id);
790
838
  return rowToRecord(row, associations);
791
839
  }
792
- async patchContent(id, patch, opts = {}) {
840
+ /**
841
+ * Apply a change set in one transaction: one UPDATE over the record row
842
+ * with only the columns the change set names, the association set
843
+ * replaced when it carries one, and the snapshot written alongside — so
844
+ * one call is one version whatever it moved.
845
+ *
846
+ * The expectedVersion check is standalone rather than folded into the
847
+ * UPDATE alone, because a content change needs fts.remove() to run
848
+ * *before* the records-table content changes. The guard is still in the
849
+ * UPDATE's WHERE, so a writer that slipped in between the read and the
850
+ * write is caught there rather than overwriting.
851
+ */
852
+ async mutateRecord(id, changes, opts = {}) {
793
853
  const existing = await this.getRecord(id);
794
854
  if (!existing)
795
- throw new Error(`Record not found: "${id}"`);
855
+ throw new StackNotFoundError(`Record not found: "${id}"`);
796
856
  this.checkExpectedVersion(existing, opts.expectedVersion);
797
- const merged = applyMergePatch(existing.content, patch);
857
+ const merged = changes.contentPatch ? applyMergePatch(existing.content, changes.contentPatch) : void 0;
858
+ const sets = [];
859
+ const values = [];
860
+ if (merged !== void 0) {
861
+ sets.push("content = ?");
862
+ values.push(JSON.stringify(merged));
863
+ }
864
+ if (changes.parentId !== void 0) {
865
+ sets.push("parent_id = ?");
866
+ values.push(changes.parentId);
867
+ }
868
+ if (changes.permissions !== void 0) {
869
+ sets.push("permissions = ?");
870
+ values.push(changes.permissions.length ? JSON.stringify(changes.permissions) : null);
871
+ }
872
+ const now = toMs(/* @__PURE__ */ new Date());
873
+ if (changes.unlisted !== void 0) {
874
+ sets.push("unlisted_at = ?");
875
+ values.push(changes.unlisted ? now : null);
876
+ }
798
877
  this.exec.transaction(() => {
799
878
  if (opts.snapshot)
800
879
  this.snapshotBeforeMutation(id, opts.snapshot);
801
- fts5Strategy.remove(this.exec, id);
802
- this.exec.run(`UPDATE records SET content = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?`, [
803
- JSON.stringify(merged),
804
- toMs(/* @__PURE__ */ new Date()),
805
- opts.updatedBy ?? null,
806
- opts.updatedVia ?? null,
807
- id
808
- ]);
809
- fts5Strategy.insert(this.exec, id, JSON.stringify(merged));
810
- this.syncContentIndex(id, existing.typeId, merged);
880
+ if (merged !== void 0)
881
+ fts5Strategy.remove(this.exec, id);
882
+ this.versionedUpdate(id, sets, values, opts, now);
883
+ if (changes.associations !== void 0) {
884
+ this.replaceAssociations(id, changes.associations);
885
+ }
886
+ if (merged !== void 0) {
887
+ fts5Strategy.insert(this.exec, id, JSON.stringify(merged));
888
+ this.syncContentIndex(id, existing.typeId, merged);
889
+ }
811
890
  });
812
- const updated = await this.getRecord(id);
813
- if (!updated)
814
- throw new Error(`Record not found after patchContent: "${id}"`);
815
- return updated;
891
+ return this.reread(id, "mutateRecord");
816
892
  }
817
893
  async deleteRecord(id, opts = {}) {
818
894
  if (opts.hard) {
819
895
  return this.exec.transaction(() => this.hardDeleteRecord(id, opts.expectedVersion));
820
- } else {
821
- this.exec.transaction(() => {
822
- if (opts.snapshot)
823
- this.snapshotBeforeMutation(id, opts.snapshot);
824
- const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
825
- const changed = this.exec.run(`UPDATE records SET deleted_at = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?${clause}`, [
826
- toMs(/* @__PURE__ */ new Date()),
827
- toMs(/* @__PURE__ */ new Date()),
828
- opts.updatedBy ?? null,
829
- opts.updatedVia ?? null,
830
- id,
831
- ...verParams
832
- ]);
833
- if (changed === 0)
834
- this.throwVersionConflict(id, opts.expectedVersion);
835
- });
836
896
  }
837
- const updated = await this.getRecord(id);
838
- if (!updated)
839
- throw new Error(`Record not found after deleteRecord: "${id}"`);
840
- return updated;
897
+ const now = toMs(/* @__PURE__ */ new Date());
898
+ this.exec.transaction(() => {
899
+ if (opts.snapshot)
900
+ this.snapshotBeforeMutation(id, opts.snapshot);
901
+ this.versionedUpdate(id, ["deleted_at = ?"], [now], opts, now);
902
+ });
903
+ return this.reread(id, "deleteRecord");
841
904
  }
842
905
  /**
843
906
  * Deletes a record's FTS entry, associations, versions, file-refs, sort
@@ -850,15 +913,12 @@ var SharedSqlRecordLogic = class {
850
913
  */
851
914
  hardDeleteRecord(id, expectedVersion) {
852
915
  const purged = this.readRecord(id);
853
- if (expectedVersion !== void 0) {
854
- if (!purged)
916
+ if (!purged) {
917
+ if (expectedVersion !== void 0)
855
918
  throw new StackNotFoundError(`Record not found: "${id}"`);
856
- if (purged.version !== expectedVersion) {
857
- throw new StackVersionConflictError(`Record "${id}" is at version ${purged.version}, expected ${expectedVersion}`, id, expectedVersion, purged.version);
858
- }
859
- }
860
- if (!purged)
861
919
  return null;
920
+ }
921
+ this.checkExpectedVersion(purged, expectedVersion);
862
922
  fts5Strategy.remove(this.exec, id);
863
923
  this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
864
924
  this.exec.run("DELETE FROM versions WHERE record_id = ?", [id]);
@@ -870,79 +930,9 @@ var SharedSqlRecordLogic = class {
870
930
  this.exec.transaction(() => {
871
931
  if (opts.snapshot)
872
932
  this.snapshotBeforeMutation(id, opts.snapshot);
873
- const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
874
- 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]);
875
- if (changed === 0)
876
- this.throwVersionConflict(id, opts.expectedVersion);
933
+ this.versionedUpdate(id, ["deleted_at = NULL"], [], opts);
877
934
  });
878
- const updated = await this.getRecord(id);
879
- if (!updated)
880
- throw new Error(`Record not found after undelete: "${id}"`);
881
- return updated;
882
- }
883
- async setPermissions(id, permissions, opts = {}) {
884
- this.exec.transaction(() => {
885
- if (opts.snapshot)
886
- this.snapshotBeforeMutation(id, opts.snapshot);
887
- const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
888
- const changed = this.exec.run(`UPDATE records SET permissions = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?${clause}`, [
889
- permissions.length ? JSON.stringify(permissions) : null,
890
- toMs(/* @__PURE__ */ new Date()),
891
- opts.updatedBy ?? null,
892
- opts.updatedVia ?? null,
893
- id,
894
- ...verParams
895
- ]);
896
- if (changed === 0)
897
- this.throwVersionConflict(id, opts.expectedVersion);
898
- });
899
- const updated = await this.getRecord(id);
900
- if (!updated)
901
- throw new Error(`Record not found after setPermissions: "${id}"`);
902
- return updated;
903
- }
904
- async setUnlisted(id, unlisted, opts = {}) {
905
- this.exec.transaction(() => {
906
- if (opts.snapshot)
907
- this.snapshotBeforeMutation(id, opts.snapshot);
908
- const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
909
- const now = toMs(/* @__PURE__ */ new Date());
910
- const changed = this.exec.run(`UPDATE records SET unlisted_at = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?${clause}`, [
911
- unlisted ? now : null,
912
- now,
913
- opts.updatedBy ?? null,
914
- opts.updatedVia ?? null,
915
- id,
916
- ...verParams
917
- ]);
918
- if (changed === 0)
919
- this.throwVersionConflict(id, opts.expectedVersion);
920
- });
921
- const updated = await this.getRecord(id);
922
- if (!updated)
923
- throw new Error(`Record not found after setUnlisted: "${id}"`);
924
- return updated;
925
- }
926
- async setParent(id, parentId, opts = {}) {
927
- this.exec.transaction(() => {
928
- if (opts.snapshot)
929
- this.snapshotBeforeMutation(id, opts.snapshot);
930
- const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
931
- const changed = this.exec.run(`UPDATE records SET parent_id = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?${clause}`, [
932
- parentId,
933
- toMs(/* @__PURE__ */ new Date()),
934
- opts.updatedBy ?? null,
935
- opts.updatedVia ?? null,
936
- id,
937
- ...verParams
938
- ]);
939
- if (changed === 0)
940
- this.throwVersionConflict(id, opts.expectedVersion);
941
- });
942
- const updated = await this.getRecord(id);
943
- if (!updated)
944
- throw new Error(`Record not found after setParent: "${id}"`);
945
- return updated;
935
+ return this.reread(id, "undelete");
946
936
  }
947
937
  async restoreVersion(id, version, opts = {}) {
948
938
  const existing = await this.getRecord(id);
@@ -955,28 +945,12 @@ var SharedSqlRecordLogic = class {
955
945
  this.exec.transaction(() => {
956
946
  if (opts.snapshot)
957
947
  this.snapshotBeforeMutation(id, opts.snapshot);
958
- fts5Strategy.remove(this.exec, id);
959
- this.exec.run(`UPDATE records SET type_id = ?, content = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ?, parent_id = ? WHERE id = ?`, [
960
- target.typeId,
961
- JSON.stringify(target.content),
962
- toMs(/* @__PURE__ */ new Date()),
963
- opts.updatedBy ?? null,
964
- opts.updatedVia ?? null,
965
- target.parentId ?? null,
966
- id
967
- ]);
968
- if (target.associations !== void 0) {
969
- this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
970
- if (target.associations.length)
971
- this.insertAssociations(id, target.associations);
972
- }
973
- fts5Strategy.insert(this.exec, id, JSON.stringify(target.content));
974
- this.syncContentIndex(id, target.typeId, target.content);
948
+ this.rewriteContent(id, target.typeId, target.content, opts, ["parent_id = ?"], [target.parentId ?? null]);
949
+ const applied = opts.restoreAssociations === false ? void 0 : target.associations;
950
+ if (applied !== void 0)
951
+ this.replaceAssociations(id, applied);
975
952
  });
976
- const updated = await this.getRecord(id);
977
- if (!updated)
978
- throw new Error(`Record not found after restoreVersion: "${id}"`);
979
- return updated;
953
+ return this.reread(id, "restoreVersion");
980
954
  }
981
955
  async commitMigration(id, toTypeId, content, opts = {}) {
982
956
  const existing = await this.getRecord(id);
@@ -986,22 +960,27 @@ var SharedSqlRecordLogic = class {
986
960
  this.exec.transaction(() => {
987
961
  if (opts.snapshot)
988
962
  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 = ? WHERE id = ?`, [
991
- toTypeId,
992
- JSON.stringify(content),
993
- toMs(/* @__PURE__ */ new Date()),
994
- opts.updatedBy ?? null,
995
- opts.updatedVia ?? null,
996
- id
997
- ]);
998
- fts5Strategy.insert(this.exec, id, JSON.stringify(content));
999
- this.syncContentIndex(id, toTypeId, content);
963
+ this.rewriteContent(id, toTypeId, content, opts);
1000
964
  });
1001
- const updated = await this.getRecord(id);
1002
- if (!updated)
1003
- throw new Error(`Record not found after commitMigration: "${id}"`);
1004
- return updated;
965
+ return this.reread(id, "commitMigration");
966
+ }
967
+ /**
968
+ * Replace a record's type and content wholesale — what restoreVersion and
969
+ * commitMigration each do, differing only in where the content came from
970
+ * and whether the container moves with it.
971
+ *
972
+ * The order is the constraint: fts5Strategy.remove() has to read the old
973
+ * content off the records row, so it runs before the UPDATE, and the
974
+ * re-index runs after. Every caller must already sit inside
975
+ * exec.transaction() — a failure between the two halves would otherwise
976
+ * leave the record unindexed.
977
+ */
978
+ rewriteContent(id, typeId, content, opts, extraSets = [], extraValues = []) {
979
+ const json = JSON.stringify(content);
980
+ fts5Strategy.remove(this.exec, id);
981
+ this.versionedUpdate(id, ["type_id = ?", "content = ?", ...extraSets], [typeId, json, ...extraValues], opts);
982
+ fts5Strategy.insert(this.exec, id, json);
983
+ this.syncContentIndex(id, typeId, content);
1005
984
  }
1006
985
  async queryRecords(query) {
1007
986
  const limit = query.limit ?? 50;
@@ -1062,8 +1041,8 @@ var SharedSqlRecordLogic = class {
1062
1041
  return rows.map(rowToVersion);
1063
1042
  }
1064
1043
  async getVersion(id, version) {
1065
- const rows = this.exec.all("SELECT * FROM versions WHERE record_id = ? AND version = ?", [id, version]);
1066
- return rows.length ? rowToVersion(rows[0]) : null;
1044
+ const row = this.exec.get("SELECT * FROM versions WHERE record_id = ? AND version = ?", [id, version]);
1045
+ return row ? rowToVersion(row) : null;
1067
1046
  }
1068
1047
  /**
1069
1048
  * A (record_id, version) collision is rejected loudly via the UNIQUE
@@ -1074,21 +1053,8 @@ var SharedSqlRecordLogic = class {
1074
1053
  insertVersionRow(id, version) {
1075
1054
  try {
1076
1055
  this.exec.run(`INSERT INTO versions
1077
- (record_id, version, type_id, content, updated_at, entity_id,
1078
- updated_by, updated_via, parent_id, associations, permissions)
1079
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
1080
- id,
1081
- version.version,
1082
- version.typeId,
1083
- JSON.stringify(version.content),
1084
- toMs(version.updatedAt),
1085
- version.entityId ?? null,
1086
- version.updatedBy ?? null,
1087
- version.updatedVia ?? null,
1088
- version.parentId ?? null,
1089
- version.associations ? JSON.stringify(version.associations) : null,
1090
- version.permissions ? JSON.stringify(version.permissions) : null
1091
- ]);
1056
+ (record_id, version, ${VERSION_COLUMNS.join(", ")})
1057
+ VALUES (?, ?, ${VERSION_COLUMNS.map(() => "?").join(", ")})`, [id, version.version, ...versionRowValues(version)]);
1092
1058
  } catch (err) {
1093
1059
  if (isUniqueConstraintViolation(err)) {
1094
1060
  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.`);
@@ -1097,28 +1063,15 @@ var SharedSqlRecordLogic = class {
1097
1063
  }
1098
1064
  }
1099
1065
  /**
1100
- * Replaces every column insertVersionRow writes, so an overwritten row is
1101
- * indistinguishable from a freshly inserted one — a column left out here
1102
- * would keep the replaced row's value and surface later as a restore
1103
- * putting back something the snapshot never said.
1066
+ * Replaces every column insertVersionRow writes — structurally, off the
1067
+ * same VERSION_COLUMNS list, because a column left out here would keep
1068
+ * the replaced row's value and surface later as a restore putting back
1069
+ * something the snapshot never said.
1104
1070
  */
1105
1071
  overwriteVersionRow(id, version) {
1106
1072
  this.exec.run(`UPDATE versions
1107
- SET type_id = ?, content = ?, updated_at = ?, entity_id = ?,
1108
- updated_by = ?, updated_via = ?, parent_id = ?, associations = ?, permissions = ?
1109
- WHERE record_id = ? AND version = ?`, [
1110
- version.typeId,
1111
- JSON.stringify(version.content),
1112
- toMs(version.updatedAt),
1113
- version.entityId ?? null,
1114
- version.updatedBy ?? null,
1115
- version.updatedVia ?? null,
1116
- version.parentId ?? null,
1117
- version.associations ? JSON.stringify(version.associations) : null,
1118
- version.permissions ? JSON.stringify(version.permissions) : null,
1119
- id,
1120
- version.version
1121
- ]);
1073
+ SET ${VERSION_COLUMNS.map((c) => `${c} = ?`).join(", ")}
1074
+ WHERE record_id = ? AND version = ?`, [...versionRowValues(version), id, version.version]);
1122
1075
  }
1123
1076
  /**
1124
1077
  * Standalone snapshot write for tooling and tests — loud on any
@@ -1169,8 +1122,8 @@ var SharedSqlRecordLogic = class {
1169
1122
  this.indexedFieldsByType.set(type.id, indexedFieldKinds(schema));
1170
1123
  }
1171
1124
  async getType(id) {
1172
- const rows = this.exec.all("SELECT * FROM types WHERE id = ?", [id]);
1173
- return rows.length ? rowToType(rows[0]) : null;
1125
+ const row = this.exec.get("SELECT * FROM types WHERE id = ?", [id]);
1126
+ return row ? rowToType(row) : null;
1174
1127
  }
1175
1128
  async listTypes() {
1176
1129
  const rows = this.exec.all("SELECT * FROM types ORDER BY base_id, version");
@@ -1183,19 +1136,16 @@ var SharedSqlRecordLogic = class {
1183
1136
  this.exec.transaction(() => {
1184
1137
  if (opts.snapshot)
1185
1138
  this.snapshotBeforeMutation(recordId, opts.snapshot);
1186
- this.bumpVersion(recordId, opts);
1139
+ this.versionedUpdate(recordId, [], [], opts);
1187
1140
  this.insertAssociations(recordId, [association]);
1188
1141
  });
1189
- const updated = await this.getRecord(recordId);
1190
- if (!updated)
1191
- throw new Error(`Record not found after associate: "${recordId}"`);
1192
- return updated;
1142
+ return this.reread(recordId, "associate");
1193
1143
  }
1194
1144
  async dissociate(recordId, association, opts = {}) {
1195
1145
  this.exec.transaction(() => {
1196
1146
  if (opts.snapshot)
1197
1147
  this.snapshotBeforeMutation(recordId, opts.snapshot);
1198
- this.bumpVersion(recordId, opts);
1148
+ this.versionedUpdate(recordId, [], [], opts);
1199
1149
  this.exec.run(`DELETE FROM associations
1200
1150
  WHERE record_id = ?
1201
1151
  AND kind = ?
@@ -1206,16 +1156,18 @@ var SharedSqlRecordLogic = class {
1206
1156
  AND related_ns = ?
1207
1157
  AND related_stack = ?`, [recordId, association.kind, association.label, ...associationKeyColumns(association)]);
1208
1158
  });
1209
- const updated = await this.getRecord(recordId);
1210
- if (!updated)
1211
- throw new Error(`Record not found after dissociate: "${recordId}"`);
1212
- return updated;
1159
+ return this.reread(recordId, "dissociate");
1213
1160
  }
1214
- bumpVersion(id, opts = {}) {
1215
- const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
1216
- 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]);
1217
- if (changed === 0)
1218
- this.throwVersionConflict(id, opts.expectedVersion);
1161
+ /**
1162
+ * Swap a record's association set for `associations` wholesale — the
1163
+ * "replace, don't merge" semantics a change set's association list and a
1164
+ * version snapshot's both carry. Caller-transactional, like every write
1165
+ * below.
1166
+ */
1167
+ replaceAssociations(recordId, associations) {
1168
+ this.exec.run("DELETE FROM associations WHERE record_id = ?", [recordId]);
1169
+ if (associations.length)
1170
+ this.insertAssociations(recordId, associations);
1219
1171
  }
1220
1172
  /**
1221
1173
  * FK enforcement (PRAGMA_FOREIGN_KEYS_ON) means inserting an
@@ -1379,8 +1331,8 @@ var DoSQLiteRecordAdapter = class _DoSQLiteRecordAdapter {
1379
1331
  getRecord(id) {
1380
1332
  return this.record.getRecord(id);
1381
1333
  }
1382
- patchContent(id, patch, opts) {
1383
- return this.record.patchContent(id, patch, opts);
1334
+ mutateRecord(id, changes, opts) {
1335
+ return this.record.mutateRecord(id, changes, opts);
1384
1336
  }
1385
1337
  deleteRecord(id, opts) {
1386
1338
  return this.record.deleteRecord(id, opts);
@@ -1388,15 +1340,6 @@ var DoSQLiteRecordAdapter = class _DoSQLiteRecordAdapter {
1388
1340
  undeleteRecord(id, opts) {
1389
1341
  return this.record.undeleteRecord(id, opts);
1390
1342
  }
1391
- setPermissions(id, permissions, opts) {
1392
- return this.record.setPermissions(id, permissions, opts);
1393
- }
1394
- setUnlisted(id, unlisted, opts) {
1395
- return this.record.setUnlisted(id, unlisted, opts);
1396
- }
1397
- setParent(id, parentId, opts) {
1398
- return this.record.setParent(id, parentId, opts);
1399
- }
1400
1343
  restoreVersion(id, version, opts) {
1401
1344
  return this.record.restoreVersion(id, version, opts);
1402
1345
  }