@haverstack/record-adapter-sqlite 0.26.0 → 0.29.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
@@ -24,14 +24,23 @@ var RECORD_SCHEMA_SQL = `
24
24
  updated_by TEXT,
25
25
  updated_via TEXT,
26
26
  deleted_at INTEGER,
27
- unlisted_at INTEGER,
28
- permissions TEXT CHECK (permissions IS NULL OR json_valid(permissions))
27
+ unlisted_at INTEGER
29
28
  ) STRICT;
30
29
 
30
+ -- Every edge a record carries, authority and data alike: a permission
31
+ -- entry has element identity and a delta the same shape as a tag's, so
32
+ -- it earns the same table and the same durability tier. The permissions
33
+ -- and associations fields are projections over it, partitioned by
34
+ -- kind. See docs/spec/access-control.md \xA7 Record-level permissions.
35
+ --
31
36
  -- A relationship's target is (related_scope, related_id) plus one
32
37
  -- qualifier: related_stack for a record in another stack, related_ns for
33
38
  -- a foreign namespace. All four are in the primary key, so two targets
34
- -- differing only by namespace are two associations.
39
+ -- differing only by namespace are two associations. A permission's
40
+ -- grantee reuses them \u2014 related_scope 'entity' or 'group', related_id the
41
+ -- DID or group id \u2014 plus related_role, which only a group grantee sets
42
+ -- and which is in the key because member and admin name two different
43
+ -- sets of people.
35
44
  --
36
45
  -- attachment_record_id stays out of the primary key: it annotates a
37
46
  -- reference rather than naming one, so re-pointing it updates the row an
@@ -43,22 +52,21 @@ var RECORD_SCHEMA_SQL = `
43
52
  -- for the filter directions it cannot answer.
44
53
  CREATE TABLE IF NOT EXISTS associations (
45
54
  record_id TEXT NOT NULL REFERENCES records(id),
46
- kind TEXT NOT NULL CHECK (kind IN ('tag', 'attachment', 'relationship')),
55
+ kind TEXT NOT NULL CHECK (kind IN ('tag', 'attachment', 'relationship', 'permission', 'anyone')),
47
56
  label TEXT NOT NULL,
48
57
  file_id TEXT NOT NULL DEFAULT '',
49
- related_scope TEXT NOT NULL DEFAULT '' CHECK (related_scope IN ('', 'record', 'entity', 'external')),
58
+ related_scope TEXT NOT NULL DEFAULT '' CHECK (related_scope IN ('', 'record', 'entity', 'external', 'group')),
50
59
  related_id TEXT NOT NULL DEFAULT '',
51
60
  related_ns TEXT NOT NULL DEFAULT '',
52
61
  related_stack TEXT NOT NULL DEFAULT '',
62
+ related_role TEXT NOT NULL DEFAULT '' CHECK (related_role IN ('', 'member', 'admin')),
53
63
  attachment_record_id TEXT NOT NULL DEFAULT '',
54
- PRIMARY KEY (record_id, kind, label, file_id, related_scope, related_id, related_ns, related_stack)
64
+ PRIMARY KEY (record_id, kind, label, file_id, related_scope, related_id, related_ns, related_stack, related_role)
55
65
  ) STRICT;
56
66
 
57
- -- parent_id holds the container the record sat in, NULL for the root --
58
- -- the same encoding records.parent_id uses, since a snapshot states
59
- -- where the record was rather than an instruction to apply.
60
- -- No associations column: associate()/dissociate() don't bump version,
61
- -- so no version ever snapshots the association set. See
67
+ -- Content and the type it is read under. No parent_id, unlisted_at or
68
+ -- associations column: none of those aspects bumps version, so no
69
+ -- version ever snapshots one. See
62
70
  -- docs/spec/versioning.md \xA7 Version history.
