@haverstack/record-adapter-do-sqlite 0.14.0 → 0.16.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,12 @@ 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 applyRecordSchema = (exec, opts) => {
136
+ exec.exec(PRAGMA_FOREIGN_KEYS_ON);
137
+ exec.exec(RECORD_SCHEMA_SQL);
138
+ exec.exec(FTS5_SCHEMA_SQL);
139
+ };
140
+ var SORT_FIELDS = NATIVE_SORT_FIELDS;
136
141
  var toBase64 = (text) => {
137
142
  const bytes = new TextEncoder().encode(text);
138
143
  let binary = "";
@@ -348,17 +353,30 @@ var recordConditions = (query) => {
348
353
  const conditions = ["r.id != '_config'"];
349
354
  const params = [];
350
355
  const f = query.filter ?? {};
356
+ const inFilter = (column, value) => {
357
+ if (value === void 0)
358
+ return;
359
+ const values = Array.isArray(value) ? value : [value];
360
+ conditions.push(`r.${column} IN (${values.map(() => "?").join(",")})`);
361
+ params.push(...values);
362
+ };
363
+ const rangeFilter = (column, range) => {
364
+ if (range?.after) {
365
+ conditions.push(`r.${column} > ?`);
366
+ params.push(range.after.getTime());
367
+ }
368
+ if (range?.before) {
369
+ conditions.push(`r.${column} < ?`);
370
+ params.push(range.before.getTime());
371
+ }
372
+ };
351
373
  if (!f.includeDeleted) {
352
374
  conditions.push("r.deleted_at IS NULL");
353
375
  }
354
376
  if (!f.includeUnlisted) {
355
377
  conditions.push("r.unlisted_at IS NULL");
356
378
  }
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
- }
379
+ inFilter("type_id", f.typeId);
362
380
  if (f.parentId !== void 0) {
363
381
  if (f.parentId === null) {
364
382
  conditions.push("r.parent_id IS NULL");
@@ -367,49 +385,24 @@ var recordConditions = (query) => {
367
385
  params.push(f.parentId);
368
386
  }
369
387
  }
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
- }
388
+ inFilter("app_id", f.appId);
389
+ inFilter("entity_id", f.entityId);
390
+ inFilter("principal_id", f.principalId);
391
+ rangeFilter("created_at", f.createdAt);
392
+ rangeFilter("updated_at", f.updatedAt);
393
+ 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
394
  if (f.tags?.length) {
402
395
  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 = ?)`);
396
+ conditions.push(hasAssociation("tag", ["a.label = ?"]));
404
397
  params.push(tag);
405
398
  }
406
399
  }
407
400
  if (f.hasAttachment) {
408
- conditions.push(`r.id IN (SELECT a.record_id FROM associations a WHERE a.kind = 'attachment' AND a.label = ?)`);
401
+ conditions.push(hasAssociation("attachment", ["a.label = ?"]));
409
402
  params.push(f.hasAttachment);
410
403
  }
411
404
  if (f.attachmentFileId) {
412
- conditions.push(`(r.id IN (SELECT a.record_id FROM associations a WHERE a.kind = 'attachment' AND a.file_id = ?)
405
+ conditions.push(`(${hasAssociation("attachment", ["a.file_id = ?"])}
413
406
  OR r.id IN (SELECT ci.record_id FROM content_index ci WHERE ci.file_id = ?))`);
414
407
  params.push(f.attachmentFileId, f.attachmentFileId);
415
408
  }
@@ -438,7 +431,7 @@ var recordConditions = (query) => {
438
431
  }
439
432
  }
440
433
  }
441
- conditions.push(`r.id IN (SELECT a.record_id FROM associations a WHERE a.kind = 'relationship'` + clauses.map((c) => ` AND ${c}`).join("") + `)`);
434
+ conditions.push(hasAssociation("relationship", clauses));
442
435
  }
