@contember/bindx 0.1.46 → 0.1.48
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/MockAdapter.d.ts +4 -1
- package/dist/adapter/MockAdapter.d.ts.map +1 -1
- 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/adapter/MockAdapter.ts +15 -5
- 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,810 @@
|
|
|
1
|
+
import { parentKeyFromOwnerPrefix, parentKeyFromRelationKey } from './relationKey.js'
|
|
2
|
+
import { RelationEdgeIndex } from './RelationEdgeIndex.js'
|
|
3
|
+
|
|
4
|
+
type ReconciliationResult = 'applied' | 'conflict'
|
|
5
|
+
|
|
6
|
+
function setsEqual<T>(a: Set<T>, b: Set<T>): boolean {
|
|
7
|
+
if (a.size !== b.size) return false
|
|
8
|
+
for (const item of a) {
|
|
9
|
+
if (!b.has(item)) return false
|
|
10
|
+
}
|
|
11
|
+
return true
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function arraysEqual(a: string[], b: string[]): boolean {
|
|
15
|
+
if (a.length !== b.length) return false
|
|
16
|
+
for (let i = 0; i < a.length; i++) {
|
|
17
|
+
if (a[i] !== b[i]) return false
|
|
18
|
+
}
|
|
19
|
+
return true
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Removal type for has-many items
|
|
24
|
+
*/
|
|
25
|
+
export type HasManyRemovalType = 'disconnect' | 'delete'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Kind of a planned has-many addition:
|
|
29
|
+
* - 'created': a newly created entity (via add())
|
|
30
|
+
* - 'connected': an existing persisted entity being connected (via connect())
|
|
31
|
+
*/
|
|
32
|
+
export type HasManyAdditionKind = 'created' | 'connected'
|
|
33
|
+
|
|
34
|
+
export interface SentHasManyAddition {
|
|
35
|
+
itemId: string
|
|
36
|
+
kind: HasManyAdditionKind
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SentHasManyRemoval {
|
|
40
|
+
itemId: string
|
|
41
|
+
type: HasManyRemovalType
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SentHasManyDelta {
|
|
45
|
+
additions: readonly SentHasManyAddition[]
|
|
46
|
+
removals: readonly SentHasManyRemoval[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Has-many list state stored in SnapshotStore
|
|
51
|
+
*/
|
|
52
|
+
export interface StoredHasManyState {
|
|
53
|
+
/** IDs of items from server */
|
|
54
|
+
serverIds: Set<string>
|
|
55
|
+
/** Explicit ordered list of item IDs, null means use default order (serverIds + plannedAdditions) */
|
|
56
|
+
orderedIds: string[] | null
|
|
57
|
+
/** Planned removals (disconnect or delete) keyed by entity ID */
|
|
58
|
+
plannedRemovals: Map<string, HasManyRemovalType>
|
|
59
|
+
/**
|
|
60
|
+
* Planned additions (IDs to add to the list) keyed by entity ID, with the
|
|
61
|
+
* value distinguishing newly CREATED entities (add()) from existing PERSISTED
|
|
62
|
+
* entities being CONNECTED (connect()). The keys are exactly the connections;
|
|
63
|
+
* the keys whose value is 'created' are exactly the created entities, so the
|
|
64
|
+
* "created ⊆ connections" invariant is structural.
|
|
65
|
+
*/
|
|
66
|
+
plannedAdditions: Map<string, HasManyAdditionKind>
|
|
67
|
+
version: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function cloneHasManyState(state: StoredHasManyState): StoredHasManyState {
|
|
71
|
+
return {
|
|
72
|
+
serverIds: new Set(state.serverIds),
|
|
73
|
+
orderedIds: state.orderedIds ? [...state.orderedIds] : null,
|
|
74
|
+
plannedRemovals: new Map(state.plannedRemovals),
|
|
75
|
+
plannedAdditions: new Map(state.plannedAdditions),
|
|
76
|
+
version: state.version,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Computes the default ordered IDs for a has-many relation.
|
|
82
|
+
* Order is: serverIds (minus removals) + plannedAdditions
|
|
83
|
+
*/
|
|
84
|
+
export function computeDefaultOrderedIds(state: StoredHasManyState): string[] {
|
|
85
|
+
const result: string[] = []
|
|
86
|
+
|
|
87
|
+
for (const id of state.serverIds) {
|
|
88
|
+
if (!state.plannedRemovals.has(id)) {
|
|
89
|
+
result.push(id)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
for (const id of state.plannedAdditions.keys()) {
|
|
94
|
+
if (!result.includes(id)) {
|
|
95
|
+
result.push(id)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return result
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Owns has-many list state ("parentType:parentId:fieldName" → {@link StoredHasManyState}).
|
|
104
|
+
*
|
|
105
|
+
* Has its own monotonic {@link mutationVersion} bumped on every actual write
|
|
106
|
+
* (funnelled through {@link writeHasMany} plus the delete/clear paths); the
|
|
107
|
+
* facade sums this with the has-one counter for {@link ReachabilityAnalyzer}.
|
|
108
|
+
*/
|
|
109
|
+
export class HasManyStore {
|
|
110
|
+
/** Has-many list states keyed by "parentType:parentId:fieldName" */
|
|
111
|
+
private readonly hasManyStates = new Map<string, StoredHasManyState>()
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Bidirectional live-edge index, maintained by {@link writeHasMany} /
|
|
115
|
+
* {@link deleteHasMany} so the parent↔child queries are O(degree) and the two
|
|
116
|
+
* directions stay consistent by construction.
|
|
117
|
+
*/
|
|
118
|
+
private readonly edges = new RelationEdgeIndex()
|
|
119
|
+
|
|
120
|
+
private mutationVersion = 0
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Monotonic counter bumped on EDITABLE-layer has-many writes (plan add/remove,
|
|
124
|
+
* add, connectExisting, remove, move, reset) — the writes an undo gesture must
|
|
125
|
+
* first record. Server- id ingestion (setHasManyServerIds), materialization
|
|
126
|
+
* (getOrCreateHasMany), post-persist commit, import, replaceEntityId, rekey and
|
|
127
|
+
* delete deliberately do NOT bump it. Read by the undo write-guard (see UndoJournal).
|
|
128
|
+
*/
|
|
129
|
+
private editableWriteVersion = 0
|
|
130
|
+
|
|
131
|
+
getMutationVersion(): number {
|
|
132
|
+
return this.mutationVersion
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
getEditableWriteVersion(): number {
|
|
136
|
+
return this.editableWriteVersion
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The single write chokepoint. Reconciles the edge index by diffing the live
|
|
141
|
+
* members of the previous state against the next, so every state-changing path
|
|
142
|
+
* (server ids / planned add/remove / move / import / replaceEntityId / ...)
|
|
143
|
+
* keeps the index correct without tracking the reverse direction itself.
|
|
144
|
+
*/
|
|
145
|
+
private writeHasMany(key: string, state: StoredHasManyState): void {
|
|
146
|
+
const oldLive = liveHasManyChildIds(this.hasManyStates.get(key))
|
|
147
|
+
const newLive = liveHasManyChildIds(state)
|
|
148
|
+
this.hasManyStates.set(key, state)
|
|
149
|
+
const parentKey = parentKeyFromRelationKey(key)
|
|
150
|
+
for (const id of newLive) if (!oldLive.has(id)) this.edges.addEdge(parentKey, id)
|
|
151
|
+
for (const id of oldLive) if (!newLive.has(id)) this.edges.removeEdge(parentKey, id)
|
|
152
|
+
this.mutationVersion++
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The single delete chokepoint — removes an entry and its live edges. Used by
|
|
157
|
+
* the bulk remove and rekey-owner paths so they don't leak edges.
|
|
158
|
+
*/
|
|
159
|
+
private deleteHasMany(key: string): void {
|
|
160
|
+
const existing = this.hasManyStates.get(key)
|
|
161
|
+
if (!existing) return
|
|
162
|
+
const parentKey = parentKeyFromRelationKey(key)
|
|
163
|
+
for (const id of liveHasManyChildIds(existing)) this.edges.removeEdge(parentKey, id)
|
|
164
|
+
this.hasManyStates.delete(key)
|
|
165
|
+
this.mutationVersion++
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Gets or creates has-many list state.
|
|
170
|
+
*/
|
|
171
|
+
getOrCreateHasMany(key: string, serverIds?: string[]): StoredHasManyState {
|
|
172
|
+
const existing = this.hasManyStates.get(key)
|
|
173
|
+
if (!existing) {
|
|
174
|
+
this.writeHasMany(key, {
|
|
175
|
+
serverIds: new Set(serverIds ?? []),
|
|
176
|
+
orderedIds: null,
|
|
177
|
+
plannedRemovals: new Map(),
|
|
178
|
+
plannedAdditions: new Map(),
|
|
179
|
+
version: 0,
|
|
180
|
+
})
|
|
181
|
+
} else if (serverIds !== undefined) {
|
|
182
|
+
const newServerIds = new Set(serverIds)
|
|
183
|
+
if (!setsEqual(existing.serverIds, newServerIds)) {
|
|
184
|
+
this.writeHasMany(key, {
|
|
185
|
+
...existing,
|
|
186
|
+
serverIds: newServerIds,
|
|
187
|
+
orderedIds: null,
|
|
188
|
+
version: existing.version + 1,
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return cloneHasManyState(this.hasManyStates.get(key)!)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Gets has-many list state.
|
|
198
|
+
*/
|
|
199
|
+
getHasMany(key: string): StoredHasManyState | undefined {
|
|
200
|
+
const state = this.hasManyStates.get(key)
|
|
201
|
+
return state ? cloneHasManyState(state) : undefined
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Sets server IDs for a has-many relation.
|
|
206
|
+
*/
|
|
207
|
+
setHasManyServerIds(key: string, serverIds: string[]): void {
|
|
208
|
+
const existing = this.hasManyStates.get(key)
|
|
209
|
+
|
|
210
|
+
if (!existing) {
|
|
211
|
+
this.writeHasMany(key, {
|
|
212
|
+
serverIds: new Set(serverIds),
|
|
213
|
+
orderedIds: null,
|
|
214
|
+
plannedRemovals: new Map(),
|
|
215
|
+
plannedAdditions: new Map(),
|
|
216
|
+
version: 0,
|
|
217
|
+
})
|
|
218
|
+
} else {
|
|
219
|
+
this.writeHasMany(key, {
|
|
220
|
+
...existing,
|
|
221
|
+
serverIds: new Set(serverIds),
|
|
222
|
+
orderedIds: null,
|
|
223
|
+
version: existing.version + 1,
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Plans a removal for a has-many item.
|
|
230
|
+
*/
|
|
231
|
+
planHasManyRemoval(key: string, itemId: string, type: HasManyRemovalType): void {
|
|
232
|
+
const existing = this.hasManyStates.get(key)
|
|
233
|
+
|
|
234
|
+
if (!existing) {
|
|
235
|
+
this.writeHasMany(key, {
|
|
236
|
+
serverIds: new Set(),
|
|
237
|
+
orderedIds: null,
|
|
238
|
+
plannedRemovals: new Map([[itemId, type]]),
|
|
239
|
+
plannedAdditions: new Map(),
|
|
240
|
+
version: 0,
|
|
241
|
+
})
|
|
242
|
+
} else {
|
|
243
|
+
const newPlannedRemovals = new Map(existing.plannedRemovals)
|
|
244
|
+
newPlannedRemovals.set(itemId, type)
|
|
245
|
+
const newPlannedAdditions = new Map(existing.plannedAdditions)
|
|
246
|
+
newPlannedAdditions.delete(itemId)
|
|
247
|
+
let newOrderedIds = existing.orderedIds
|
|
248
|
+
if (newOrderedIds !== null) {
|
|
249
|
+
newOrderedIds = newOrderedIds.filter(id => id !== itemId)
|
|
250
|
+
}
|
|
251
|
+
this.writeHasMany(key, {
|
|
252
|
+
...existing,
|
|
253
|
+
orderedIds: newOrderedIds,
|
|
254
|
+
plannedRemovals: newPlannedRemovals,
|
|
255
|
+
plannedAdditions: newPlannedAdditions,
|
|
256
|
+
version: existing.version + 1,
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
this.editableWriteVersion++
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Plans a connection for a has-many item.
|
|
264
|
+
*/
|
|
265
|
+
planHasManyConnection(key: string, itemId: string): void {
|
|
266
|
+
const existing = this.hasManyStates.get(key)
|
|
267
|
+
|
|
268
|
+
if (!existing) {
|
|
269
|
+
this.writeHasMany(key, {
|
|
270
|
+
serverIds: new Set(),
|
|
271
|
+
orderedIds: null,
|
|
272
|
+
plannedRemovals: new Map(),
|
|
273
|
+
plannedAdditions: new Map([[itemId, 'connected']]),
|
|
274
|
+
version: 0,
|
|
275
|
+
})
|
|
276
|
+
} else {
|
|
277
|
+
const newPlannedAdditions = new Map(existing.plannedAdditions)
|
|
278
|
+
// Do not downgrade an existing 'created' addition to 'connected'.
|
|
279
|
+
if (newPlannedAdditions.get(itemId) !== 'created') {
|
|
280
|
+
newPlannedAdditions.set(itemId, 'connected')
|
|
281
|
+
}
|
|
282
|
+
const newPlannedRemovals = new Map(existing.plannedRemovals)
|
|
283
|
+
newPlannedRemovals.delete(itemId)
|
|
284
|
+
let newOrderedIds = existing.orderedIds
|
|
285
|
+
if (newOrderedIds !== null && !newOrderedIds.includes(itemId)) {
|
|
286
|
+
newOrderedIds = [...newOrderedIds, itemId]
|
|
287
|
+
}
|
|
288
|
+
this.writeHasMany(key, {
|
|
289
|
+
...existing,
|
|
290
|
+
orderedIds: newOrderedIds,
|
|
291
|
+
plannedAdditions: newPlannedAdditions,
|
|
292
|
+
plannedRemovals: newPlannedRemovals,
|
|
293
|
+
version: existing.version + 1,
|
|
294
|
+
})
|
|
295
|
+
}
|
|
296
|
+
this.editableWriteVersion++
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Commits has-many state after successful persist.
|
|
301
|
+
*/
|
|
302
|
+
commitHasMany(key: string, newServerIds: string[]): void {
|
|
303
|
+
const existing = this.hasManyStates.get(key)
|
|
304
|
+
|
|
305
|
+
this.writeHasMany(key, {
|
|
306
|
+
serverIds: new Set(newServerIds),
|
|
307
|
+
orderedIds: null,
|
|
308
|
+
plannedRemovals: new Map(),
|
|
309
|
+
plannedAdditions: new Map(),
|
|
310
|
+
version: (existing?.version ?? 0) + 1,
|
|
311
|
+
})
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Advances the server baseline by the confirmed sent delta and rebases local
|
|
316
|
+
* edits made after the request started onto that new baseline.
|
|
317
|
+
*/
|
|
318
|
+
reconcileSentDelta(key: string, delta: SentHasManyDelta): ReconciliationResult {
|
|
319
|
+
const existing = this.hasManyStates.get(key)
|
|
320
|
+
if (!existing) return 'conflict'
|
|
321
|
+
|
|
322
|
+
const next = reconcileHasManyState(existing, delta)
|
|
323
|
+
this.writeHasMany(key, next.state)
|
|
324
|
+
return next.result
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Resets has-many state to server state (clears planned operations).
|
|
329
|
+
*/
|
|
330
|
+
resetHasMany(key: string): void {
|
|
331
|
+
const existing = this.hasManyStates.get(key)
|
|
332
|
+
if (!existing) return
|
|
333
|
+
|
|
334
|
+
this.writeHasMany(key, {
|
|
335
|
+
serverIds: existing.serverIds,
|
|
336
|
+
orderedIds: null,
|
|
337
|
+
plannedRemovals: new Map(),
|
|
338
|
+
plannedAdditions: new Map(),
|
|
339
|
+
version: existing.version + 1,
|
|
340
|
+
})
|
|
341
|
+
this.editableWriteVersion++
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Adds a newly created entity to a has-many relation.
|
|
346
|
+
* Used by HasManyListHandle.add() for inline entity creation.
|
|
347
|
+
*/
|
|
348
|
+
addToHasMany(key: string, itemId: string): void {
|
|
349
|
+
const existing = this.hasManyStates.get(key)
|
|
350
|
+
|
|
351
|
+
if (!existing) {
|
|
352
|
+
this.writeHasMany(key, {
|
|
353
|
+
serverIds: new Set(),
|
|
354
|
+
orderedIds: [itemId],
|
|
355
|
+
plannedRemovals: new Map(),
|
|
356
|
+
plannedAdditions: new Map([[itemId, 'created']]),
|
|
357
|
+
version: 0,
|
|
358
|
+
})
|
|
359
|
+
} else {
|
|
360
|
+
const newPlannedAdditions = new Map(existing.plannedAdditions)
|
|
361
|
+
newPlannedAdditions.set(itemId, 'created')
|
|
362
|
+
const currentOrderedIds = existing.orderedIds ?? computeDefaultOrderedIds(existing)
|
|
363
|
+
const newOrderedIds = [...currentOrderedIds, itemId]
|
|
364
|
+
this.writeHasMany(key, {
|
|
365
|
+
...existing,
|
|
366
|
+
orderedIds: newOrderedIds,
|
|
367
|
+
plannedAdditions: newPlannedAdditions,
|
|
368
|
+
version: existing.version + 1,
|
|
369
|
+
})
|
|
370
|
+
}
|
|
371
|
+
this.editableWriteVersion++
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Connects an existing (persisted) entity to a has-many relation.
|
|
376
|
+
* Unlike addToHasMany, records the addition as 'connected' (not 'created') —
|
|
377
|
+
* used for materializing embedded connect references to existing entities.
|
|
378
|
+
*/
|
|
379
|
+
connectExistingToHasMany(key: string, itemId: string): void {
|
|
380
|
+
const existing = this.hasManyStates.get(key)
|
|
381
|
+
|
|
382
|
+
if (!existing) {
|
|
383
|
+
this.writeHasMany(key, {
|
|
384
|
+
serverIds: new Set(),
|
|
385
|
+
orderedIds: [itemId],
|
|
386
|
+
plannedRemovals: new Map(),
|
|
387
|
+
plannedAdditions: new Map([[itemId, 'connected']]),
|
|
388
|
+
version: 0,
|
|
389
|
+
})
|
|
390
|
+
} else {
|
|
391
|
+
const newPlannedAdditions = new Map(existing.plannedAdditions)
|
|
392
|
+
// Do not downgrade an existing 'created' addition to 'connected'.
|
|
393
|
+
if (newPlannedAdditions.get(itemId) !== 'created') {
|
|
394
|
+
newPlannedAdditions.set(itemId, 'connected')
|
|
395
|
+
}
|
|
396
|
+
// Only touch an explicit order — the default order already derives from
|
|
397
|
+
// plannedAdditions (see computeDefaultOrderedIds). Guard against
|
|
398
|
+
// re-appending an id that is already listed: this path re-runs whenever an
|
|
399
|
+
// embedded connect reference is re-materialized, and an unconditional
|
|
400
|
+
// append would surface the same item twice (mirrors planHasManyConnection).
|
|
401
|
+
// A re-connect cancels a pending removal of the same item (mirrors
|
|
402
|
+
// planHasManyConnection); leaving both recorded hides the item from the
|
|
403
|
+
// live-edge index while it is still listed.
|
|
404
|
+
const newPlannedRemovals = new Map(existing.plannedRemovals)
|
|
405
|
+
newPlannedRemovals.delete(itemId)
|
|
406
|
+
let newOrderedIds = existing.orderedIds
|
|
407
|
+
if (newOrderedIds !== null && !newOrderedIds.includes(itemId)) {
|
|
408
|
+
newOrderedIds = [...newOrderedIds, itemId]
|
|
409
|
+
}
|
|
410
|
+
this.writeHasMany(key, {
|
|
411
|
+
...existing,
|
|
412
|
+
orderedIds: newOrderedIds,
|
|
413
|
+
plannedAdditions: newPlannedAdditions,
|
|
414
|
+
plannedRemovals: newPlannedRemovals,
|
|
415
|
+
version: existing.version + 1,
|
|
416
|
+
})
|
|
417
|
+
}
|
|
418
|
+
this.editableWriteVersion++
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Removes an entity from a has-many relation.
|
|
423
|
+
* For newly created entities (via add()), cancels the connection.
|
|
424
|
+
* For existing server entities, plans the specified removal type.
|
|
425
|
+
* Returns true if the state changed (caller should notify), false if it was a no-op.
|
|
426
|
+
*/
|
|
427
|
+
removeFromHasMany(key: string, itemId: string, removalType: HasManyRemovalType): boolean {
|
|
428
|
+
const existing = this.hasManyStates.get(key)
|
|
429
|
+
if (!existing) return false
|
|
430
|
+
|
|
431
|
+
const isCreatedEntity = existing.plannedAdditions.get(itemId) === 'created'
|
|
432
|
+
|
|
433
|
+
if (isCreatedEntity) {
|
|
434
|
+
const newPlannedAdditions = new Map(existing.plannedAdditions)
|
|
435
|
+
newPlannedAdditions.delete(itemId)
|
|
436
|
+
let newOrderedIds = existing.orderedIds
|
|
437
|
+
if (newOrderedIds !== null) {
|
|
438
|
+
newOrderedIds = newOrderedIds.filter(id => id !== itemId)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const newState: StoredHasManyState = {
|
|
442
|
+
...existing,
|
|
443
|
+
orderedIds: newOrderedIds,
|
|
444
|
+
plannedAdditions: newPlannedAdditions,
|
|
445
|
+
version: existing.version + 1,
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (
|
|
449
|
+
newPlannedAdditions.size === 0 &&
|
|
450
|
+
existing.plannedRemovals.size === 0 &&
|
|
451
|
+
newOrderedIds !== null
|
|
452
|
+
) {
|
|
453
|
+
const defaultOrder = computeDefaultOrderedIds(newState)
|
|
454
|
+
if (arraysEqual(newOrderedIds, defaultOrder)) {
|
|
455
|
+
newState.orderedIds = null
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
this.writeHasMany(key, newState)
|
|
460
|
+
this.editableWriteVersion++
|
|
461
|
+
return true
|
|
462
|
+
} else {
|
|
463
|
+
// planHasManyRemoval bumps editableWriteVersion itself.
|
|
464
|
+
this.planHasManyRemoval(key, itemId, removalType)
|
|
465
|
+
return true
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Moves an item within a has-many relation from one index to another.
|
|
471
|
+
*/
|
|
472
|
+
moveInHasMany(key: string, fromIndex: number, toIndex: number): void {
|
|
473
|
+
const existing = this.hasManyStates.get(key)
|
|
474
|
+
if (!existing) return
|
|
475
|
+
|
|
476
|
+
const currentOrderedIds = existing.orderedIds ?? computeDefaultOrderedIds(existing)
|
|
477
|
+
|
|
478
|
+
if (fromIndex < 0 || fromIndex >= currentOrderedIds.length) return
|
|
479
|
+
if (toIndex < 0 || toIndex >= currentOrderedIds.length) return
|
|
480
|
+
if (fromIndex === toIndex) return
|
|
481
|
+
|
|
482
|
+
const newOrderedIds = [...currentOrderedIds]
|
|
483
|
+
const movedItem = newOrderedIds.splice(fromIndex, 1)[0]
|
|
484
|
+
if (movedItem === undefined) return
|
|
485
|
+
newOrderedIds.splice(toIndex, 0, movedItem)
|
|
486
|
+
|
|
487
|
+
this.writeHasMany(key, {
|
|
488
|
+
...existing,
|
|
489
|
+
orderedIds: newOrderedIds,
|
|
490
|
+
version: existing.version + 1,
|
|
491
|
+
})
|
|
492
|
+
this.editableWriteVersion++
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Gets the ordered list of item IDs for a has-many relation.
|
|
497
|
+
*/
|
|
498
|
+
getHasManyOrderedIds(key: string): string[] {
|
|
499
|
+
const existing = this.hasManyStates.get(key)
|
|
500
|
+
if (!existing) return []
|
|
501
|
+
|
|
502
|
+
if (existing.orderedIds !== null) {
|
|
503
|
+
return [...existing.orderedIds]
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return computeDefaultOrderedIds(existing)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Collects the ids of child entities reachable through LIVE has-many edges
|
|
511
|
+
* (key prefix "parentType:parentId:") — an O(degree) read of the edge index.
|
|
512
|
+
*/
|
|
513
|
+
collectLiveChildIds(keyPrefix: string, ids: Set<string>): void {
|
|
514
|
+
this.edges.collectChildren(parentKeyFromOwnerPrefix(keyPrefix), ids)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Adds the composite parent keys of every LIVE has-many edge containing
|
|
519
|
+
* {@link childId} — an O(degree) read of the edge index, the exact reverse of
|
|
520
|
+
* {@link collectLiveChildIds}.
|
|
521
|
+
*/
|
|
522
|
+
collectParentKeysForChild(childId: string, parents: Set<string>): void {
|
|
523
|
+
this.edges.collectParents(childId, parents)
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Collects the keys of every has-many list owned by an entity (keys under the
|
|
528
|
+
* given owner prefix). Mirrors {@link removeOwnedRelations} but only enumerates.
|
|
529
|
+
*/
|
|
530
|
+
collectOwnedKeys(keyPrefix: string, keys: string[]): void {
|
|
531
|
+
for (const key of this.hasManyStates.keys()) {
|
|
532
|
+
if (key.startsWith(keyPrefix)) keys.push(key)
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Removes a single has-many list state (and its live edges). Used by undo
|
|
538
|
+
* restore to drop a list that did not exist before the gesture.
|
|
539
|
+
*/
|
|
540
|
+
removeHasMany(key: string): void {
|
|
541
|
+
this.deleteHasMany(key)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Removes all has-many state owned by an entity (keys under the given owner
|
|
546
|
+
* prefix), dropping each entry's edges through {@link deleteHasMany}.
|
|
547
|
+
*/
|
|
548
|
+
removeOwnedRelations(keyPrefix: string): void {
|
|
549
|
+
for (const key of [...this.hasManyStates.keys()]) {
|
|
550
|
+
if (key.startsWith(keyPrefix)) {
|
|
551
|
+
this.deleteHasMany(key)
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Commits all has-many relations for an entity.
|
|
558
|
+
*
|
|
559
|
+
* `pendingItems` (by relation key) names planned additions/removals that were NOT
|
|
560
|
+
* sent — they stay planned instead of being folded into the server baseline.
|
|
561
|
+
*/
|
|
562
|
+
commitAllRelations(keyPrefix: string, pendingItems?: ReadonlyMap<string, ReadonlySet<string>>): void {
|
|
563
|
+
for (const [key, state] of this.hasManyStates) {
|
|
564
|
+
if (!key.startsWith(keyPrefix)) continue
|
|
565
|
+
|
|
566
|
+
const pending = pendingItems?.get(key)
|
|
567
|
+
const newServerIds = new Set(state.serverIds)
|
|
568
|
+
const keptRemovals = new Map<string, HasManyRemovalType>()
|
|
569
|
+
const keptAdditions = new Map<string, HasManyAdditionKind>()
|
|
570
|
+
|
|
571
|
+
for (const [removedId, type] of state.plannedRemovals) {
|
|
572
|
+
if (pending?.has(removedId)) {
|
|
573
|
+
keptRemovals.set(removedId, type)
|
|
574
|
+
} else {
|
|
575
|
+
newServerIds.delete(removedId)
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
for (const [addedId, kind] of state.plannedAdditions) {
|
|
579
|
+
if (pending?.has(addedId)) {
|
|
580
|
+
keptAdditions.set(addedId, kind)
|
|
581
|
+
} else {
|
|
582
|
+
newServerIds.add(addedId)
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
this.writeHasMany(key, {
|
|
587
|
+
serverIds: newServerIds,
|
|
588
|
+
orderedIds: null,
|
|
589
|
+
plannedRemovals: keptRemovals,
|
|
590
|
+
plannedAdditions: keptAdditions,
|
|
591
|
+
version: state.version + 1,
|
|
592
|
+
})
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Resets all has-many relations for an entity to server state.
|
|
598
|
+
*/
|
|
599
|
+
resetAllRelations(keyPrefix: string): void {
|
|
600
|
+
for (const key of this.hasManyStates.keys()) {
|
|
601
|
+
if (key.startsWith(keyPrefix)) {
|
|
602
|
+
this.resetHasMany(key)
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Collects the field names of dirty has-many relations for an entity.
|
|
609
|
+
*/
|
|
610
|
+
collectDirtyRelations(keyPrefix: string, dirtyRelations: string[]): void {
|
|
611
|
+
for (const [key, state] of this.hasManyStates) {
|
|
612
|
+
if (!key.startsWith(keyPrefix)) continue
|
|
613
|
+
const fieldName = key.slice(keyPrefix.length)
|
|
614
|
+
|
|
615
|
+
if (state.plannedRemovals.size > 0 || state.plannedAdditions.size > 0) {
|
|
616
|
+
dirtyRelations.push(fieldName)
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Exports has-many states for given keys.
|
|
623
|
+
*/
|
|
624
|
+
exportHasManyStates(keys: string[]): Map<string, StoredHasManyState> {
|
|
625
|
+
const result = new Map<string, StoredHasManyState>()
|
|
626
|
+
for (const key of keys) {
|
|
627
|
+
const state = this.hasManyStates.get(key)
|
|
628
|
+
if (state) {
|
|
629
|
+
result.set(key, cloneHasManyState(state))
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return result
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Imports has-many states from a snapshot.
|
|
637
|
+
* Returns the keys that were imported for notification.
|
|
638
|
+
*/
|
|
639
|
+
importHasManyStates(states: Map<string, StoredHasManyState>): string[] {
|
|
640
|
+
const keys: string[] = []
|
|
641
|
+
for (const [key, state] of states) {
|
|
642
|
+
this.writeHasMany(key, {
|
|
643
|
+
serverIds: new Set(state.serverIds),
|
|
644
|
+
orderedIds: state.orderedIds ? [...state.orderedIds] : null,
|
|
645
|
+
plannedRemovals: new Map(state.plannedRemovals),
|
|
646
|
+
plannedAdditions: new Map(state.plannedAdditions),
|
|
647
|
+
version: state.version + 1,
|
|
648
|
+
})
|
|
649
|
+
keys.push(key)
|
|
650
|
+
}
|
|
651
|
+
return keys
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Replaces all occurrences of oldId with newId across has-many states
|
|
656
|
+
* (serverIds, orderedIds, plannedAdditions, plannedRemovals).
|
|
657
|
+
*/
|
|
658
|
+
replaceEntityId(oldId: string, newId: string): void {
|
|
659
|
+
for (const [key, state] of this.hasManyStates) {
|
|
660
|
+
let changed = false
|
|
661
|
+
|
|
662
|
+
let serverIds = state.serverIds
|
|
663
|
+
if (serverIds.has(oldId)) {
|
|
664
|
+
serverIds = new Set(serverIds)
|
|
665
|
+
serverIds.delete(oldId)
|
|
666
|
+
serverIds.add(newId)
|
|
667
|
+
changed = true
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
let orderedIds = state.orderedIds
|
|
671
|
+
if (orderedIds) {
|
|
672
|
+
const idx = orderedIds.indexOf(oldId)
|
|
673
|
+
if (idx !== -1) {
|
|
674
|
+
orderedIds = [...orderedIds]
|
|
675
|
+
orderedIds[idx] = newId
|
|
676
|
+
changed = true
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
let plannedAdditions = state.plannedAdditions
|
|
681
|
+
if (plannedAdditions.has(oldId)) {
|
|
682
|
+
plannedAdditions = new Map(plannedAdditions)
|
|
683
|
+
plannedAdditions.delete(oldId)
|
|
684
|
+
// The id only changes when the item was persisted, so a still-planned
|
|
685
|
+
// 'created' addition (the item went out on its own, not nested in this
|
|
686
|
+
// parent) must now connect the server row rather than create a second one.
|
|
687
|
+
plannedAdditions.set(newId, 'connected')
|
|
688
|
+
changed = true
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
let plannedRemovals = state.plannedRemovals
|
|
692
|
+
if (plannedRemovals.has(oldId)) {
|
|
693
|
+
const removalType = plannedRemovals.get(oldId)!
|
|
694
|
+
plannedRemovals = new Map(plannedRemovals)
|
|
695
|
+
plannedRemovals.delete(oldId)
|
|
696
|
+
plannedRemovals.set(newId, removalType)
|
|
697
|
+
changed = true
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if (changed) {
|
|
701
|
+
this.writeHasMany(key, {
|
|
702
|
+
serverIds,
|
|
703
|
+
orderedIds,
|
|
704
|
+
plannedRemovals,
|
|
705
|
+
plannedAdditions,
|
|
706
|
+
version: state.version + 1,
|
|
707
|
+
})
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Rekeys has-many entries owned by an entity (changes the parent ID in the key).
|
|
714
|
+
* Routing through {@link deleteHasMany} + {@link writeHasMany} migrates the edge
|
|
715
|
+
* index from the old parent key to the new one for free.
|
|
716
|
+
*/
|
|
717
|
+
rekeyOwner(oldKeyPrefix: string, newKeyPrefix: string): void {
|
|
718
|
+
const toMove: [string, StoredHasManyState][] = []
|
|
719
|
+
for (const [key, value] of this.hasManyStates) {
|
|
720
|
+
if (key.startsWith(oldKeyPrefix)) {
|
|
721
|
+
toMove.push([key, value])
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
for (const [oldKey, value] of toMove) {
|
|
725
|
+
this.deleteHasMany(oldKey)
|
|
726
|
+
this.writeHasMany(newKeyPrefix + oldKey.slice(oldKeyPrefix.length), value)
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Clears all has-many relation data.
|
|
732
|
+
*/
|
|
733
|
+
clear(): void {
|
|
734
|
+
this.hasManyStates.clear()
|
|
735
|
+
this.edges.clear()
|
|
736
|
+
this.mutationVersion++
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* The single liveness predicate for has-many membership: effective members are
|
|
742
|
+
* (serverIds ∪ plannedAdditions) minus plannedRemovals. Defined once and consumed
|
|
743
|
+
* by the write chokepoint so the forward/reverse index can never drift from it.
|
|
744
|
+
*/
|
|
745
|
+
function liveHasManyChildIds(state: StoredHasManyState | undefined): Set<string> {
|
|
746
|
+
const live = new Set<string>()
|
|
747
|
+
if (!state) return live
|
|
748
|
+
for (const id of state.serverIds) {
|
|
749
|
+
if (!state.plannedRemovals.has(id)) live.add(id)
|
|
750
|
+
}
|
|
751
|
+
for (const id of state.plannedAdditions.keys()) {
|
|
752
|
+
if (!state.plannedRemovals.has(id)) live.add(id)
|
|
753
|
+
}
|
|
754
|
+
return live
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
interface HasManyReconciliation {
|
|
758
|
+
state: StoredHasManyState
|
|
759
|
+
result: ReconciliationResult
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function reconcileHasManyState(
|
|
763
|
+
existing: StoredHasManyState,
|
|
764
|
+
delta: SentHasManyDelta,
|
|
765
|
+
): HasManyReconciliation {
|
|
766
|
+
const currentLive = liveHasManyChildIds(existing)
|
|
767
|
+
const serverIds = new Set(existing.serverIds)
|
|
768
|
+
const plannedAdditions = new Map(existing.plannedAdditions)
|
|
769
|
+
const plannedRemovals = new Map(existing.plannedRemovals)
|
|
770
|
+
let result: ReconciliationResult = 'applied'
|
|
771
|
+
|
|
772
|
+
for (const addition of delta.additions) {
|
|
773
|
+
serverIds.add(addition.itemId)
|
|
774
|
+
plannedAdditions.delete(addition.itemId)
|
|
775
|
+
plannedRemovals.delete(addition.itemId)
|
|
776
|
+
if (!currentLive.has(addition.itemId)) {
|
|
777
|
+
plannedRemovals.set(
|
|
778
|
+
addition.itemId,
|
|
779
|
+
addition.kind === 'created' ? 'delete' : 'disconnect',
|
|
780
|
+
)
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
for (const removal of delta.removals) {
|
|
785
|
+
serverIds.delete(removal.itemId)
|
|
786
|
+
const currentRemoval = plannedRemovals.get(removal.itemId)
|
|
787
|
+
if (currentRemoval === removal.type) plannedRemovals.delete(removal.itemId)
|
|
788
|
+
|
|
789
|
+
if (!currentLive.has(removal.itemId)) {
|
|
790
|
+
if (removal.type === 'delete') plannedRemovals.delete(removal.itemId)
|
|
791
|
+
continue
|
|
792
|
+
}
|
|
793
|
+
if (removal.type === 'delete') {
|
|
794
|
+
result = 'conflict'
|
|
795
|
+
} else if (!plannedAdditions.has(removal.itemId)) {
|
|
796
|
+
plannedAdditions.set(removal.itemId, 'connected')
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
return {
|
|
801
|
+
state: {
|
|
802
|
+
serverIds,
|
|
803
|
+
orderedIds: existing.orderedIds ? [...existing.orderedIds] : null,
|
|
804
|
+
plannedRemovals,
|
|
805
|
+
plannedAdditions,
|
|
806
|
+
version: existing.version + 1,
|
|
807
|
+
},
|
|
808
|
+
result,
|
|
809
|
+
}
|
|
810
|
+
}
|