@contember/bindx 0.1.51 → 0.1.53

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 (54) hide show
  1. package/dist/core/ActionDispatcher.d.ts.map +1 -1
  2. package/dist/core/EntityLoader.d.ts +7 -0
  3. package/dist/core/EntityLoader.d.ts.map +1 -1
  4. package/dist/core/actions.d.ts +1 -2
  5. package/dist/core/actions.d.ts.map +1 -1
  6. package/dist/dataview/types.d.ts +6 -6
  7. package/dist/dataview/types.d.ts.map +1 -1
  8. package/dist/handles/EntityHandle.d.ts.map +1 -1
  9. package/dist/handles/HasManyListHandle.d.ts +8 -0
  10. package/dist/handles/HasManyListHandle.d.ts.map +1 -1
  11. package/dist/handles/HasOneHandle.d.ts.map +1 -1
  12. package/dist/index.d.ts +1 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/persistence/BatchPersister.d.ts.map +1 -1
  15. package/dist/store/HasManyStore.d.ts +94 -78
  16. package/dist/store/HasManyStore.d.ts.map +1 -1
  17. package/dist/store/RelationStore.d.ts +21 -12
  18. package/dist/store/RelationStore.d.ts.map +1 -1
  19. package/dist/store/ResponseOccurrenceIndex.d.ts +65 -0
  20. package/dist/store/ResponseOccurrenceIndex.d.ts.map +1 -0
  21. package/dist/store/SnapshotStore.d.ts +53 -15
  22. package/dist/store/SnapshotStore.d.ts.map +1 -1
  23. package/dist/store/SubscriptionManager.d.ts +19 -4
  24. package/dist/store/SubscriptionManager.d.ts.map +1 -1
  25. package/dist/store/hasManyState.d.ts +164 -0
  26. package/dist/store/hasManyState.d.ts.map +1 -0
  27. package/dist/store/relationKey.d.ts +11 -0
  28. package/dist/store/relationKey.d.ts.map +1 -1
  29. package/dist/undo/UndoJournal.d.ts.map +1 -1
  30. package/dist/undo/rekeyJournalEntry.d.ts +4 -4
  31. package/dist/undo/rekeyJournalEntry.d.ts.map +1 -1
  32. package/dist/utils/aliasGenerator.d.ts +1 -1
  33. package/dist/utils/aliasGenerator.d.ts.map +1 -1
  34. package/package.json +2 -2
  35. package/src/core/ActionDispatcher.ts +0 -1
  36. package/src/core/EntityLoader.ts +13 -6
  37. package/src/core/actions.ts +1 -3
  38. package/src/dataview/types.ts +6 -6
  39. package/src/handles/EntityHandle.ts +11 -0
  40. package/src/handles/HasManyListHandle.ts +15 -17
  41. package/src/handles/HasOneHandle.ts +4 -2
  42. package/src/index.ts +6 -0
  43. package/src/persistence/BatchPersister.ts +15 -11
  44. package/src/store/HasManyStore.ts +312 -488
  45. package/src/store/RelationStore.ts +60 -24
  46. package/src/store/ResponseOccurrenceIndex.ts +224 -0
  47. package/src/store/SnapshotStore.ts +183 -109
  48. package/src/store/SubscriptionManager.ts +116 -58
  49. package/src/store/hasManyState.ts +408 -0
  50. package/src/store/relationKey.ts +17 -0
  51. package/src/undo/UndoJournal.ts +3 -1
  52. package/src/undo/UndoManager.ts +1 -1
  53. package/src/undo/rekeyJournalEntry.ts +31 -43
  54. package/src/utils/aliasGenerator.ts +1 -1
@@ -2,6 +2,9 @@ import type { RekeyContext, Rekeyable } from './RekeyOrchestrator.js'
2
2
 
3
3
  type Subscriber = () => void
4
4
 
5
+ /** Rejects async callbacks at compile time: a notification batch would end at the first `await`. */
6
+ export type SynchronousResult<T> = T extends PromiseLike<unknown> ? never : T
7
+
5
8
  /**
6
9
  * Callback interface for SubscriptionManager to request snapshot version bumps.
7
10
  * This decouples notification logic from entity snapshot storage.
@@ -18,6 +21,8 @@ export interface SnapshotVersionBumper {
18
21
  */
