@haverstack/core 0.32.0 → 0.34.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.
Files changed (46) hide show
  1. package/dist/adapter-entry.d.ts +2 -2
  2. package/dist/adapter-entry.d.ts.map +1 -1
  3. package/dist/adapter-entry.js +1 -1
  4. package/dist/adapter-entry.js.map +1 -1
  5. package/dist/changes.d.ts +62 -14
  6. package/dist/changes.d.ts.map +1 -1
  7. package/dist/changes.js +94 -33
  8. package/dist/changes.js.map +1 -1
  9. package/dist/combine.d.ts.map +1 -1
  10. package/dist/combine.js +2 -1
  11. package/dist/combine.js.map +1 -1
  12. package/dist/index.d.ts +2 -2
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/query-validation.d.ts +25 -1
  16. package/dist/query-validation.d.ts.map +1 -1
  17. package/dist/query-validation.js +48 -1
  18. package/dist/query-validation.js.map +1 -1
  19. package/dist/record-changes.d.ts +55 -1
  20. package/dist/record-changes.d.ts.map +1 -1
  21. package/dist/record-changes.js +82 -4
  22. package/dist/record-changes.js.map +1 -1
  23. package/dist/scoped-stack.d.ts +12 -5
  24. package/dist/scoped-stack.d.ts.map +1 -1
  25. package/dist/scoped-stack.js +17 -14
  26. package/dist/scoped-stack.js.map +1 -1
  27. package/dist/stack.d.ts +113 -27
  28. package/dist/stack.d.ts.map +1 -1
  29. package/dist/stack.js +202 -53
  30. package/dist/stack.js.map +1 -1
  31. package/dist/testing.d.ts +41 -16
  32. package/dist/testing.d.ts.map +1 -1
  33. package/dist/testing.js +144 -27
  34. package/dist/testing.js.map +1 -1
  35. package/dist/types.d.ts +172 -41
  36. package/dist/types.d.ts.map +1 -1
  37. package/dist/types.js.map +1 -1
  38. package/dist/wire-entry.d.ts +1 -1
  39. package/dist/wire-entry.d.ts.map +1 -1
  40. package/dist/wire-entry.js +4 -3
  41. package/dist/wire-entry.js.map +1 -1
  42. package/dist/wire-request.d.ts +11 -1
  43. package/dist/wire-request.d.ts.map +1 -1
  44. package/dist/wire-request.js +19 -0
  45. package/dist/wire-request.js.map +1 -1
  46. package/package.json +1 -1