443
436
  if (f.content) {
444
437
  let aliasSeq = 0;
@@ -643,15 +636,17 @@ var isForeignKeyViolation = (err) => err instanceof Error && err.message.include
643
636
  var isUniqueConstraintViolation = (err) => err instanceof Error && err.message.includes("UNIQUE constraint failed");
644
637
 
645
638
  // ../sqlite-shared/dist/config.js
639
+ var CONFIG_ROW_SQL = `SELECT content FROM records WHERE id = '_config'`;
646
640
  var insertConfigRecord = (exec, entityId, timezone) => {
647
641
  const now = Date.now();
648
642
  exec.run(`INSERT INTO records (id, type_id, created_at, updated_at, content, version)
649
643
  VALUES ('_config', '_config@1', ?, ?, ?, 1)`, [now, now, JSON.stringify({ entityId, timezone })]);
644
+ return { entityId, timezone };
650
645
  };
651
- var readStackConfig = (exec) => {
652
- const row = exec.get(`SELECT content FROM records WHERE id = '_config'`);
646
+ var tryReadStackConfig = (exec) => {
647
+ const row = exec.get(CONFIG_ROW_SQL);
653
648
  if (!row)
654
- throw new Error("Stack database is missing its config record.");
649
+ return null;
655
650
  const content = JSON.parse(row.content);
656
651
  return { entityId: content.entityId, timezone: content.timezone };
657
652
  };
@@ -664,6 +659,29 @@ var asStackQueryError = (query, run) => {
664
659
  throw new StackQueryError(`Search text could not be parsed: ${err instanceof Error ? err.message : String(err)}`);
665
660
  }
666
661
  };
662
+ var versionConflict = (id, expected, actual) => new StackVersionConflictError(`Record "${id}" is at version ${actual}, expected ${expected}`, id, expected, actual);
663
+ var VERSION_COLUMNS = [
664
+ "type_id",
665
+ "content",
666
+ "updated_at",
667
+ "entity_id",
668
+ "updated_by",
669
+ "updated_via",
670
+ "parent_id",
671
+ "associations",
672
+ "permissions"
673
+ ];
674
+ var versionRowValues = (version) => [
675
+ version.typeId,
676
+ JSON.stringify(version.content),
677
+ toMs(version.updatedAt),
678
+ version.entityId ?? null,
679
+ version.updatedBy ?? null,
680
+ version.updatedVia ?? null,
681
+ version.parentId ?? null,
682
+ version.associations ? JSON.stringify(version.associations) : null,
683
+ version.permissions ? JSON.stringify(version.permissions) : null
684
+ ];
667
685
  var indexedFieldKinds = (schema) => {
668
686
  const kinds = /* @__PURE__ */ new Map();
669
687
  for (const [field, def] of Object.entries(schema)) {
@@ -689,26 +707,22 @@ var SharedSqlRecordLogic = class {
689
707
  return this.deps.exec;
690
708
  }
691
709
  /**
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.
710
+ * Precondition check for a mutation that has already read the record —
711
+ * either because it can't fold the check into its own statement
712
+ * (hardDeleteRecord: children reference the row, so the DELETE can't
713
+ * carry it), or because it has to settle the precondition *before*
714
+ * fts5Strategy.remove() runs, which needs the records row still holding
715
+ * the old content. In the latter case versionedUpdate() re-checks it
716
+ * inside the statement, and that is the enforcement; this is what lets
717
+ * the work before it be skipped.
704
718
  */
705
719
  checkExpectedVersion(record, expectedVersion) {
706
720
  if (expectedVersion === void 0 || record.version === expectedVersion)
707
721
  return;
708
- throw new StackVersionConflictError(`Record "${record.id}" is at version ${record.version}, expected ${expectedVersion}`, record.id, expectedVersion, record.version);
722
+ throw versionConflict(record.id, expectedVersion, record.version);
709
723
  }
710
724
  /**
711
- * Called after a versionGuard()-gated statement affected zero rows.
725
+ * Called after a version-gated statement affected zero rows.
712
726
  * Distinguishes "record doesn't exist" (StackNotFoundError) from "it
713
727
  * exists but isn't at expectedVersion" (StackVersionConflictError, with
714
728
  * the actual current version for the caller to act on).
@@ -719,7 +733,48 @@ var SharedSqlRecordLogic = class {
719
733
  ]);
720
734
  if (!row)
721
735
  throw new StackNotFoundError(`Record not found: "${id}"`);
722
- throw new StackVersionConflictError(`Record "${id}" is at version ${row.version}, expected ${expectedVersion}`, id, expectedVersion, row.version);
736
+ throw versionConflict(id, expectedVersion, row.version);
737
+ }
738
+ /**
739
+ * The one shape every mutating UPDATE in this class has: whatever columns
740
+ * the caller is changing, then the version bump and the actor/timestamp
741
+ * stamp that every mutation owes, gated on the opt-in `expectedVersion`
742
+ * precondition and failing loudly when nothing matched.
743
+ *
744
+ * The guard rides in the WHERE clause even where the caller has already
745
+ * checked the version against a record it read: that read is outside this
746
+ * statement, so a writer slipping in between the two is caught here
747
+ * rather than silently overwritten.
748
+ */
749
+ versionedUpdate(id, sets, values, opts, now = toMs(/* @__PURE__ */ new Date())) {
750
+ const guarded = opts.expectedVersion !== void 0;
751
+ const changed = this.exec.run(`UPDATE records SET ${[...sets, "version = version + 1", "updated_at = ?", "updated_by = ?", "updated_via = ?"].join(", ")} WHERE id = ?${guarded ? " AND version = ?" : ""}`, [
752
+ ...values,
753
+ now,
754
+ opts.updatedBy ?? null,
755
+ opts.updatedVia ?? null,
756
+ id,
757
+ ...guarded ? [opts.expectedVersion] : []
758
+ ]);
759
+ if (changed === 0)
760
+ this.throwVersionConflict(id, opts.expectedVersion);
761
+ }
762
+ /**
763
+ * Read a record back after its own mutation committed, for the caller to
764
+ * return. Its absence is not a "not found" for the caller to handle — the
765
+ * mutation just succeeded against it — so it fails as the invariant
766
+ * breach it would be.
767
+ *
768
+ * Synchronous, via readRecord() rather than getRecord(), so the read
769
+ * cannot be separated from the commit by anything: an async caller
770
+ * returning this still hands back a record read in the same tick its
771
+ * transaction committed. See readRecord() for why that matters.
772
+ */
773
+ reread(id, verb) {
774
+ const updated = this.readRecord(id);
775
+ if (!updated)
776
+ throw new Error(`Record not found after ${verb}: "${id}"`);
777
+ return updated;
723
778
  }
724
779
  // -------------------------------------------------------
725
780
  // Records
@@ -776,11 +831,11 @@ var SharedSqlRecordLogic = class {
776
831
  * reason (see SqlExecutor.transaction).
777
832
  *
778
833
  * 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.
834
+ * version their own mutation produced: reread() calls this directly, so
835
+ * nothing interleaves between the commit and the read even though the
836
+ * method returning it is async. A backend that made these reads genuinely
837
+ * asynchronous would open that window, and a concurrent mutation could be
838
+ * reported under this one's verb.
784
839
  */
785
840
  readRecord(id) {
786
841
  const row = this.exec.get("SELECT * FROM records WHERE id = ?", [id]);
@@ -831,49 +886,28 @@ var SharedSqlRecordLogic = class {
831
886
  this.snapshotBeforeMutation(id, opts.snapshot);
832
887
  if (merged !== void 0)
833
888
  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);
889
+ this.versionedUpdate(id, sets, values, opts, now);
838
890
  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);
891
+ this.replaceAssociations(id, changes.associations);
842
892
  }
843
893
  if (merged !== void 0) {
844
894
  fts5Strategy.insert(this.exec, id, JSON.stringify(merged));
845
895
  this.syncContentIndex(id, existing.typeId, merged);
846
896
  }
847
897
  });
848
- const updated = await this.getRecord(id);
849
- if (!updated)
850
- throw new Error(`Record not found after mutateRecord: "${id}"`);
851
- return updated;
898
+ return this.reread(id, "mutateRecord");
852
899
  }