19
22
  export interface ParentKeyLookup {
20
23
  getParentKeysForChild(childId: string): Set<string>
24
+ /** Increases on every relation write, so an unchanged value proves the edges are unchanged. */
25
+ getMutationVersion(): number
21
26
  }
22
27
 
23
28
  /**
@@ -45,6 +50,61 @@ export class SubscriptionManager implements Rekeyable {
45
50
 
46
51
  /** Global version number for change detection */
47
52
  private globalVersion = 0
53
+ private notificationBatchDepth = 0
54
+ private pendingEntities = new Set<string>()
55
+ private pendingRelations = new Set<string>()
56
+ private pendingGlobal = false
57
+ /** Keys the current batch has already walked; valid only while the relation edges are unchanged. */
58
+ private readonly batchWalkedKeys = new Set<string>()
59
+ private batchWalkedEdgeVersion = -1
60
+
61
+ /** Versions advance immediately; callbacks observe the completed synchronous batch. */
62
+ batchNotifications<T>(fn: () => SynchronousResult<T>): T {
63
+ this.notificationBatchDepth++
64
+ let completed = false
65
+ try {
66
+ const result = fn()
67
+ completed = true
68
+ return result
69
+ } finally {
70
+ if (--this.notificationBatchDepth === 0) this.flushNotifications(completed)
71
+ }
72
+ }
73
+
74
+ private flushNotifications(callbackCompleted: boolean): void {
75
+ const entities = this.pendingEntities
76
+ const relations = this.pendingRelations
77
+ const global = this.pendingGlobal
78
+ this.pendingEntities = new Set()
79
+ this.pendingRelations = new Set()
80
+ this.pendingGlobal = false
81
+ this.batchWalkedKeys.clear()
82
+ const notified = new Set<Subscriber>()
83
+ const errors: unknown[] = []
84
+ const deliver = (subscribers: Set<Subscriber> | undefined): void => {
85
+ for (const sub of subscribers ?? []) {
86
+ if (notified.has(sub)) continue
87
+ notified.add(sub)
88
+ try {
89
+ sub()
90
+ } catch (error) {
91
+ errors.push(error)
92
+ }
93
+ }
94
+ }
95
+ for (const key of entities) deliver(this.entitySubscribers.get(key))
96
+ for (const key of relations) deliver(this.relationSubscribers.get(key))
97
+ if (global) deliver(this.globalSubscribers)
98
+ throwSubscriberErrors(errors, callbackCompleted)
99
+ }
100
+
101
+ private notifyGlobalSubscribers(): void {
102
+ if (this.notificationBatchDepth > 0) {
103
+ this.pendingGlobal = true
104
+ return
105
+ }
106
+ for (const sub of this.globalSubscribers) sub()
107
+ }
48
108
 
49
109
  /**
50
110
  * Resolves a child's parents from live relation edges. Injected after
@@ -127,9 +187,7 @@ export class SubscriptionManager implements Rekeyable {
127
187
  */
128
188
  notify(): void {
129
189
  this.globalVersion++
130
- for (const sub of this.globalSubscribers) {
131
- sub()
132
- }
190
+ this.notifyGlobalSubscribers()
133
191
  }
134
192
 
135
193
  /**
@@ -145,13 +203,9 @@ export class SubscriptionManager implements Rekeyable {
145
203
  // Iterate the live sets, as the per-key paths do: a subscriber that unsubscribes a
146
204
  // not-yet-visited sibling removes it from the iteration, whereas a copied array would
147
205
  // still invoke it after its unsubscribe() returned.
148
- for (const subs of this.entitySubscribers.values()) {
149
- for (const sub of subs) sub()
150
- }
151
- for (const subs of this.relationSubscribers.values()) {
152
- for (const sub of subs) sub()
153
- }
154
- for (const sub of this.globalSubscribers) sub()
206
+ for (const key of this.entitySubscribers.keys()) this.notifyEntityDirect(key)
207
+ for (const key of this.relationSubscribers.keys()) this.notifyRelationDirect(key)
208
+ this.notifyGlobalSubscribers()
155
209
  }
156
210
 
157
211
  // ==================== Parent-Child Relationships ====================
@@ -193,7 +247,19 @@ export class SubscriptionManager implements Rekeyable {
193
247
  bumper: SnapshotVersionBumper,
194
248
  ): void {
195
249
  this.globalVersion++
196
- this.notifyEntityAndParentSubscribers(key, bumper, new Set())
250
+ this.notifyEntityAndParentSubscribers(key, bumper, this.getWalkedKeys())
251
+ this.notifyGlobalSubscribers()
252
+ }
253
+
254
+ /** A batch shares one walked set, so rows under a common hub walk it once instead of once per row. */
255
+ private getWalkedKeys(): Set<string> {
256
+ if (this.notificationBatchDepth === 0) return new Set()
257
+ const edgeVersion = this.parentKeyLookup?.getMutationVersion() ?? 0
258
+ if (edgeVersion !== this.batchWalkedEdgeVersion) {
259
+ this.batchWalkedKeys.clear()
260
+ this.batchWalkedEdgeVersion = edgeVersion
261
+ }
262
+ return this.batchWalkedKeys
197
263
  }
