@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.
Files changed (118) hide show
  1. package/dist/core/ActionDispatcher.d.ts +4 -1
  2. package/dist/core/ActionDispatcher.d.ts.map +1 -1
  3. package/dist/core/actions.d.ts +7 -1
  4. package/dist/core/actions.d.ts.map +1 -1
  5. package/dist/events/EventEmitter.d.ts +13 -1
  6. package/dist/events/EventEmitter.d.ts.map +1 -1
  7. package/dist/handles/BaseHandle.d.ts +14 -3
  8. package/dist/handles/BaseHandle.d.ts.map +1 -1
  9. package/dist/handles/EntityHandle.d.ts +14 -5
  10. package/dist/handles/EntityHandle.d.ts.map +1 -1
  11. package/dist/handles/FieldHandle.d.ts +11 -4
  12. package/dist/handles/FieldHandle.d.ts.map +1 -1
  13. package/dist/handles/HasManyListHandle.d.ts +36 -2
  14. package/dist/handles/HasManyListHandle.d.ts.map +1 -1
  15. package/dist/handles/HasOneHandle.d.ts +52 -9
  16. package/dist/handles/HasOneHandle.d.ts.map +1 -1
  17. package/dist/handles/PlaceholderHandle.d.ts +25 -13
  18. package/dist/handles/PlaceholderHandle.d.ts.map +1 -1
  19. package/dist/handles/index.d.ts +1 -1
  20. package/dist/handles/index.d.ts.map +1 -1
  21. package/dist/handles/proxyFactory.d.ts +7 -1
  22. package/dist/handles/proxyFactory.d.ts.map +1 -1
  23. package/dist/handles/types.d.ts +33 -0
  24. package/dist/handles/types.d.ts.map +1 -1
  25. package/dist/index.d.ts +3 -3
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/persistence/BatchPersister.d.ts +53 -37
  28. package/dist/persistence/BatchPersister.d.ts.map +1 -1
  29. package/dist/persistence/ChangeRegistry.d.ts +15 -0
  30. package/dist/persistence/ChangeRegistry.d.ts.map +1 -1
  31. package/dist/persistence/MutationCollector.d.ts +106 -3
  32. package/dist/persistence/MutationCollector.d.ts.map +1 -1
  33. package/dist/persistence/PersistExecution.d.ts +26 -0
  34. package/dist/persistence/PersistExecution.d.ts.map +1 -0
  35. package/dist/persistence/types.d.ts +8 -2
  36. package/dist/persistence/types.d.ts.map +1 -1
  37. package/dist/store/EntityMetaStore.d.ts +32 -10
  38. package/dist/store/EntityMetaStore.d.ts.map +1 -1
  39. package/dist/store/EntitySnapshotStore.d.ts +50 -3
  40. package/dist/store/EntitySnapshotStore.d.ts.map +1 -1
  41. package/dist/store/ErrorStore.d.ts +3 -2
  42. package/dist/store/ErrorStore.d.ts.map +1 -1
  43. package/dist/store/HasManyStore.d.ts +214 -0
  44. package/dist/store/HasManyStore.d.ts.map +1 -0
  45. package/dist/store/HasOneStore.d.ts +159 -0
  46. package/dist/store/HasOneStore.d.ts.map +1 -0
  47. package/dist/store/ReachabilityAnalyzer.d.ts +19 -0
  48. package/dist/store/ReachabilityAnalyzer.d.ts.map +1 -1
  49. package/dist/store/RekeyOrchestrator.d.ts +76 -0
  50. package/dist/store/RekeyOrchestrator.d.ts.map +1 -0
  51. package/dist/store/RelationEdgeIndex.d.ts +34 -0
  52. package/dist/store/RelationEdgeIndex.d.ts.map +1 -0
  53. package/dist/store/RelationStore.d.ts +74 -137
  54. package/dist/store/RelationStore.d.ts.map +1 -1
  55. package/dist/store/RootRegistry.d.ts +25 -4
  56. package/dist/store/RootRegistry.d.ts.map +1 -1
  57. package/dist/store/SnapshotStore.d.ts +137 -13
  58. package/dist/store/SnapshotStore.d.ts.map +1 -1
  59. package/dist/store/SubscriptionManager.d.ts +63 -14
  60. package/dist/store/SubscriptionManager.d.ts.map +1 -1
  61. package/dist/store/TouchedStore.d.ts +3 -2
  62. package/dist/store/TouchedStore.d.ts.map +1 -1
  63. package/dist/store/relationKey.d.ts +15 -0
  64. package/dist/store/relationKey.d.ts.map +1 -0
  65. package/dist/undo/UndoJournal.d.ts +133 -0
  66. package/dist/undo/UndoJournal.d.ts.map +1 -0
  67. package/dist/undo/UndoManager.d.ts +25 -82
  68. package/dist/undo/UndoManager.d.ts.map +1 -1
  69. package/dist/undo/UnrecordedWriteError.d.ts +16 -0
  70. package/dist/undo/UnrecordedWriteError.d.ts.map +1 -0
  71. package/dist/undo/index.d.ts +3 -1
  72. package/dist/undo/index.d.ts.map +1 -1
  73. package/dist/undo/rekeyJournalEntry.d.ts +20 -0
  74. package/dist/undo/rekeyJournalEntry.d.ts.map +1 -0
  75. package/dist/undo/types.d.ts +0 -48
  76. package/dist/undo/types.d.ts.map +1 -1
  77. package/package.json +2 -2
  78. package/src/core/ActionDispatcher.ts +56 -36
  79. package/src/core/actions.ts +8 -1
  80. package/src/events/EventEmitter.ts +78 -24
  81. package/src/handles/BaseHandle.ts +19 -2
  82. package/src/handles/EntityHandle.ts +48 -17
  83. package/src/handles/FieldHandle.ts +36 -10
  84. package/src/handles/HasManyListHandle.ts +121 -17
  85. package/src/handles/HasOneHandle.ts +177 -44
  86. package/src/handles/PlaceholderHandle.ts +184 -36
  87. package/src/handles/index.ts +3 -0
  88. package/src/handles/proxyFactory.ts +27 -0
  89. package/src/handles/types.ts +39 -0
  90. package/src/index.ts +4 -4
  91. package/src/persistence/BatchPersister.ts +810 -446
  92. package/src/persistence/ChangeRegistry.ts +27 -3
  93. package/src/persistence/MutationCollector.ts +265 -31
  94. package/src/persistence/PersistExecution.ts +217 -0
  95. package/src/persistence/types.ts +8 -2
  96. package/src/store/EntityMetaStore.ts +89 -45
  97. package/src/store/EntitySnapshotStore.ts +102 -19
  98. package/src/store/ErrorStore.ts +6 -5
  99. package/src/store/HasManyStore.ts +810 -0
  100. package/src/store/HasOneStore.ts +454 -0
  101. package/src/store/ReachabilityAnalyzer.ts +37 -0
  102. package/src/store/RekeyOrchestrator.ts +123 -0
  103. package/src/store/RelationEdgeIndex.ts +79 -0
  104. package/src/store/RelationStore.ts +176 -725
  105. package/src/store/RootRegistry.ts +50 -8
  106. package/src/store/SnapshotStore.ts +572 -101
  107. package/src/store/SubscriptionManager.ts +122 -75
  108. package/src/store/TouchedStore.ts +5 -2
  109. package/src/store/relationKey.ts +20 -0
  110. package/src/undo/UndoJournal.ts +258 -0
  111. package/src/undo/UndoManager.ts +151 -281
  112. package/src/undo/UnrecordedWriteError.ts +23 -0
  113. package/src/undo/index.ts +8 -7
  114. package/src/undo/rekeyJournalEntry.ts +145 -0
  115. package/src/undo/types.ts +0 -58
  116. package/dist/core/actionClassification.d.ts +0 -48
  117. package/dist/core/actionClassification.d.ts.map +0 -1
  118. 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
+ }