853
900
  async deleteRecord(id, opts = {}) {
854
901
  if (opts.hard) {
855
902
  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
903
  }
873
- const updated = await this.getRecord(id);
874
- if (!updated)
875
- throw new Error(`Record not found after deleteRecord: "${id}"`);
876
- return updated;
904
+ const now = toMs(/* @__PURE__ */ new Date());
905
+ this.exec.transaction(() => {
906
+ if (opts.snapshot)
907
+ this.snapshotBeforeMutation(id, opts.snapshot);
908
+ this.versionedUpdate(id, ["deleted_at = ?"], [now], opts, now);
909
+ });
910
+ return this.reread(id, "deleteRecord");
877
911
  }
878
912
  /**
879
913
  * Deletes a record's FTS entry, associations, versions, file-refs, sort
@@ -886,15 +920,12 @@ var SharedSqlRecordLogic = class {
886
920
  */
887
921
  hardDeleteRecord(id, expectedVersion) {
888
922
  const purged = this.readRecord(id);
889
- if (expectedVersion !== void 0) {
890
- if (!purged)
923
+ if (!purged) {
924
+ if (expectedVersion !== void 0)
891
925
  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
926
  return null;
927
+ }
928
+ this.checkExpectedVersion(purged, expectedVersion);
898
929
  fts5Strategy.remove(this.exec, id);
899
930
  this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
900
931
  this.exec.run("DELETE FROM versions WHERE record_id = ?", [id]);
@@ -906,15 +937,9 @@ var SharedSqlRecordLogic = class {
906
937
  this.exec.transaction(() => {
907
938
  if (opts.snapshot)
908
939
  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);
940
+ this.versionedUpdate(id, ["deleted_at = NULL"], [], opts);
913
941
  });
914
- const updated = await this.getRecord(id);
915
- if (!updated)
916
- throw new Error(`Record not found after undelete: "${id}"`);
917
- return updated;
942
+ return this.reread(id, "undelete");
918
943
  }
919
944
  async restoreVersion(id, version, opts = {}) {
920
945
  const existing = await this.getRecord(id);
@@ -927,29 +952,12 @@ var SharedSqlRecordLogic = class {
927
952
  this.exec.transaction(() => {
928
953
  if (opts.snapshot)
929
954
  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
- ]);
955
+ this.rewriteContent(id, target.typeId, target.content, opts, ["parent_id = ?"], [target.parentId ?? null]);
940
956
  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);
957
+ if (applied !== void 0)
958
+ this.replaceAssociations(id, applied);
948
959
  });