198
264
 
199
265
  /**
@@ -209,43 +275,21 @@ export class SubscriptionManager implements Rekeyable {
209
275
  private notifyEntityAndParentSubscribers(
210
276
  key: string,
211
277
  bumper: SnapshotVersionBumper,
212
- notifiedKeys: Set<string>,
278
+ walkedKeys: Set<string>,
213
279
  ): void {
214
- // Prevent infinite recursion
215
- if (notifiedKeys.has(key)) return
216
- // Capture "am I the root invocation" BEFORE adding/recursing — the parent
217
- // propagation below grows the shared `notifiedKeys` set, so checking its
218
- // size after recursion would misclassify any child-with-parent as non-root
219
- // and skip the global notification (see issue #51).
220
- const isRoot = notifiedKeys.size === 0
221
- notifiedKeys.add(key)
222
-
223
- // Notify entity-specific subscribers
224
- const entitySubs = this.entitySubscribers.get(key)
225
- if (entitySubs) {
226
- for (const sub of entitySubs) {
227
- sub()
228
- }
229
- }
280
+ if (walkedKeys.has(key)) return
281
+ walkedKeys.add(key)
282
+
283
+ this.notifyEntityDirect(key)
230
284
 
231
- // Notify parent entity subscribers (propagate change up the tree).
232
285
  // Parents are derived from the relation store's LIVE edges, so a
233
286
  // disconnected child no longer reaches its former parent.
234
- const parents = this.getParentKeys(key)
235
- for (const parentKey of parents) {
287
+ for (const parentKey of this.getParentKeys(key)) {
236
288
  // An ancestor reachable through several edges is bumped and walked once.
237
- if (notifiedKeys.has(parentKey)) continue
289
+ if (walkedKeys.has(parentKey)) continue
238
290
  // Bump parent snapshot version so useSyncExternalStore detects a change
239
291
  bumper.bumpEntitySnapshotVersion(parentKey)
240
- this.notifyEntityAndParentSubscribers(parentKey, bumper, notifiedKeys)
241
- }
242
-
243
- // Notify global subscribers (only once, from the root invocation — not
244
- // again for each parent reached via propagation)
245
- if (isRoot) {
246
- for (const sub of this.globalSubscribers) {
247
- sub()
248
- }
292
+ this.notifyEntityAndParentSubscribers(parentKey, bumper, walkedKeys)
249
293
  }
250
294
  }
251
295
 
@@ -262,17 +306,13 @@ export class SubscriptionManager implements Rekeyable {
262
306
  this.globalVersion++
263
307
 
264
308
  // Notify relation-specific subscribers
265
- const relationSubs = this.relationSubscribers.get(key)
266
- if (relationSubs) {
267
- for (const sub of relationSubs) {
268
- sub()
269
- }
270
- }
309
+ this.notifyRelationDirect(key)
271
310
 
272
311
  // Bump entity snapshot version so isEqual detects a change
273
312
  bumper.bumpEntitySnapshotVersion(entityKey)
274
313
 
275
- this.notifyEntityAndParentSubscribers(entityKey, bumper, new Set())
314
+ this.notifyEntityAndParentSubscribers(entityKey, bumper, this.getWalkedKeys())
315
+ this.notifyGlobalSubscribers()
276
316
  }
277
317
 
278
318
  /**
@@ -280,6 +320,10 @@ export class SubscriptionManager implements Rekeyable {
280
320
  * Used during batch imports (e.g., undo/redo).
281
321
  */
