@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.
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 +120 -16
  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
@@ -1,876 +1,327 @@
1
- import type { HasOneRelationState } from '../handles/types.js'
2
1
  import type { EntitySnapshot } from './snapshots.js'
3
-
4
- function setsEqual<T>(a: Set<T>, b: Set<T>): boolean {
5
- if (a.size !== b.size) return false
6
- for (const item of a) {
7
- if (!b.has(item)) return false
8
- }
9
- return true
10
- }
11
-
12
- /**
13
- * Removal type for has-many items
14
- */
15
- export type HasManyRemovalType = 'disconnect' | 'delete'
16
-
17
- /**
18
- * Has-many list state stored in SnapshotStore
19
- */
20
- export interface StoredHasManyState {
21
- /** IDs of items from server */
22
- serverIds: Set<string>
23
- /** Explicit ordered list of item IDs, null means use default order (serverIds + plannedConnections) */
24
- orderedIds: string[] | null
25
- /** Planned removals (disconnect or delete) keyed by entity ID */
26
- plannedRemovals: Map<string, HasManyRemovalType>
27
- /** Planned connections (IDs to add to the list) */
28
- plannedConnections: Set<string>
29
- /** Entity IDs created via add() - tracked for proper remove() semantics and mutation generation */
30
- createdEntities: Set<string>
31
- version: number
32
- }
33
-
34
- /**
35
- * Relation state stored in SnapshotStore
36
- */
37
- export interface StoredRelationState {
38
- currentId: string | null
39
- serverId: string | null
40
- state: HasOneRelationState
41
- serverState: HasOneRelationState
42
- placeholderData: Record<string, unknown>
43
- version: number
44
- }
2
+ import type { RekeyContext, Rekeyable } from './RekeyOrchestrator.js'
3
+ import {
4
+ HasOneStore,
5
+ type SentHasOneTransition,
6
+ type StoredRelationState,
7
+ } from './HasOneStore.js'
8
+ import {
9
+ HasManyStore,
10
+ computeDefaultOrderedIds,
11
+ type HasManyAdditionKind,
12
+ type HasManyRemovalType,
13
+ type SentHasManyDelta,
14
+ type StoredHasManyState,
15
+ } from './HasManyStore.js'
16
+
17
+ // Re-exported so existing imports from './RelationStore.js' keep resolving.
18
+ export type { StoredRelationState } from './HasOneStore.js'
19
+ export type { SentHasOneTransition } from './HasOneStore.js'
20
+ export type {
21
+ HasManyAdditionKind,
22
+ HasManyRemovalType,
23
+ SentHasManyAddition,
24
+ SentHasManyDelta,
25
+ SentHasManyRemoval,
26
+ StoredHasManyState,
27
+ } from './HasManyStore.js'
28
+ export { computeDefaultOrderedIds } from './HasManyStore.js'
29
+
30
+ export type RelationReconciliationResult = 'applied' | 'conflict'
45
31
 
46
32
  /**
47
33
  * Manages has-one and has-many relation state.
48
34
  *
49
35
  * Relation keys use the format "parentType:parentId:fieldName".
50
36
  * Notification is handled by callers via callback returns.
37
+ *
38
+ * Thin facade composing a {@link HasOneStore} and a {@link HasManyStore}: each
39
+ * sub-store owns its own state map and write helper, and the facade merges the
40
+ * cross-cutting queries (mutation version sum; live-child/parent-key unions;
41
+ * dirty union) and fans out the cross-cutting mutations (rekey, removeOwned,
42
+ * clear) to both.
51
43
  */