949
- const updated = await this.getRecord(id);
950
- if (!updated)
951
- throw new Error(`Record not found after restoreVersion: "${id}"`);
952
- return updated;
960
+ return this.reread(id, "restoreVersion");
953
961
  }
954
962
  async commitMigration(id, toTypeId, content, opts = {}) {
955
963
  const existing = await this.getRecord(id);
@@ -959,22 +967,27 @@ var SharedSqlRecordLogic = class {
959
967
  this.exec.transaction(() => {
960
968
  if (opts.snapshot)
961
969
  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);
970
+ this.rewriteContent(id, toTypeId, content, opts);
973
971
  });
974
- const updated = await this.getRecord(id);
975
- if (!updated)
976
- throw new Error(`Record not found after commitMigration: "${id}"`);
977
- return updated;
972
+ return this.reread(id, "commitMigration");
973
+ }
974
+ /**
975
+ * Replace a record's type and content wholesale — what restoreVersion and
976
+ * commitMigration each do, differing only in where the content came from
977
+ * and whether the container moves with it.
978
+ *
979
+ * The order is the constraint: fts5Strategy.remove() has to read the old
980
+ * content off the records row, so it runs before the UPDATE, and the
981
+ * re-index runs after. Every caller must already sit inside
982
+ * exec.transaction() — a failure between the two halves would otherwise
983
+ * leave the record unindexed.
984
+ */
985
+ rewriteContent(id, typeId, content, opts, extraSets = [], extraValues = []) {
986
+ const json = JSON.stringify(content);
987
+ fts5Strategy.remove(this.exec, id);
988
+ this.versionedUpdate(id, ["type_id = ?", "content = ?", ...extraSets], [typeId, json, ...extraValues], opts);
989
+ fts5Strategy.insert(this.exec, id, json);
990
+ this.syncContentIndex(id, typeId, content);
978
991
  }