63
71
  CREATE TABLE IF NOT EXISTS versions (
64
72
  record_id TEXT NOT NULL REFERENCES records(id),
@@ -69,8 +77,6 @@ var RECORD_SCHEMA_SQL = `
69
77
  entity_id TEXT,
70
78
  updated_by TEXT,
71
79
  updated_via TEXT,
72
- parent_id TEXT,
73
- permissions TEXT CHECK (permissions IS NULL OR json_valid(permissions)),
74
80
  PRIMARY KEY (record_id, version)
75
81
  ) STRICT;
76
82
 
@@ -582,6 +588,10 @@ var buildQueryPlan = (query) => {
582
588
  // ../sqlite-shared/dist/mappers.js
583
589
  var toMs = (d) => d.getTime();
584
590
  var fromMs = (ms) => new Date(ms);
591
+ var partitionAssociations = (associations) => ({
592
+ associations: associations.filter((a) => a.kind !== "permission" && a.kind !== "anyone"),
593
+ permissions: associations.filter((a) => a.kind === "permission" || a.kind === "anyone")
594
+ });
585
595
  var rowToRecord = (row, associations) => {
586
596
  const record = {
587
597
  id: row.id,
@@ -607,16 +617,27 @@ var rowToRecord = (row, associations) => {
607
617
  record.deletedAt = fromMs(row.deleted_at);
608
618
  if (row.unlisted_at != null)
609
619
  record.unlistedAt = fromMs(row.unlisted_at);
610
- if (row.permissions != null)
611
- record.permissions = JSON.parse(row.permissions);
612
- if (associations.length)
613
- record.associations = associations;
620
+ const partitioned = partitionAssociations(associations);
621
+ if (partitioned.associations.length)
622
+ record.associations = partitioned.associations;
623
+ if (partitioned.permissions.length)
624
+ record.permissions = partitioned.permissions;
614
625
  return record;
615
626
  };
616
627
  var rowToAssociation = (row) => {
617
628
  if (row.kind === "tag") {
618
629
  return { kind: "tag", label: row.label };
619
630
  }
631
+ if (row.kind === "anyone") {
632
+ return { kind: "anyone", label: "read" };
633
+ }
634
+ if (row.kind === "permission") {
635
+ return {
636
+ kind: "permission",
637
+ label: row.label,
638
+ grantee: rowToGrantee(row)
639
+ };
640
+ }
620
641
  if (row.kind === "attachment") {
621
642
  const attachmentRecordId = row.attachment_record_id;
622
643
  return {
@@ -637,16 +658,25 @@ var rowToAssociation = (row) => {
637
658
  };
638
659
  var associationKeyColumns = (a) => {
639
660
  if (a.kind === "attachment")
640
- return [a.fileId, "", "", "", ""];
661
+ return [a.fileId, "", "", "", "", ""];
662
+ if (a.kind === "permission") {
663
+ const g = a.grantee;
664
+ return g.scope === "entity" ? ["", "entity", g.entityId, "", "", ""] : ["", "group", g.groupId, "", "", g.role];
665
+ }
641
666
  if (a.kind !== "relationship")
642
- return ["", "", "", "", ""];
667
+ return ["", "", "", "", "", ""];
643
668
  const t = a.target;
644
669
  if (t.scope === "entity")
645
- return ["", "entity", t.entityId, "", ""];
670
+ return ["", "entity", t.entityId, "", "", ""];
646
671
  if (t.scope === "external")
647
- return ["", "external", t.id, t.ns, ""];
648
- return ["", "record", t.recordId, "", t.stackUrl ?? ""];
672
+ return ["", "external", t.id, t.ns, "", ""];
673
+ return ["", "record", t.recordId, "", t.stackUrl ?? "", ""];
649
674
  };
675
+ var rowToGrantee = (row) => row.related_scope === "group" ? {
676
+ scope: "group",
677
+ groupId: row.related_id,
678
+ role: row.related_role
679
+ } : { scope: "entity", entityId: row.related_id };
650
680
  var rowToTarget = (row) => {
651
681
  const id = row.related_id;
652
682
  if (row.related_scope === "entity")
@@ -684,10 +714,6 @@ var rowToVersion = (row) => {
684
714
  v.updatedBy = row.updated_by;
685
715
  if (row.updated_via != null)
686
716
  v.updatedVia = row.updated_via;
687
- if (row.parent_id != null)
688
- v.parentId = row.parent_id;
689
- if (row.permissions != null)
690
- v.permissions = JSON.parse(row.permissions);
691
717
  return v;
692
718
  };
693
719
  var rowToJournalEntry = (row) => {
@@ -752,9 +778,7 @@ var VERSION_COLUMNS = [
752
778
  "updated_at",
753
779
  "entity_id",
754
780
  "updated_by",
755
- "updated_via",
756
- "parent_id",
757
- "permissions"
781
+ "updated_via"
758
782
  ];
759
783
  var versionRowValues = (version) => [
760
784
  version.typeId,
@@ -762,9 +786,7 @@ var versionRowValues = (version) => [
762
786
  toMs(version.updatedAt),
763
787
  version.entityId ?? null,
764
788
  version.updatedBy ?? null,
765
- version.updatedVia ?? null,
766
- version.parentId ?? null,
767
- version.permissions ? JSON.stringify(version.permissions) : null
789
+ version.updatedVia ?? null
768
790
  ];
769
791
  var indexedFieldKinds = (schema) => {
770
792
  const kinds = /* @__PURE__ */ new Map();
@@ -775,7 +797,7 @@ var indexedFieldKinds = (schema) => {
775
797
  }
776
798
  return kinds;
777
799
  };
778
- var SharedSqlRecordLogic = class {
800
+ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
779
801
  deps;
780
802
  /**
781
803
  * Per-typeId cache of the fields content_index covers and their declared
@@ -881,8 +903,8 @@ var SharedSqlRecordLogic = class {
881
903
  this.exec.run(`INSERT INTO records
882
904
  (id, type_id, created_at, updated_at, content, version,
883
905
  parent_id, entity_id, app_id, principal_id, updated_by, updated_via,
884
- deleted_at, unlisted_at, permissions)
885
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
906
+ deleted_at, unlisted_at)
907
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
886
908
  record.id,
