@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.
- package/dist/adapter-entry.d.ts +2 -2
- package/dist/adapter-entry.d.ts.map +1 -1
- package/dist/adapter-entry.js +1 -1
- package/dist/adapter-entry.js.map +1 -1
- package/dist/changes.d.ts +62 -14
- package/dist/changes.d.ts.map +1 -1
- package/dist/changes.js +94 -33
- package/dist/changes.js.map +1 -1
- package/dist/combine.d.ts.map +1 -1
- package/dist/combine.js +2 -1
- package/dist/combine.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/query-validation.d.ts +25 -1
- package/dist/query-validation.d.ts.map +1 -1
- package/dist/query-validation.js +48 -1
- package/dist/query-validation.js.map +1 -1
- package/dist/record-changes.d.ts +55 -1
- package/dist/record-changes.d.ts.map +1 -1
- package/dist/record-changes.js +82 -4
- package/dist/record-changes.js.map +1 -1
- package/dist/scoped-stack.d.ts +12 -5
- package/dist/scoped-stack.d.ts.map +1 -1
- package/dist/scoped-stack.js +17 -14
- package/dist/scoped-stack.js.map +1 -1
- package/dist/stack.d.ts +113 -27
- package/dist/stack.d.ts.map +1 -1
- package/dist/stack.js +202 -53
- package/dist/stack.js.map +1 -1
- package/dist/testing.d.ts +41 -16
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +144 -27
- package/dist/testing.js.map +1 -1
- package/dist/types.d.ts +172 -41
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/wire-entry.d.ts +1 -1
- package/dist/wire-entry.d.ts.map +1 -1
- package/dist/wire-entry.js +4 -3
- package/dist/wire-entry.js.map +1 -1
- package/dist/wire-request.d.ts +11 -1
- package/dist/wire-request.d.ts.map +1 -1
- package/dist/wire-request.js +19 -0
- package/dist/wire-request.js.map +1 -1
- 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,
|
|
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
|
-
|
|
101
|
-
this.changes.emit(
|
|
100
|
+
announce(change, record, at) {
|
|
101
|
+
this.changes.emit(change.emission(record, at));
|
|
102
102
|
}
|
|
103
103
|
/**
|
|
104
|
-
* The
|
|
105
|
-
*
|
|
106
|
-
*
|
|
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
|
|
109
|
-
|
|
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
|
|
523
|
-
this.
|
|
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
|
-
|
|
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
|
|
600
|
-
|
|
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.
|
|
702
|
-
*
|
|
703
|
-
*
|
|
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
|
-
*
|
|
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
|
|
726
|
-
|
|
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.
|
|
731
|
-
*
|
|
732
|
-
*
|
|
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
|
-
|
|
744
|
-
if (!
|
|
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
|
|
755
|
-
|
|
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
|
-
|
|
836
|
-
|
|
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
|
|
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.
|
|
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
|
|
865
|
-
this.
|
|
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
|
|
930
|
-
*
|
|
931
|
-
*
|
|
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
|
-
//
|
|
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
|
-
|
|
1124
|
+
journal: change.journal,
|
|
985
1125
|
});
|
|
986
|
-
this.
|
|
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
|
|
1078
|
-
this.
|
|
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
|
-
|
|
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.
|
|
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. `
|
|
1816
|
-
*
|
|
1817
|
-
*
|
|
1818
|
-
*
|
|
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
|
}
|