@contember/bindx 0.1.46 → 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 +120 -16
- 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
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import type { HasOneRelationState } from '../handles/types.js'
|
|
2
|
+
import type { EntitySnapshot } from './snapshots.js'
|
|
3
|
+
import { parentKeyFromOwnerPrefix, parentKeyFromRelationKey } from './relationKey.js'
|
|
4
|
+
import { RelationEdgeIndex } from './RelationEdgeIndex.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Relation state stored in SnapshotStore
|
|
8
|
+
*/
|
|
9
|
+
export interface StoredRelationState {
|
|
10
|
+
currentId: string | null
|
|
11
|
+
serverId: string | null
|
|
12
|
+
state: HasOneRelationState
|
|
13
|
+
serverState: HasOneRelationState
|
|
14
|
+
placeholderData: Record<string, unknown>
|
|
15
|
+
version: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type ReconciliationResult = 'applied' | 'conflict'
|
|
19
|
+
|
|
20
|
+
export type SentHasOneTransition =
|
|
21
|
+
| { operation: 'connect'; targetId: string }
|
|
22
|
+
| { operation: 'create'; targetId: string }
|
|
23
|
+
| { operation: 'disconnect'; targetId: string }
|
|
24
|
+
| { operation: 'delete'; targetId: string }
|
|
25
|
+
|
|
26
|
+
function cloneRelationState(state: StoredRelationState): StoredRelationState {
|
|
27
|
+
return {
|
|
28
|
+
...state,
|
|
29
|
+
placeholderData: { ...state.placeholderData },
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Owns has-one relation state ("parentType:parentId:fieldName" → {@link StoredRelationState}).
|
|
35
|
+
*
|
|
36
|
+
* Has its own monotonic {@link mutationVersion} bumped on every actual write
|
|
37
|
+
* (funnelled through {@link writeRelation} plus the delete/clear paths); the
|
|
38
|
+
* facade sums this with the has-many counter for {@link ReachabilityAnalyzer}.
|
|
39
|
+
*/
|
|
40
|
+
export class HasOneStore {
|
|
41
|
+
/** Relation states keyed by "parentType:parentId:fieldName" */
|
|
42
|
+
private readonly relationStates = new Map<string, StoredRelationState>()
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Bidirectional live-edge index, maintained by {@link writeRelation} /
|
|
46
|
+
* {@link deleteRelation} so the parent↔child queries are O(degree) and the two
|
|
47
|
+
* directions stay consistent by construction.
|
|
48
|
+
*/
|
|
49
|
+
private readonly edges = new RelationEdgeIndex()
|
|
50
|
+
|
|
51
|
+
private mutationVersion = 0
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Monotonic counter bumped on EDITABLE-layer has-one writes (setRelation /
|
|
55
|
+
* resetRelation) — the writes an undo gesture must first record. Materialization
|
|
56
|
+
* (getOrCreateRelation), post-persist commit, import, replaceEntityId, rekey and
|
|
57
|
+
* delete deliberately do NOT bump it. Read by the undo write-guard (see UndoJournal).
|
|
58
|
+
*/
|
|
59
|
+
private editableWriteVersion = 0
|
|
60
|
+
|
|
61
|
+
getMutationVersion(): number {
|
|
62
|
+
return this.mutationVersion
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
getEditableWriteVersion(): number {
|
|
66
|
+
return this.editableWriteVersion
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The single write chokepoint. Reconciles the edge index by diffing the live
|
|
71
|
+
* child of the previous state against the next, so every state-changing path
|
|
72
|
+
* (set/commit/reset/import/replaceEntityId/...) keeps the index correct without
|
|
73
|
+
* tracking the reverse direction itself.
|
|
74
|
+
*/
|
|
75
|
+
private writeRelation(key: string, state: StoredRelationState): void {
|
|
76
|
+
const oldChild = liveHasOneChildId(this.relationStates.get(key))
|
|
77
|
+
const newChild = liveHasOneChildId(state)
|
|
78
|
+
this.relationStates.set(key, state)
|
|
79
|
+
if (oldChild !== newChild) {
|
|
80
|
+
const parentKey = parentKeyFromRelationKey(key)
|
|
81
|
+
if (oldChild !== null) this.edges.removeEdge(parentKey, oldChild)
|
|
82
|
+
if (newChild !== null) this.edges.addEdge(parentKey, newChild)
|
|
83
|
+
}
|
|
84
|
+
this.mutationVersion++
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The single delete chokepoint — removes an entry and its live edge. Used by
|
|
89
|
+
* the bulk remove and rekey-owner paths so they don't leak edges.
|
|
90
|
+
*/
|
|
91
|
+
private deleteRelation(key: string): void {
|
|
92
|
+
const existing = this.relationStates.get(key)
|
|
93
|
+
if (!existing) return
|
|
94
|
+
const child = liveHasOneChildId(existing)
|
|
95
|
+
if (child !== null) this.edges.removeEdge(parentKeyFromRelationKey(key), child)
|
|
96
|
+
this.relationStates.delete(key)
|
|
97
|
+
this.mutationVersion++
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Gets or creates relation state.
|
|
102
|
+
*/
|
|
103
|
+
getOrCreateRelation(
|
|
104
|
+
key: string,
|
|
105
|
+
initial: Omit<StoredRelationState, 'version'>,
|
|
106
|
+
): StoredRelationState {
|
|
107
|
+
if (!this.relationStates.has(key)) {
|
|
108
|
+
this.writeRelation(key, { ...initial, version: 0 })
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return cloneRelationState(this.relationStates.get(key)!)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Gets relation state.
|
|
116
|
+
*/
|
|
117
|
+
getRelation(key: string): StoredRelationState | undefined {
|
|
118
|
+
const state = this.relationStates.get(key)
|
|
119
|
+
return state ? cloneRelationState(state) : undefined
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Updates relation state.
|
|
124
|
+
* If the relation state doesn't exist, creates it using entity snapshot for server data.
|
|
125
|
+
*/
|
|
126
|
+
setRelation(
|
|
127
|
+
key: string,
|
|
128
|
+
updates: Partial<Omit<StoredRelationState, 'version'>>,
|
|
129
|
+
entitySnapshot: EntitySnapshot | undefined,
|
|
130
|
+
fieldName: string,
|
|
131
|
+
): void {
|
|
132
|
+
const existing = this.relationStates.get(key)
|
|
133
|
+
|
|
134
|
+
if (!existing) {
|
|
135
|
+
let serverId: string | null = null
|
|
136
|
+
let serverState: HasOneRelationState = 'disconnected'
|
|
137
|
+
|
|
138
|
+
if (entitySnapshot?.serverData) {
|
|
139
|
+
const relatedData = (entitySnapshot.serverData as Record<string, unknown>)[fieldName]
|
|
140
|
+
if (relatedData && typeof relatedData === 'object' && 'id' in relatedData) {
|
|
141
|
+
serverId = (relatedData as { id: string }).id
|
|
142
|
+
serverState = 'connected'
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
this.writeRelation(key, {
|
|
147
|
+
currentId: 'currentId' in updates ? updates.currentId! : serverId,
|
|
148
|
+
serverId,
|
|
149
|
+
state: 'state' in updates ? updates.state! : serverState,
|
|
150
|
+
serverState,
|
|
151
|
+
placeholderData: updates.placeholderData ?? {},
|
|
152
|
+
version: 0,
|
|
153
|
+
})
|
|
154
|
+
} else {
|
|
155
|
+
this.writeRelation(key, {
|
|
156
|
+
...existing,
|
|
157
|
+
...updates,
|
|
158
|
+
version: existing.version + 1,
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
this.editableWriteVersion++
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Commits relation state (server = current).
|
|
166
|
+
*/
|
|
167
|
+
commitRelation(key: string): void {
|
|
168
|
+
const existing = this.relationStates.get(key)
|
|
169
|
+
if (!existing) return
|
|
170
|
+
|
|
171
|
+
this.writeRelation(key, {
|
|
172
|
+
...existing,
|
|
173
|
+
serverId: existing.currentId,
|
|
174
|
+
serverState: existing.state === 'creating' ? 'connected' : existing.state,
|
|
175
|
+
placeholderData: {},
|
|
176
|
+
version: existing.version + 1,
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Advances the server baseline by one confirmed sent transition while keeping
|
|
182
|
+
* edits made after the request started as pending local state.
|
|
183
|
+
*/
|
|
184
|
+
reconcileSentTransition(
|
|
185
|
+
key: string,
|
|
186
|
+
transition: SentHasOneTransition,
|
|
187
|
+
): ReconciliationResult {
|
|
188
|
+
const existing = this.relationStates.get(key)
|
|
189
|
+
if (!existing) return 'conflict'
|
|
190
|
+
|
|
191
|
+
const next = reconcileHasOneState(existing, transition)
|
|
192
|
+
this.writeRelation(key, next.state)
|
|
193
|
+
return next.result
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Resets relation to server state.
|
|
198
|
+
*/
|
|
199
|
+
resetRelation(key: string): void {
|
|
200
|
+
const existing = this.relationStates.get(key)
|
|
201
|
+
if (!existing) return
|
|
202
|
+
|
|
203
|
+
this.writeRelation(key, {
|
|
204
|
+
...existing,
|
|
205
|
+
currentId: existing.serverId,
|
|
206
|
+
state: existing.serverState,
|
|
207
|
+
placeholderData: {},
|
|
208
|
+
version: existing.version + 1,
|
|
209
|
+
})
|
|
210
|
+
this.editableWriteVersion++
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Collects the ids of child entities reachable through LIVE has-one edges
|
|
215
|
+
* (key prefix "parentType:parentId:") — an O(degree) read of the edge index.
|
|
216
|
+
*/
|
|
217
|
+
collectLiveChildIds(keyPrefix: string, ids: Set<string>): void {
|
|
218
|
+
this.edges.collectChildren(parentKeyFromOwnerPrefix(keyPrefix), ids)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Adds the composite parent keys of every LIVE has-one edge pointing at
|
|
223
|
+
* {@link childId} — an O(degree) read of the edge index, the exact reverse of
|
|
224
|
+
* {@link collectLiveChildIds}.
|
|
225
|
+
*/
|
|
226
|
+
collectParentKeysForChild(childId: string, parents: Set<string>): void {
|
|
227
|
+
this.edges.collectParents(childId, parents)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Collects the keys of every has-one relation owned by an entity (keys under the
|
|
232
|
+
* given owner prefix). Mirrors {@link removeOwnedRelations} but only enumerates.
|
|
233
|
+
*/
|
|
234
|
+
collectOwnedKeys(keyPrefix: string, keys: string[]): void {
|
|
235
|
+
for (const key of this.relationStates.keys()) {
|
|
236
|
+
if (key.startsWith(keyPrefix)) keys.push(key)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Removes a single has-one relation state (and its live edge). Used by undo
|
|
242
|
+
* restore to drop a relation that did not exist before the gesture.
|
|
243
|
+
*/
|
|
244
|
+
removeRelation(key: string): void {
|
|
245
|
+
this.deleteRelation(key)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Removes all has-one state owned by an entity (keys under the given owner
|
|
250
|
+
* prefix), dropping each entry's edge through {@link deleteRelation}.
|
|
251
|
+
*/
|
|
252
|
+
removeOwnedRelations(keyPrefix: string): void {
|
|
253
|
+
for (const key of [...this.relationStates.keys()]) {
|
|
254
|
+
if (key.startsWith(keyPrefix)) {
|
|
255
|
+
this.deleteRelation(key)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Commits all has-one relations for an entity.
|
|
262
|
+
*
|
|
263
|
+
* A relation whose planned op targets an item in `pendingItems` (by relation key)
|
|
264
|
+
* was not sent and is left uncommitted, so the op is retried on the next persist.
|
|
265
|
+
*/
|
|
266
|
+
commitAllRelations(keyPrefix: string, pendingItems?: ReadonlyMap<string, ReadonlySet<string>>): void {
|
|
267
|
+
for (const [key, state] of this.relationStates) {
|
|
268
|
+
if (!key.startsWith(keyPrefix)) continue
|
|
269
|
+
const pending = pendingItems?.get(key)
|
|
270
|
+
if (pending && ((state.currentId !== null && pending.has(state.currentId)) || (state.serverId !== null && pending.has(state.serverId)))) {
|
|
271
|
+
continue
|
|
272
|
+
}
|
|
273
|
+
this.commitRelation(key)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Resets all has-one relations for an entity to server state.
|
|
279
|
+
*/
|
|
280
|
+
resetAllRelations(keyPrefix: string): void {
|
|
281
|
+
for (const key of this.relationStates.keys()) {
|
|
282
|
+
if (key.startsWith(keyPrefix)) {
|
|
283
|
+
this.resetRelation(key)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Collects the field names of dirty has-one relations for an entity.
|
|
290
|
+
*/
|
|
291
|
+
collectDirtyRelations(keyPrefix: string, dirtyRelations: string[]): void {
|
|
292
|
+
for (const [key, state] of this.relationStates) {
|
|
293
|
+
if (!key.startsWith(keyPrefix)) continue
|
|
294
|
+
const fieldName = key.slice(keyPrefix.length)
|
|
295
|
+
|
|
296
|
+
if (
|
|
297
|
+
state.currentId !== state.serverId ||
|
|
298
|
+
state.state !== state.serverState ||
|
|
299
|
+
Object.keys(state.placeholderData).length > 0
|
|
300
|
+
) {
|
|
301
|
+
dirtyRelations.push(fieldName)
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Exports relation states for given keys.
|
|
308
|
+
*/
|
|
309
|
+
exportRelationStates(keys: string[]): Map<string, StoredRelationState> {
|
|
310
|
+
const result = new Map<string, StoredRelationState>()
|
|
311
|
+
for (const key of keys) {
|
|
312
|
+
const state = this.relationStates.get(key)
|
|
313
|
+
if (state) {
|
|
314
|
+
result.set(key, cloneRelationState(state))
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return result
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Imports relation states from a snapshot.
|
|
322
|
+
* Returns the keys that were imported for notification.
|
|
323
|
+
*/
|
|
324
|
+
importRelationStates(states: Map<string, StoredRelationState>): string[] {
|
|
325
|
+
const keys: string[] = []
|
|
326
|
+
for (const [key, state] of states) {
|
|
327
|
+
this.writeRelation(key, {
|
|
328
|
+
...state,
|
|
329
|
+
placeholderData: { ...state.placeholderData },
|
|
330
|
+
})
|
|
331
|
+
keys.push(key)
|
|
332
|
+
}
|
|
333
|
+
return keys
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Replaces all occurrences of oldId with newId across has-one relation states
|
|
338
|
+
* (currentId, serverId).
|
|
339
|
+
*/
|
|
340
|
+
replaceEntityId(oldId: string, newId: string): void {
|
|
341
|
+
for (const [key, state] of this.relationStates) {
|
|
342
|
+
let changed = false
|
|
343
|
+
let currentId = state.currentId
|
|
344
|
+
let serverId = state.serverId
|
|
345
|
+
|
|
346
|
+
if (currentId === oldId) { currentId = newId; changed = true }
|
|
347
|
+
if (serverId === oldId) { serverId = newId; changed = true }
|
|
348
|
+
|
|
349
|
+
if (changed) {
|
|
350
|
+
this.writeRelation(key, { ...state, currentId, serverId, version: state.version + 1 })
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Rekeys has-one entries owned by an entity (changes the parent ID in the key).
|
|
357
|
+
* Routing through {@link deleteRelation} + {@link writeRelation} migrates the
|
|
358
|
+
* edge index from the old parent key to the new one for free.
|
|
359
|
+
*/
|
|
360
|
+
rekeyOwner(oldKeyPrefix: string, newKeyPrefix: string): void {
|
|
361
|
+
const toMove: [string, StoredRelationState][] = []
|
|
362
|
+
for (const [key, value] of this.relationStates) {
|
|
363
|
+
if (key.startsWith(oldKeyPrefix)) {
|
|
364
|
+
toMove.push([key, value])
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
for (const [oldKey, value] of toMove) {
|
|
368
|
+
this.deleteRelation(oldKey)
|
|
369
|
+
this.writeRelation(newKeyPrefix + oldKey.slice(oldKeyPrefix.length), value)
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Clears all has-one relation data.
|
|
375
|
+
*/
|
|
376
|
+
clear(): void {
|
|
377
|
+
this.relationStates.clear()
|
|
378
|
+
this.edges.clear()
|
|
379
|
+
this.mutationVersion++
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* The single liveness predicate for a has-one edge: the related id is live when
|
|
385
|
+
* the relation is connected to it and not deleted. Defined once and consumed by
|
|
386
|
+
* the write chokepoint so the forward/reverse index can never drift from it.
|
|
387
|
+
*/
|
|
388
|
+
function liveHasOneChildId(state: StoredRelationState | undefined): string | null {
|
|
389
|
+
if (!state) return null
|
|
390
|
+
return state.currentId !== null && state.state !== 'deleted' ? state.currentId : null
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
interface HasOneReconciliation {
|
|
394
|
+
state: StoredRelationState
|
|
395
|
+
result: ReconciliationResult
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function reconcileHasOneState(
|
|
399
|
+
existing: StoredRelationState,
|
|
400
|
+
transition: SentHasOneTransition,
|
|
401
|
+
): HasOneReconciliation {
|
|
402
|
+
if (transition.operation === 'connect' || transition.operation === 'create') {
|
|
403
|
+
return reconcileHasOneAddition(existing, transition)
|
|
404
|
+
}
|
|
405
|
+
return reconcileHasOneRemoval(existing, transition)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function reconcileHasOneAddition(
|
|
409
|
+
existing: StoredRelationState,
|
|
410
|
+
transition: Extract<SentHasOneTransition, { operation: 'connect' | 'create' }>,
|
|
411
|
+
): HasOneReconciliation {
|
|
412
|
+
const wasRemoved = existing.currentId !== transition.targetId || existing.state !== 'connected'
|
|
413
|
+
const removedCreatedTarget = transition.operation === 'create'
|
|
414
|
+
&& existing.currentId === null
|
|
415
|
+
&& existing.state === 'disconnected'
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
state: {
|
|
419
|
+
...existing,
|
|
420
|
+
currentId: removedCreatedTarget ? transition.targetId : existing.currentId,
|
|
421
|
+
serverId: transition.targetId,
|
|
422
|
+
state: removedCreatedTarget ? 'deleted' : existing.state,
|
|
423
|
+
serverState: 'connected',
|
|
424
|
+
placeholderData: wasRemoved ? existing.placeholderData : {},
|
|
425
|
+
version: existing.version + 1,
|
|
426
|
+
},
|
|
427
|
+
result: 'applied',
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function reconcileHasOneRemoval(
|
|
432
|
+
existing: StoredRelationState,
|
|
433
|
+
transition: Extract<SentHasOneTransition, { operation: 'disconnect' | 'delete' }>,
|
|
434
|
+
): HasOneReconciliation {
|
|
435
|
+
const isCompletedDelete = transition.operation === 'delete'
|
|
436
|
+
&& existing.currentId === transition.targetId
|
|
437
|
+
&& existing.state === 'deleted'
|
|
438
|
+
const isDeleteReversal = transition.operation === 'delete'
|
|
439
|
+
&& existing.currentId === transition.targetId
|
|
440
|
+
&& existing.state !== 'deleted'
|
|
441
|
+
|
|
442
|
+
return {
|
|
443
|
+
state: {
|
|
444
|
+
...existing,
|
|
445
|
+
currentId: isCompletedDelete ? null : existing.currentId,
|
|
446
|
+
serverId: null,
|
|
447
|
+
state: isCompletedDelete ? 'disconnected' : existing.state,
|
|
448
|
+
serverState: 'disconnected',
|
|
449
|
+
placeholderData: isCompletedDelete ? {} : existing.placeholderData,
|
|
450
|
+
version: existing.version + 1,
|
|
451
|
+
},
|
|
452
|
+
result: isDeleteReversal ? 'conflict' : 'applied',
|
|
453
|
+
}
|
|
454
|
+
}
|
|
@@ -18,6 +18,14 @@ import type { RootRegistry } from './RootRegistry.js'
|
|
|
18
18
|
* The graph is read from {@link RelationStore} (the source of truth for live
|
|
19
19
|
* relation membership), never from the subscription parent-child registry,
|
|
20
20
|
* which is append-mostly for notifications and does not reflect disconnects.
|
|
21
|
+
*
|
|
22
|
+
* The walk is O(E+R) and runs on every dirty check and post-persist sweep, so
|
|
23
|
+
* its result is memoized. Each sub-store the walk reads exposes a monotonic
|
|
24
|
+
* `getMutationVersion()` that bumps only when graph-relevant state changes
|
|
25
|
+
* (entity key set, `existsOnServer`/`isPersisting`, roots, relation edges).
|
|
26
|
+
* Their sum is a cache key: it is strictly increasing, so an unchanged sum
|
|
27
|
+
* proves nothing relevant changed and the cached set can be returned. Pure
|
|
28
|
+
* field edits do not bump any counter, keeping the cache warm on the hot path.
|
|
21
29
|
*/
|
|
22
30
|
export class ReachabilityAnalyzer {
|
|
23
31
|
constructor(
|
|
@@ -27,11 +35,40 @@ export class ReachabilityAnalyzer {
|
|
|
27
35
|
private readonly roots: RootRegistry,
|
|
28
36
|
) {}
|
|
29
37
|
|
|
38
|
+
private cache: { version: number; result: Set<string> } | null = null
|
|
39
|
+
|
|
30
40
|
/**
|
|
31
41
|
* Returns the set of entity keys ("entityType:id") for created
|
|
32
42
|
* (never-persisted) entities reachable from a root through live relations.
|
|
43
|
+
*
|
|
44
|
+
* The returned set is the cached instance and is owned by the analyzer —
|
|
45
|
+
* callers must treat it as read-only (the current consumers only call
|
|
46
|
+
* `.has(...)` on it).
|
|
33
47
|
*/
|
|
34
48
|
computeReachableCreated(): Set<string> {
|
|
49
|
+
const version = this.graphVersion()
|
|
50
|
+
const cached = this.cache
|
|
51
|
+
if (cached !== null && cached.version === version) {
|
|
52
|
+
return cached.result
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const result = this.walk()
|
|
56
|
+
this.cache = { version, result }
|
|
57
|
+
return result
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Sum of the graph-relevant mutation counters across the sub-stores the walk
|
|
62
|
+
* reads. Monotonic, so an unchanged value means no relevant mutation happened.
|
|
63
|
+
*/
|
|
64
|
+
private graphVersion(): number {
|
|
65
|
+
return this.entitySnapshots.getMutationVersion()
|
|
66
|
+
+ this.meta.getMutationVersion()
|
|
67
|
+
+ this.relations.getMutationVersion()
|
|
68
|
+
+ this.roots.getMutationVersion()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private walk(): Set<string> {
|
|
35
72
|
const reachableCreated = new Set<string>()
|
|
36
73
|
const visited = new Set<string>()
|
|
37
74
|
const stack: string[] = []
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { isPersistedId, isPlaceholderId } from './entityId.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Describes a single temp→persisted rekey in every shape the participating
|
|
5
|
+
* sub-stores need (full key, owner prefix, bare id).
|
|
6
|
+
*/
|
|
7
|
+
export interface RekeyContext {
|
|
8
|
+
/** Old composite key, "entityType:tempId". */
|
|
9
|
+
readonly oldKey: string
|
|
10
|
+
/** New composite key, "entityType:persistedId". */
|
|
11
|
+
readonly newKey: string
|
|
12
|
+
/** Old owner prefix, "entityType:tempId:". */
|
|
13
|
+
readonly oldKeyPrefix: string
|
|
14
|
+
/** New owner prefix, "entityType:persistedId:". */
|
|
15
|
+
readonly newKeyPrefix: string
|
|
16
|
+
/** The temp id being replaced. */
|
|
17
|
+
readonly oldId: string
|
|
18
|
+
/** The server-assigned id. */
|
|
19
|
+
readonly newId: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A store that owns per-entity state keyed (directly or by prefix) on an entity
|
|
24
|
+
* id and can migrate it from a temp id to its persisted id.
|
|
25
|
+
*/
|
|
26
|
+
export interface Rekeyable {
|
|
27
|
+
rekey(ctx: RekeyContext): void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Single source of temp→persisted identity, and the one place the rekey fan-out
|
|
32
|
+
* is sequenced.
|
|
33
|
+
*
|
|
34
|
+
* Owns the only id-redirect map: "entityType:tempId" → persistedId. Both key
|
|
35
|
+
* resolution ({@link resolveKey}/{@link resolveId}) and persisted-id queries
|
|
36
|
+
* ({@link getPersistedId}/{@link isNewEntity}) derive from it, so there is no
|
|
37
|
+
* second copy to keep in sync — the former `SnapshotStore.rekeyedEntities` and
|
|
38
|
+
* `EntityMetaStore.tempToPersistedId` are both gone. `SubscriptionManager` keeps
|
|
39
|
+
* its own closure-redirect chain, which tracks relation-key prefixes and stale
|
|
40
|
+
* unsubscribe closures rather than entity identity, so it stays internal there.
|
|
41
|
+
*/
|
|
42
|
+
export class RekeyOrchestrator {
|
|
43
|
+
/** "entityType:tempId" → persistedId. The single identity-redirect map. */
|
|
44
|
+
private readonly tempToPersisted = new Map<string, string>()
|
|
45
|
+
private readonly participants: Rekeyable[] = []
|
|
46
|
+
private readonly participantSet = new Set<Rekeyable>()
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param participants the sub-stores to migrate, in the exact order
|
|
50
|
+
* {@link rekey} must visit them (the order is load-bearing — see rekey()).
|
|
51
|
+
*/
|
|
52
|
+
constructor(participants: readonly Rekeyable[]) {
|
|
53
|
+
for (const participant of participants) this.registerParticipant(participant)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Adds a participant once, preserving registration order. */
|
|
57
|
+
registerParticipant(participant: Rekeyable): void {
|
|
58
|
+
if (this.participantSet.has(participant)) return
|
|
59
|
+
this.participantSet.add(participant)
|
|
60
|
+
this.participants.push(participant)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Resolves an entity key, following a temp→persisted redirect if present. */
|
|
64
|
+
resolveKey(entityType: string, id: string): string {
|
|
65
|
+
const persisted = this.tempToPersisted.get(`${entityType}:${id}`)
|
|
66
|
+
return persisted !== undefined ? `${entityType}:${persisted}` : `${entityType}:${id}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Resolves an entity id to its persisted id if it has been rekeyed. */
|
|
70
|
+
resolveId(entityType: string, id: string): string {
|
|
71
|
+
return this.tempToPersisted.get(`${entityType}:${id}`) ?? id
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The persisted id for an entity, or null if it has none yet. */
|
|
75
|
+
getPersistedId(entityType: string, id: string): string | null {
|
|
76
|
+
if (isPlaceholderId(id)) return null
|
|
77
|
+
if (isPersistedId(id)) return id
|
|
78
|
+
return this.tempToPersisted.get(`${entityType}:${id}`) ?? null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Whether an entity has never been persisted (no server-assigned id yet). */
|
|
82
|
+
isNewEntity(entityType: string, id: string): boolean {
|
|
83
|
+
if (isPlaceholderId(id)) return true
|
|
84
|
+
if (isPersistedId(id)) return false
|
|
85
|
+
return !this.tempToPersisted.has(`${entityType}:${id}`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Migrates an entity from its temp id to its server-assigned id across every
|
|
90
|
+
* participating store.
|
|
91
|
+
*
|
|
92
|
+
* Ordering contract (do not reorder — each step relies on the previous):
|
|
93
|
+
* 0. register the redirect, so any resolveKey/resolveId during the fan-out
|
|
94
|
+
* already sees the persisted key;
|
|
95
|
+
* 1. roots — keep the root registry aligned;
|
|
96
|
+
* 2. entity snapshot — move data, rewrite the id field and id index;
|
|
97
|
+
* 3. meta — move metadata/load/persisting, then mark exists-on-server;
|
|
98
|
+
* 4. subscriptions — move entity + relation subscribers and parent links;
|
|
99
|
+
* 5. relations — rekey owned relation keys, then replace value references;
|
|
100
|
+
* 6. errors / touched / propagation — move the remaining per-entity state.
|
|
101
|
+
* The caller performs the final notification on the new key.
|
|
102
|
+
*/
|
|
103
|
+
rekey(entityType: string, tempId: string, persistedId: string): void {
|
|
104
|
+
const ctx: RekeyContext = {
|
|
105
|
+
oldKey: `${entityType}:${tempId}`,
|
|
106
|
+
newKey: `${entityType}:${persistedId}`,
|
|
107
|
+
oldKeyPrefix: `${entityType}:${tempId}:`,
|
|
108
|
+
newKeyPrefix: `${entityType}:${persistedId}:`,
|
|
109
|
+
oldId: tempId,
|
|
110
|
+
newId: persistedId,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
this.tempToPersisted.set(ctx.oldKey, persistedId)
|
|
114
|
+
|
|
115
|
+
for (const participant of this.participants) {
|
|
116
|
+
participant.rekey(ctx)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
clear(): void {
|
|
121
|
+
this.tempToPersisted.clear()
|
|
122
|
+
}
|
|
123
|
+
}
|