979
992
  async queryRecords(query) {
980
993
  const limit = query.limit ?? 50;
@@ -1035,8 +1048,8 @@ var SharedSqlRecordLogic = class {
1035
1048
  return rows.map(rowToVersion);
1036
1049
  }
1037
1050
  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;
1051
+ const row = this.exec.get("SELECT * FROM versions WHERE record_id = ? AND version = ?", [id, version]);
1052
+ return row ? rowToVersion(row) : null;
1040
1053
  }
1041
1054
  /**
1042
1055
  * A (record_id, version) collision is rejected loudly via the UNIQUE
@@ -1047,21 +1060,8 @@ var SharedSqlRecordLogic = class {
1047
1060
  insertVersionRow(id, version) {
1048
1061
  try {
1049
1062
  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
- ]);
1063
+ (record_id, version, ${VERSION_COLUMNS.join(", ")})
1064
+ VALUES (?, ?, ${VERSION_COLUMNS.map(() => "?").join(", ")})`, [id, version.version, ...versionRowValues(version)]);
1065
1065
  } catch (err) {
1066
1066
  if (isUniqueConstraintViolation(err)) {
1067
1067
  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 +1070,15 @@ var SharedSqlRecordLogic = class {
1070
1070
  }
1071
1071
  }
1072
1072
  /**
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.
1073
+ * Replaces every column insertVersionRow writes — structurally, off the
1074
+ * same VERSION_COLUMNS list, because a column left out here would keep
1075
+ * the replaced row's value and surface later as a restore putting back
1076
+ * something the snapshot never said.
1077
1077
  */
1078
1078
  overwriteVersionRow(id, version) {
1079
1079
  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
- ]);
1080
+ SET ${VERSION_COLUMNS.map((c) => `${c} = ?`).join(", ")}
1081
+ WHERE record_id = ? AND version = ?`, [...versionRowValues(version), id, version.version]);
1095
1082
  }
1096
1083
  /**
1097
1084
  * Standalone snapshot write for tooling and tests — loud on any
@@ -1142,8 +1129,8 @@ var SharedSqlRecordLogic = class {
1142
1129
  this.indexedFieldsByType.set(type.id, indexedFieldKinds(schema));
1143
1130
  }
1144
1131
  async getType(id) {
1145
- const rows = this.exec.all("SELECT * FROM types WHERE id = ?", [id]);
1146
- return rows.length ? rowToType(rows[0]) : null;
1132
+ const row = this.exec.get("SELECT * FROM types WHERE id = ?", [id]);
1133
+ return row ? rowToType(row) : null;
1147
1134
  }
1148
1135
  async listTypes() {
1149
1136
  const rows = this.exec.all("SELECT * FROM types ORDER BY base_id, version");
@@ -1156,19 +1143,16 @@ var SharedSqlRecordLogic = class {
1156
1143
  this.exec.transaction(() => {
1157
1144
  if (opts.snapshot)
1158
1145
  this.snapshotBeforeMutation(recordId, opts.snapshot);
1159
- this.bumpVersion(recordId, opts);
1146
+ this.versionedUpdate(recordId, [], [], opts);
1160
1147
  this.insertAssociations(recordId, [association]);
1161
1148
  });
1162
- const updated = await this.getRecord(recordId);
1163
- if (!updated)
1164
- throw new Error(`Record not found after associate: "${recordId}"`);
1165
- return updated;
1149
+ return this.reread(recordId, "associate");
1166
1150
  }
1167
1151
  async dissociate(recordId, association, opts = {}) {
1168
1152
  this.exec.transaction(() => {
1169
1153
  if (opts.snapshot)
1170
1154
  this.snapshotBeforeMutation(recordId, opts.snapshot);
1171
- this.bumpVersion(recordId, opts);
1155
+ this.versionedUpdate(recordId, [], [], opts);
1172
1156
  this.exec.run(`DELETE FROM associations
1173
1157
  WHERE record_id = ?
1174
1158
  AND kind = ?
