@haverstack/record-adapter-do-sqlite 0.14.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: mutateRecord/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]);
@@ -831,49 +879,28 @@ var SharedSqlRecordLogic = class {
831
879
  this.snapshotBeforeMutation(id, opts.snapshot);
832
880
  if (merged !== void 0)
833
881
  fts5Strategy.remove(this.exec, id);
834
- const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
835
- 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]);
836
- if (changed === 0)
837
- this.throwVersionConflict(id, opts.expectedVersion);
882
+ this.versionedUpdate(id, sets, values, opts, now);
838
883
  if (changes.associations !== void 0) {
839
- this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
840
- if (changes.associations.length)
841
- this.insertAssociations(id, changes.associations);
884
+ this.replaceAssociations(id, changes.associations);
842
885
  }
843
886
  if (merged !== void 0) {
844
887
  fts5Strategy.insert(this.exec, id, JSON.stringify(merged));
845
888
  this.syncContentIndex(id, existing.typeId, merged);
846
889
  }
847
890
  });
848
- const updated = await this.getRecord(id);
849
- if (!updated)
850
- throw new Error(`Record not found after mutateRecord: "${id}"`);
851
- return updated;
891
+ return this.reread(id, "mutateRecord");
852
892
  }
853
893
  async deleteRecord(id, opts = {}) {
854
894
  if (opts.hard) {
855
895
  return this.exec.transaction(() => this.hardDeleteRecord(id, opts.expectedVersion));
856
- } else {
857
- this.exec.transaction(() => {
858
- if (opts.snapshot)
859
- this.snapshotBeforeMutation(id, opts.snapshot);
860
- const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
861
- const changed = this.exec.run(`UPDATE records SET deleted_at = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?${clause}`, [
862
- toMs(/* @__PURE__ */ new Date()),
863
- toMs(/* @__PURE__ */ new Date()),
864
- opts.updatedBy ?? null,
865
- opts.updatedVia ?? null,
866
- id,
867
- ...verParams
868
- ]);
869
- if (changed === 0)
870
- this.throwVersionConflict(id, opts.expectedVersion);
871
- });
872
896
  }
873
- const updated = await this.getRecord(id);
874
- if (!updated)
875
- throw new Error(`Record not found after deleteRecord: "${id}"`);
876
- 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");
877
904
  }
878
905
  /**
879
906
  * Deletes a record's FTS entry, associations, versions, file-refs, sort
@@ -886,15 +913,12 @@ var SharedSqlRecordLogic = class {
886
913
  */
887
914
  hardDeleteRecord(id, expectedVersion) {
888
915
  const purged = this.readRecord(id);
889
- if (expectedVersion !== void 0) {
890
- if (!purged)
916
+ if (!purged) {
917
+ if (expectedVersion !== void 0)
891
918
  throw new StackNotFoundError(`Record not found: "${id}"`);
892
- if (purged.version !== expectedVersion) {
893
- throw new StackVersionConflictError(`Record "${id}" is at version ${purged.version}, expected ${expectedVersion}`, id, expectedVersion, purged.version);
894
- }
895
- }
896
- if (!purged)
897
919
  return null;
920
+ }
921
+ this.checkExpectedVersion(purged, expectedVersion);
898
922
  fts5Strategy.remove(this.exec, id);
899
923
  this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
900
924
  this.exec.run("DELETE FROM versions WHERE record_id = ?", [id]);
@@ -906,15 +930,9 @@ var SharedSqlRecordLogic = class {
906
930
  this.exec.transaction(() => {
907
931
  if (opts.snapshot)
908
932
  this.snapshotBeforeMutation(id, opts.snapshot);
909
- const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
910
- 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]);
911
- if (changed === 0)
912
- this.throwVersionConflict(id, opts.expectedVersion);
933
+ this.versionedUpdate(id, ["deleted_at = NULL"], [], opts);
913
934
  });
914
- const updated = await this.getRecord(id);
915
- if (!updated)
916
- throw new Error(`Record not found after undelete: "${id}"`);
917
- return updated;
935
+ return this.reread(id, "undelete");
918
936
  }