52
- export class RelationStore {
53
- /** Relation states keyed by "parentType:parentId:fieldName" */
54
- private readonly relationStates = new Map<string, StoredRelationState>()
44
+ export class RelationStore implements Rekeyable {
45
+ private readonly hasOne = new HasOneStore()
46
+ private readonly hasMany = new HasManyStore()
47
+
48
+ /**
49
+ * Increases whenever EITHER sub-store mutates. Used by {@link ReachabilityAnalyzer}
50
+ * to memoize its walk; only monotonic-increase-on-mutation matters, so the sum
51
+ * of the two per-store counters is sufficient.
52
+ */
53
+ getMutationVersion(): number {
54
+ return this.hasOne.getMutationVersion() + this.hasMany.getMutationVersion()
55
+ }
55
56
 
56
- /** Has-many list states keyed by "parentType:parentId:fieldName" */
57
- private readonly hasManyStates = new Map<string, StoredHasManyState>()
57
+ /** Editable-layer has-one write counter — read by the undo write-guard. */
58
+ getEditableHasOneWriteVersion(): number {
59
+ return this.hasOne.getEditableWriteVersion()
60
+ }
61
+
62
+ /** Editable-layer has-many write counter — read by the undo write-guard. */
63
+ getEditableHasManyWriteVersion(): number {
64
+ return this.hasMany.getEditableWriteVersion()
65
+ }
58
66
 
59
67
  // ==================== Has-One Relations ====================
60
68
 
61
- /**
62
- * Gets or creates relation state.
63
- */
64
69
  getOrCreateRelation(
65
70
  key: string,
66
71
  initial: Omit<StoredRelationState, 'version'>,
67
72
  ): StoredRelationState {
68
- if (!this.relationStates.has(key)) {
69
- this.relationStates.set(key, { ...initial, version: 0 })
70
- }
71
-
72
- return this.relationStates.get(key)!
73
+ return this.hasOne.getOrCreateRelation(key, initial)
73
74
  }
74
75
 
75
- /**
76
- * Gets relation state.
77
- */
78
76
  getRelation(key: string): StoredRelationState | undefined {
79
- return this.relationStates.get(key)
77
+ return this.hasOne.getRelation(key)
80
78
  }
81
79
 
82
- /**
83
- * Updates relation state.
84
- * If the relation state doesn't exist, creates it using entity snapshot for server data.
85
- */
86
80
  setRelation(
87
81
  key: string,
88
82
  updates: Partial<Omit<StoredRelationState, 'version'>>,
89
83
  entitySnapshot: EntitySnapshot | undefined,
90
84
  fieldName: string,
91
85
  ): void {
92
- const existing = this.relationStates.get(key)
93
-
94
- if (!existing) {
95
- let serverId: string | null = null
96
- let serverState: HasOneRelationState = 'disconnected'
97
-
98
- if (entitySnapshot?.serverData) {
99
- const relatedData = (entitySnapshot.serverData as Record<string, unknown>)[fieldName]
100
- if (relatedData && typeof relatedData === 'object' && 'id' in relatedData) {
101
- serverId = (relatedData as { id: string }).id
102
- serverState = 'connected'
103
- }
104
- }
105
-
106
- this.relationStates.set(key, {
107
- currentId: 'currentId' in updates ? updates.currentId! : serverId,
108
- serverId,
109
- state: 'state' in updates ? updates.state! : serverState,
110
- serverState,
111
- placeholderData: updates.placeholderData ?? {},
112
- version: 0,
113
- })
114
- } else {
115
- this.relationStates.set(key, {
116
- ...existing,
117
- ...updates,
118
- version: existing.version + 1,
119
- })
120
- }
86
+ this.hasOne.setRelation(key, updates, entitySnapshot, fieldName)
121
87
  }
122
88
 
123
- /**
124
- * Commits relation state (server = current).
125
- */
126
89
  commitRelation(key: string): void {
127
- const existing = this.relationStates.get(key)
128
- if (!existing) return
90
+ this.hasOne.commitRelation(key)
91
+ }
129
92
 
130
- this.relationStates.set(key, {
131
- ...existing,
132
- serverId: existing.currentId,
133
- serverState: existing.state === 'creating' ? 'connected' : existing.state,
134
- placeholderData: {},
135
- version: existing.version + 1,
136
- })
93
+ reconcileSentRelation(
94
+ key: string,
95
+ transition: SentHasOneTransition,
96
+ ): RelationReconciliationResult {
97
+ return this.hasOne.reconcileSentTransition(key, transition)
137
98
  }
138
99
 
139
- /**
140
- * Resets relation to server state.
141
- */
142
100
  resetRelation(key: string): void {
143
- const existing = this.relationStates.get(key)
144
- if (!existing) return
145
-
146
- this.relationStates.set(key, {
147
- ...existing,
148
- currentId: existing.serverId,
149
- state: existing.serverState,
150
- placeholderData: {},
151
- version: existing.version + 1,
152
- })
101
+ this.hasOne.resetRelation(key)
153
102
  }
154
103
 
155
104
  // ==================== Has-Many Relations ====================
156
105
 
157
- /**
158
- * Gets or creates has-many list state.
159
- */
160
106
  getOrCreateHasMany(key: string, serverIds?: string[]): StoredHasManyState {
161
- const existing = this.hasManyStates.get(key)
162
- if (!existing) {
163
- this.hasManyStates.set(key, {
164
- serverIds: new Set(serverIds ?? []),
165
- orderedIds: null,
166
- plannedRemovals: new Map(),
167
- plannedConnections: new Set(),
168
- createdEntities: new Set(),
169
- version: 0,
170
- })
171
- } else if (serverIds !== undefined) {
172
- const newServerIds = new Set(serverIds)
173
- if (!setsEqual(existing.serverIds, newServerIds)) {
174
- this.hasManyStates.set(key, {
175
- ...existing,
176
- serverIds: newServerIds,
177
- orderedIds: null,
178
- version: existing.version + 1,
179
- })
180
- }
181
- }
182
-
183
- return this.hasManyStates.get(key)!
107
+ return this.hasMany.getOrCreateHasMany(key, serverIds)
184
108
  }
185
109
 
186
- /**
187
- * Gets has-many list state.
188
- */
189
110
  getHasMany(key: string): StoredHasManyState | undefined {
190
- return this.hasManyStates.get(key)
111
+ return this.hasMany.getHasMany(key)
191
112
  }
192
113
 
193
- /**
194
- * Sets server IDs for a has-many relation.
195
- */
196
114
  setHasManyServerIds(key: string, serverIds: string[]): void {
197
- const existing = this.hasManyStates.get(key)
198
-
199
- if (!existing) {
200
- this.hasManyStates.set(key, {
201
- serverIds: new Set(serverIds),
202
- orderedIds: null,
203
- plannedRemovals: new Map(),
204
- plannedConnections: new Set(),
205
- createdEntities: new Set(),
206
- version: 0,
207
- })
208
- } else {
209
- this.hasManyStates.set(key, {
210
- ...existing,
211
- serverIds: new Set(serverIds),
212
- orderedIds: null,
213
- version: existing.version + 1,
214
- })
215
- }
115
+ this.hasMany.setHasManyServerIds(key, serverIds)
216
116
  }
217
117
 
218
- /**
219
- * Plans a removal for a has-many item.
220
- */
221
118
  planHasManyRemoval(key: string, itemId: string, type: HasManyRemovalType): void {
222
- const existing = this.hasManyStates.get(key)
223
-
224
- if (!existing) {
225
- this.hasManyStates.set(key, {
226
- serverIds: new Set(),
227
- orderedIds: null,
228
- plannedRemovals: new Map([[itemId, type]]),
229
- plannedConnections: new Set(),
230
- createdEntities: new Set(),
231
- version: 0,
232
- })
233
- } else {
234
- const newPlannedRemovals = new Map(existing.plannedRemovals)
235
- newPlannedRemovals.set(itemId, type)
236
- const newPlannedConnections = new Set(existing.plannedConnections)
237
- newPlannedConnections.delete(itemId)
238
- let newOrderedIds = existing.orderedIds
239
- if (newOrderedIds !== null) {
240
- newOrderedIds = newOrderedIds.filter(id => id !== itemId)
241
- }
242
- const newCreatedEntities = new Set(existing.createdEntities)
243
- newCreatedEntities.delete(itemId)
244
- this.hasManyStates.set(key, {
245
- ...existing,
246
- orderedIds: newOrderedIds,
247
- plannedRemovals: newPlannedRemovals,
248
- plannedConnections: newPlannedConnections,
249
- createdEntities: newCreatedEntities,
250
- version: existing.version + 1,
251
- })
252
- }
119
+ this.hasMany.planHasManyRemoval(key, itemId, type)
253
120
  }
254
121
 
255
- /**
256
- * Plans a connection for a has-many item.
257
- */
258
122
  planHasManyConnection(key: string, itemId: string): void {
259
- const existing = this.hasManyStates.get(key)
260
-
261
- if (!existing) {
262
- this.hasManyStates.set(key, {
263
- serverIds: new Set(),
264
- orderedIds: null,
265
- plannedRemovals: new Map(),
266
- plannedConnections: new Set([itemId]),
267
- createdEntities: new Set(),
268
- version: 0,
269
- })
270
- } else {
271
- const newPlannedConnections = new Set(existing.plannedConnections)
272
- newPlannedConnections.add(itemId)
273
- const newPlannedRemovals = new Map(existing.plannedRemovals)
274
- newPlannedRemovals.delete(itemId)
275
- let newOrderedIds = existing.orderedIds
276
- if (newOrderedIds !== null && !newOrderedIds.includes(itemId)) {
277
- newOrderedIds = [...newOrderedIds, itemId]
278
- }
279
- this.hasManyStates.set(key, {
280
- ...existing,
281
- orderedIds: newOrderedIds,
282
- plannedConnections: newPlannedConnections,
283
- plannedRemovals: newPlannedRemovals,
284
- version: existing.version + 1,
285
- })
286
- }
123
+ this.hasMany.planHasManyConnection(key, itemId)
287
124
  }
288
125
 
289
- /**
290
- * Commits has-many state after successful persist.
291
- */
292
126
  commitHasMany(key: string, newServerIds: string[]): void {
293
- const existing = this.hasManyStates.get(key)
127
+ this.hasMany.commitHasMany(key, newServerIds)
128
+ }
294
129
 
295
- this.hasManyStates.set(key, {
296
- serverIds: new Set(newServerIds),
297
- orderedIds: null,
298
- plannedRemovals: new Map(),
299
- plannedConnections: new Set(),
300
- createdEntities: new Set(),
301
- version: (existing?.version ?? 0) + 1,
302
- })
130
+ reconcileSentHasMany(
131
+ key: string,
132
+ delta: SentHasManyDelta,
133
+ ): RelationReconciliationResult {
134
+ return this.hasMany.reconcileSentDelta(key, delta)
303
135
  }
304
136
 
305
- /**
306
- * Resets has-many state to server state (clears planned operations).
307
- */
308
137
  resetHasMany(key: string): void {
309
- const existing = this.hasManyStates.get(key)
310
- if (!existing) return
311
-
312
- this.hasManyStates.set(key, {
313
- serverIds: existing.serverIds,
314
- orderedIds: null,
315
- plannedRemovals: new Map(),
316
- plannedConnections: new Set(),
317
- createdEntities: new Set(),
318
- version: existing.version + 1,
319
- })
138
+ this.hasMany.resetHasMany(key)
320
139
  }
321
140
 
322
- /**
323
- * Adds a newly created entity to a has-many relation.
324
- * Used by HasManyListHandle.add() for inline entity creation.
325
- */
326
141
  addToHasMany(key: string, itemId: string): void {
327
- const existing = this.hasManyStates.get(key)
328
-
329
- if (!existing) {
330
- this.hasManyStates.set(key, {
331
- serverIds: new Set(),
332
- orderedIds: [itemId],
333
- plannedRemovals: new Map(),
334
- plannedConnections: new Set([itemId]),
335
- createdEntities: new Set([itemId]),
336
- version: 0,
337
- })
338
- } else {
339
- const newPlannedConnections = new Set(existing.plannedConnections)
340
- newPlannedConnections.add(itemId)
341
- const newCreatedEntities = new Set(existing.createdEntities)
342
- newCreatedEntities.add(itemId)
343
- const currentOrderedIds = existing.orderedIds ?? computeDefaultOrderedIds(existing)
344
- const newOrderedIds = [...currentOrderedIds, itemId]
345
- this.hasManyStates.set(key, {
346
- ...existing,
347
- orderedIds: newOrderedIds,
348
- plannedConnections: newPlannedConnections,
349
- createdEntities: newCreatedEntities,
350
- version: existing.version + 1,
351
- })
352
- }
142
+ this.hasMany.addToHasMany(key, itemId)
353
143
  }
354
144
 
355
- /**
356
- * Connects an existing (persisted) entity to a has-many relation.
357
- * Unlike addToHasMany, does NOT add to createdEntities — used for
358
- * materializing embedded connect references to existing entities.
359
- */
360
145
  connectExistingToHasMany(key: string, itemId: string): void {
361
- const existing = this.hasManyStates.get(key)
362
-
363
- if (!existing) {
364
- this.hasManyStates.set(key, {
365
- serverIds: new Set(),
366
- orderedIds: [itemId],
367
- plannedRemovals: new Map(),
368
- plannedConnections: new Set([itemId]),
369
- createdEntities: new Set(),
370
- version: 0,
371
- })
372
- } else {
373
- const newPlannedConnections = new Set(existing.plannedConnections)
374
- newPlannedConnections.add(itemId)
375
- const currentOrderedIds = existing.orderedIds ?? computeDefaultOrderedIds(existing)
376
- const newOrderedIds = [...currentOrderedIds, itemId]
377
- this.hasManyStates.set(key, {
378
- ...existing,
379
- orderedIds: newOrderedIds,
380
- plannedConnections: newPlannedConnections,
381
- version: existing.version + 1,
382
- })
383
- }
146
+ this.hasMany.connectExistingToHasMany(key, itemId)
384
147
  }
385
148
 
386
- /**
387
- * Removes an entity from a has-many relation.
388
- * For newly created entities (via add()), cancels the connection.
389
- * For existing server entities, plans the specified removal type.
390
- * Returns true if the state changed (caller should notify), false if it was a no-op.
391
- */
392
149
  removeFromHasMany(key: string, itemId: string, removalType: HasManyRemovalType): boolean {
393
- const existing = this.hasManyStates.get(key)
394
- if (!existing) return false
395
-
396
- const isCreatedEntity = existing.createdEntities.has(itemId)
397
-
398
- if (isCreatedEntity) {
399
- const newPlannedConnections = new Set(existing.plannedConnections)
400
- newPlannedConnections.delete(itemId)
401
- const newCreatedEntities = new Set(existing.createdEntities)
402
- newCreatedEntities.delete(itemId)
403
- let newOrderedIds = existing.orderedIds
404
- if (newOrderedIds !== null) {
405
- newOrderedIds = newOrderedIds.filter(id => id !== itemId)
406
- }
407
-
408
- const newState: StoredHasManyState = {
409
- ...existing,
410
- orderedIds: newOrderedIds,
411
- plannedConnections: newPlannedConnections,
412
- createdEntities: newCreatedEntities,
413
- version: existing.version + 1,
414
- }
415
-
416
- if (
417
- newPlannedConnections.size === 0 &&
418
- newCreatedEntities.size === 0 &&
419
- existing.plannedRemovals.size === 0 &&
420
- newOrderedIds !== null
421
- ) {
422
- const defaultOrder = computeDefaultOrderedIds(newState)
423
- if (arraysEqual(newOrderedIds, defaultOrder)) {
424
- newState.orderedIds = null
425
- }
426
- }
427
-
428
- this.hasManyStates.set(key, newState)
429
- return true
430
- } else {
431
- this.planHasManyRemoval(key, itemId, removalType)
432
- return true
433
- }
150
+ return this.hasMany.removeFromHasMany(key, itemId, removalType)
434
151
  }
435
152
 
153
+ moveInHasMany(key: string, fromIndex: number, toIndex: number): void {
154
+ this.hasMany.moveInHasMany(key, fromIndex, toIndex)
155
+ }
156
+
157
+ getHasManyOrderedIds(key: string): string[] {
158
+ return this.hasMany.getHasManyOrderedIds(key)
159
+ }
160
+
161
+ // ==================== Reachability / Reverse Lookup ====================
162
+
436
163
  /**
437
164
  * Collects the ids of child entities currently reachable through an entity's
438
- * LIVE relations (key prefix "parentType:parentId:"). Used by reachability-based
439
- * create detection to walk the relation graph from roots.
440
- *
441
- * Live edges are:
442
- * - has-one: currentId, when the relation is not disconnected/deleted
443
- * (a disconnected relation has a null currentId; a 'deleted' relation is
444
- * removing its target, so the target is not anchored by it).
445
- * - has-many: effective members = (serverIds ∪ plannedConnections ∪
446
- * createdEntities) minus plannedRemovals.
165
+ * LIVE relations (key prefix "parentType:parentId:"). Unions the live has-one
166
+ * edges with the live has-many members.
447
167
  */
448
168
  getLiveChildIds(keyPrefix: string): string[] {
449
169
  const ids = new Set<string>()
450
-
451
- for (const [key, state] of this.relationStates) {
452
- if (!key.startsWith(keyPrefix)) continue
453
- if (state.currentId !== null && state.state !== 'deleted') {
454
- ids.add(state.currentId)
455
- }
456
- }
457
-
458
- for (const [key, state] of this.hasManyStates) {
459
- if (!key.startsWith(keyPrefix)) continue
460
- for (const id of state.serverIds) {
461
- if (!state.plannedRemovals.has(id)) ids.add(id)
462
- }
463
- for (const id of state.plannedConnections) {
464
- if (!state.plannedRemovals.has(id)) ids.add(id)
465
- }
466
- for (const id of state.createdEntities) {
467
- if (!state.plannedRemovals.has(id)) ids.add(id)
468
- }
469
- }
470
-
170
+ this.hasOne.collectLiveChildIds(keyPrefix, ids)
171
+ this.hasMany.collectLiveChildIds(keyPrefix, ids)
471
172
  return Array.from(ids)
472
173
  }
473
174
 
474
175
  /**
475
- * Removes all relation and has-many state owned by an entity (keys under the
476
- * given owner prefix). Called by removeEntity so a removed entity leaves no
477
- * stale relation state behind.
176
+ * Collects the composite keys ("parentType:parentId") of every entity that
177
+ * currently has a LIVE relation edge pointing at {@link childId} — the reverse
178
+ * of {@link getLiveChildIds}. Unions both sub-stores' results, scanning the
179
+ * same live edges the forward query reads so the two never disagree.
180
+ *
181
+ * {@link childId} is a BARE entity id (no type): relation state records child ids
182
+ * without their type, so the match is by id alone. This deliberately relies on
183
+ * the store-wide invariant that entity ids are globally unique across types
184
+ * (server UUIDs / minted temp ids — the same invariant behind
185
+ * EntitySnapshotStore's idIndex / keyForId), so a bare id resolves to exactly one
186
+ * entity. The forward reachability walk matches on the same bare ids, so making
187
+ * this type-aware in isolation would only diverge the two halves of one query.
478
188
  */
479
- removeOwnedRelations(keyPrefix: string): void {
480
- for (const key of [...this.relationStates.keys()]) {
481
- if (key.startsWith(keyPrefix)) {
482
- this.relationStates.delete(key)
483
- }
484
- }
485
- for (const key of [...this.hasManyStates.keys()]) {
486
- if (key.startsWith(keyPrefix)) {
487
- this.hasManyStates.delete(key)
488
- }
489
- }
189
+ getParentKeysForChild(childId: string): Set<string> {
190
+ const parents = new Set<string>()
191
+ this.hasOne.collectParentKeysForChild(childId, parents)
192
+ this.hasMany.collectParentKeysForChild(childId, parents)
193
+ return parents
490
194
  }
491
195
 
196
+ // ==================== Bulk Operations ====================
197
+
492
198
  /**
493
- * Moves an item within a has-many relation from one index to another.
199
+ * Keys of every has-one relation owned by an entity (owner prefix
200
+ * "parentType:parentId:"). Used by the journal's entry-closure to enumerate a
201
+ * detached created entity's own relation cells.
494
202
  */
495
- moveInHasMany(key: string, fromIndex: number, toIndex: number): void {
496
- const existing = this.hasManyStates.get(key)
497
- if (!existing) return
498
-
499
- const currentOrderedIds = existing.orderedIds ?? computeDefaultOrderedIds(existing)
500
-
501
- if (fromIndex < 0 || fromIndex >= currentOrderedIds.length) return
502
- if (toIndex < 0 || toIndex >= currentOrderedIds.length) return
503
- if (fromIndex === toIndex) return
504
-
505
- const newOrderedIds = [...currentOrderedIds]
506
- const movedItem = newOrderedIds.splice(fromIndex, 1)[0]
507
- if (movedItem === undefined) return
508
- newOrderedIds.splice(toIndex, 0, movedItem)
509
-
510
- this.hasManyStates.set(key, {
511
- ...existing,
512
- orderedIds: newOrderedIds,
513
- version: existing.version + 1,
514
- })
203
+ getOwnedRelationKeys(keyPrefix: string): string[] {
204
+ const keys: string[] = []
205
+ this.hasOne.collectOwnedKeys(keyPrefix, keys)
206
+ return keys
515
207
  }
516
208
 
517
209
  /**
518
- * Gets the ordered list of item IDs for a has-many relation.
210
+ * Keys of every has-many list owned by an entity (owner prefix
211
+ * "parentType:parentId:"). Counterpart of {@link getOwnedRelationKeys}.
519
212
  */
520
- getHasManyOrderedIds(key: string): string[] {
521
- const existing = this.hasManyStates.get(key)
522
- if (!existing) return []
523
-
524
- if (existing.orderedIds !== null) {
525
- return existing.orderedIds
526
- }
527
-
528
- return computeDefaultOrderedIds(existing)
213
+ getOwnedHasManyKeys(keyPrefix: string): string[] {
214
+ const keys: string[] = []
215
+ this.hasMany.collectOwnedKeys(keyPrefix, keys)
216
+ return keys
529
217
  }
530
218
 
531
219
  /**
532
- * Restores a has-many state from a captured snapshot.
533
- * Used in pessimistic mode after successful server confirmation.
220
+ * Removes a single has-one relation state by key (undo restore of an
221
+ * absent-before-the-gesture relation).
534
222
  */
535
- restoreHasManyState(key: string, state: StoredHasManyState): void {
536
- this.hasManyStates.set(key, {
537
- serverIds: new Set(state.serverIds),
538
- orderedIds: state.orderedIds ? [...state.orderedIds] : null,
539
- plannedRemovals: new Map(state.plannedRemovals),
540
- plannedConnections: new Set(state.plannedConnections),
541
- createdEntities: new Set(state.createdEntities),
542
- version: state.version + 1,
543
- })
223
+ removeRelationState(key: string): void {
224
+ this.hasOne.removeRelation(key)
544
225
  }
545
226
 
546
- // ==================== Bulk Operations ====================
547
-
548
227
  /**
549
- * Commits all relations (hasOne and hasMany) for an entity.
228
+ * Removes a single has-many list state by key (undo restore of an
229
+ * absent-before-the-gesture list).
550
230
  */
551
- commitAllRelations(keyPrefix: string): void {
552
- for (const key of this.relationStates.keys()) {
553
- if (key.startsWith(keyPrefix)) {
554
- this.commitRelation(key)
555
- }
556
- }
557
-
558
- for (const [key, state] of this.hasManyStates) {
559
- if (key.startsWith(keyPrefix)) {
560
- const newServerIds = new Set(state.serverIds)
561
- for (const removedId of state.plannedRemovals.keys()) {
562
- newServerIds.delete(removedId)
563
- }
564
- for (const connectedId of state.plannedConnections) {
565
- newServerIds.add(connectedId)
566
- }
567
- this.commitHasMany(key, Array.from(newServerIds))
568
- }
569
- }
231
+ removeHasManyState(key: string): void {
232
+ this.hasMany.removeHasMany(key)
570
233
  }
571
234
 
572
235
  /**
573
- * Resets all relations (hasOne and hasMany) for an entity to server state.
236
+ * Removes all relation and has-many state owned by an entity (keys under the
237
+ * given owner prefix). Called by removeEntity so a removed entity leaves no
238
+ * stale relation state behind.
574
239
  */
575
- resetAllRelations(keyPrefix: string): void {
576
- for (const key of this.relationStates.keys()) {
577
- if (key.startsWith(keyPrefix)) {
578
- this.resetRelation(key)
579
- }
580
- }
581
-
582
- for (const key of this.hasManyStates.keys()) {
583
- if (key.startsWith(keyPrefix)) {
584
- this.resetHasMany(key)
585
- }
586
- }
240
+ removeOwnedRelations(keyPrefix: string): void {
241
+ this.hasOne.removeOwnedRelations(keyPrefix)
242
+ this.hasMany.removeOwnedRelations(keyPrefix)
587
243
  }
588
244
 
589
245
  /**
590
- * Gets all relation states for an entity.
246
+ * Commits all relations (hasOne and hasMany) for an entity.
591
247
  */
592
- getAllRelationsForEntity(keyPrefix: string): Map<string, StoredRelationState> {
593
- const result = new Map<string, StoredRelationState>()
594
- for (const [key, state] of this.relationStates) {
595
- if (key.startsWith(keyPrefix)) {
596
- result.set(key, { ...state })
597
- }
598
- }
599
- return result
248
+ commitAllRelations(keyPrefix: string, pendingItems?: ReadonlyMap<string, ReadonlySet<string>>): void {
249
+ this.hasOne.commitAllRelations(keyPrefix, pendingItems)
250
+ this.hasMany.commitAllRelations(keyPrefix, pendingItems)
600
251
  }
601
252
 
602
253
  /**
603
- * Gets all has-many states for an entity.
254
+ * Resets all relations (hasOne and hasMany) for an entity to server state.
604
255
  */
605
- getAllHasManyForEntity(keyPrefix: string): Map<string, StoredHasManyState> {
606
- const result = new Map<string, StoredHasManyState>()
607
- for (const [key, state] of this.hasManyStates) {
608
- if (key.startsWith(keyPrefix)) {
609
- result.set(key, {
610
- serverIds: new Set(state.serverIds),
611
- orderedIds: state.orderedIds ? [...state.orderedIds] : null,
612
- plannedRemovals: new Map(state.plannedRemovals),
613
- plannedConnections: new Set(state.plannedConnections),
614
- createdEntities: new Set(state.createdEntities),
615
- version: state.version,
616
- })
617
- }
618
- }
619
- return result
256
+ resetAllRelations(keyPrefix: string): void {
257
+ this.hasOne.resetAllRelations(keyPrefix)
258
+ this.hasMany.resetAllRelations(keyPrefix)
620
259
  }
621
260
 
622
261
  // ==================== Dirty Tracking ====================
623
262
 
624
263
  /**
625
- * Gets the list of dirty relations for an entity.
264
+ * Gets the list of dirty relations (has-one and has-many) for an entity.
626
265
  */
627
266
  getDirtyRelations(keyPrefix: string): string[] {
628
267
  const dirtyRelations: string[] = []
629
-
630
- for (const [key, state] of this.relationStates) {
631
- if (!key.startsWith(keyPrefix)) continue
632
- const fieldName = key.slice(keyPrefix.length)
633
-
634
- if (
635
- state.currentId !== state.serverId ||
636
- state.state !== state.serverState ||
637
- Object.keys(state.placeholderData).length > 0
638
- ) {
639
- dirtyRelations.push(fieldName)
640
- }
641
- }
642
-
643
- for (const [key, state] of this.hasManyStates) {
644
- if (!key.startsWith(keyPrefix)) continue
645
- const fieldName = key.slice(keyPrefix.length)
646
-
647
- if (state.plannedRemovals.size > 0 || state.plannedConnections.size > 0) {
648
- dirtyRelations.push(fieldName)
649
- }
650
- }
651
-
268
+ this.hasOne.collectDirtyRelations(keyPrefix, dirtyRelations)
269
+ this.hasMany.collectDirtyRelations(keyPrefix, dirtyRelations)
652
270
  return dirtyRelations
653
271
  }
654
272
 
655
273
  // ==================== Export/Import ====================
656
274
 
657
- /**
658
- * Exports relation states for given keys.
659
- */
660
275
  exportRelationStates(keys: string[]): Map<string, StoredRelationState> {
661
- const result = new Map<string, StoredRelationState>()
662
- for (const key of keys) {
663
- const state = this.relationStates.get(key)
664
- if (state) {
665
- result.set(key, {
666
- ...state,
667
- placeholderData: { ...state.placeholderData },
668
- })
669
- }
670
- }
671
- return result
276
+ return this.hasOne.exportRelationStates(keys)
672
277
  }
673
278
 
674
- /**
675
- * Exports has-many states for given keys.
676
- */
677
279
  exportHasManyStates(keys: string[]): Map<string, StoredHasManyState> {
678
- const result = new Map<string, StoredHasManyState>()
679
- for (const key of keys) {
680
- const state = this.hasManyStates.get(key)
681
- if (state) {
682
- result.set(key, {
683
- serverIds: new Set(state.serverIds),
684
- orderedIds: state.orderedIds ? [...state.orderedIds] : null,
685
- plannedRemovals: new Map(state.plannedRemovals),
686
- plannedConnections: new Set(state.plannedConnections),
687
- createdEntities: new Set(state.createdEntities),
688
- version: state.version,
689
- })
690
- }
691
- }
692
- return result
280
+ return this.hasMany.exportHasManyStates(keys)
693
281
  }
694
282
 
695
- /**
696
- * Imports relation states from a snapshot.
697
- * Returns the keys that were imported for notification.
698
- */
699
283
  importRelationStates(states: Map<string, StoredRelationState>): string[] {
700
- const keys: string[] = []
701
- for (const [key, state] of states) {
702
- this.relationStates.set(key, {
703
- ...state,
704
- placeholderData: { ...state.placeholderData },
705
- })
706
- keys.push(key)
707
- }
708
- return keys
284
+ return this.hasOne.importRelationStates(states)
709
285
  }
710
286
 
711
- /**
712
- * Imports has-many states from a snapshot.
713
- * Returns the keys that were imported for notification.
714
- */
715
287
  importHasManyStates(states: Map<string, StoredHasManyState>): string[] {
716
- const keys: string[] = []
717
- for (const [key, state] of states) {
718
- this.hasManyStates.set(key, {
719
- serverIds: new Set(state.serverIds),
720
- orderedIds: state.orderedIds ? [...state.orderedIds] : null,
721
- plannedRemovals: new Map(state.plannedRemovals),
722
- plannedConnections: new Set(state.plannedConnections),
723
- createdEntities: new Set(state.createdEntities),
724
- version: state.version + 1,
725
- })
726
- keys.push(key)
727
- }
728
- return keys
288
+ return this.hasMany.importHasManyStates(states)
729
289
  }
730
290
 
291
+ // ==================== Rekey ====================
292
+
731
293
  /**
732
294
  * Replaces all occurrences of oldId with newId across relation and hasMany states.
733
295
  * Used after persist to rekey temp IDs to server-assigned IDs.
734
296
  */
735
297
  replaceEntityId(oldId: string, newId: string): void {
736
- // Replace in has-one relation states: currentId, serverId
737
- for (const [key, state] of this.relationStates) {
738
- let changed = false
739
- let currentId = state.currentId
740
- let serverId = state.serverId
741
-
742
- if (currentId === oldId) { currentId = newId; changed = true }
743
- if (serverId === oldId) { serverId = newId; changed = true }
744
-
745
- if (changed) {
746
- this.relationStates.set(key, { ...state, currentId, serverId, version: state.version + 1 })
747
- }
748
- }
749
-
750
- // Replace in has-many states: serverIds, orderedIds, plannedConnections, createdEntities, plannedRemovals
751
- for (const [key, state] of this.hasManyStates) {
752
- let changed = false
753
-
754
- let serverIds = state.serverIds
755
- if (serverIds.has(oldId)) {
756
- serverIds = new Set(serverIds)
757
- serverIds.delete(oldId)
758
- serverIds.add(newId)
759
- changed = true
760
- }
761
-
762
- let orderedIds = state.orderedIds
763
- if (orderedIds) {
764
- const idx = orderedIds.indexOf(oldId)
765
- if (idx !== -1) {
766
- orderedIds = [...orderedIds]
767
- orderedIds[idx] = newId
768
- changed = true
769
- }
770
- }
771
-
772
- let plannedConnections = state.plannedConnections
773
- if (plannedConnections.has(oldId)) {
774
- plannedConnections = new Set(plannedConnections)
775
- plannedConnections.delete(oldId)
776
- plannedConnections.add(newId)
777
- changed = true
778
- }
779
-
780
- let createdEntities = state.createdEntities
781
- if (createdEntities.has(oldId)) {
782
- createdEntities = new Set(createdEntities)
783
- createdEntities.delete(oldId)
784
- createdEntities.add(newId)
785
- changed = true
786
- }
787
-
788
- let plannedRemovals = state.plannedRemovals
789
- if (plannedRemovals.has(oldId)) {
790
- const removalType = plannedRemovals.get(oldId)!
791
- plannedRemovals = new Map(plannedRemovals)
792
- plannedRemovals.delete(oldId)
793
- plannedRemovals.set(newId, removalType)
794
- changed = true
795
- }
796
-
797
- if (changed) {
798
- this.hasManyStates.set(key, {
799
- serverIds,
800
- orderedIds,
801
- plannedRemovals,
802
- plannedConnections,
803
- createdEntities,
804
- version: state.version + 1,
805
- })
806
- }
807
- }
298
+ this.hasOne.replaceEntityId(oldId, newId)
299
+ this.hasMany.replaceEntityId(oldId, newId)
300
+ }
301
+
302
+ /**
303
+ * Migrates an entity's relation state for a temp→persisted rekey: first the
304
+ * relation/has-many keys it owns (the parent id in the key), then every value
305
+ * reference to its id from other entities' relations.
306
+ */
307
+ rekey(ctx: RekeyContext): void {
308
+ this.rekeyOwner(ctx.oldKeyPrefix, ctx.newKeyPrefix)
309
+ this.replaceEntityId(ctx.oldId, ctx.newId)
808
310
  }
809
311
 
810
312
  /**
811
313
  * Rekeys relation/hasMany entries owned by an entity (changes the parent ID in the key).
812
314
  */
813
315
  rekeyOwner(oldKeyPrefix: string, newKeyPrefix: string): void {
814
- const toMoveRelations: [string, StoredRelationState][] = []
815
- for (const [key, value] of this.relationStates) {
816
- if (key.startsWith(oldKeyPrefix)) {
817
- toMoveRelations.push([key, value])
818
- }
819
- }
820
- for (const [oldKey, value] of toMoveRelations) {
821
- this.relationStates.delete(oldKey)
822
- this.relationStates.set(newKeyPrefix + oldKey.slice(oldKeyPrefix.length), value)
823
- }
824
-
825
- const toMoveHasMany: [string, StoredHasManyState][] = []
826
- for (const [key, value] of this.hasManyStates) {
827
- if (key.startsWith(oldKeyPrefix)) {
828
- toMoveHasMany.push([key, value])
829
- }
830
- }
831
- for (const [oldKey, value] of toMoveHasMany) {
832
- this.hasManyStates.delete(oldKey)
833
- this.hasManyStates.set(newKeyPrefix + oldKey.slice(oldKeyPrefix.length), value)
834
- }
316
+ this.hasOne.rekeyOwner(oldKeyPrefix, newKeyPrefix)
317
+ this.hasMany.rekeyOwner(oldKeyPrefix, newKeyPrefix)
835
318
  }
836
319
 
837
320
  /**
838
321
  * Clears all relation data.
839
322
  */
840
323
  clear(): void {
841
- this.relationStates.clear()
842
- this.hasManyStates.clear()
843
- }
844
- }
845
-
846
- // ==================== Helper Functions ====================
847
-
848
- /**
849
- * Computes the default ordered IDs for a has-many relation.
850
- * Order is: serverIds (minus removals) + plannedConnections
851
- */
852
- export function computeDefaultOrderedIds(state: StoredHasManyState): string[] {
853
- const result: string[] = []
854
-
855
- for (const id of state.serverIds) {
856
- if (!state.plannedRemovals.has(id)) {
857
- result.push(id)
858
- }
859
- }
860
-
861
- for (const id of state.plannedConnections) {
862
- if (!result.includes(id)) {
863
- result.push(id)
864
- }
865
- }
866
-
867
- return result
868
- }
869
-
870
- function arraysEqual(a: string[], b: string[]): boolean {
871
- if (a.length !== b.length) return false
872
- for (let i = 0; i < a.length; i++) {
873
- if (a[i] !== b[i]) return false
324
+ this.hasOne.clear()
325
+ this.hasMany.clear()
874
326
  }
875
- return true
876
327
  }