@@ -1179,16 +1163,18 @@ var SharedSqlRecordLogic = class {
1179
1163
  AND related_ns = ?
1180
1164
  AND related_stack = ?`, [recordId, association.kind, association.label, ...associationKeyColumns(association)]);
1181
1165
  });
1182
- const updated = await this.getRecord(recordId);
1183
- if (!updated)
1184
- throw new Error(`Record not found after dissociate: "${recordId}"`);
1185
- return updated;
1166
+ return this.reread(recordId, "dissociate");
1186
1167
  }
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);
1168
+ /**
1169
+ * Swap a record's association set for `associations` wholesale — the
1170
+ * "replace, don't merge" semantics a change set's association list and a
1171
+ * version snapshot's both carry. Caller-transactional, like every write
1172
+ * below.
1173
+ */
1174
+ replaceAssociations(recordId, associations) {
1175
+ this.exec.run("DELETE FROM associations WHERE record_id = ?", [recordId]);
1176
+ if (associations.length)
1177
+ this.insertAssociations(recordId, associations);
1192
1178
  }
1193
1179
  /**
1194
1180
  * FK enforcement (PRAGMA_FOREIGN_KEYS_ON) means inserting an
@@ -1261,87 +1247,31 @@ var SharedSqlRecordLogic = class {
1261
1247
  }
1262
1248
  };
1263
1249
 
1264
- // src/executor.ts
1265
- var DurableObjectSqliteExecutor = class {
1266
- constructor(storage) {
1267
- this.storage = storage;
1268
- }
1269
- storage;
1270
- get sql() {
1271
- return this.storage.sql;
1272
- }
1273
- exec(sql) {
1274
- this.sql.exec(sql);
1275
- }
1276
- run(sql, params = []) {
1277
- const cursor = this.sql.exec(sql, ...params);
1278
- return cursor.rowsWritten;
1279
- }
1280
- get(sql, params = []) {
1281
- const rows = this.sql.exec(sql, ...params).toArray();
1282
- return rows[0];
1283
- }
1284
- all(sql, params = []) {
1285
- return this.sql.exec(sql, ...params).toArray();
1286
- }
1287
- transaction(fn) {
1288
- return this.storage.transactionSync(fn);
1250
+ // ../sqlite-shared/dist/record-adapter.js
1251
+ var SQLITE_RECORD_CAPABILITIES = {
1252
+ filter: {
1253
+ content: "path",
1254
+ contentPresent: true,
1255
+ search: true
1256
+ },
1257
+ sort: {
1258
+ fields: ["createdAt", "updatedAt", "version"],
1259
+ contentField: true
1260
+ },
1261
+ limits: {
1262
+ attachmentBytes: null,
1263
+ contentBytes: null
1289
1264
  }
1290
1265
  };
1291
-
1292
- // src/index.ts
1293
- var DoSQLiteRecordAdapter = class _DoSQLiteRecordAdapter {
1294
- constructor(exec) {
1295
- this.exec = exec;
1296
- this.record = new SharedSqlRecordLogic({ exec });
1297
- }
1298
- exec;
1299
- capabilities = {
1300
- filter: {
1301
- content: "path",
1302
- contentPresent: true,
1303
- search: true
1304
- },
1305
- sort: {
1306
- fields: ["createdAt", "updatedAt", "version"],
1307
- contentField: true
1308
- },
1309
- limits: {
1310
- attachmentBytes: null,
1311
- contentBytes: null
1312
- }
1313
- };
1266
+ var SharedSqlRecordAdapter = class {
1267
+ capabilities = SQLITE_RECORD_CAPABILITIES;
1314
1268
  ownerEntityId;
1315
1269
  timezone;
1316
1270
  record;
1317
- /**
1318
- * Create (or reattach to) the adapter for a DO instance. There is no
1319
- * initialize()/open() split the way file-based adapters need one: a DO
1320
- * id either already has a config record (a previous call created it —
1321
- * reattach, opts.entityId/timezone ignored in favor of what's stored)
1322
- * or it doesn't (first call — opts.entityId/timezone become the config).
1323
- * Schema DDL is `CREATE TABLE IF NOT EXISTS`, so running it every call
1324
- * is idempotent and cheap.
1325
- */
1326
- static async create(storage, opts) {
1327
- const exec = new DurableObjectSqliteExecutor(storage);
1328
- exec.exec(RECORD_SCHEMA_SQL);
1329
- exec.exec(FTS5_SCHEMA_SQL);
1330
- exec.exec(PRAGMA_FOREIGN_KEYS_ON);
1331
- const adapter = new _DoSQLiteRecordAdapter(exec);
1332
- const existing = exec.get(
1333
- `SELECT content FROM records WHERE id = '_config'`
1334
- );
1335
- if (existing) {
1336
- const config = readStackConfig(exec);
1337
- adapter.ownerEntityId = config.entityId;
1338
- adapter.timezone = config.timezone;
1339
- } else {
1340
- insertConfigRecord(exec, opts.entityId, opts.timezone);
1341
- adapter.ownerEntityId = opts.entityId;
1342
- adapter.timezone = opts.timezone;
1343
- }
1344
- return adapter;
1271
+ constructor(exec, config) {
1272
+ this.record = new SharedSqlRecordLogic({ exec });
1273
+ this.ownerEntityId = config.entityId;
1274
+ this.timezone = config.timezone;
1345
1275
  }
1346
1276
  // -------------------------------------------------------
1347
1277
  // Records
@@ -1406,6 +1336,60 @@ var DoSQLiteRecordAdapter = class _DoSQLiteRecordAdapter {
1406
1336
  dissociate(recordId, association, opts) {
1407
1337
  return this.record.dissociate(recordId, association, opts);
1408
1338
  }
1339
+ };
1340
+
1341
+ // src/executor.ts
1342
+ var DurableObjectSqliteExecutor = class {
1343
+ constructor(storage) {
1344
+ this.storage = storage;
1345
+ }
1346
+ storage;
1347
+ get sql() {
1348
+ return this.storage.sql;
1349
+ }
1350
+ exec(sql) {
1351
+ this.sql.exec(sql);
1352
+ }
1353
+ run(sql, params = []) {
1354
+ const cursor = this.sql.exec(sql, ...params);
1355
+ return cursor.rowsWritten;
1356
+ }
1357
+ get(sql, params = []) {
1358
+ const rows = this.sql.exec(sql, ...params).toArray();
1359
+ return rows[0];
1360
+ }
1361
+ all(sql, params = []) {
1362
+ return this.sql.exec(sql, ...params).toArray();
1363
+ }
1364
+ transaction(fn) {
1365
+ return this.storage.transactionSync(fn);
1366
+ }
1367
+ };
1368
+
1369
+ // src/index.ts
1370
+ var DoSQLiteRecordAdapter = class _DoSQLiteRecordAdapter extends SharedSqlRecordAdapter {
1371
+ constructor(exec, config) {
1372
+ super(exec, config);
1373
+ }
1374
+ /**
1375
+ * Create (or reattach to) the adapter for a DO instance. There is no
1376
+ * initialize()/open() split the way file-based adapters need one: a DO
1377
+ * id either already has a config record (a previous call created it —
1378
+ * reattach, opts.entityId/timezone ignored in favor of what's stored)
1379
+ * or it doesn't (first call — opts.entityId/timezone become the config).
1380
+ * Schema DDL is `CREATE TABLE IF NOT EXISTS`, so running it every call
1381
+ * is idempotent and cheap.
1382
+ *
1383
+ * DO SQLite manages its own durability and rejects PRAGMA journal_mode
1384
+ * outright ("not authorized") — verified against the real runtime, not
1385
+ * assumed — hence `wal: false`, unlike record-adapter-sqlite.
1386
+ */
1387
+ static async create(storage, opts) {
1388
+ const exec = new DurableObjectSqliteExecutor(storage);
1389
+ applyRecordSchema(exec);
1390
+ const config = tryReadStackConfig(exec) ?? insertConfigRecord(exec, opts.entityId, opts.timezone);
1391
+ return new _DoSQLiteRecordAdapter(exec, config);
1392
+ }
1409
1393
  // -------------------------------------------------------
1410
1394
  // Lifecycle
1411
1395
  // -------------------------------------------------------