887
909
  record.typeId,
888
910
  toMs(record.createdAt),
@@ -896,8 +918,7 @@ var SharedSqlRecordLogic = class {
896
918
  record.updatedBy ?? null,
897
919
  record.updatedVia ?? null,
898
920
  record.deletedAt ? toMs(record.deletedAt) : null,
899
- record.unlistedAt ? toMs(record.unlistedAt) : null,
900
- record.permissions ? JSON.stringify(record.permissions) : null
921
+ record.unlistedAt ? toMs(record.unlistedAt) : null
901
922
  ]);
902
923
  } catch (err) {
903
924
  if (isUniqueConstraintViolation(err)) {
@@ -905,9 +926,9 @@ var SharedSqlRecordLogic = class {
905
926
  }
906
927
  throw err;
907
928
  }
908
- if (record.associations?.length) {
909
- this.insertAssociations(record.id, record.associations);
910
- }
929
+ const edges = [...record.associations ?? [], ...record.permissions ?? []];
930
+ if (edges.length)
931
+ this.insertAssociations(record.id, edges);
911
932
  fts5Strategy.insert(this.exec, record.id, JSON.stringify(record.content));
912
933
  this.syncContentIndex(record.id, record.typeId, record.content);
913
934
  this.appendJournal(record.id, opts.journal);
@@ -945,13 +966,13 @@ var SharedSqlRecordLogic = class {
945
966
  * one call is one version whatever it moved.
946
967
  *
947
968
  * `opts.bumpsVersion: false` is a change set that touches only
948
- * `associations` — Stack computes this from which ops the set actually
949
- * moves. Such a write skips the records-table UPDATE (and the snapshot)
950
- * entirely: nothing on that row changes, so there is nothing to bump and
951
- * no version to snapshot. `expectedVersion`, if given, is still
952
- * re-checked synchronously inside the transaction — the CAS guard the
953
- * bumping path gets from its UPDATE's WHERE clause, restated as a read
954
- * because there is no UPDATE here to carry it. See
969
+ * `associations`, `parentId` and/or `unlisted` — Stack computes this from
970
+ * which ops the set actually moves. Such a write still lands its columns,
971
+ * but leaves `version`, `updatedAt` and the actor stamps exactly as they
972
+ * stood and takes no snapshot. `expectedVersion`, if given, is re-checked
973
+ * synchronously inside the transaction — the CAS guard the bumping path
974
+ * gets from its UPDATE's WHERE clause, restated as a read because the
975
+ * UPDATE here carries no version predicate. See
955
976
  * docs/spec/versioning.md § Version history.
956
977
  *
957
978
  * The expectedVersion check for a bumping write is standalone rather than
@@ -965,38 +986,35 @@ var SharedSqlRecordLogic = class {
965
986
  if (!existing)
966
987
  throw new StackNotFoundError(`Record not found: "${id}"`);
967
988
  this.checkExpectedVersion(existing, opts.expectedVersion);
989
+ const now = toMs(/* @__PURE__ */ new Date());
990
+ const sets = [];
991
+ const values = [];
992
+ if (changes.parentId !== void 0) {
993
+ sets.push("parent_id = ?");
994
+ values.push(changes.parentId);
995
+ }
996
+ if (changes.unlisted !== void 0) {
997
+ sets.push("unlisted_at = ?");
998
+ values.push(changes.unlisted ? now : null);
999
+ }
968
1000
  if (opts.bumpsVersion === false) {
969
1001
  this.exec.transaction(() => {
970
1002
  const current = this.readRecord(id);
971
1003
  if (!current)
972
1004
  throw new StackNotFoundError(`Record not found: "${id}"`);
973
1005
  this.checkExpectedVersion(current, opts.expectedVersion);
974
- if (changes.associations !== void 0) {
975
- this.replaceAssociations(id, changes.associations);
1006
+ if (sets.length > 0) {
1007
+ this.exec.run(`UPDATE records SET ${sets.join(", ")} WHERE id = ?`, [...values, id]);
976
1008
  }
1009
+ this.replaceAssociationHalves(id, changes);
977
1010
  this.appendJournal(id, opts.journal);
978
1011
  });
979
1012
  return this.reread(id, "mutateRecord");
980
1013
  }
981
1014
  const merged = changes.contentPatch ? applyMergePatch(existing.content, changes.contentPatch) : void 0;
982
- const sets = [];
983
- const values = [];
984
1015
  if (merged !== void 0) {
985
- sets.push("content = ?");
986
- values.push(JSON.stringify(merged));
987
- }
988
- if (changes.parentId !== void 0) {
989
- sets.push("parent_id = ?");
990
- values.push(changes.parentId);
991
- }
992
- if (changes.permissions !== void 0) {
993
- sets.push("permissions = ?");
994
- values.push(changes.permissions.length ? JSON.stringify(changes.permissions) : null);
995
- }
996
- const now = toMs(/* @__PURE__ */ new Date());
997
- if (changes.unlisted !== void 0) {
998
- sets.push("unlisted_at = ?");
999
- values.push(changes.unlisted ? now : null);
1016
+ sets.unshift("content = ?");
1017
+ values.unshift(JSON.stringify(merged));
1000
1018
  }
1001
1019
  this.exec.transaction(() => {
1002
1020
  if (opts.snapshot)
@@ -1004,9 +1022,7 @@ var SharedSqlRecordLogic = class {
1004
1022
  if (merged !== void 0)
1005
1023
  fts5Strategy.remove(this.exec, id);
1006
1024
  this.versionedUpdate(id, sets, values, opts, now);
1007
- if (changes.associations !== void 0) {
1008
- this.replaceAssociations(id, changes.associations);
1009
- }
1025
+ this.replaceAssociationHalves(id, changes);
1010
1026
  if (merged !== void 0) {
1011
1027
  fts5Strategy.insert(this.exec, id, JSON.stringify(merged));
1012
1028
  this.syncContentIndex(id, existing.typeId, merged);
@@ -1073,7 +1089,7 @@ var SharedSqlRecordLogic = class {
1073
1089
  this.exec.transaction(() => {
1074
1090
  if (opts.snapshot)
1075
1091
  this.snapshotBeforeMutation(id, opts.snapshot);
1076
- this.rewriteContent(id, target.typeId, target.content, opts, ["parent_id = ?"], [target.parentId ?? null]);
1092
+ this.rewriteContent(id, target.typeId, target.content, opts);
1077
1093
  this.appendJournal(id, opts.journal);
1078
1094
  });
1079
1095
  return this.reread(id, "restoreVersion");
@@ -1093,8 +1109,7 @@ var SharedSqlRecordLogic = class {
1093
1109
  }
1094
1110
  /**
1095
1111
  * Replace a record's type and content wholesale — what restoreVersion and
1096
- * commitMigration each do, differing only in where the content came from
1097
- * and whether the container moves with it.
1112
+ * commitMigration each do, differing only in where the content came from.
1098
1113
  *
1099
1114
  * The order is the constraint: fts5Strategy.remove() has to read the old
1100
1115
  * content off the records row, so it runs before the UPDATE, and the
@@ -1102,10 +1117,10 @@ var SharedSqlRecordLogic = class {
1102
1117
  * exec.transaction() — a failure between the two halves would otherwise
1103
1118
  * leave the record unindexed.
1104
1119
  */
1105
- rewriteContent(id, typeId, content, opts, extraSets = [], extraValues = []) {
1120
+ rewriteContent(id, typeId, content, opts) {
1106
1121
  const json = JSON.stringify(content);
1107
1122
  fts5Strategy.remove(this.exec, id);
1108
- this.versionedUpdate(id, ["type_id = ?", "content = ?", ...extraSets], [typeId, json, ...extraValues], opts);
1123
+ this.versionedUpdate(id, ["type_id = ?", "content = ?"], [typeId, json], opts);
1109
1124
  fts5Strategy.insert(this.exec, id, json);
1110
1125
  this.syncContentIndex(id, typeId, content);
1111
1126
  }
@@ -1313,11 +1328,9 @@ var SharedSqlRecordLogic = class {
1313
1328
  // Associations
1314
1329
  // -------------------------------------------------------
1315
1330
  /**
1316
- * Never bumps `version`/`updatedAt` and never snapshots — a set-add
1317
- * composes correctly regardless of write order, so it needs neither OCC
1318
- * nor rollback history. The records row is untouched entirely; only the
1319
- * associations table changes. See docs/spec/versioning.md § Version
1320
- * history.
1331
+ * Never bumps `version`/`updatedAt` and never snapshots — see
1332
+ * StackRecordAdapter.associate(). The records row is untouched entirely;
1333
+ * only the associations table changes.
1321
1334
  */
1322
1335
  async associate(recordId, association, opts = {}) {
1323
1336
  this.exec.transaction(() => {
@@ -1341,19 +1354,37 @@ var SharedSqlRecordLogic = class {
1341
1354
  AND related_scope = ?
1342
1355
  AND related_id = ?
1343
1356
  AND related_ns = ?
1344
- AND related_stack = ?`, [recordId, association.kind, association.label, ...associationKeyColumns(association)]);
1357
+ AND related_stack = ?
1358
+ AND related_role = ?`, [recordId, association.kind, association.label, ...associationKeyColumns(association)]);
1345
1359
  this.appendJournal(recordId, opts.journal);
1346
1360
  });
1347
1361
  return this.reread(recordId, "dissociate");
1348
1362
  }
1363
+ /** The kinds each change-set key replaces, and nothing else. */
1364
+ static DATA_KINDS = ["tag", "attachment", "relationship"];
1365
+ static AUTHORITY_KINDS = ["permission", "anyone"];
1366
+ /**
1367
+ * Apply a change set's two association keys, each replacing only within
1368
+ * its own half of the partition. That partition is the whole reason a
1369
+ * tag write cannot drop an ACL: the `associations` key deletes data
1370
+ * kinds alone, so the authority rows beside them are never in scope.
1371
+ * Caller-transactional, like every write below.
1372
+ * See docs/spec/access-control.md § Record-level permissions.
1373
+ */
1374
+ replaceAssociationHalves(recordId, changes) {
1375
+ if (changes.associations !== void 0) {
1376
+ this.replaceAssociations(recordId, changes.associations, _SharedSqlRecordLogic.DATA_KINDS);
1377
+ }
1378
+ if (changes.permissions !== void 0) {
1379
+ this.replaceAssociations(recordId, changes.permissions, _SharedSqlRecordLogic.AUTHORITY_KINDS);
1380
+ }
1381
+ }
1349
1382
  /**
1350
- * Swap a record's association set for `associations` wholesale — the
1351
- * "replace, don't merge" semantics a change set's association list and a
1352
- * version snapshot's both carry. Caller-transactional, like every write
1353
- * below.
1383
+ * Swap one half of a record's association set wholesale — the "replace,
1384
+ * don't merge" semantics a change set's association list carries.
1354
1385
  */
1355
- replaceAssociations(recordId, associations) {
1356
- this.exec.run("DELETE FROM associations WHERE record_id = ?", [recordId]);
1386
+ replaceAssociations(recordId, associations, kinds) {
1387
+ this.exec.run(`DELETE FROM associations WHERE record_id = ? AND kind IN (${kinds.map(() => "?").join(", ")})`, [recordId, ...kinds]);
1357
1388
  if (associations.length)
1358
1389
  this.insertAssociations(recordId, associations);
1359
1390
  }
@@ -1372,8 +1403,8 @@ var SharedSqlRecordLogic = class {
1372
1403
  try {
1373
1404
  this.exec.run(`INSERT INTO associations
1374
1405
  (record_id, kind, label, file_id, related_scope, related_id, related_ns,
1375
- related_stack, attachment_record_id)
1376
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1406
+ related_stack, related_role, attachment_record_id)
1407
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1377
1408
  ON CONFLICT DO UPDATE SET attachment_record_id = excluded.attachment_record_id`, [
1378
1409
  recordId,
1379
1410
  assoc.kind,