919
937
  async restoreVersion(id, version, opts = {}) {
920
938
  const existing = await this.getRecord(id);
@@ -927,29 +945,12 @@ var SharedSqlRecordLogic = class {
927
945
  this.exec.transaction(() => {
928
946
  if (opts.snapshot)
929
947
  this.snapshotBeforeMutation(id, opts.snapshot);
930
- fts5Strategy.remove(this.exec, id);
931
- this.exec.run(`UPDATE records SET type_id = ?, content = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ?, parent_id = ? WHERE id = ?`, [
932
- target.typeId,
933
- JSON.stringify(target.content),
934
- toMs(/* @__PURE__ */ new Date()),
935
- opts.updatedBy ?? null,
936
- opts.updatedVia ?? null,
937
- target.parentId ?? null,
938
- id
939
- ]);
948
+ this.rewriteContent(id, target.typeId, target.content, opts, ["parent_id = ?"], [target.parentId ?? null]);
940
949
  const applied = opts.restoreAssociations === false ? void 0 : target.associations;
941
- if (applied !== void 0) {
942
- this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
943
- if (applied.length)
944
- this.insertAssociations(id, applied);
945
- }
946
- fts5Strategy.insert(this.exec, id, JSON.stringify(target.content));
947
- this.syncContentIndex(id, target.typeId, target.content);
950
+ if (applied !== void 0)
951
+ this.replaceAssociations(id, applied);
948
952
  });
949
- const updated = await this.getRecord(id);
950
- if (!updated)
951
- throw new Error(`Record not found after restoreVersion: "${id}"`);
952
- return updated;
953
+ return this.reread(id, "restoreVersion");
953
954
  }
954
955
  async commitMigration(id, toTypeId, content, opts = {}) {
955
956
  const existing = await this.getRecord(id);
@@ -959,22 +960,27 @@ var SharedSqlRecordLogic = class {
959
960
  this.exec.transaction(() => {
960
961
  if (opts.snapshot)
961
962
  this.snapshotBeforeMutation(id, opts.snapshot);
962
- fts5Strategy.remove(this.exec, id);
963
- this.exec.run(`UPDATE records SET type_id = ?, content = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?`, [
964
- toTypeId,
965
- JSON.stringify(content),
966
- toMs(/* @__PURE__ */ new Date()),
967
- opts.updatedBy ?? null,
968
- opts.updatedVia ?? null,
969
- id
970
- ]);
971
- fts5Strategy.insert(this.exec, id, JSON.stringify(content));
972
- this.syncContentIndex(id, toTypeId, content);
963
+ this.rewriteContent(id, toTypeId, content, opts);
973
964
  });
974
- const updated = await this.getRecord(id);
975
- if (!updated)
976
- throw new Error(`Record not found after commitMigration: "${id}"`);
977
- 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);
978
984
  }
979
985
  async queryRecords(query) {
980
986
  const limit = query.limit ?? 50;
@@ -1035,8 +1041,8 @@ var SharedSqlRecordLogic = class {
1035
1041
  return rows.map(rowToVersion);
1036
1042
  }
1037
1043
  async getVersion(id, version) {
1038
- const rows = this.exec.all("SELECT * FROM versions WHERE record_id = ? AND version = ?", [id, version]);
1039
- 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;
1040
1046
  }
1041
1047
  /**
1042
1048
  * A (record_id, version) collision is rejected loudly via the UNIQUE
@@ -1047,21 +1053,8 @@ var SharedSqlRecordLogic = class {
1047
1053
  insertVersionRow(id, version) {
1048
1054
  try {
1049
1055
  this.exec.run(`INSERT INTO versions
1050
- (record_id, version, type_id, content, updated_at, entity_id,
1051
- updated_by, updated_via, parent_id, associations, permissions)
1052
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
1053
- id,
1054
- version.version,
1055
- version.typeId,
1056
- JSON.stringify(version.content),
1057
- toMs(version.updatedAt),
1058
- version.entityId ?? null,
1059
- version.updatedBy ?? null,
1060
- version.updatedVia ?? null,
1061
- version.parentId ?? null,
1062
- version.associations ? JSON.stringify(version.associations) : null,
1063
- version.permissions ? JSON.stringify(version.permissions) : null
1064
- ]);
1056
+ (record_id, version, ${VERSION_COLUMNS.join(", ")})
1057
+ VALUES (?, ?, ${VERSION_COLUMNS.map(() => "?").join(", ")})`, [id, version.version, ...versionRowValues(version)]);
1065
1058
  } catch (err) {
1066
1059
  if (isUniqueConstraintViolation(err)) {
1067
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.`);
@@ -1070,28 +1063,15 @@ var SharedSqlRecordLogic = class {
1070
1063
  }
1071
1064
  }
1072
1065
  /**
1073
- * Replaces every column insertVersionRow writes, so an overwritten row is
1074
- * indistinguishable from a freshly inserted one — a column left out here
1075
- * would keep the replaced row's value and surface later as a restore
1076
- * 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.
1077
1070
  */
1078
1071
  overwriteVersionRow(id, version) {
1079
1072
  this.exec.run(`UPDATE versions
1080
- SET type_id = ?, content = ?, updated_at = ?, entity_id = ?,
1081
- updated_by = ?, updated_via = ?, parent_id = ?, associations = ?, permissions = ?
1082
- WHERE record_id = ? AND version = ?`, [
1083
- version.typeId,
1084
- JSON.stringify(version.content),
1085
- toMs(version.updatedAt),
1086
- version.entityId ?? null,
1087
- version.updatedBy ?? null,
1088
- version.updatedVia ?? null,
1089
- version.parentId ?? null,
1090
- version.associations ? JSON.stringify(version.associations) : null,
1091
- version.permissions ? JSON.stringify(version.permissions) : null,
1092
- id,
1093
- version.version
1094
- ]);
1073
+ SET ${VERSION_COLUMNS.map((c) => `${c} = ?`).join(", ")}
1074
+ WHERE record_id = ? AND version = ?`, [...versionRowValues(version), id, version.version]);
1095
1075
  }
1096
1076
  /**
1097
1077
  * Standalone snapshot write for tooling and tests — loud on any
@@ -1142,8 +1122,8 @@ var SharedSqlRecordLogic = class {
1142
1122
  this.indexedFieldsByType.set(type.id, indexedFieldKinds(schema));
1143
1123
  }
1144
1124
  async getType(id) {
1145
- const rows = this.exec.all("SELECT * FROM types WHERE id = ?", [id]);
1146
- 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;
1147
1127
  }
1148
1128
  async listTypes() {
1149
1129
  const rows = this.exec.all("SELECT * FROM types ORDER BY base_id, version");
@@ -1156,19 +1136,16 @@ var SharedSqlRecordLogic = class {
1156
1136
  this.exec.transaction(() => {
1157
1137
  if (opts.snapshot)
1158
1138
  this.snapshotBeforeMutation(recordId, opts.snapshot);
1159
- this.bumpVersion(recordId, opts);
1139
+ this.versionedUpdate(recordId, [], [], opts);
1160
1140
  this.insertAssociations(recordId, [association]);
1161
1141
  });
1162
- const updated = await this.getRecord(recordId);
1163
- if (!updated)
1164
- throw new Error(`Record not found after associate: "${recordId}"`);
1165
- return updated;
1142
+ return this.reread(recordId, "associate");
1166
1143
  }
1167
1144
  async dissociate(recordId, association, opts = {}) {
1168
1145
  this.exec.transaction(() => {
1169
1146
  if (opts.snapshot)
1170
1147
  this.snapshotBeforeMutation(recordId, opts.snapshot);
1171
- this.bumpVersion(recordId, opts);
1148
+ this.versionedUpdate(recordId, [], [], opts);
1172
1149
  this.exec.run(`DELETE FROM associations
1173
1150
  WHERE record_id = ?
1174
1151
  AND kind = ?
@@ -1179,16 +1156,18 @@ var SharedSqlRecordLogic = class {
1179
1156
  AND related_ns = ?
1180
1157
  AND related_stack = ?`, [recordId, association.kind, association.label, ...associationKeyColumns(association)]);
1181
1158
  });
1182
- const updated = await this.getRecord(recordId);
1183
- if (!updated)
1184
- throw new Error(`Record not found after dissociate: "${recordId}"`);
1185
- return updated;
1159
+ return this.reread(recordId, "dissociate");
1186
1160
  }
1187
- bumpVersion(id, opts = {}) {
1188
- const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
1189
- 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]);
1190
- if (changed === 0)
1191
- 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);
1192
1171
  }
1193
1172
  /**
1194
1173
  * FK enforcement (PRAGMA_FOREIGN_KEYS_ON) means inserting an