@contember/bindx 0.1.45 → 0.1.47
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/core/ActionDispatcher.d.ts +4 -1
- package/dist/core/ActionDispatcher.d.ts.map +1 -1
- package/dist/core/actions.d.ts +7 -1
- package/dist/core/actions.d.ts.map +1 -1
- package/dist/events/EventEmitter.d.ts +13 -1
- package/dist/events/EventEmitter.d.ts.map +1 -1
- package/dist/handles/BaseHandle.d.ts +14 -3
- package/dist/handles/BaseHandle.d.ts.map +1 -1
- package/dist/handles/EntityHandle.d.ts +14 -5
- package/dist/handles/EntityHandle.d.ts.map +1 -1
- package/dist/handles/FieldHandle.d.ts +11 -4
- package/dist/handles/FieldHandle.d.ts.map +1 -1
- package/dist/handles/HasManyListHandle.d.ts +36 -2
- package/dist/handles/HasManyListHandle.d.ts.map +1 -1
- package/dist/handles/HasOneHandle.d.ts +52 -9
- package/dist/handles/HasOneHandle.d.ts.map +1 -1
- package/dist/handles/PlaceholderHandle.d.ts +25 -13
- package/dist/handles/PlaceholderHandle.d.ts.map +1 -1
- package/dist/handles/index.d.ts +1 -1
- package/dist/handles/index.d.ts.map +1 -1
- package/dist/handles/proxyFactory.d.ts +7 -1
- package/dist/handles/proxyFactory.d.ts.map +1 -1
- package/dist/handles/types.d.ts +33 -0
- package/dist/handles/types.d.ts.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/persistence/BatchPersister.d.ts +53 -37
- package/dist/persistence/BatchPersister.d.ts.map +1 -1
- package/dist/persistence/ChangeRegistry.d.ts +15 -0
- package/dist/persistence/ChangeRegistry.d.ts.map +1 -1
- package/dist/persistence/MutationCollector.d.ts +106 -3
- package/dist/persistence/MutationCollector.d.ts.map +1 -1
- package/dist/persistence/PersistExecution.d.ts +26 -0
- package/dist/persistence/PersistExecution.d.ts.map +1 -0
- package/dist/persistence/types.d.ts +8 -2
- package/dist/persistence/types.d.ts.map +1 -1
- package/dist/store/EntityMetaStore.d.ts +32 -10
- package/dist/store/EntityMetaStore.d.ts.map +1 -1
- package/dist/store/EntitySnapshotStore.d.ts +50 -3
- package/dist/store/EntitySnapshotStore.d.ts.map +1 -1
- package/dist/store/ErrorStore.d.ts +3 -2
- package/dist/store/ErrorStore.d.ts.map +1 -1
- package/dist/store/HasManyStore.d.ts +214 -0
- package/dist/store/HasManyStore.d.ts.map +1 -0
- package/dist/store/HasOneStore.d.ts +159 -0
- package/dist/store/HasOneStore.d.ts.map +1 -0
- package/dist/store/ReachabilityAnalyzer.d.ts +19 -0
- package/dist/store/ReachabilityAnalyzer.d.ts.map +1 -1
- package/dist/store/RekeyOrchestrator.d.ts +76 -0
- package/dist/store/RekeyOrchestrator.d.ts.map +1 -0
- package/dist/store/RelationEdgeIndex.d.ts +34 -0
- package/dist/store/RelationEdgeIndex.d.ts.map +1 -0
- package/dist/store/RelationStore.d.ts +74 -137
- package/dist/store/RelationStore.d.ts.map +1 -1
- package/dist/store/RootRegistry.d.ts +25 -4
- package/dist/store/RootRegistry.d.ts.map +1 -1
- package/dist/store/SnapshotStore.d.ts +137 -13
- package/dist/store/SnapshotStore.d.ts.map +1 -1
- package/dist/store/SubscriptionManager.d.ts +63 -14
- package/dist/store/SubscriptionManager.d.ts.map +1 -1
- package/dist/store/TouchedStore.d.ts +3 -2
- package/dist/store/TouchedStore.d.ts.map +1 -1
- package/dist/store/relationKey.d.ts +15 -0
- package/dist/store/relationKey.d.ts.map +1 -0
- package/dist/undo/UndoJournal.d.ts +133 -0
- package/dist/undo/UndoJournal.d.ts.map +1 -0
- package/dist/undo/UndoManager.d.ts +25 -82
- package/dist/undo/UndoManager.d.ts.map +1 -1
- package/dist/undo/UnrecordedWriteError.d.ts +16 -0
- package/dist/undo/UnrecordedWriteError.d.ts.map +1 -0
- package/dist/undo/index.d.ts +3 -1
- package/dist/undo/index.d.ts.map +1 -1
- package/dist/undo/rekeyJournalEntry.d.ts +20 -0
- package/dist/undo/rekeyJournalEntry.d.ts.map +1 -0
- package/dist/undo/types.d.ts +0 -48
- package/dist/undo/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/core/ActionDispatcher.ts +56 -36
- package/src/core/actions.ts +8 -1
- package/src/events/EventEmitter.ts +78 -24
- package/src/handles/BaseHandle.ts +19 -2
- package/src/handles/EntityHandle.ts +48 -17
- package/src/handles/FieldHandle.ts +36 -10
- package/src/handles/HasManyListHandle.ts +121 -17
- package/src/handles/HasOneHandle.ts +177 -44
- package/src/handles/PlaceholderHandle.ts +184 -36
- package/src/handles/index.ts +3 -0
- package/src/handles/proxyFactory.ts +27 -0
- package/src/handles/types.ts +39 -0
- package/src/index.ts +4 -4
- package/src/persistence/BatchPersister.ts +810 -446
- package/src/persistence/ChangeRegistry.ts +27 -3
- package/src/persistence/MutationCollector.ts +265 -31
- package/src/persistence/PersistExecution.ts +217 -0
- package/src/persistence/types.ts +8 -2
- package/src/store/EntityMetaStore.ts +89 -45
- package/src/store/EntitySnapshotStore.ts +102 -19
- package/src/store/ErrorStore.ts +6 -5
- package/src/store/HasManyStore.ts +810 -0
- package/src/store/HasOneStore.ts +454 -0
- package/src/store/ReachabilityAnalyzer.ts +37 -0
- package/src/store/RekeyOrchestrator.ts +123 -0
- package/src/store/RelationEdgeIndex.ts +79 -0
- package/src/store/RelationStore.ts +176 -725
- package/src/store/RootRegistry.ts +50 -8
- package/src/store/SnapshotStore.ts +572 -101
- package/src/store/SubscriptionManager.ts +122 -75
- package/src/store/TouchedStore.ts +5 -2
- package/src/store/relationKey.ts +20 -0
- package/src/undo/UndoJournal.ts +258 -0
- package/src/undo/UndoManager.ts +151 -281
- package/src/undo/UnrecordedWriteError.ts +23 -0
- package/src/undo/index.ts +8 -7
- package/src/undo/rekeyJournalEntry.ts +145 -0
- package/src/undo/types.ts +0 -58
- package/dist/core/actionClassification.d.ts +0 -48
- package/dist/core/actionClassification.d.ts.map +0 -1
- package/src/core/actionClassification.ts +0 -199
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import type { EntitySnapshot, LoadStatus } from './snapshots.js'
|
|
2
|
+
import { createEntitySnapshot } from './snapshots.js'
|
|
2
3
|
import type { FieldError } from '../errors/types.js'
|
|
3
4
|
import { SubscriptionManager, type SnapshotVersionBumper } from './SubscriptionManager.js'
|
|
4
5
|
import { ErrorStore } from './ErrorStore.js'
|
|
5
6
|
import {
|
|
6
7
|
RelationStore,
|
|
7
8
|
type HasManyRemovalType,
|
|
9
|
+
type RelationReconciliationResult,
|
|
10
|
+
type SentHasManyDelta,
|
|
11
|
+
type SentHasOneTransition,
|
|
8
12
|
type StoredHasManyState,
|
|
9
13
|
type StoredRelationState,
|
|
10
14
|
} from './RelationStore.js'
|
|
@@ -15,8 +19,28 @@ import { DirtyTracker } from './DirtyTracker.js'
|
|
|
15
19
|
import { EntitySnapshotStore } from './EntitySnapshotStore.js'
|
|
16
20
|
import { RootRegistry } from './RootRegistry.js'
|
|
17
21
|
import { ReachabilityAnalyzer } from './ReachabilityAnalyzer.js'
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
import { RekeyOrchestrator } from './RekeyOrchestrator.js'
|
|
23
|
+
import type { RekeyContext, Rekeyable } from './RekeyOrchestrator.js'
|
|
24
|
+
import type {
|
|
25
|
+
UndoJournal,
|
|
26
|
+
JournalTarget,
|
|
27
|
+
JournalCellImage,
|
|
28
|
+
EntityCellImage,
|
|
29
|
+
RelationCellImage,
|
|
30
|
+
HasManyCellImage,
|
|
31
|
+
EditableWriteCounters,
|
|
32
|
+
} from '../undo/UndoJournal.js'
|
|
33
|
+
|
|
34
|
+
export type {
|
|
35
|
+
HasManyRemovalType,
|
|
36
|
+
RelationReconciliationResult,
|
|
37
|
+
SentHasManyAddition,
|
|
38
|
+
SentHasManyDelta,
|
|
39
|
+
SentHasManyRemoval,
|
|
40
|
+
SentHasOneTransition,
|
|
41
|
+
StoredHasManyState,
|
|
42
|
+
StoredRelationState,
|
|
43
|
+
} from './RelationStore.js'
|
|
20
44
|
export type { EntityMeta } from './EntityMetaStore.js'
|
|
21
45
|
export { isTempId, isPlaceholderId, isPersistedId, generatePlaceholderId } from './entityId.js'
|
|
22
46
|
|
|
@@ -39,7 +63,7 @@ type Subscriber = () => void
|
|
|
39
63
|
* - EntityMetaStore — entity metadata, load state, persisting, temp ID mapping
|
|
40
64
|
* - TouchedStore — field touched state tracking
|
|
41
65
|
*/
|
|
42
|
-
export class SnapshotStore implements SnapshotVersionBumper {
|
|
66
|
+
export class SnapshotStore implements SnapshotVersionBumper, JournalTarget {
|
|
43
67
|
private readonly entitySnapshots = new EntitySnapshotStore()
|
|
44
68
|
private readonly subscriptions = new SubscriptionManager()
|
|
45
69
|
private readonly errors = new ErrorStore()
|
|
@@ -49,6 +73,7 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
49
73
|
private readonly roots = new RootRegistry()
|
|
50
74
|
private readonly reachability: ReachabilityAnalyzer
|
|
51
75
|
private readonly dirtyTracker: DirtyTracker
|
|
76
|
+
private readonly rekeyOrchestrator: RekeyOrchestrator
|
|
52
77
|
|
|
53
78
|
/**
|
|
54
79
|
* Tracks the last embedded data reference propagated from parent to child.
|
|
@@ -58,37 +83,120 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
58
83
|
*/
|
|
59
84
|
private readonly lastPropagatedData = new Map<string, unknown>()
|
|
60
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Optional write-journal. When set (by an attached UndoManager), mutating
|
|
88
|
+
* methods record editable-layer pre-images of the cells they touch so a gesture
|
|
89
|
+
* can be undone. Null ⇒ no undo tracking and the transaction helpers are no-ops.
|
|
90
|
+
*/
|
|
91
|
+
private journal: UndoJournal | null = null
|
|
92
|
+
|
|
61
93
|
constructor() {
|
|
62
94
|
this.reachability = new ReachabilityAnalyzer(this.entitySnapshots, this.meta, this.relations, this.roots)
|
|
63
95
|
this.dirtyTracker = new DirtyTracker(this.entitySnapshots, this.meta, this.relations, this.reachability)
|
|
96
|
+
// Parent re-render propagation is derived from the relation store's live
|
|
97
|
+
// edges — the single source of truth for relation membership — rather than
|
|
98
|
+
// a separate parent-child registry.
|
|
99
|
+
this.subscriptions.setParentKeyLookup(this.relations)
|
|
100
|
+
// The participants are visited in this exact order on every rekey — see the
|
|
101
|
+
// ordering contract in RekeyOrchestrator.rekey(). Propagation tracking lives
|
|
102
|
+
// on this store, so it joins as a small inline adapter.
|
|
103
|
+
this.rekeyOrchestrator = new RekeyOrchestrator([
|
|
104
|
+
this.roots,
|
|
105
|
+
this.entitySnapshots,
|
|
106
|
+
this.meta,
|
|
107
|
+
this.subscriptions,
|
|
108
|
+
this.relations,
|
|
109
|
+
this.errors,
|
|
110
|
+
this.touched,
|
|
111
|
+
{ rekey: ctx => this.rekeyPropagatedData(ctx.oldKeyPrefix, ctx.newKeyPrefix) },
|
|
112
|
+
])
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ==================== Undo Journal ====================
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Attaches (or detaches) the write-journal. Called by UndoManager when it is
|
|
119
|
+
* wired to a dispatcher.
|
|
120
|
+
*/
|
|
121
|
+
setJournal(journal: UndoJournal | null): void {
|
|
122
|
+
this.journal = journal
|
|
64
123
|
}
|
|
65
124
|
|
|
66
|
-
|
|
125
|
+
clearJournal(journal: UndoJournal): void {
|
|
126
|
+
if (this.journal === journal) {
|
|
127
|
+
this.journal = null
|
|
128
|
+
}
|
|
129
|
+
}
|
|
67
130
|
|
|
68
|
-
|
|
69
|
-
|
|
131
|
+
private recordExistingEntity(key: string): void {
|
|
132
|
+
if (this.entitySnapshots.has(key)) {
|
|
133
|
+
this.journal?.recordEntity(key)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Aggregates the sub-stores' editable-layer write counters into the three
|
|
139
|
+
* journal kinds. The "entity" kind fuses snapshot + meta + roots — the cells a
|
|
140
|
+
* single {@link recordEntity} pre-image captures together. Read by the journal's
|
|
141
|
+
* write-guard at each transaction boundary; a few integer adds, hot-path cheap.
|
|
142
|
+
*/
|
|
143
|
+
getEditableWriteCounters(): EditableWriteCounters {
|
|
144
|
+
return {
|
|
145
|
+
entity: this.entitySnapshots.getEditableWriteVersion()
|
|
146
|
+
+ this.meta.getEditableWriteVersion()
|
|
147
|
+
+ this.roots.getEditableWriteVersion(),
|
|
148
|
+
relation: this.relations.getEditableHasOneWriteVersion(),
|
|
149
|
+
hasMany: this.relations.getEditableHasManyWriteVersion(),
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Opens a journal transaction (re-entrant). All primary-store writes until the
|
|
155
|
+
* matching {@link commitTransaction} are captured as ONE undoable gesture.
|
|
156
|
+
*/
|
|
157
|
+
beginTransaction(): void {
|
|
158
|
+
this.journal?.begin()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
commitTransaction(): void {
|
|
162
|
+
this.journal?.commit()
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Runs a gesture as a single undoable transaction. Used by handle operations
|
|
167
|
+
* (e.g. list add / has-one create) that write to the store outside the
|
|
168
|
+
* dispatcher, so their pre-create writes join the same undo entry.
|
|
169
|
+
*/
|
|
170
|
+
transaction<T>(fn: () => T): T {
|
|
171
|
+
this.beginTransaction()
|
|
172
|
+
try {
|
|
173
|
+
return fn()
|
|
174
|
+
} finally {
|
|
175
|
+
this.commitTransaction()
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ==================== Key Generation ====================
|
|
70
180
|
|
|
71
181
|
private getEntityKey(entityType: string, id: string): string {
|
|
72
|
-
|
|
73
|
-
return this.rekeyedEntities.get(key) ?? key
|
|
182
|
+
return this.rekeyOrchestrator.resolveKey(entityType, id)
|
|
74
183
|
}
|
|
75
184
|
|
|
76
185
|
private getRelationKey(parentType: string, parentId: string, fieldName: string): string {
|
|
77
|
-
const resolvedParentId = this.
|
|
186
|
+
const resolvedParentId = this.resolveEntityId(parentType, parentId)
|
|
78
187
|
return `${parentType}:${resolvedParentId}:${fieldName}`
|
|
79
188
|
}
|
|
80
189
|
|
|
81
190
|
/**
|
|
82
191
|
* Resolves an ID to its persisted ID if it has been rekeyed.
|
|
83
192
|
*/
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
return id
|
|
193
|
+
resolveEntityId(entityType: string, id: string): string {
|
|
194
|
+
return this.rekeyOrchestrator.resolveId(entityType, id)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Attaches identity-keyed state to the store's single rekey fan-out. */
|
|
198
|
+
attachRekeyParticipant(participant: Rekeyable): void {
|
|
199
|
+
this.rekeyOrchestrator.registerParticipant(participant)
|
|
92
200
|
}
|
|
93
201
|
|
|
94
202
|
// ==================== SnapshotVersionBumper ====================
|
|
@@ -134,7 +242,7 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
134
242
|
* Removes all propagation tracking entries for a given parent entity.
|
|
135
243
|
*/
|
|
136
244
|
clearPropagatedDataForEntity(parentType: string, parentId: string): void {
|
|
137
|
-
const prefix = `${parentType}:${this.
|
|
245
|
+
const prefix = `${parentType}:${this.resolveEntityId(parentType, parentId)}:`
|
|
138
246
|
for (const key of this.lastPropagatedData.keys()) {
|
|
139
247
|
if (key.startsWith(prefix)) {
|
|
140
248
|
this.lastPropagatedData.delete(key)
|
|
@@ -174,7 +282,11 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
174
282
|
skipNotify: boolean = false,
|
|
175
283
|
): EntitySnapshot<T> {
|
|
176
284
|
const key = this.getEntityKey(entityType, id)
|
|
177
|
-
|
|
285
|
+
// Server loads are not user gestures; only journal local data sets (incl. the
|
|
286
|
+
// initial write of a freshly created entity, which captures an absent pre-image).
|
|
287
|
+
if (!isServerData) this.journal?.recordEntity(key)
|
|
288
|
+
// The caller may still hold a rekeyed temp id; the snapshot must carry the live one.
|
|
289
|
+
const newSnapshot = this.entitySnapshots.setData(key, this.resolveEntityId(entityType, id), entityType, data, isServerData)
|
|
178
290
|
|
|
179
291
|
if (isServerData) {
|
|
180
292
|
this.meta.setExistsOnServer(key, true)
|
|
@@ -198,7 +310,7 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
198
310
|
skipNotify: boolean = false,
|
|
199
311
|
): EntitySnapshot<T> {
|
|
200
312
|
const key = this.getEntityKey(entityType, id)
|
|
201
|
-
const newSnapshot = this.entitySnapshots.refreshServerData(key, id, entityType, data)
|
|
313
|
+
const newSnapshot = this.entitySnapshots.refreshServerData(key, this.resolveEntityId(entityType, id), entityType, data)
|
|
202
314
|
this.meta.setExistsOnServer(key, true)
|
|
203
315
|
if (!skipNotify) {
|
|
204
316
|
this.notifyEntitySubscribers(key)
|
|
@@ -212,6 +324,7 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
212
324
|
updates: Partial<T>,
|
|
213
325
|
): EntitySnapshot<T> | undefined {
|
|
214
326
|
const key = this.getEntityKey(entityType, id)
|
|
327
|
+
this.recordExistingEntity(key)
|
|
215
328
|
const result = this.entitySnapshots.updateFields<T>(key, updates)
|
|
216
329
|
if (result) {
|
|
217
330
|
this.notifyEntitySubscribers(key)
|
|
@@ -226,6 +339,7 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
226
339
|
value: unknown,
|
|
227
340
|
): void {
|
|
228
341
|
const key = this.getEntityKey(entityType, id)
|
|
342
|
+
this.recordExistingEntity(key)
|
|
229
343
|
if (this.entitySnapshots.setFieldValue(key, fieldPath, value)) {
|
|
230
344
|
this.notifyEntitySubscribers(key)
|
|
231
345
|
}
|
|
@@ -239,6 +353,7 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
239
353
|
|
|
240
354
|
resetEntity(entityType: string, id: string): void {
|
|
241
355
|
const key = this.getEntityKey(entityType, id)
|
|
356
|
+
this.recordExistingEntity(key)
|
|
242
357
|
this.entitySnapshots.reset(key)
|
|
243
358
|
this.notifyEntitySubscribers(key)
|
|
244
359
|
}
|
|
@@ -317,12 +432,14 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
317
432
|
|
|
318
433
|
scheduleForDeletion(entityType: string, id: string): void {
|
|
319
434
|
const key = this.getEntityKey(entityType, id)
|
|
435
|
+
this.recordExistingEntity(key)
|
|
320
436
|
this.meta.scheduleForDeletion(key)
|
|
321
437
|
this.notifyEntitySubscribers(key)
|
|
322
438
|
}
|
|
323
439
|
|
|
324
440
|
unscheduleForDeletion(entityType: string, id: string): void {
|
|
325
441
|
const key = this.getEntityKey(entityType, id)
|
|
442
|
+
this.recordExistingEntity(key)
|
|
326
443
|
this.meta.unscheduleForDeletion(key)
|
|
327
444
|
this.notifyEntitySubscribers(key)
|
|
328
445
|
}
|
|
@@ -342,15 +459,21 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
342
459
|
const data = { ...initialData, id }
|
|
343
460
|
|
|
344
461
|
this.setEntityData(entityType, id, data, false)
|
|
345
|
-
this.setExistsOnServer(entityType, id, false)
|
|
346
462
|
|
|
347
463
|
// A freshly created entity is pending-persist by default — a root for
|
|
348
464
|
// reachability-based create detection. It stops being a root the moment a
|
|
349
465
|
// relation anchors it as a child (see registerParentChild). A top-level
|
|
350
466
|
// create (<Entity create>, useEntityList add) is never anchored, so it stays
|
|
351
467
|
// a root and is reported as a `create`.
|
|
468
|
+
//
|
|
469
|
+
// Registered after the snapshot exists (the reachability seed skips a root
|
|
470
|
+
// without one) but BEFORE the notifying setExistsOnServer, so the last
|
|
471
|
+
// notification of the create already carries it — a subscriber reading
|
|
472
|
+
// getAllDirtyEntities() would otherwise miss the new create entirely.
|
|
352
473
|
this.roots.register(this.getEntityKey(entityType, id))
|
|
353
474
|
|
|
475
|
+
this.setExistsOnServer(entityType, id, false)
|
|
476
|
+
|
|
354
477
|
return id
|
|
355
478
|
}
|
|
356
479
|
|
|
@@ -367,52 +490,32 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
367
490
|
}
|
|
368
491
|
|
|
369
492
|
mapTempIdToPersistedId(entityType: string, tempId: string, persistedId: string): void {
|
|
370
|
-
//
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
this.
|
|
385
|
-
|
|
386
|
-
// Rekey metadata FIRST, then set persisted mapping (which sets existsOnServer)
|
|
387
|
-
this.meta.rekey(oldKey, newKey)
|
|
388
|
-
this.meta.mapTempIdToPersistedId(newKey, persistedId)
|
|
389
|
-
|
|
390
|
-
// Rekey subscriptions, parent-child relationships, and relation subscribers
|
|
391
|
-
this.subscriptions.rekey(oldKey, newKey, oldKeyPrefix, newKeyPrefix)
|
|
392
|
-
|
|
393
|
-
// Rekey relations/hasMany owned by this entity (parent key changes)
|
|
394
|
-
this.relations.rekeyOwner(oldKeyPrefix, newKeyPrefix)
|
|
395
|
-
|
|
396
|
-
// Replace tempId with persistedId in all relation/hasMany VALUE references
|
|
397
|
-
this.relations.replaceEntityId(tempId, persistedId)
|
|
398
|
-
|
|
399
|
-
// Rekey errors, touched state, and propagation tracking
|
|
400
|
-
this.errors.rekey(oldKey, newKey, oldKeyPrefix, newKeyPrefix)
|
|
401
|
-
this.touched.rekey(oldKeyPrefix, newKeyPrefix)
|
|
402
|
-
this.rekeyPropagatedData(oldKeyPrefix, newKeyPrefix)
|
|
493
|
+
// The orchestrator owns the temp→persisted redirect and drives the rekey
|
|
494
|
+
// fan-out across every sub-store in its documented order.
|
|
495
|
+
this.rekeyOrchestrator.rekey(entityType, tempId, persistedId)
|
|
496
|
+
|
|
497
|
+
// Keep the undo journal aligned: rewrite stored keys + embedded id references
|
|
498
|
+
// in every entry, and seal the now-persisted create so undo can't delete it.
|
|
499
|
+
const ctx: RekeyContext = {
|
|
500
|
+
oldKey: `${entityType}:${tempId}`,
|
|
501
|
+
newKey: `${entityType}:${persistedId}`,
|
|
502
|
+
oldKeyPrefix: `${entityType}:${tempId}:`,
|
|
503
|
+
newKeyPrefix: `${entityType}:${persistedId}:`,
|
|
504
|
+
oldId: tempId,
|
|
505
|
+
newId: persistedId,
|
|
506
|
+
}
|
|
507
|
+
this.journal?.rekey(ctx)
|
|
403
508
|
|
|
404
|
-
// Notify on the NEW key so React picks up the change
|
|
405
|
-
this.notifyEntitySubscribers(
|
|
509
|
+
// Notify on the NEW key so React picks up the change.
|
|
510
|
+
this.notifyEntitySubscribers(`${entityType}:${persistedId}`)
|
|
406
511
|
}
|
|
407
512
|
|
|
408
513
|
getPersistedId(entityType: string, id: string): string | null {
|
|
409
|
-
|
|
410
|
-
return this.meta.getPersistedId(key, id)
|
|
514
|
+
return this.rekeyOrchestrator.getPersistedId(entityType, id)
|
|
411
515
|
}
|
|
412
516
|
|
|
413
517
|
isNewEntity(entityType: string, id: string): boolean {
|
|
414
|
-
|
|
415
|
-
return this.meta.isNewEntity(key, id)
|
|
518
|
+
return this.rekeyOrchestrator.isNewEntity(entityType, id)
|
|
416
519
|
}
|
|
417
520
|
|
|
418
521
|
// ==================== Has-Many State (delegated to RelationStore) ====================
|
|
@@ -459,6 +562,7 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
459
562
|
alias?: string,
|
|
460
563
|
): void {
|
|
461
564
|
const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
|
|
565
|
+
this.journal?.recordHasMany(key)
|
|
462
566
|
this.relations.planHasManyRemoval(key, itemId, type)
|
|
463
567
|
this.notifyRelationSubscribers(key)
|
|
464
568
|
}
|
|
@@ -481,6 +585,7 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
481
585
|
alias?: string,
|
|
482
586
|
): void {
|
|
483
587
|
const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
|
|
588
|
+
this.journal?.recordHasMany(key)
|
|
484
589
|
this.relations.planHasManyConnection(key, itemId)
|
|
485
590
|
this.notifyRelationSubscribers(key)
|
|
486
591
|
}
|
|
@@ -492,7 +597,9 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
492
597
|
alias?: string,
|
|
493
598
|
): Set<string> | undefined {
|
|
494
599
|
const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
|
|
495
|
-
|
|
600
|
+
const state = this.relations.getHasMany(key)
|
|
601
|
+
if (!state) return undefined
|
|
602
|
+
return new Set(state.plannedAdditions.keys())
|
|
496
603
|
}
|
|
497
604
|
|
|
498
605
|
commitHasMany(
|
|
@@ -507,6 +614,19 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
507
614
|
this.notifyRelationSubscribers(key)
|
|
508
615
|
}
|
|
509
616
|
|
|
617
|
+
reconcileSentHasMany(
|
|
618
|
+
parentType: string,
|
|
619
|
+
parentId: string,
|
|
620
|
+
fieldName: string,
|
|
621
|
+
delta: SentHasManyDelta,
|
|
622
|
+
alias?: string,
|
|
623
|
+
): RelationReconciliationResult {
|
|
624
|
+
const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
|
|
625
|
+
const result = this.relations.reconcileSentHasMany(key, delta)
|
|
626
|
+
this.notifyRelationSubscribers(key)
|
|
627
|
+
return result
|
|
628
|
+
}
|
|
629
|
+
|
|
510
630
|
resetHasMany(
|
|
511
631
|
parentType: string,
|
|
512
632
|
parentId: string,
|
|
@@ -514,6 +634,7 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
514
634
|
alias?: string,
|
|
515
635
|
): void {
|
|
516
636
|
const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
|
|
637
|
+
this.journal?.recordHasMany(key)
|
|
517
638
|
this.relations.resetHasMany(key)
|
|
518
639
|
this.notifyRelationSubscribers(key)
|
|
519
640
|
}
|
|
@@ -526,6 +647,7 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
526
647
|
alias?: string,
|
|
527
648
|
): void {
|
|
528
649
|
const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
|
|
650
|
+
this.journal?.recordHasMany(key)
|
|
529
651
|
this.relations.addToHasMany(key, itemId)
|
|
530
652
|
this.notifyRelationSubscribers(key)
|
|
531
653
|
}
|
|
@@ -537,6 +659,7 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
537
659
|
itemId: string,
|
|
538
660
|
): void {
|
|
539
661
|
const key = this.getRelationKey(parentType, parentId, fieldName)
|
|
662
|
+
this.journal?.recordHasMany(key)
|
|
540
663
|
this.relations.connectExistingToHasMany(key, itemId)
|
|
541
664
|
this.notifyRelationSubscribers(key)
|
|
542
665
|
}
|
|
@@ -550,6 +673,7 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
550
673
|
alias?: string,
|
|
551
674
|
): void {
|
|
552
675
|
const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
|
|
676
|
+
this.journal?.recordHasMany(key)
|
|
553
677
|
// Cancelling the add of a never-persisted child just removes it from the
|
|
554
678
|
// list; its now-unreachable snapshot is no longer reported as a `create` and
|
|
555
679
|
// is collected by the lazy memory sweep.
|
|
@@ -567,6 +691,7 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
567
691
|
alias?: string,
|
|
568
692
|
): void {
|
|
569
693
|
const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
|
|
694
|
+
this.journal?.recordHasMany(key)
|
|
570
695
|
this.relations.moveInHasMany(key, fromIndex, toIndex)
|
|
571
696
|
this.notifyRelationSubscribers(key)
|
|
572
697
|
}
|
|
@@ -581,6 +706,21 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
581
706
|
return this.relations.getHasManyOrderedIds(key)
|
|
582
707
|
}
|
|
583
708
|
|
|
709
|
+
getPresentationHasManyOrderedIds(
|
|
710
|
+
parentType: string,
|
|
711
|
+
parentId: string,
|
|
712
|
+
fieldName: string,
|
|
713
|
+
alias?: string,
|
|
714
|
+
): string[] {
|
|
715
|
+
const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
|
|
716
|
+
if (!this.meta.isPessimisticInFlight(this.getEntityKey(parentType, parentId))) {
|
|
717
|
+
return this.relations.getHasManyOrderedIds(key)
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const state = this.relations.getHasMany(key)
|
|
721
|
+
return state ? [...state.serverIds] : []
|
|
722
|
+
}
|
|
723
|
+
|
|
584
724
|
isHasManyItemCreated(
|
|
585
725
|
parentType: string,
|
|
586
726
|
parentId: string,
|
|
@@ -590,7 +730,7 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
590
730
|
): boolean {
|
|
591
731
|
const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
|
|
592
732
|
const state = this.relations.getHasMany(key)
|
|
593
|
-
return state?.
|
|
733
|
+
return state?.plannedAdditions.get(itemId) === 'created'
|
|
594
734
|
}
|
|
595
735
|
|
|
596
736
|
getHasManyCreatedEntities(
|
|
@@ -600,7 +740,13 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
600
740
|
alias?: string,
|
|
601
741
|
): Set<string> | undefined {
|
|
602
742
|
const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
|
|
603
|
-
|
|
743
|
+
const state = this.relations.getHasMany(key)
|
|
744
|
+
if (!state) return undefined
|
|
745
|
+
const created = new Set<string>()
|
|
746
|
+
for (const [id, kind] of state.plannedAdditions) {
|
|
747
|
+
if (kind === 'created') created.add(id)
|
|
748
|
+
}
|
|
749
|
+
return created
|
|
604
750
|
}
|
|
605
751
|
|
|
606
752
|
// ==================== Persisting State (delegated to EntityMetaStore) ====================
|
|
@@ -610,12 +756,47 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
610
756
|
return this.meta.isPersisting(key)
|
|
611
757
|
}
|
|
612
758
|
|
|
613
|
-
setPersisting(entityType: string, id: string, isPersisting: boolean): void {
|
|
759
|
+
setPersisting(entityType: string, id: string, isPersisting: boolean, pessimistic: boolean = false): void {
|
|
614
760
|
const key = this.getEntityKey(entityType, id)
|
|
615
|
-
this.meta.
|
|
761
|
+
const wasPessimistic = this.meta.isPessimisticInFlight(key)
|
|
762
|
+
this.meta.setPersisting(key, isPersisting, pessimistic)
|
|
763
|
+
if (wasPessimistic !== this.meta.isPessimisticInFlight(key)) {
|
|
764
|
+
this.bumpEntitySnapshotVersion(key)
|
|
765
|
+
}
|
|
616
766
|
this.notifyEntitySubscribers(key)
|
|
617
767
|
}
|
|
618
768
|
|
|
769
|
+
/**
|
|
770
|
+
* Returns the snapshot a consumer should DISPLAY for an entity.
|
|
771
|
+
*
|
|
772
|
+
* This equals the canonical {@link getEntitySnapshot} except while the entity
|
|
773
|
+
* is pessimistically in-flight, when it returns the server baseline (data ===
|
|
774
|
+
* serverData) WITHOUT mutating the store. The canonical snapshot stays dirty,
|
|
775
|
+
* so dirty tracking, mutation building, and retry are unaffected — only the
|
|
776
|
+
* presented value is the pre-persist server view.
|
|
777
|
+
*
|
|
778
|
+
* Inert until consumers route their display reads through it (see PR 4); a
|
|
779
|
+
* non-pessimistic entity is returned verbatim, so optimistic mode and the
|
|
780
|
+
* not-persisting case share this one path.
|
|
781
|
+
*/
|
|
782
|
+
getPresentationSnapshot<T extends object>(entityType: string, id: string): EntitySnapshot<T> | undefined {
|
|
783
|
+
const snapshot = this.getEntitySnapshot<T>(entityType, id)
|
|
784
|
+
if (!snapshot) return undefined
|
|
785
|
+
|
|
786
|
+
const key = this.getEntityKey(entityType, id)
|
|
787
|
+
if (!this.meta.isPessimisticInFlight(key)) {
|
|
788
|
+
return snapshot
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
return createEntitySnapshot<T>(
|
|
792
|
+
snapshot.id,
|
|
793
|
+
snapshot.entityType,
|
|
794
|
+
snapshot.serverData,
|
|
795
|
+
snapshot.serverData,
|
|
796
|
+
snapshot.version,
|
|
797
|
+
)
|
|
798
|
+
}
|
|
799
|
+
|
|
619
800
|
// ==================== Error State (delegated to ErrorStore) ====================
|
|
620
801
|
|
|
621
802
|
getFieldErrors(entityType: string, id: string, fieldName: string): readonly FieldError[] {
|
|
@@ -690,6 +871,7 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
690
871
|
const entityKey = this.getEntityKey(entityType, id)
|
|
691
872
|
const keyPrefix = `${entityType}:${id}:`
|
|
692
873
|
this.errors.clearAllServerErrors(entityKey, keyPrefix)
|
|
874
|
+
this.notifyEntitySubscribers(entityKey)
|
|
693
875
|
}
|
|
694
876
|
|
|
695
877
|
clearAllErrors(entityType: string, id: string): void {
|
|
@@ -752,17 +934,42 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
752
934
|
return this.relations.getRelation(key)
|
|
753
935
|
}
|
|
754
936
|
|
|
937
|
+
getPresentationRelation(
|
|
938
|
+
parentType: string,
|
|
939
|
+
parentId: string,
|
|
940
|
+
fieldName: string,
|
|
941
|
+
): StoredRelationState | undefined {
|
|
942
|
+
const relation = this.getRelation(parentType, parentId, fieldName)
|
|
943
|
+
if (!relation || !this.meta.isPessimisticInFlight(this.getEntityKey(parentType, parentId))) {
|
|
944
|
+
return relation
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
return {
|
|
948
|
+
...relation,
|
|
949
|
+
currentId: relation.serverId,
|
|
950
|
+
state: relation.serverState,
|
|
951
|
+
placeholderData: {},
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
755
955
|
setRelation(
|
|
756
956
|
parentType: string,
|
|
757
957
|
parentId: string,
|
|
758
958
|
fieldName: string,
|
|
759
959
|
updates: Partial<Omit<StoredRelationState, 'version'>>,
|
|
960
|
+
skipNotify: boolean = false,
|
|
760
961
|
): void {
|
|
761
962
|
const key = this.getRelationKey(parentType, parentId, fieldName)
|
|
963
|
+
this.journal?.recordRelation(key)
|
|
762
964
|
const entityKey = this.getEntityKey(parentType, parentId)
|
|
763
965
|
const entitySnapshot = this.entitySnapshots.get(entityKey)
|
|
764
966
|
this.relations.setRelation(key, updates, entitySnapshot, fieldName)
|
|
765
|
-
|
|
967
|
+
// skipNotify is for writes that happen during a render-phase read (e.g.
|
|
968
|
+
// HasOneHandle materialization) where the change that triggered them already
|
|
969
|
+
// notified subscribers — notifying again would call subscribers mid-render.
|
|
970
|
+
if (!skipNotify) {
|
|
971
|
+
this.notifyRelationSubscribers(key)
|
|
972
|
+
}
|
|
766
973
|
}
|
|
767
974
|
|
|
768
975
|
commitRelation(parentType: string, parentId: string, fieldName: string): void {
|
|
@@ -771,15 +978,36 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
771
978
|
this.notifyRelationSubscribers(key)
|
|
772
979
|
}
|
|
773
980
|
|
|
981
|
+
reconcileSentRelation(
|
|
982
|
+
parentType: string,
|
|
983
|
+
parentId: string,
|
|
984
|
+
fieldName: string,
|
|
985
|
+
transition: SentHasOneTransition,
|
|
986
|
+
): RelationReconciliationResult {
|
|
987
|
+
const key = this.getRelationKey(parentType, parentId, fieldName)
|
|
988
|
+
const result = this.relations.reconcileSentRelation(key, transition)
|
|
989
|
+
this.notifyRelationSubscribers(key)
|
|
990
|
+
return result
|
|
991
|
+
}
|
|
992
|
+
|
|
774
993
|
resetRelation(parentType: string, parentId: string, fieldName: string): void {
|
|
775
994
|
const key = this.getRelationKey(parentType, parentId, fieldName)
|
|
995
|
+
this.journal?.recordRelation(key)
|
|
776
996
|
this.relations.resetRelation(key)
|
|
777
997
|
this.notifyRelationSubscribers(key)
|
|
778
998
|
}
|
|
779
999
|
|
|
780
|
-
|
|
1000
|
+
/**
|
|
1001
|
+
* Commits every relation of an entity. Items listed in `pendingItems` (by relation
|
|
1002
|
+
* key) were not sent in the persist and stay planned.
|
|
1003
|
+
*/
|
|
1004
|
+
commitAllRelations(
|
|
1005
|
+
entityType: string,
|
|
1006
|
+
entityId: string,
|
|
1007
|
+
pendingItems?: ReadonlyMap<string, ReadonlySet<string>>,
|
|
1008
|
+
): void {
|
|
781
1009
|
const keyPrefix = `${entityType}:${entityId}:`
|
|
782
|
-
this.relations.commitAllRelations(keyPrefix)
|
|
1010
|
+
this.relations.commitAllRelations(keyPrefix, pendingItems)
|
|
783
1011
|
}
|
|
784
1012
|
|
|
785
1013
|
resetAllRelations(entityType: string, entityId: string): void {
|
|
@@ -787,28 +1015,6 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
787
1015
|
this.relations.resetAllRelations(keyPrefix)
|
|
788
1016
|
}
|
|
789
1017
|
|
|
790
|
-
getAllRelationsForEntity(entityType: string, entityId: string): Map<string, StoredRelationState> {
|
|
791
|
-
const keyPrefix = `${entityType}:${entityId}:`
|
|
792
|
-
return this.relations.getAllRelationsForEntity(keyPrefix)
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
getAllHasManyForEntity(entityType: string, entityId: string): Map<string, StoredHasManyState> {
|
|
796
|
-
const keyPrefix = `${entityType}:${entityId}:`
|
|
797
|
-
return this.relations.getAllHasManyForEntity(keyPrefix)
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
restoreHasManyState(
|
|
801
|
-
parentType: string,
|
|
802
|
-
parentId: string,
|
|
803
|
-
fieldName: string,
|
|
804
|
-
state: StoredHasManyState,
|
|
805
|
-
alias?: string,
|
|
806
|
-
): void {
|
|
807
|
-
const key = this.getRelationKey(parentType, parentId, alias ?? fieldName)
|
|
808
|
-
this.relations.restoreHasManyState(key, state)
|
|
809
|
-
this.notifyRelationSubscribers(key)
|
|
810
|
-
}
|
|
811
|
-
|
|
812
1018
|
// ==================== Subscriptions (delegated to SubscriptionManager) ====================
|
|
813
1019
|
|
|
814
1020
|
subscribeToEntity(entityType: string, id: string, callback: Subscriber): () => void {
|
|
@@ -840,21 +1046,25 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
840
1046
|
|
|
841
1047
|
// ==================== Parent-Child Relationships ====================
|
|
842
1048
|
|
|
1049
|
+
/**
|
|
1050
|
+
* Anchors a child under a parent relation. Parent re-render notification is now
|
|
1051
|
+
* DERIVED from the relation store's live edges (see
|
|
1052
|
+
* {@link SubscriptionManager.getParentKeys}), so the only remaining effect is
|
|
1053
|
+
* the reachability one: a child anchored by a parent relation is no longer a
|
|
1054
|
+
* top-level root; its reachability flows through the parent. (No-op for server
|
|
1055
|
+
* children that were never roots.)
|
|
1056
|
+
*
|
|
1057
|
+
* Callers (handles, the action dispatcher) keep calling this on connect so the
|
|
1058
|
+
* root-unregister stays centralized in one place.
|
|
1059
|
+
*/
|
|
843
1060
|
registerParentChild(parentType: string, parentId: string, childType: string, childId: string): void {
|
|
844
|
-
const parentKey = this.getEntityKey(parentType, parentId)
|
|
845
1061
|
const childKey = this.getEntityKey(childType, childId)
|
|
846
|
-
|
|
847
|
-
//
|
|
848
|
-
|
|
1062
|
+
// Capture the child's root membership before it is dropped, so undoing the
|
|
1063
|
+
// connect/add that anchored it restores it as a reachable create.
|
|
1064
|
+
this.recordExistingEntity(childKey)
|
|
849
1065
|
this.roots.unregister(childKey)
|
|
850
1066
|
}
|
|
851
1067
|
|
|
852
|
-
unregisterParentChild(parentType: string, parentId: string, childType: string, childId: string): void {
|
|
853
|
-
const parentKey = this.getEntityKey(parentType, parentId)
|
|
854
|
-
const childKey = this.getEntityKey(childType, childId)
|
|
855
|
-
this.subscriptions.unregisterParentChild(parentKey, childKey)
|
|
856
|
-
}
|
|
857
|
-
|
|
858
1068
|
// ==================== Partial Snapshot Export/Import ====================
|
|
859
1069
|
|
|
860
1070
|
exportPartialSnapshot(keys: {
|
|
@@ -900,8 +1110,245 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
900
1110
|
}
|
|
901
1111
|
}
|
|
902
1112
|
|
|
1113
|
+
// ==================== Undo Journal Target (cell capture / restore) ====================
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* Captures the editable-layer pre-image of an entity cell: the current snapshot
|
|
1117
|
+
* (data + baseline), plus the meta and root membership needed to restore or
|
|
1118
|
+
* re-create it. `present: false` means the entity did not exist — undo removes it.
|
|
1119
|
+
*/
|
|
1120
|
+
exportEntityCell(key: string): EntityCellImage {
|
|
1121
|
+
const snapshot = this.entitySnapshots.get(key)
|
|
1122
|
+
if (!snapshot) {
|
|
1123
|
+
return { kind: 'entity', key, present: false }
|
|
1124
|
+
}
|
|
1125
|
+
return {
|
|
1126
|
+
kind: 'entity',
|
|
1127
|
+
key,
|
|
1128
|
+
present: true,
|
|
1129
|
+
snapshot,
|
|
1130
|
+
existsOnServer: this.meta.existsOnServer(key),
|
|
1131
|
+
isScheduledForDeletion: this.meta.isScheduledForDeletion(key),
|
|
1132
|
+
isRoot: this.roots.has(key),
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
exportRelationCell(key: string): RelationCellImage {
|
|
1137
|
+
const state = this.relations.getRelation(key)
|
|
1138
|
+
if (!state) return { kind: 'relation', key, present: false }
|
|
1139
|
+
return {
|
|
1140
|
+
kind: 'relation',
|
|
1141
|
+
key,
|
|
1142
|
+
present: true,
|
|
1143
|
+
state: { ...state, placeholderData: { ...state.placeholderData } },
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* Live server-member ids of a has-many list by its raw relation key. Used by the
|
|
1149
|
+
* journal's rekey to rebase a pre-image when a just-persisted create became a
|
|
1150
|
+
* permanent member of the list (membership rebase for sealed creates).
|
|
1151
|
+
*/
|
|
1152
|
+
getLiveHasManyServerIds(relationKey: string): Set<string> {
|
|
1153
|
+
const state = this.relations.getHasMany(relationKey)
|
|
1154
|
+
return new Set(state?.serverIds ?? [])
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
exportHasManyCell(key: string): HasManyCellImage {
|
|
1158
|
+
const state = this.relations.getHasMany(key)
|
|
1159
|
+
if (!state) return { kind: 'hasMany', key, present: false }
|
|
1160
|
+
return {
|
|
1161
|
+
kind: 'hasMany',
|
|
1162
|
+
key,
|
|
1163
|
+
present: true,
|
|
1164
|
+
state: {
|
|
1165
|
+
serverIds: new Set(state.serverIds),
|
|
1166
|
+
orderedIds: state.orderedIds ? [...state.orderedIds] : null,
|
|
1167
|
+
plannedRemovals: new Map(state.plannedRemovals),
|
|
1168
|
+
plannedAdditions: new Map(state.plannedAdditions),
|
|
1169
|
+
version: state.version,
|
|
1170
|
+
},
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* Entry-closure export: starting from the given seed ids, walks and exports the
|
|
1176
|
+
* entity + owned-relation cell images of every created, currently-unreachable
|
|
1177
|
+
* subtree — the exact `!existsOnServer && !reachable` set {@link sweepUnreachableCreated}
|
|
1178
|
+
* may reclaim. Referenced children (has-one currentId, has-many members / planned
|
|
1179
|
+
* additions) are pushed back on the worklist, so nested created grandchildren are
|
|
1180
|
+
* captured transitively. Lets an undo restore a detached create even after a sweep.
|
|
1181
|
+
*/
|
|
1182
|
+
exportUnreachableCreatedSubgraph(seedIds: ReadonlySet<string>): JournalCellImage[] {
|
|
1183
|
+
const reachable = this.reachability.computeReachableCreated()
|
|
1184
|
+
const images: JournalCellImage[] = []
|
|
1185
|
+
const visited = new Set<string>()
|
|
1186
|
+
const worklist: string[] = [...seedIds]
|
|
1187
|
+
|
|
1188
|
+
while (worklist.length > 0) {
|
|
1189
|
+
const id = worklist.pop()!
|
|
1190
|
+
const key = this.entitySnapshots.keyForId(id)
|
|
1191
|
+
if (!key || visited.has(key)) continue
|
|
1192
|
+
visited.add(key)
|
|
1193
|
+
// The sweep gate: only created, currently-unreachable entities are reclaimed.
|
|
1194
|
+
if (this.meta.existsOnServer(key) || reachable.has(key)) continue
|
|
1195
|
+
if (!this.entitySnapshots.has(key)) continue // placeholder-only: nothing to restore
|
|
1196
|
+
|
|
1197
|
+
images.push(this.exportEntityCell(key))
|
|
1198
|
+
|
|
1199
|
+
const ownerPrefix = `${key}:`
|
|
1200
|
+
for (const relationKey of this.relations.getOwnedRelationKeys(ownerPrefix)) {
|
|
1201
|
+
const image = this.exportRelationCell(relationKey)
|
|
1202
|
+
images.push(image)
|
|
1203
|
+
if (image.state?.currentId) worklist.push(image.state.currentId)
|
|
1204
|
+
}
|
|
1205
|
+
for (const hasManyKey of this.relations.getOwnedHasManyKeys(ownerPrefix)) {
|
|
1206
|
+
const image = this.exportHasManyCell(hasManyKey)
|
|
1207
|
+
images.push(image)
|
|
1208
|
+
if (image.state) {
|
|
1209
|
+
if (image.state.orderedIds) worklist.push(...image.state.orderedIds)
|
|
1210
|
+
for (const additionId of image.state.plannedAdditions.keys()) worklist.push(additionId)
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
return images
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/**
|
|
1219
|
+
* Restores a set of cell pre-images through the write paths (so the edge index
|
|
1220
|
+
* reconciles and the reachability cache invalidates). Order matters: present
|
|
1221
|
+
* entities are restored/recreated first so relations can reference live targets,
|
|
1222
|
+
* then present relations, then un-creates, then dropped relations.
|
|
1223
|
+
*/
|
|
1224
|
+
applyJournalImages(images: JournalCellImage[]): void {
|
|
1225
|
+
const notifyEntities = new Set<string>()
|
|
1226
|
+
const notifyRelations = new Set<string>()
|
|
1227
|
+
|
|
1228
|
+
const presentEntities: EntityCellImage[] = []
|
|
1229
|
+
const presentRelations: Array<RelationCellImage | HasManyCellImage> = []
|
|
1230
|
+
const absentEntities: EntityCellImage[] = []
|
|
1231
|
+
const absentRelations: Array<RelationCellImage | HasManyCellImage> = []
|
|
1232
|
+
|
|
1233
|
+
for (const img of images) {
|
|
1234
|
+
if (img.kind === 'entity') {
|
|
1235
|
+
notifyEntities.add(img.key)
|
|
1236
|
+
;(img.present ? presentEntities : absentEntities).push(img)
|
|
1237
|
+
} else {
|
|
1238
|
+
notifyRelations.add(img.key)
|
|
1239
|
+
notifyEntities.add(entityKeyOfRelationKey(img.key))
|
|
1240
|
+
;(img.present ? presentRelations : absentRelations).push(img)
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
for (const img of presentEntities) this.applyEntityImage(img)
|
|
1245
|
+
for (const img of presentRelations) this.applyRelationImage(img)
|
|
1246
|
+
for (const img of absentEntities) {
|
|
1247
|
+
const [type, id] = splitEntityKey(img.key)
|
|
1248
|
+
this.removeEntity(type, id)
|
|
1249
|
+
}
|
|
1250
|
+
for (const img of absentRelations) {
|
|
1251
|
+
if (img.kind === 'relation') this.relations.removeRelationState(img.key)
|
|
1252
|
+
else this.relations.removeHasManyState(img.key)
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
this.subscriptions.notifyGlobal()
|
|
1256
|
+
for (const key of notifyEntities) this.subscriptions.notifyEntityDirect(key)
|
|
1257
|
+
for (const key of notifyRelations) this.subscriptions.notifyRelationDirect(key)
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
private applyEntityImage(img: EntityCellImage): void {
|
|
1261
|
+
const snapshot = img.snapshot!
|
|
1262
|
+
const live = this.entitySnapshots.get(img.key)
|
|
1263
|
+
if (live) {
|
|
1264
|
+
// Splice the editable layer (data) onto the LIVE server baseline; keep the
|
|
1265
|
+
// live id so an undo after persist re-dirties against the current baseline.
|
|
1266
|
+
const spliced = createEntitySnapshot(
|
|
1267
|
+
live.id,
|
|
1268
|
+
live.entityType,
|
|
1269
|
+
{ ...(snapshot.data as Record<string, unknown>), id: live.id },
|
|
1270
|
+
live.serverData,
|
|
1271
|
+
live.version + 1,
|
|
1272
|
+
)
|
|
1273
|
+
this.entitySnapshots.importSnapshots(new Map([[img.key, spliced]]))
|
|
1274
|
+
this.meta.importMetas(new Map([[img.key, {
|
|
1275
|
+
existsOnServer: this.meta.existsOnServer(img.key),
|
|
1276
|
+
isScheduledForDeletion: img.isScheduledForDeletion ?? false,
|
|
1277
|
+
}]]))
|
|
1278
|
+
} else {
|
|
1279
|
+
// Entity is gone (swept / un-created): recreate it from the captured snapshot.
|
|
1280
|
+
this.entitySnapshots.importSnapshots(new Map([[img.key, snapshot]]))
|
|
1281
|
+
this.meta.importMetas(new Map([[img.key, {
|
|
1282
|
+
existsOnServer: img.existsOnServer ?? false,
|
|
1283
|
+
isScheduledForDeletion: img.isScheduledForDeletion ?? false,
|
|
1284
|
+
}]]))
|
|
1285
|
+
}
|
|
1286
|
+
if (img.isRoot) this.roots.register(img.key)
|
|
1287
|
+
else this.roots.unregister(img.key)
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
private applyRelationImage(img: RelationCellImage | HasManyCellImage): void {
|
|
1291
|
+
if (img.kind === 'relation') {
|
|
1292
|
+
const s = img.state!
|
|
1293
|
+
const live = this.relations.getRelation(img.key)
|
|
1294
|
+
this.relations.importRelationStates(new Map([[img.key, {
|
|
1295
|
+
currentId: s.currentId,
|
|
1296
|
+
state: s.state,
|
|
1297
|
+
placeholderData: { ...s.placeholderData },
|
|
1298
|
+
serverId: live ? live.serverId : s.serverId,
|
|
1299
|
+
serverState: live ? live.serverState : s.serverState,
|
|
1300
|
+
version: (live?.version ?? s.version) + 1,
|
|
1301
|
+
}]]))
|
|
1302
|
+
} else {
|
|
1303
|
+
const s = img.state!
|
|
1304
|
+
const live = this.relations.getHasMany(img.key)
|
|
1305
|
+
this.relations.importHasManyStates(new Map([[img.key, {
|
|
1306
|
+
serverIds: new Set(live ? live.serverIds : s.serverIds),
|
|
1307
|
+
orderedIds: s.orderedIds ? [...s.orderedIds] : null,
|
|
1308
|
+
plannedRemovals: new Map(s.plannedRemovals),
|
|
1309
|
+
plannedAdditions: new Map(s.plannedAdditions),
|
|
1310
|
+
version: (live?.version ?? s.version) + 1,
|
|
1311
|
+
}]]))
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
|
|
903
1315
|
// ==================== Dirty Tracking (delegated to DirtyTracker) ====================
|
|
904
1316
|
|
|
1317
|
+
/**
|
|
1318
|
+
* Sum of the monotonic mutation counters of every sub-store {@link getAllDirtyEntities}
|
|
1319
|
+
* reads. Strictly increasing, so an unchanged value proves no write could have
|
|
1320
|
+
* changed the dirty set — the cache key {@link ChangeRegistry} memoizes the
|
|
1321
|
+
* full-store scan on.
|
|
1322
|
+
*
|
|
1323
|
+
* Deliberately NOT derived from {@link getVersion} (the notification version):
|
|
1324
|
+
* several writes change dirtiness without notifying — `createEntity` registers
|
|
1325
|
+
* its root after its last notify, `registerParentChild` un-registers one,
|
|
1326
|
+
* `commit/resetAllRelations` never notify, and `refreshServerData` can skip it —
|
|
1327
|
+
* so a notification-keyed memo would serve a stale result. Counters bump inside
|
|
1328
|
+
* the write itself and cannot be bypassed that way.
|
|
1329
|
+
*
|
|
1330
|
+
* The entity snapshot store contributes its data-write counter alone: it bumps
|
|
1331
|
+
* on a superset of the writes `getMutationVersion()` covers.
|
|
1332
|
+
*
|
|
1333
|
+
* REQUIRES snapshot values to be replaced, never mutated in place.
|
|
1334
|
+
* `createEntitySnapshot` freezes only the top level, so a nested object inside
|
|
1335
|
+
* `data` (a rich-text JSON payload, say) stays writable. Editing one in place
|
|
1336
|
+
* changes dirtiness without touching any counter, and the memo will serve the
|
|
1337
|
+
* pre-edit answer — where the former unconditional scan happened to notice it on
|
|
1338
|
+
* the next read. The store already implies this rule by freezing at all; the memo
|
|
1339
|
+
* is what makes breaking it consequential.
|
|
1340
|
+
*
|
|
1341
|
+
* @internal Cache key for the dirty-scan memo. Monotonic and otherwise
|
|
1342
|
+
* meaningless — the absolute value carries no consumer-facing information.
|
|
1343
|
+
*/
|
|
1344
|
+
getDirtyVersion(): number {
|
|
1345
|
+
return this.entitySnapshots.getDataWriteVersion()
|
|
1346
|
+
+ this.meta.getMutationVersion()
|
|
1347
|
+
+ this.meta.getEditableWriteVersion()
|
|
1348
|
+
+ this.relations.getMutationVersion()
|
|
1349
|
+
+ this.roots.getMutationVersion()
|
|
1350
|
+
}
|
|
1351
|
+
|
|
905
1352
|
getAllDirtyEntities(): Array<{
|
|
906
1353
|
entityType: string
|
|
907
1354
|
entityId: string
|
|
@@ -953,8 +1400,32 @@ export class SnapshotStore implements SnapshotVersionBumper {
|
|
|
953
1400
|
this.errors.clear()
|
|
954
1401
|
this.touched.clear()
|
|
955
1402
|
this.roots.clear()
|
|
1403
|
+
this.rekeyOrchestrator.clear()
|
|
956
1404
|
this.lastPropagatedData.clear()
|
|
1405
|
+
// Undo history describes the now-wiped world; drop it with the store.
|
|
1406
|
+
this.journal?.clear()
|
|
957
1407
|
|
|
958
|
-
|
|
1408
|
+
// Every subscription's data just disappeared, so notify entity/relation subscribers too —
|
|
1409
|
+
// a global-only notify leaves them rendering wiped data with no consumer-side remedy.
|
|
1410
|
+
this.subscriptions.notifyAll()
|
|
959
1411
|
}
|
|
960
1412
|
}
|
|
1413
|
+
|
|
1414
|
+
/**
|
|
1415
|
+
* Splits an entity key "entityType:id" into its parts. Entity ids never contain
|
|
1416
|
+
* ':' (store-wide invariant), so the first ':' is the type/id boundary.
|
|
1417
|
+
*/
|
|
1418
|
+
function splitEntityKey(key: string): [string, string] {
|
|
1419
|
+
const i = key.indexOf(':')
|
|
1420
|
+
return [key.slice(0, i), key.slice(i + 1)]
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
/**
|
|
1424
|
+
* Derives the owning entity key "entityType:id" from a relation/has-many key
|
|
1425
|
+
* "entityType:id:fieldName".
|
|
1426
|
+
*/
|
|
1427
|
+
function entityKeyOfRelationKey(key: string): string {
|
|
1428
|
+
const first = key.indexOf(':')
|
|
1429
|
+
const second = key.indexOf(':', first + 1)
|
|
1430
|
+
return second === -1 ? key : key.slice(0, second)
|
|
1431
|
+
}
|