282
322
  notifyEntityDirect(key: string): void {
323
+ if (this.notificationBatchDepth > 0) {
324
+ this.pendingEntities.add(key)
325
+ return
326
+ }
283
327
  const subs = this.entitySubscribers.get(key)
284
328
  if (subs) {
285
329
  for (const sub of subs) {
@@ -293,6 +337,10 @@ export class SubscriptionManager implements Rekeyable {
293
337
  * Used during batch imports (e.g., undo/redo).
294
338
  */
295
339
  notifyRelationDirect(key: string): void {
340
+ if (this.notificationBatchDepth > 0) {
341
+ this.pendingRelations.add(key)
342
+ return
343
+ }
296
344
  const subs = this.relationSubscribers.get(key)
297
345
  if (subs) {
298
346
  for (const sub of subs) {
@@ -301,16 +349,6 @@ export class SubscriptionManager implements Rekeyable {
301
349
  }
302
350
  }
303
351
 
304
- /**
305
- * Bumps global version and notifies global subscribers.
306
- */
307
- notifyGlobal(): void {
308
- this.globalVersion++
309
- for (const sub of this.globalSubscribers) {
310
- sub()
311
- }
312
- }
313
-
314
352
  /**
315
353
  * Moves entity and relation subscriptions from oldKey to newKey.
316
354
  * Also rekeys relation subscribers under oldKeyPrefix to newKeyPrefix.
@@ -328,6 +366,7 @@ export class SubscriptionManager implements Rekeyable {
328
366
  }
329
367
  }
330
368
  this.rekeyedKeys.set(oldKey, newKey)
369
+ this.movePendingNotifications(ctx)
331
370
 
332
371
  // Move entity subscribers, merging into anything already subscribed under the new key
333
372
  this.moveSubscribers(this.entitySubscribers, oldKey, newKey)
@@ -354,6 +393,16 @@ export class SubscriptionManager implements Rekeyable {
354
393
  }
355
394
  }
356
395
 
396
+ /** Queued keys follow their subscribers, so a flush never reads the long-lived redirect map. */
397
+ private movePendingNotifications({ oldKey, newKey, oldKeyPrefix, newKeyPrefix }: RekeyContext): void {
398
+ if (this.pendingEntities.delete(oldKey)) this.pendingEntities.add(newKey)
399
+ for (const key of [...this.pendingRelations]) {
400
+ if (!key.startsWith(oldKeyPrefix)) continue
401
+ this.pendingRelations.delete(key)
402
+ this.pendingRelations.add(newKeyPrefix + key.slice(oldKeyPrefix.length))
403
+ }
404
+ }
405
+
357
406
  /**
358
407
  * Re-homes a subscriber set under a new key. The destination may already hold
359
408
  * subscribers (a component mounted on the persisted id before the draft was rekeyed
@@ -372,3 +421,12 @@ export class SubscriptionManager implements Rekeyable {
372
421
  for (const sub of moved) existing.add(sub)
373
422
  }
374
423
  }
424
+
425
+ /** The first subscriber error propagates, as it would unbatched, and the rest are logged; a failed callback's own error wins. */
426
+ function throwSubscriberErrors(errors: readonly unknown[], callbackCompleted: boolean): void {
427
+ const [first, ...rest] = errors
428
+ for (const error of callbackCompleted ? rest : errors) {
429
+ console.error('[Bindx SubscriptionManager] Subscriber error:', error)
430
+ }
431
+ if (callbackCompleted && errors.length > 0) throw first
432
+ }
@@ -0,0 +1,408 @@
1
+ /**
2
+ * The has-many state algebra: the stored shape plus every pure function over it.
3
+ *
4
+ * Separated from {@link HasManyStore}, which owns the keyed map, the write
5
+ * chokepoint and the three indexes. Nothing here touches the store's state.
6
+ *
7
+ * ## Reads are per view, writes are per relation
8
+ *
9
+ * A has-many selected with params (filter/orderBy/limit/offset) is fetched under an
10
+ * auto-generated alias (`tags_a7x9k2`), and several such VIEWS of one relation can be
11
+ * mounted at once. What the server returned differs per view, so {@link HasManyView}
12
+ * is keyed by alias. The pending mutations do NOT: the backend has one `tags` relation
13
+ * and one mutation input for it, so `plannedAdditions` / `plannedRemovals` live at the
14
+ * relation level and the state is keyed by the SCHEMA FIELD NAME alone.
15
+ *
16
+ * The two directions are deliberately asymmetric, because subset-closure only holds
17
+ * one way:
18
+ *
19
+ * - A **removal** says the item is no longer a member of the relation. Every view is
20
+ * a subset of the relation, so the item is hidden in all of them.
21
+ * - An **addition** says the item is a member of the relation, which does not settle
22
+ * whether it belongs in a *filtered* view — the client cannot evaluate the filter.
23
+ * So an addition renders in the views it was made in ({@link PlannedHasManyAddition.origins})
24
+ * plus every view that cannot exclude anything ({@link HasManyView.membership} `'total'`).
25
+ */
26
+
27
+ export type ReconciliationResult = 'applied' | 'conflict'
28
+
29
+ export function setsEqual<T>(a: Set<T>, b: Set<T>): boolean {
30
+ if (a.size !== b.size) return false
31
+ for (const item of a) {
32
+ if (!b.has(item)) return false
33
+ }
34
+ return true
35
+ }
36
+
37
+ export function arraysEqual(a: string[], b: string[]): boolean {
38
+ if (a.length !== b.length) return false
39
+ for (let i = 0; i < a.length; i++) {
40
+ if (a[i] !== b[i]) return false
41
+ }
42
+ return true
43
+ }
44
+
45
+ /**
46
+ * Removal type for has-many items
47
+ */
48
+ export type HasManyRemovalType = 'disconnect' | 'delete'
49
+
50
+ /**
51
+ * Kind of a planned has-many addition:
52
+ * - 'created': a newly created entity (via add())
53
+ * - 'connected': an existing persisted entity being connected (via connect())
54
+ */
55
+ export type HasManyAdditionKind = 'created' | 'connected'
56
+
57
+ /**
58
+ * Whether a view's query args can exclude a member of the relation.
59
+ * - 'total': no filter/limit/offset, so every member belongs in this view.
60
+ * - 'partial': the args may exclude members and the client cannot evaluate them.
61
+ */
62
+ export type HasManyViewMembership = 'total' | 'partial'
63
+
64
+ /** What the server returned for ONE args-view of the relation, plus its local order. */
65
+ export interface HasManyView {
66
+ /** IDs of items the server returned for this view's args */
67
+ serverIds: Set<string>
68
+ /** Explicit ordered list of item IDs, null means use the view's default order */
69
+ orderedIds: string[] | null
70
+ membership: HasManyViewMembership
71
+ }
72
+
73
+ export interface PlannedHasManyAddition {
74
+ kind: HasManyAdditionKind
75
+ /**
76
+ * Aliases of the views the addition was made in — never empty. Presentation only:
77
+ * it never affects what is sent. A `connect()` of the same id can legitimately
78
+ * arrive from two views, and both are entitled to show it, hence a set.
79
+ */
80
+ origins: Set<string>
81
+ }
82
+
83
+ export interface SentHasManyAddition {
84
+ itemId: string
85
+ kind: HasManyAdditionKind
86
+ }
87
+
88
+ export interface SentHasManyRemoval {
89
+ itemId: string
90
+ type: HasManyRemovalType
91
+ }
92
+
93
+ export interface SentHasManyDelta {
94
+ additions: readonly SentHasManyAddition[]
95
+ removals: readonly SentHasManyRemoval[]
96
+ }
97
+
98
+ /**
99
+ * Has-many relation state stored in SnapshotStore, keyed by
100
+ * "parentType:parentId:fieldName" — the schema field name, never an alias.
101
+ */
102
+ export interface StoredHasManyState {
103
+ /** Per-args-view server data, keyed by alias; the unparameterized view uses the field name. */
104
+ views: Map<string, HasManyView>
105
+ /** Planned removals (disconnect or delete) keyed by entity ID. Relation-level. */
106
+ plannedRemovals: Map<string, HasManyRemovalType>
107
+ /**
108
+ * Planned additions keyed by entity ID. Relation-level: these are exactly the
109
+ * operations the next persist will send. The entries whose kind is 'created' are
110
+ * exactly the created entities, so the "created ⊆ connections" invariant is structural.
111
+ */
112
+ plannedAdditions: Map<string, PlannedHasManyAddition>
113
+ version: number
114
+ }
115
+
116
+ /**
117
+ * What every consumer that names the relation the way the schema and the server do
118
+ * sees: persistence, dirty tracking, error paths. Views are not its business, so the
119
+ * baseline it reads is the union across them.
120
+ */
121
+ export interface HasManyRelationProjection {
122
+ serverIds: Set<string>
123
+ plannedRemovals: Map<string, HasManyRemovalType>
124
+ plannedAdditions: Map<string, HasManyAdditionKind>
125
+ version: number
126
+ }
127
+
128
+ /**
129
+ * What is this view's own, as opposed to the relation's: the server rows its args
130
+ * returned and the order its user arranged. The pending writes are not here — they
131
+ * belong to the relation, so consumers read {@link HasManyRelationProjection}.
132
+ */
133
+ export interface HasManyViewProjection {
134
+ serverIds: Set<string>
135
+ orderedIds: string[] | null
136
+ }
137
+
138
+ export function createHasManyView(
139
+ membership: HasManyViewMembership,
140
+ serverIds?: Iterable<string>,
141
+ ): HasManyView {
142
+ return { serverIds: new Set(serverIds ?? []), orderedIds: null, membership }
143
+ }
144
+
145
+ export function cloneHasManyView(view: HasManyView): HasManyView {
146
+ return {
147
+ serverIds: new Set(view.serverIds),
148
+ orderedIds: view.orderedIds ? [...view.orderedIds] : null,
149
+ membership: view.membership,
150
+ }
151
+ }
152
+
153
+ export function cloneViews(views: ReadonlyMap<string, HasManyView>): Map<string, HasManyView> {
154
+ const clone = new Map<string, HasManyView>()
155
+ for (const [alias, view] of views) clone.set(alias, cloneHasManyView(view))
156
+ return clone
157
+ }
158
+
159
+ export function cloneAddition(addition: PlannedHasManyAddition): PlannedHasManyAddition {
160
+ return { kind: addition.kind, origins: new Set(addition.origins) }
161
+ }
162
+
163
+ export function cloneAdditions(
164
+ additions: ReadonlyMap<string, PlannedHasManyAddition>,
165
+ ): Map<string, PlannedHasManyAddition> {
166
+ const clone = new Map<string, PlannedHasManyAddition>()
167
+ for (const [id, addition] of additions) clone.set(id, cloneAddition(addition))
168
+ return clone
169
+ }
170
+
171
+ /**
172
+ * Deep clone. Every path that hands a state out or stores a new one goes through
173
+ * this: {@link SnapshotStore}'s dirty-version memo assumes values are REPLACED, never
174
+ * mutated in place, and with two nesting levels a shallow copy would share mutable
175
+ * Sets across store generations.
176
+ */
177
+ export function cloneHasManyState(state: StoredHasManyState): StoredHasManyState {
178
+ return {
179
+ views: cloneViews(state.views),
180
+ plannedRemovals: new Map(state.plannedRemovals),
181
+ plannedAdditions: cloneAdditions(state.plannedAdditions),
182
+ version: state.version,
183
+ }
184
+ }
185
+
186
+ export function emptyHasManyState(): StoredHasManyState {
187
+ return { views: new Map(), plannedRemovals: new Map(), plannedAdditions: new Map(), version: 0 }
188
+ }
189
+
190
+ /** Whether an addition renders in {@link alias} — see the asymmetry note at the top. */
191
+ export function additionRendersIn(
192
+ views: ReadonlyMap<string, HasManyView>,
193
+ alias: string,
194
+ addition: PlannedHasManyAddition,
195
+ ): boolean {
196
+ if (addition.origins.has(alias)) return true
197
+ return views.get(alias)?.membership === 'total'
198
+ }
199
+
200
+ /** The aliases of views whose args cannot exclude any member of the relation. */
201
+ function totalAliases(views: ReadonlyMap<string, HasManyView>): string[] {
202
+ const aliases: string[] = []
203
+ for (const [alias, view] of views) {
204
+ if (view.membership === 'total') aliases.push(alias)
205
+ }
206
+ return aliases
207
+ }
208
+
209
+ /**
210
+ * The aliases a confirmed member should render in, never empty.
211
+ *
212
+ * {@link preferred} is what the local record says; when nothing is left of it the row
213
+ * falls back to the views that cannot exclude it. If there are none — every mounted
214
+ * view is filtered — it is parked in the unparameterized view, created here if absent:
215
+ * the relation's baseline is the UNION across views, so a row that joins no view is a
216
+ * server row the store has lost. That view is `total` by definition, so parking shows
217
+ * it to nobody it does not belong to.
218
+ */
219
+ function renderTargets(
220
+ views: Map<string, HasManyView>,
221
+ fieldName: string,
222
+ preferred: string[],
223
+ ): string[] {
224
+ if (preferred.length > 0) return preferred
225
+ const totals = totalAliases(views)
226
+ if (totals.length > 0) return totals
227
+ if (!views.has(fieldName)) views.set(fieldName, createHasManyView('total'))
228
+ return [fieldName]
229
+ }
230
+
231
+ /** The aliases of existing views a confirmed addition folds into. */
232
+ export function additionFoldTargets(
233
+ state: StoredHasManyState,
234
+ addition: PlannedHasManyAddition,
235
+ ): string[] {
236
+ const targets: string[] = []
237
+ for (const alias of state.views.keys()) {
238
+ if (additionRendersIn(state.views, alias, addition)) targets.push(alias)
239
+ }
240
+ return targets
241
+ }
242
+
243
+ /**
244
+ * Default order of one view: its server rows minus the relation's planned removals,
245
+ * then the planned additions that render there.
246
+ */
247
+ export function computeViewOrderedIds(state: StoredHasManyState, alias: string): string[] {
248
+ const result: string[] = []
249
+ const view = state.views.get(alias)
250
+
251
+ if (view) {
252
+ for (const id of view.serverIds) {
253
+ if (!state.plannedRemovals.has(id)) result.push(id)
254
+ }
255
+ }
256
+
257
+ for (const [id, addition] of state.plannedAdditions) {
258
+ if (state.plannedRemovals.has(id)) continue
259
+ if (!additionRendersIn(state.views, alias, addition)) continue
260
+ if (!result.includes(id)) result.push(id)
261
+ }
262
+
263
+ return result
264
+ }
265
+
266
+ /** Explicit order of a view if it has one, otherwise its default order. */
267
+ export function viewOrderedIds(state: StoredHasManyState, alias: string): string[] {
268
+ const explicit = state.views.get(alias)?.orderedIds
269
+ return explicit !== null && explicit !== undefined ? [...explicit] : computeViewOrderedIds(state, alias)
270
+ }
271
+
272
+ /**
273
+ * The relation's server baseline: the union across views.
274
+ * Known limitation: views are never dropped, so a view nobody refetches any more can
275
+ * keep a row the server has since removed in it.
276
+ */
277
+ export function relationServerIds(state: StoredHasManyState): Set<string> {
278
+ const ids = new Set<string>()
279
+ for (const view of state.views.values()) {
280
+ for (const id of view.serverIds) ids.add(id)
281
+ }
282
+ return ids
283
+ }
284
+
285
+ export function toRelationProjection(state: StoredHasManyState): HasManyRelationProjection {
286
+ const plannedAdditions = new Map<string, HasManyAdditionKind>()
287
+ for (const [id, addition] of state.plannedAdditions) plannedAdditions.set(id, addition.kind)
288
+ return {
289
+ serverIds: relationServerIds(state),
290
+ plannedRemovals: new Map(state.plannedRemovals),
291
+ plannedAdditions,
292
+ version: state.version,
293
+ }
294
+ }
295
+
296
+ export function toViewProjection(
297
+ state: StoredHasManyState | undefined,
298
+ alias: string,
299
+ ): HasManyViewProjection {
300
+ const view = state?.views.get(alias)
301
+ return {
302
+ serverIds: new Set(view?.serverIds),
303
+ orderedIds: view?.orderedIds ? [...view.orderedIds] : null,
304
+ }
305
+ }
306
+
307
+ /**
308
+ * The single liveness predicate for has-many membership: effective members are
309
+ * (every view's serverIds ∪ plannedAdditions) minus plannedRemovals. Defined once and
310
+ * consumed by the write chokepoint so the forward/reverse index can never drift from it.
311
+ */
312
+ export function liveHasManyChildIds(state: StoredHasManyState | undefined): Set<string> {
313
+ const live = new Set<string>()
314
+ if (!state) return live
315
+ for (const view of state.views.values()) {
316
+ for (const id of view.serverIds) {
317
+ if (!state.plannedRemovals.has(id)) live.add(id)
318
+ }
319
+ }
320
+ for (const id of state.plannedAdditions.keys()) {
321
+ if (!state.plannedRemovals.has(id)) live.add(id)
322
+ }
323
+ return live
324
+ }
325
+
326
+ export function plannedDeleteChildIds(state: StoredHasManyState | undefined): Set<string> {
327
+ const ids = new Set<string>()
328
+ if (!state) return ids
329
+ for (const [id, type] of state.plannedRemovals) {
330
+ if (type === 'delete') ids.add(id)
331
+ }
332
+ return ids
333
+ }
334
+
335
+ export interface HasManyReconciliation {
336
+ state: StoredHasManyState
337
+ result: ReconciliationResult
338
+ }
339
+
340
+ /**
341
+ * Advances the server baseline by the confirmed sent delta and rebases local edits
342
+ * made after the request went out.
343
+ *
344
+ * The rebase logic is relation-level and unchanged by views — "did the user locally
345
+ * undo this while the request was in flight" has one answer per relation. Only the
346
+ * baseline bookkeeping is per view: a confirmed addition joins the views it renders
347
+ * in, a confirmed removal leaves every view.
348
+ */
349
+ export function reconcileHasManyState(
350
+ existing: StoredHasManyState,
351
+ delta: SentHasManyDelta,
352
+ fieldName: string,
353
+ ): HasManyReconciliation {
354
+ const currentLive = liveHasManyChildIds(existing)
355
+ const views = cloneViews(existing.views)
356
+ const plannedAdditions = cloneAdditions(existing.plannedAdditions)
357
+ const plannedRemovals = new Map(existing.plannedRemovals)
358
+ let result: ReconciliationResult = 'applied'
359
+
360
+ for (const addition of delta.additions) {
361
+ // Read the origins before dropping the record. A locally cancelled addition
362
+ // leaves none, and the row then joins only the views that cannot exclude it.
363
+ const planned = plannedAdditions.get(addition.itemId)
364
+ const targets = renderTargets(views, fieldName, planned ? additionFoldTargets(existing, planned) : [])
365
+ for (const alias of targets) views.get(alias)?.serverIds.add(addition.itemId)
366
+
367
+ plannedAdditions.delete(addition.itemId)
368
+ plannedRemovals.delete(addition.itemId)
369
+ if (!currentLive.has(addition.itemId)) {
370
+ plannedRemovals.set(
371
+ addition.itemId,
372
+ addition.kind === 'created' ? 'delete' : 'disconnect',
373
+ )
374
+ }
375
+ }
376
+
377
+ for (const removal of delta.removals) {
378
+ // Which views listed the row, read as the baseline drops it: a rebase below must
379
+ // not claim membership in a filtered view that never showed it.
380
+ const shownIn: string[] = []
381
+ for (const [alias, view] of views) {
382
+ if (view.serverIds.delete(removal.itemId)) shownIn.push(alias)
383
+ }
384
+
385
+ const currentRemoval = plannedRemovals.get(removal.itemId)
386
+ if (currentRemoval === removal.type) plannedRemovals.delete(removal.itemId)
387
+
388
+ if (!currentLive.has(removal.itemId)) {
389
+ if (removal.type === 'delete') plannedRemovals.delete(removal.itemId)
390
+ continue
391
+ }
392
+ if (removal.type === 'delete') {
393
+ result = 'conflict'
394
+ } else if (!plannedAdditions.has(removal.itemId)) {
395
+ // Re-plan the connection where the row actually was, so the rebase is visible
396
+ // where the user is looking without inventing membership elsewhere.
397
+ plannedAdditions.set(removal.itemId, {
398
+ kind: 'connected',
399
+ origins: new Set(renderTargets(views, fieldName, shownIn)),
400
+ })
401
+ }
402
+ }
403
+
404
+ return {
405
+ state: { views, plannedRemovals, plannedAdditions, version: existing.version + 1 },
406
+ result,
407
+ }
408
+ }