package/dist/stack.js CHANGED
@@ -19,16 +19,16 @@ import { validateContent, validateContentKeys, validatePatchValues, validateRese
19
19
  import { applyMergePatch } from './merge.js';
20
20
  import { hasGroupAdmin, isGroupAdminAssociation, validatePermissions } from './access.js';
21
21
  import { compareRecordedAttachments } from './attachment-download.js';
22
- import { ChangeEmitter, RelayDelivery, buildEmission, assertSinceUsable } from './changes.js';
22
+ import { ChangeEmitter, RelayDelivery, PendingChange, assertSinceUsable } from './changes.js';
23
23
  import { SYSTEM_TYPES } from './types.js';
24
24
  import { StackClosedError, StackConflictError, StackMigrationError, StackNotFoundError, StackPermissionError, StackQueryError, StackSchemaDriftError, StackValidationError, StackVersionConflictError, } from './errors.js';
25
- import { assertQueryCapabilities, assertSortCapability, assertValidRelatedTo, assertValidSort, filtersContent, validateAssociation, validateAssociations, } from './query-validation.js';
25
+ import { assertQueryCapabilities, assertSortCapability, assertValidRelatedTo, assertValidJournalQuery, assertValidSort, filtersContent, validateAssociation, validateAssociations, } from './query-validation.js';
26
26
  import { GRANT_ACTION_SET, READ_COMPANIONS, grantConveys, matchesGrantTarget, validateGrantTarget, grantCoversGrantee, UNGRANTABLE_SYSTEM_TYPES, loadGrantRecords, } from './grants.js';
27
27
  import { bindingFieldsOf, uniqueBindingFieldsOf } from './identity-bindings.js';
28
28
  import { assertAttachmentSize, assertContentSize } from './limits.js';
29
29
  import { validateParentId, validateRecordId, validateClockField, validateIdTimestampSkew, DEFAULT_ID_TIMESTAMP_SKEW_MS, } from './record-id.js';
30
30
  import { queryAllPages, findFirstMatch, lookupEntityByDid, MAX_QUERY_LIMIT, } from './stack-reads.js';
31
- import { associationEqual, associationIdentical, assertNonEmptyChangeSet, changeSetOps, effectiveChanges, stampGroupAdmin, isGroupRecord, } from './record-changes.js';
31
+ import { associationDelta, associationEqual, associationIdentical, assertNonEmptyChangeSet, changeSetOps, bumpsVersion, takesIfVersion, effectiveChanges, stampGroupAdmin, isGroupRecord, } from './record-changes.js';
32
32
  import { ScopedStack } from './scoped-stack.js';
33
33
  // -------------------------------------------------------
34
34
  // Supporting types
@@ -97,16 +97,39 @@ export class Stack {
97
97
  * and nothing about work they deferred. A handler cannot fail the write:
98
98
  * there is nothing left to fail. See docs/spec/events.md § Handlers.
99
99
  */
100
- emitChange(op, record, opts = {}) {
101
- this.changes.emit(buildEmission(op, record, opts));
100
+ announce(change, record, at) {
101
+ this.changes.emit(change.emission(record, at));
102
102
  }
103
103
  /**
104
- * The requester behind a hard delete, which stamps nothing on a record
105
- * that no longer exists. Owner-acting-alone is the only way to reach the
106
- * verb, so there is never a principal to name beside the subject.
104
+ * The actor for a `create` journal entry, read off the record as just
105
+ * built. Create is the one journaled write that stamps the requester
106
+ * onto the row itself, so the record in hand is authoritative — and it
107
+ * carries `appId`, which ActorOptions has no field for.
107
108
  */
108
- static purgeActor(opts) {
109
- return opts.updatedBy ? { entityId: opts.updatedBy } : undefined;
109
+ static createActor(record) {
110
+ if (!record.updatedBy)
111
+ return undefined;
112
+ return {
113
+ entityId: record.updatedBy,
114
+ ...(record.updatedVia !== undefined && { principalId: record.updatedVia }),
115
+ ...(record.appId !== undefined && { appId: record.appId }),
116
+ };
117
+ }
118
+ /**
119
+ * The requester behind a write that stamps nothing on the record itself —
120
+ * a hard delete, which leaves no record to stamp, and associate()/
121
+ * dissociate(), which don't bump and so don't touch updatedBy/updatedVia.
122
+ * Hard delete is owner-acting-alone only, so it never has a principal to
123
+ * name beside the subject; associate()/dissociate() can run delegated,
124
+ * so `updatedVia` rides along here when present.
125
+ */
126
+ static actorFrom(opts) {
127
+ if (!opts.updatedBy)
128
+ return undefined;
129
+ return {
130
+ entityId: opts.updatedBy,
131
+ ...(opts.updatedVia !== undefined && { principalId: opts.updatedVia }),
132
+ };
110
133
  }
111
134
  async getTypeCached(id) {
112
135
  const cached = this.typeCache.get(id);
@@ -519,8 +542,9 @@ export class Stack {
519
542
  ...(associations?.length && { associations }),
520
543
  ...(opts.unlisted && { unlistedAt: createdAt }),
521
544
  };
522
- const created = await this.adapter.createRecord(record);
523
- this.emitChange('create', created);
545
+ const change = new PendingChange('create', { actor: Stack.createActor(record) });
546
+ const created = await this.adapter.createRecord(record, { journal: change.journal });
547
+ this.announce(change, created);
524
548
  return created;
525
549
  }
526
550
  /**
@@ -586,7 +610,12 @@ export class Stack {
586
610
  if (!existing) {
587
611
  throw new StackNotFoundError(`Record not found: "${id}"`);
588
612
  }
589
- this.checkIfVersion(existing, opts.ifVersion);
613
+ // Checked before validation, so a caller that lost the race learns its
614
+ // version is stale rather than that its patch is bad, and before the
615
+ // no-op short-circuit below, so a precondition is never satisfied by a
616
+ // write that turned out to move nothing.
617
+ if (takesIfVersion(changes))
618
+ this.checkIfVersion(existing, opts.ifVersion);
590
619
  const merged = await this.validateChangeSet(id, existing, changes);
591
620
  const ops = changeSetOps(existing, changes, merged);
592
621
  // Nothing moved, so there is nothing to version. Decided here rather
@@ -595,9 +624,35 @@ export class Stack {
595
624
  // writes nothing.
596
625
  if (ops.length === 0)
597
626
  return existing;
627
+ // A change set touching only `associations` doesn't bump — the same
628
+ // rule associate()/dissociate() follow unconditionally. Its actor
629
+ // travels as an explicit opt rather than a record stamp, since a
630
+ // non-bumping write never touches updatedBy/updatedVia.
631
+ // See docs/spec/versioning.md § Version history.
632
+ const bumps = bumpsVersion(ops);
633
+ // Computed against the same before/after changeSetOps compared, so
634
+ // whether associate/dissociate appear in `ops` and what it lists can
635
+ // never disagree. See docs/spec/events.md § The event shape.
636
+ const assocDelta = changes.associations
637
+ ? associationDelta(existing.associations ?? [], changes.associations)
638
+ : undefined;
598
639
  const previousParentId = existing.parentId ?? null;
599
- const updated = await this.adapter.mutateRecord(id, effectiveChanges(changes, ops), this.writeOptions(existing, opts));
600
- this.emitChange(ops, updated, ops.includes('reparent') ? { previousParentId } : {});
640
+ const change = new PendingChange(ops, {
641
+ actor: Stack.actorFrom(opts),
642
+ ...(ops.includes('reparent') && { previousParentId }),
643
+ ...(assocDelta && { associations: assocDelta }),
644
+ });
645
+ const updated = await this.adapter.mutateRecord(id, effectiveChanges(changes, ops), {
646
+ ...(bumps
647
+ ? this.writeOptions(existing, opts)
648
+ : {
649
+ updatedBy: opts.updatedBy,
650
+ updatedVia: opts.updatedVia,
651
+ }),
652
+ bumpsVersion: bumps,
653
+ journal: change.journal,
654
+ });
655
+ this.announce(change, updated);
601
656
  return updated;
602
657
  }
603
658
  /**
@@ -698,15 +753,22 @@ export class Stack {
698
753
  'least one `admin` relationship association. Name the incoming admin in the same write.');
699
754
  }
700
755
  /**
701
- * Add an association to a record. Snapshots the record's prior state and
702
- * bumps version, same as patchContent() — associations are covered by the same
703
- * versioning rule as content.
756
+ * Add an association to a record. Never bumps `version`/`updatedAt` and
757
+ * never snapshots — a set-add composes correctly regardless of write
758
+ * order, so it needs neither the OCC `ifVersion` guards content,
759
+ * `parentId` and `permissions`, nor the rollback history that guards.
760
+ * See docs/spec/versioning.md § Version history.
761
+ *
704
762
  * An association the record already holds, saying the same thing, is a
705
763
  * no-op; one matching an existing association's identity but naming a
706
764
  * different `attachmentRecordId` — or none, which clears the one stored —
707
765
  * re-points that association in place, rather than adding a second
708
- * reference to the same file.
709
- * Returns the record as it now stands — unchanged on a no-op.
766
+ * reference to the same file. The emitted event's `associationsAdded`
767
+ * carries the association as it now stands either way; what a re-point
768
+ * overwrote is kept only by the journal, on that entry's `repoint`. See
769
+ * docs/spec/events.md § The event shape and docs/spec/journal.md
770
+ * § The entry. Returns the record as it now stands — unchanged on a
771
+ * no-op.
710
772
  */
711
773
  async associate(id, association, opts = {}) {
712
774
  this.assertOpen();
@@ -717,19 +779,31 @@ export class Stack {
717
779
  if (!existing) {
718
780
  throw new StackNotFoundError(`Record not found: "${id}"`);
719
781
  }
720
- this.checkIfVersion(existing, opts.ifVersion);
721
782
  if ((existing.associations ?? []).some((a) => associationIdentical(a, association))) {
722
783
  return existing;
723
784
  }
785
+ const replaced = (existing.associations ?? []).find((a) => associationEqual(a, association));
724
786
  await this.checkAttachmentAssociationPointers([association]);
725
- const updated = await this.adapter.associate(id, association, this.writeOptions(existing, opts));
726
- this.emitChange('associate', updated);
787
+ const change = new PendingChange('associate', {
788
+ actor: Stack.actorFrom(opts),
789
+ // A re-point carries what it overwrote: nothing else retains the
790
+ // attachmentRecordId that association held.
791
+ associations: [
792
+ replaced ? { op: 'repoint', association, previous: replaced } : { op: 'add', association },
793
+ ],
794
+ });
795
+ const updated = await this.adapter.associate(id, association, { journal: change.journal });
796
+ this.announce(change, updated);
727
797
  return updated;
728
798
  }
729
799
  /**
730
- * Remove an association from a record. Snapshots and bumps version, same
731
- * as associate(). Matched by kind, label, and payload. No-op if not found.
732
- * Returns the record as it now stands — unchanged on a no-op.
800
+ * Remove an association from a record. Never bumps `version`/`updatedAt`
801
+ * — see associate(). Matched by kind, label, and payload. No-op if not
802
+ * found. The emitted event's `associationsRemoved` names the association
803
+ * by identity only — an attachment's `attachmentRecordId` is not
804
+ * repeated there, since it no longer describes anything current, and the
805
+ * journal is where it survives. See docs/spec/events.md § The event
806
+ * shape. Returns the record as it now stands — unchanged on a no-op.
733
807
  */
734
808
  async dissociate(id, association, opts = {}) {
735
809
  this.assertOpen();
@@ -740,8 +814,8 @@ export class Stack {
740
814
  if (!existing) {
741
815
  throw new StackNotFoundError(`Record not found: "${id}"`);
742
816
  }
743
- this.checkIfVersion(existing, opts.ifVersion);
744
- if (!(existing.associations ?? []).some((a) => associationEqual(a, association))) {
817
+ const matched = (existing.associations ?? []).find((a) => associationEqual(a, association));
818
+ if (!matched) {
745
819
  return existing;
746
820
  }
747
821
  // Removing anything else cannot take the roster to zero, so only an
@@ -751,8 +825,15 @@ export class Stack {
751
825
  if (isGroupAdminAssociation(association)) {
752
826
  this.assertGroupAdminRemains(existing, (existing.associations ?? []).filter((a) => !associationEqual(a, association)));
753
827
  }
754
- const updated = await this.adapter.dissociate(id, association, this.writeOptions(existing, opts));
755
- this.emitChange('dissociate', updated);
828
+ const change = new PendingChange('dissociate', {
829
+ actor: Stack.actorFrom(opts),
830
+ // In full, annotation included — what makes a removal as undoable
831
+ // from the log as a re-point is. The frame this becomes still names
832
+ // identity only. See docs/spec/journal.md § The entry.
833
+ associations: [{ op: 'remove', previous: matched }],
834
+ });
835
+ const updated = await this.adapter.dissociate(id, association, { journal: change.journal });
836
+ this.announce(change, updated);
756
837
  return updated;
757
838
  }
758
839
  /**
@@ -816,6 +897,12 @@ export class Stack {
816
897
  * version; a no-op if already deleted. `_config` is never deletable
817
898
  * (docs/spec.md § The `_config` record). See docs/spec/versioning.md
818
899
  * § Deletion.
900
+ *
901
+ * A purge reports the files the record referenced. It deletes none of
902
+ * them — byte lifetime is not the purged record's to decide — but it
903
+ * destroys the only rows naming them, so a caller who means to erase the
904
+ * bytes too has nowhere else to read the argument from afterwards. See
905
+ * docs/spec/attachments.md § A purge strands the bytes it referenced.
819
906
  */
820
907
  async delete(id, opts = {}) {
821
908
  this.assertOpen();
@@ -827,24 +914,48 @@ export class Stack {
827
914
  // write: a read here instead would race the delete, and afterwards
828
915
  // there is nothing left to read. Null means there was no record, so
829
916
  // nothing was purged and nothing is announced.
917
+ const change = new PendingChange('hard-delete', { actor: Stack.actorFrom(opts) });
830
918
  const purged = await this.adapter.deleteRecord(id, {
831
919
  hard: true,
832
920
  expectedVersion: opts.ifVersion,
921
+ journal: change.journal,
833
922
  });
834
- if (purged)
835
- this.emitChange('hard-delete', purged, { actor: Stack.purgeActor(opts) });
836
- return;
923
+ if (!purged)
924
+ return { referencedFileIds: [] };
925
+ this.announce(change, purged);
926
+ return { referencedFileIds: await this.referencedFileIds(purged) };
837
927
  }
838
928
  const existing = await this.adapter.getRecord(id);
839
929
  if (!existing) {
840
930
  throw new StackNotFoundError(`Record not found: "${id}"`);
841
931
  }
842
932
  this.checkIfVersion(existing, opts.ifVersion);
933
+ // A tombstone's references stand — undelete() must find its
934
+ // attachments intact — so nothing is stranded and nothing is reported.
843
935
  if (existing.deletedAt)
844
- return;
845
- const deleted = await this.adapter.deleteRecord(id, this.writeOptions(existing, opts));
936
+ return { referencedFileIds: [] };
937
+ const change = new PendingChange('delete', { actor: Stack.actorFrom(opts) });
938
+ const deleted = await this.adapter.deleteRecord(id, {
939
+ ...this.writeOptions(existing, opts),
940
+ journal: change.journal,
941
+ });
846
942
  if (deleted)
847
- this.emitChange('delete', deleted);
943
+ this.announce(change, deleted);
944
+ return { referencedFileIds: [] };
945
+ }
946
+ /**
947
+ * The files a record references, by the same definition
948
+ * deleteAttachment() and the garbage sweep use: attachment associations
949
+ * and top-level `file-ref` content fields. An `_attachment` record's own
950
+ * `fileId` is a plain string by design and is not one, which is why
951
+ * purging a metadata record reports nothing — see initSystemTypes().
952
+ */
953
+ async referencedFileIds(record) {
954
+ const fromAssociations = (record.associations ?? []).flatMap((a) => a.kind === 'attachment' ? [a.fileId] : []);
955
+ const type = await this.getType(record.typeId);
956
+ const content = record.content;
957
+ const fromContent = Object.entries(type?.schema ?? {}).flatMap(([field, def]) => def.kind === 'file-ref' && typeof content[field] === 'string' ? [content[field]] : []);
958
+ return [...new Set([...fromAssociations, ...fromContent])];
848
959
  }
849
960
  /**
850
961
  * Reverse a soft delete. Idempotent — undeleting a record that isn't
@@ -861,8 +972,12 @@ export class Stack {
861
972
  this.checkIfVersion(existing, opts.ifVersion);
862
973
  if (!existing.deletedAt)
863
974
  return existing;
864
- const undeleted = await this.adapter.undeleteRecord(id, this.writeOptions(existing, opts));
865
- this.emitChange('undelete', undeleted);
975
+ const change = new PendingChange('undelete', { actor: Stack.actorFrom(opts) });
976
+ const undeleted = await this.adapter.undeleteRecord(id, {
977
+ ...this.writeOptions(existing, opts),
978
+ journal: change.journal,
979
+ });
980
+ this.announce(change, undeleted);
866
981
  return undeleted;
867
982
  }
868
983
  /**
@@ -919,6 +1034,26 @@ export class Stack {
919
1034
  this.assertOpen();
920
1035
  return this.adapter.getVersions(id);
921
1036
  }
1037
+ /**
1038
+ * A record's change journal, oldest first — what moved, who moved it,
1039
+ * and the association deltas nothing else retains. The mutation
1040
+ * surface's read, on the same footing as getVersions(): a log of who
1041
+ * changed what, gated on current read access, would make a record's past
1042
+ * exactly as reachable as its present. `ScopedStack` applies that gate;
1043
+ * this layer is unscoped and trusted by definition.
1044
+ *
1045
+ * An adapter with no journal to read refuses rather than answering an
1046
+ * empty log — "nothing changed" and "this stack does not remember" are
1047
+ * not the same answer, and a caller reconstructing an association's
1048
+ * history cannot tell them apart. A record that isn't there is
1049
+ * StackNotFoundError for the same reason, a purged one included.
1050
+ * See docs/spec/journal.md § Reading it.
1051
+ */
1052
+ async getJournal(id, query = {}) {
1053
+ this.assertOpen();
1054
+ assertValidJournalQuery(query);
1055
+ return this.adapter.getJournal(id, query);
1056
+ }
922
1057
  async getVersion(id, version) {
923
1058
  this.assertOpen();
924
1059
  return this.adapter.getVersion(id, version);
@@ -926,9 +1061,9 @@ export class Stack {
926
1061
  /**
927
1062
  * Restore a record to a previous version by creating a new version —
928
1063
  * never rewrites history. The snapshot is validated against its own
929
- * stored typeId (not the record's current type), restores associations
930
- * and `parentId` (absent on the snapshot is the root, so a restore
931
- * always settles containment), and never restores permissions. See
1064
+ * stored typeId (not the record's current type), restores `parentId`
1065
+ * (absent on the snapshot is the root, so a restore always settles
1066
+ * containment), and restores neither associations nor permissions. See
932
1067
  * docs/spec/versioning.md § Restore semantics.
933
1068
  */
934
1069
  async restoreVersion(id, version, opts = {}) {
@@ -977,13 +1112,18 @@ export class Stack {
977
1112
  if (moves && targetParentId !== null) {
978
1113
  await this.assertNoParentCycle(id, targetParentId);
979
1114
  }
980
- // A `_group`'s roster is authority, not data: it does not roll back.
1115
+ // Associations never restore, for any Record — a snapshot never
1116
+ // carries them, so there's nothing here for the adapter to roll back.
981
1117
  // See docs/spec/versioning.md § Restore semantics.
1118
+ const change = new PendingChange('restore', {
1119
+ actor: Stack.actorFrom(opts),
1120
+ ...(moves && { previousParentId }),
1121
+ });
982
1122
  const restored = await this.adapter.restoreVersion(id, version, {
983
1123
  ...this.writeOptions(existing, opts),
984
- ...(isGroupRecord(existing) && { restoreAssociations: false }),
1124
+ journal: change.journal,
985
1125
  });
986
- this.emitChange('restore', restored, moves ? { previousParentId } : {});
1126
+ this.announce(change, restored);
987
1127
  return restored;
988
1128
  }
989
1129
  /**
@@ -1074,8 +1214,12 @@ export class Stack {
1074
1214
  if (id === SYSTEM_TYPES.CONFIG) {
1075
1215
  this.checkConfigEntityIdUnchanged(existing.content.entityId, content.entityId);
1076
1216
  }
1077
- const migrated = await this.adapter.commitMigration(id, toTypeId, content, this.writeOptions(existing, opts));
1078
- this.emitChange('migrate', migrated);
1217
+ const change = new PendingChange('migrate', { actor: Stack.actorFrom(opts) });
1218
+ const migrated = await this.adapter.commitMigration(id, toTypeId, content, {
1219
+ ...this.writeOptions(existing, opts),
1220
+ journal: change.journal,
1221
+ });
1222
+ this.announce(change, migrated);
1079
1223
  return migrated;
1080
1224
  }
1081
1225
  /** Uniqueness for every unique binding field a newly created card claims. */
@@ -1312,7 +1456,11 @@ export class Stack {
1312
1456
  // The metadata record is written inside the adapter, so create()
1313
1457
  // never sees it and this is the only place it can be announced.
1314
1458
  const record = await this.adapter.putAttachmentWithMetadata(data, mimeType, filename, appId);
1315
- this.emitChange('create', record);
1459
+ // The one emission with no journal half of its own: the far side
1460
+ // wrote the record, so it appended the entry in that same write —
1461
+ // the same division saveVersion() follows over this adapter. See
1462
+ // docs/spec/journal.md § The entry set is the event set.
1463
+ this.announce(new PendingChange('create'), record);
1316
1464
  return record;
1317
1465
  }
1318
1466
  const fileId = await this.adapter.putAttachment(data);
@@ -1378,7 +1526,7 @@ export class Stack {
1378
1526
  deletedRecords = await this.adapter.deleteUnreferencedAttachmentRecords(fileId, await this.attachmentTypeIds());
1379
1527
  const at = new Date();
1380
1528
  for (const record of deletedRecords) {
1381
- this.emitChange('hard-delete', record, { actor: Stack.purgeActor(opts), at });
1529
+ this.announce(new PendingChange('hard-delete', { actor: Stack.actorFrom(opts) }), record, at);
1382
1530
  }
1383
1531
  }
1384
1532
  else {
@@ -1812,10 +1960,12 @@ export class Stack {
1812
1960
  }
1813
1961
  /**
1814
1962
  * Snapshot of a record's prior state, passed with the mutating adapter
1815
- * call so snapshot and mutation land in one atomic write. `associations`
1816
- * and `parentId` are always present (`[]` and `null` where the record has
1817
- * neither) so restore can distinguish "cleared" and "at the root" from a
1818
- * snapshot that omits the key entirely ("leave as-is"). See
1963
+ * call so snapshot and mutation land in one atomic write. `parentId` is
1964
+ * always present (`null` where the record has none) so restore can
1965
+ * distinguish "at the root" from a snapshot that omits the key entirely
1966
+ * ("leave as-is"). Never carries `associations` — associate()/
1967
+ * dissociate() don't bump, so no version ever snapshots the association
1968
+ * set, and there is nothing for a restore to roll back to. See
1819
1969
  * docs/spec/versioning.md § Version history.
1820
1970
  */
1821
1971
  buildVersionSnapshot(record) {
@@ -1828,7 +1978,6 @@ export class Stack {
1828
1978
  ...(record.updatedBy && { updatedBy: record.updatedBy }),
1829
1979
  ...(record.updatedVia && { updatedVia: record.updatedVia }),
1830
1980
  ...(record.parentId !== undefined && { parentId: record.parentId }),
1831
- associations: record.associations ?? [],
1832
1981
  ...(record.permissions && { permissions: record.permissions }),
1833
1982
  };
1834
1983
  }