@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
@@ -1,115 +1,45 @@
1
- import { parentKeyFromOwnerPrefix, parentKeyFromRelationKey } from './relationKey.js'
1
+ import { entityTypeFromRelationKey, fieldFromRelationKey, parentKeyFromOwnerPrefix, parentKeyFromRelationKey } from './relationKey.js'
2
2
  import { PlannedDeleteIndex } from './PlannedDeleteIndex.js'
3
3
  import { RelationEdgeIndex } from './RelationEdgeIndex.js'
4
4
  import { RelationOwnerIndex } from './RelationOwnerIndex.js'
5
-
6
- type ReconciliationResult = 'applied' | 'conflict'
7
-
8
- function setsEqual<T>(a: Set<T>, b: Set<T>): boolean {
9
- if (a.size !== b.size) return false
10
- for (const item of a) {
11
- if (!b.has(item)) return false
12
- }
13
- return true
14
- }
15
-
16
- function arraysEqual(a: string[], b: string[]): boolean {
17
- if (a.length !== b.length) return false
18
- for (let i = 0; i < a.length; i++) {
19
- if (a[i] !== b[i]) return false
20
- }
21
- return true
22
- }
23
-
24
- /**
25
- * Removal type for has-many items
26
- */
27
- export type HasManyRemovalType = 'disconnect' | 'delete'
28
-
29
- /**
30
- * Kind of a planned has-many addition:
31
- * - 'created': a newly created entity (via add())
32
- * - 'connected': an existing persisted entity being connected (via connect())
33
- */
34
- export type HasManyAdditionKind = 'created' | 'connected'
35
-
36
- export interface SentHasManyAddition {
37
- itemId: string
38
- kind: HasManyAdditionKind
39
- }
40
-
41
- export interface SentHasManyRemoval {
42
- itemId: string
43
- type: HasManyRemovalType
44
- }
45
-
46
- export interface SentHasManyDelta {
47
- additions: readonly SentHasManyAddition[]
48
- removals: readonly SentHasManyRemoval[]
49
- }
50
-
51
- /**
52
- * Has-many list state stored in SnapshotStore
53
- */
54
- export interface StoredHasManyState {
55
- /** IDs of items from server */
56
- serverIds: Set<string>
57
- /** Explicit ordered list of item IDs, null means use default order (serverIds + plannedAdditions) */
58
- orderedIds: string[] | null
59
- /** Planned removals (disconnect or delete) keyed by entity ID */
60
- plannedRemovals: Map<string, HasManyRemovalType>
61
- /**
62
- * Planned additions (IDs to add to the list) keyed by entity ID, with the
63
- * value distinguishing newly CREATED entities (add()) from existing PERSISTED
64
- * entities being CONNECTED (connect()). The keys are exactly the connections;
65
- * the keys whose value is 'created' are exactly the created entities, so the
66
- * "created ⊆ connections" invariant is structural.
67
- */
68
- plannedAdditions: Map<string, HasManyAdditionKind>
69
- version: number
70
- }
71
-
72
- function cloneHasManyState(state: StoredHasManyState): StoredHasManyState {
73
- return {
74
- serverIds: new Set(state.serverIds),
75
- orderedIds: state.orderedIds ? [...state.orderedIds] : null,
76
- plannedRemovals: new Map(state.plannedRemovals),
77
- plannedAdditions: new Map(state.plannedAdditions),
78
- version: state.version,
79
- }
80
- }
5
+ import {
6
+ additionFoldTargets,
7
+ additionRendersIn,
8
+ arraysEqual,
9
+ cloneHasManyState,
10
+ computeViewOrderedIds,
11
+ createHasManyView,
12
+ emptyHasManyState,
13
+ liveHasManyChildIds,
14
+ plannedDeleteChildIds,
15
+ reconcileHasManyState,
16
+ setsEqual,
17
+ toRelationProjection,
18
+ toViewProjection,
19
+ viewOrderedIds,
20
+ type HasManyRelationProjection,
21
+ type HasManyRemovalType,
22
+ type HasManyViewMembership,
23
+ type HasManyViewProjection,
24
+ type PlannedHasManyAddition,
25
+ type ReconciliationResult,
26
+ type SentHasManyDelta,
27
+ type StoredHasManyState,
28
+ } from './hasManyState.js'
81
29
 
82
30
  /**
83
- * Computes the default ordered IDs for a has-many relation.
84
- * Order is: serverIds (minus removals) + plannedAdditions
85
- */
86
- export function computeDefaultOrderedIds(state: StoredHasManyState): string[] {
87
- const result: string[] = []
88
-
89
- for (const id of state.serverIds) {
90
- if (!state.plannedRemovals.has(id)) {
91
- result.push(id)
92
- }
93
- }
94
-
95
- for (const id of state.plannedAdditions.keys()) {
96
- if (!result.includes(id)) {
97
- result.push(id)
98
- }
99
- }
100
-
101
- return result
102
- }
103
-
104
- /**
105
- * Owns has-many list state ("parentType:parentId:fieldName" → {@link StoredHasManyState}).
31
+ * Owns has-many relation state ("parentType:parentId:fieldName" → {@link StoredHasManyState}).
32
+ *
33
+ * The key's last segment is always the SCHEMA FIELD NAME; an args-view's alias
34
+ * addresses a view INSIDE the state, never a key of its own. See the module doc of
35
+ * `hasManyState.ts` for why reads are per view and writes are not.
106
36
  *
107
37
  * Has its own monotonic {@link mutationVersion} bumped on every actual write
108
38
  * (funnelled through {@link writeHasMany} plus the delete/clear paths); the
109
39
  * facade sums this with the has-one counter for {@link ReachabilityAnalyzer}.
110
40
  */
111
41
  export class HasManyStore {
112
- /** Has-many list states keyed by "parentType:parentId:fieldName" */
42
+ /** Has-many relation states keyed by "parentType:parentId:fieldName" */
113
43
  private readonly hasManyStates = new Map<string, StoredHasManyState>()
114
44
  private readonly owners = new RelationOwnerIndex()
115
45
 
@@ -191,178 +121,154 @@ export class HasManyStore {
191
121
  }
192
122
 
193
123
  /**
194
- * Gets or creates has-many list state.
124
+ * Membership declared for an args-view, keyed by "entityType:fieldName:alias" — one
125
+ * entry per distinct selection in the application, so it does not grow with the data.
126
+ * The alias alone would not do: two fields, or two entities' fields of the same name,
127
+ * may carry the same explicit `as`.
128
+ *
129
+ * Only the caller that selected the relation knows its params, and the alias is a
130
+ * hash it cannot reconstruct them from. {@link EntityHandle} declares them before
131
+ * it hands out a handle, which is strictly before any path can materialize the view.
195
132
  */
196
- getOrCreateHasMany(key: string, serverIds?: string[]): StoredHasManyState {
197
- const existing = this.hasManyStates.get(key)
198
- if (!existing) {
199
- this.writeHasMany(key, {
200
- serverIds: new Set(serverIds ?? []),
201
- orderedIds: null,
202
- plannedRemovals: new Map(),
203
- plannedAdditions: new Map(),
204
- version: 0,
205
- })
206
- } else if (serverIds !== undefined) {
207
- const newServerIds = new Set(serverIds)
208
- if (!setsEqual(existing.serverIds, newServerIds)) {
209
- this.writeHasMany(key, {
210
- ...existing,
211
- serverIds: newServerIds,
212
- orderedIds: null,
213
- version: existing.version + 1,
214
- })
215
- }
216
- }
133
+ private readonly declaredMembership = new Map<string, HasManyViewMembership>()
217
134
 
218
- return cloneHasManyState(this.hasManyStates.get(key)!)
135
+ declareViewMembership(
136
+ entityType: string,
137
+ fieldName: string,
138
+ alias: string,
139
+ membership: HasManyViewMembership,
140
+ ): void {
141
+ this.declaredMembership.set(`${entityType}:${fieldName}:${alias}`, membership)
219
142
  }
220
143
 
221
144
  /**
222
- * Gets has-many list state.
145
+ * A view addressed by the field name itself is the unparameterized selection, so
146
+ * its args cannot exclude any member of the relation. Anything else needs a
147
+ * declaration; without one the view is assumed filtered, which is the safe
148
+ * direction — the client then never claims membership it cannot prove.
223
149
  */
224
- getHasMany(key: string): StoredHasManyState | undefined {
225
- const state = this.hasManyStates.get(key)
226
- return state ? cloneHasManyState(state) : undefined
150
+ private viewMembership(key: string, alias: string): HasManyViewMembership {
151
+ const fieldName = fieldFromRelationKey(key)
152
+ if (alias === fieldName) return 'total'
153
+ return this.declaredMembership.get(`${entityTypeFromRelationKey(key)}:${fieldName}:${alias}`) ?? 'partial'
227
154
  }
228
155
 
229
156
  /**
230
- * Sets server IDs for a has-many relation.
157
+ * Starts an edit: a deep copy of the current state (or a fresh one), with the
158
+ * addressed view materialized. Every mutator builds on this, so no path can hand
159
+ * {@link writeHasMany} a state sharing mutable structure with the live one — which
160
+ * SnapshotStore's dirty-version memo and the undo journal's images both rely on.
231
161
  */
232
- setHasManyServerIds(key: string, serverIds: string[]): void {
162
+ private editableState(key: string, alias?: string): StoredHasManyState {
233
163
  const existing = this.hasManyStates.get(key)
234
-
235
- if (!existing) {
236
- this.writeHasMany(key, {
237
- serverIds: new Set(serverIds),
238
- orderedIds: null,
239
- plannedRemovals: new Map(),
240
- plannedAdditions: new Map(),
241
- version: 0,
242
- })
243
- } else {
244
- this.writeHasMany(key, {
245
- ...existing,
246
- serverIds: new Set(serverIds),
247
- orderedIds: null,
248
- version: existing.version + 1,
249
- })
164
+ const state = existing ? cloneHasManyState(existing) : emptyHasManyState()
165
+ if (alias !== undefined && !state.views.has(alias)) {
166
+ state.views.set(alias, createHasManyView(this.viewMembership(key, alias)))
250
167
  }
168
+ return state
251
169
  }
252
170
 
253
- /**
254
- * Plans a removal for a has-many item.
255
- */
256
- planHasManyRemoval(key: string, itemId: string, type: HasManyRemovalType): void {
171
+ private commitEdit(key: string, state: StoredHasManyState): void {
257
172
  const existing = this.hasManyStates.get(key)
258
-
259
- if (!existing) {
260
- this.writeHasMany(key, {
261
- serverIds: new Set(),
262
- orderedIds: null,
263
- plannedRemovals: new Map([[itemId, type]]),
264
- plannedAdditions: new Map(),
265
- version: 0,
266
- })
267
- } else {
268
- const newPlannedRemovals = new Map(existing.plannedRemovals)
269
- newPlannedRemovals.set(itemId, type)
270
- const newPlannedAdditions = new Map(existing.plannedAdditions)
271
- newPlannedAdditions.delete(itemId)
272
- let newOrderedIds = existing.orderedIds
273
- if (newOrderedIds !== null) {
274
- newOrderedIds = newOrderedIds.filter(id => id !== itemId)
275
- }
276
- this.writeHasMany(key, {
277
- ...existing,
278
- orderedIds: newOrderedIds,
279
- plannedRemovals: newPlannedRemovals,
280
- plannedAdditions: newPlannedAdditions,
281
- version: existing.version + 1,
282
- })
283
- }
284
- this.editableWriteVersion++
173
+ state.version = existing ? existing.version + 1 : 0
174
+ this.writeHasMany(key, state)
285
175
  }
286
176
 
287
177
  /**
288
- * Plans a connection for a has-many item.
178
+ * Gets or creates the relation state and the addressed view, refreshing that
179
+ * view's server baseline when one is supplied.
289
180
  */
290
- planHasManyConnection(key: string, itemId: string): void {
291
- const existing = this.hasManyStates.get(key)
181
+ getOrCreateHasMany(key: string, alias: string, serverIds?: string[]): void {
182
+ const view = this.hasManyStates.get(key)?.views.get(alias)
183
+ if (view && (serverIds === undefined || setsEqual(view.serverIds, new Set(serverIds)))) {
184
+ return
185
+ }
292
186
 
293
- if (!existing) {
294
- this.writeHasMany(key, {
295
- serverIds: new Set(),
296
- orderedIds: null,
297
- plannedRemovals: new Map(),
298
- plannedAdditions: new Map([[itemId, 'connected']]),
299
- version: 0,
300
- })
301
- } else {
302
- const newPlannedAdditions = new Map(existing.plannedAdditions)
303
- // Do not downgrade an existing 'created' addition to 'connected'.
304
- if (newPlannedAdditions.get(itemId) !== 'created') {
305
- newPlannedAdditions.set(itemId, 'connected')
306
- }
307
- const newPlannedRemovals = new Map(existing.plannedRemovals)
308
- newPlannedRemovals.delete(itemId)
309
- let newOrderedIds = existing.orderedIds
310
- if (newOrderedIds !== null && !newOrderedIds.includes(itemId)) {
311
- newOrderedIds = [...newOrderedIds, itemId]
312
- }
313
- this.writeHasMany(key, {
314
- ...existing,
315
- orderedIds: newOrderedIds,
316
- plannedAdditions: newPlannedAdditions,
317
- plannedRemovals: newPlannedRemovals,
318
- version: existing.version + 1,
319
- })
187
+ const state = this.editableState(key, alias)
188
+ if (serverIds !== undefined) {
189
+ const next = state.views.get(alias)!
190
+ next.serverIds = new Set(serverIds)
191
+ // A refreshed baseline invalidates only THIS view's manual ordering.
192
+ next.orderedIds = null
320
193
  }
321
- this.editableWriteVersion++
194
+ this.commitEdit(key, state)
322
195
  }
323
196
 
324
- /**
325
- * Commits has-many state after successful persist.
326
- */
327
- commitHasMany(key: string, newServerIds: string[]): void {
328
- const existing = this.hasManyStates.get(key)
197
+ /** Relation state, deep-copied. For the export / undo paths that move whole states. */
198
+ getHasMany(key: string): StoredHasManyState | undefined {
199
+ const state = this.hasManyStates.get(key)
200
+ return state ? cloneHasManyState(state) : undefined
201
+ }
202
+
203
+ /** What persistence, dirty tracking and error paths see: one relation, no views. */
204
+ getRelationProjection(key: string): HasManyRelationProjection | undefined {
205
+ const state = this.hasManyStates.get(key)
206
+ return state ? toRelationProjection(state) : undefined
207
+ }
329
208
 
330
- this.writeHasMany(key, {
331
- serverIds: new Set(newServerIds),
332
- orderedIds: null,
333
- plannedRemovals: new Map(),
334
- plannedAdditions: new Map(),
335
- version: (existing?.version ?? 0) + 1,
336
- })
209
+ /** What one mounted has-many handle sees. */
210
+ getViewProjection(key: string, alias: string): HasManyViewProjection {
211
+ return toViewProjection(this.hasManyStates.get(key), alias)
212
+ }
213
+
214
+ /** Whether the relation carries writes the next persist would send. */
215
+ hasPendingWrites(key: string): boolean {
216
+ const state = this.hasManyStates.get(key)
217
+ if (!state) return false
218
+ return state.plannedRemovals.size > 0 || state.plannedAdditions.size > 0
219
+ }
220
+
221
+ /** Whether one view carries an order its user arranged rather than the default one. */
222
+ hasExplicitOrder(key: string, alias: string): boolean {
223
+ const orderedIds = this.hasManyStates.get(key)?.views.get(alias)?.orderedIds
224
+ return orderedIds !== null && orderedIds !== undefined
337
225
  }
338
226
 
339
227
  /**
340
- * Advances the server baseline by the confirmed sent delta and rebases local
341
- * edits made after the request started onto that new baseline.
228
+ * Per-view server baselines by reference, for callers that only read them.
229
+ *
230
+ * Not cloned, deliberately: every mutator builds on {@link editableState}, so a
231
+ * stored Set is never mutated in place — only replaced. A handed-out reference can
232
+ * therefore go stale but can never be written through.
342
233
  */
343
- reconcileSentDelta(key: string, delta: SentHasManyDelta): ReconciliationResult {
344
- const existing = this.hasManyStates.get(key)
345
- if (!existing) return 'conflict'
234
+ collectViewServerIds(key: string): ReadonlyMap<string, ReadonlySet<string>> {
235
+ const byView = new Map<string, ReadonlySet<string>>()
236
+ const state = this.hasManyStates.get(key)
237
+ if (state) {
238
+ for (const [alias, view] of state.views) byView.set(alias, view.serverIds)
239
+ }
240
+ return byView
241
+ }
346
242
 
347
- const next = reconcileHasManyState(existing, delta)
348
- this.writeHasMany(key, next.state)
349
- return next.result
243
+ /** Replaces one view's server baseline. */
244
+ setHasManyServerIds(key: string, alias: string, serverIds: string[]): void {
245
+ const state = this.editableState(key, alias)
246
+ const view = state.views.get(alias)!
247
+ view.serverIds = new Set(serverIds)
248
+ view.orderedIds = null
249
+ this.commitEdit(key, state)
350
250
  }
351
251
 
352
252
  /**
353
- * Resets has-many state to server state (clears planned operations).
253
+ * Plans a removal. Relation-level: the item stops being a member of the relation,
254
+ * so it leaves every view's ordering too.
354
255
  */
355
- resetHasMany(key: string): void {
356
- const existing = this.hasManyStates.get(key)
357
- if (!existing) return
256
+ planHasManyRemoval(key: string, itemId: string, type: HasManyRemovalType): void {
257
+ const state = this.editableState(key)
258
+ state.plannedRemovals.set(itemId, type)
259
+ state.plannedAdditions.delete(itemId)
260
+ this.dropFromEveryExplicitOrder(state, itemId)
261
+ this.commitEdit(key, state)
262
+ this.editableWriteVersion++
263
+ }
358
264
 
359
- this.writeHasMany(key, {
360
- serverIds: existing.serverIds,
361
- orderedIds: null,
362
- plannedRemovals: new Map(),
363
- plannedAdditions: new Map(),
364
- version: existing.version + 1,
365
- })
265
+ /**
266
+ * Plans a connection of an existing entity, rendered in the view it was made in.
267
+ */
268
+ planHasManyConnection(key: string, alias: string, itemId: string): void {
269
+ const state = this.editableState(key, alias)
270
+ this.recordAddition(state, alias, itemId, 'connected')
271
+ this.commitEdit(key, state)
366
272
  this.editableWriteVersion++
367
273
  }
368
274
 
@@ -370,29 +276,14 @@ export class HasManyStore {
370
276
  * Adds a newly created entity to a has-many relation.
371
277
  * Used by HasManyListHandle.add() for inline entity creation.
372
278
  */
373
- addToHasMany(key: string, itemId: string): void {
374
- const existing = this.hasManyStates.get(key)
375
-
376
- if (!existing) {
377
- this.writeHasMany(key, {
378
- serverIds: new Set(),
379
- orderedIds: [itemId],
380
- plannedRemovals: new Map(),
381
- plannedAdditions: new Map([[itemId, 'created']]),
382
- version: 0,
383
- })
384
- } else {
385
- const newPlannedAdditions = new Map(existing.plannedAdditions)
386
- newPlannedAdditions.set(itemId, 'created')
387
- const currentOrderedIds = existing.orderedIds ?? computeDefaultOrderedIds(existing)
388
- const newOrderedIds = [...currentOrderedIds, itemId]
389
- this.writeHasMany(key, {
390
- ...existing,
391
- orderedIds: newOrderedIds,
392
- plannedAdditions: newPlannedAdditions,
393
- version: existing.version + 1,
394
- })
395
- }
279
+ addToHasMany(key: string, alias: string, itemId: string): void {
280
+ const state = this.editableState(key, alias)
281
+ // The order is materialized BEFORE the addition is recorded, so the append
282
+ // cannot duplicate an id the default order would by then already contain.
283
+ const order = viewOrderedIds(state, alias)
284
+ this.recordAddition(state, alias, itemId, 'created')
285
+ state.views.get(alias)!.orderedIds = [...order, itemId]
286
+ this.commitEdit(key, state)
396
287
  this.editableWriteVersion++
397
288
  }
398
289
 
@@ -401,45 +292,92 @@ export class HasManyStore {
401
292
  * Unlike addToHasMany, records the addition as 'connected' (not 'created') —
402
293
  * used for materializing embedded connect references to existing entities.
403
294
  */
404
- connectExistingToHasMany(key: string, itemId: string): void {
405
- const existing = this.hasManyStates.get(key)
295
+ connectExistingToHasMany(key: string, alias: string, itemId: string): void {
296
+ const fresh = !this.hasManyStates.has(key)
297
+ const state = this.editableState(key, alias)
298
+ this.recordAddition(state, alias, itemId, 'connected')
299
+ if (fresh) {
300
+ state.views.get(alias)!.orderedIds = [itemId]
301
+ }
302
+ this.commitEdit(key, state)
303
+ this.editableWriteVersion++
304
+ }
406
305
 
407
- if (!existing) {
408
- this.writeHasMany(key, {
409
- serverIds: new Set(),
410
- orderedIds: [itemId],
411
- plannedRemovals: new Map(),
412
- plannedAdditions: new Map([[itemId, 'connected']]),
413
- version: 0,
414
- })
415
- } else {
416
- const newPlannedAdditions = new Map(existing.plannedAdditions)
417
- // Do not downgrade an existing 'created' addition to 'connected'.
418
- if (newPlannedAdditions.get(itemId) !== 'created') {
419
- newPlannedAdditions.set(itemId, 'connected')
306
+ /**
307
+ * Records an addition and the view it renders in. Never downgrades an existing
308
+ * 'created' addition to 'connected', and cancels a pending removal of the same
309
+ * item — leaving both recorded would hide a listed item from the live-edge index.
310
+ */
311
+ private recordAddition(
312
+ state: StoredHasManyState,
313
+ alias: string,
314
+ itemId: string,
315
+ kind: 'created' | 'connected',
316
+ ): void {
317
+ const existing = state.plannedAdditions.get(itemId)
318
+ const addition = existing ?? { kind, origins: new Set<string>() }
319
+ addition.origins.add(alias)
320
+ if (kind === 'created') addition.kind = 'created'
321
+ state.plannedAdditions.set(itemId, addition)
322
+ state.plannedRemovals.delete(itemId)
323
+ this.appendToShowingExplicitOrders(state, itemId, addition)
324
+ }
325
+
326
+ /**
327
+ * An explicit order replaces the default one, which is where additions otherwise
328
+ * appear, so every view that shows the item must list it — not only the view it was
329
+ * made in. That includes a view whose own server rows hold it: a removal dropped it
330
+ * from every explicit order, and re-connecting it must give it back. Guards against
331
+ * re-appending an id that is already listed: the connect paths re-run whenever an
332
+ * embedded reference is re-materialized.
333
+ */
334
+ private appendToShowingExplicitOrders(
335
+ state: StoredHasManyState,
336
+ itemId: string,
337
+ addition: PlannedHasManyAddition,
338
+ ): void {
339
+ for (const [alias, view] of state.views) {
340
+ if (view.orderedIds === null || view.orderedIds.includes(itemId)) continue
341
+ if (view.serverIds.has(itemId) || additionRendersIn(state.views, alias, addition)) {
342
+ view.orderedIds = [...view.orderedIds, itemId]
420
343
  }
421
- // Only touch an explicit order — the default order already derives from
422
- // plannedAdditions (see computeDefaultOrderedIds). Guard against
423
- // re-appending an id that is already listed: this path re-runs whenever an
424
- // embedded connect reference is re-materialized, and an unconditional
425
- // append would surface the same item twice (mirrors planHasManyConnection).
426
- // A re-connect cancels a pending removal of the same item (mirrors
427
- // planHasManyConnection); leaving both recorded hides the item from the
428
- // live-edge index while it is still listed.
429
- const newPlannedRemovals = new Map(existing.plannedRemovals)
430
- newPlannedRemovals.delete(itemId)
431
- let newOrderedIds = existing.orderedIds
432
- if (newOrderedIds !== null && !newOrderedIds.includes(itemId)) {
433
- newOrderedIds = [...newOrderedIds, itemId]
344
+ }
345
+ }
346
+
347
+ private dropFromEveryExplicitOrder(state: StoredHasManyState, itemId: string): void {
348
+ for (const view of state.views.values()) {
349
+ if (view.orderedIds !== null) {
350
+ view.orderedIds = view.orderedIds.filter(id => id !== itemId)
434
351
  }
435
- this.writeHasMany(key, {
436
- ...existing,
437
- orderedIds: newOrderedIds,
438
- plannedAdditions: newPlannedAdditions,
439
- plannedRemovals: newPlannedRemovals,
440
- version: existing.version + 1,
441
- })
442
352
  }
353
+ }
354
+
355
+ /**
356
+ * Advances the server baseline by the confirmed sent delta and rebases local
357
+ * edits made after the request started onto that new baseline.
358
+ */
359
+ reconcileSentDelta(key: string, delta: SentHasManyDelta): ReconciliationResult {
360
+ const existing = this.hasManyStates.get(key)
361
+ if (!existing) return 'conflict'
362
+
363
+ const next = reconcileHasManyState(existing, delta, fieldFromRelationKey(key))
364
+ this.writeHasMany(key, next.state)
365
+ return next.result
366
+ }
367
+
368
+ /**
369
+ * Resets has-many state to server state (clears planned operations).
370
+ * Relation-level: planned removals carry no view, so a per-view reset could not
371
+ * know which of them to drop.
372
+ */
373
+ resetHasMany(key: string): void {
374
+ if (!this.hasManyStates.has(key)) return
375
+
376
+ const state = this.editableState(key)
377
+ state.plannedRemovals.clear()
378
+ state.plannedAdditions.clear()
379
+ for (const view of state.views.values()) view.orderedIds = null
380
+ this.commitEdit(key, state)
443
381
  this.editableWriteVersion++
444
382
  }
445
383
 
@@ -453,52 +391,40 @@ export class HasManyStore {
453
391
  const existing = this.hasManyStates.get(key)
454
392
  if (!existing) return false
455
393
 
456
- const isCreatedEntity = existing.plannedAdditions.get(itemId) === 'created'
457
-
458
- if (isCreatedEntity) {
459
- const newPlannedAdditions = new Map(existing.plannedAdditions)
460
- newPlannedAdditions.delete(itemId)
461
- let newOrderedIds = existing.orderedIds
462
- if (newOrderedIds !== null) {
463
- newOrderedIds = newOrderedIds.filter(id => id !== itemId)
464
- }
394
+ if (existing.plannedAdditions.get(itemId)?.kind !== 'created') {
395
+ // planHasManyRemoval bumps editableWriteVersion itself.
396
+ this.planHasManyRemoval(key, itemId, removalType)
397
+ return true
398
+ }
465
399
 
466
- const newState: StoredHasManyState = {
467
- ...existing,
468
- orderedIds: newOrderedIds,
469
- plannedAdditions: newPlannedAdditions,
470
- version: existing.version + 1,
471
- }
400
+ // Cancelling a pending create cancels it in every view at once: the entity is
401
+ // going away, so leaving it listed elsewhere would show a row nothing persists.
402
+ const state = this.editableState(key)
403
+ state.plannedAdditions.delete(itemId)
404
+ this.dropFromEveryExplicitOrder(state, itemId)
472
405
 
473
- if (
474
- newPlannedAdditions.size === 0 &&
475
- existing.plannedRemovals.size === 0 &&
476
- newOrderedIds !== null
477
- ) {
478
- const defaultOrder = computeDefaultOrderedIds(newState)
479
- if (arraysEqual(newOrderedIds, defaultOrder)) {
480
- newState.orderedIds = null
406
+ if (state.plannedAdditions.size === 0 && state.plannedRemovals.size === 0) {
407
+ for (const [alias, view] of state.views) {
408
+ if (view.orderedIds !== null && arraysEqual(view.orderedIds, computeViewOrderedIds(state, alias))) {
409
+ view.orderedIds = null
481
410
  }
482
411
  }
483
-
484
- this.writeHasMany(key, newState)
485
- this.editableWriteVersion++
486
- return true
487
- } else {
488
- // planHasManyRemoval bumps editableWriteVersion itself.
489
- this.planHasManyRemoval(key, itemId, removalType)
490
- return true
491
412
  }
413
+
414
+ this.commitEdit(key, state)
415
+ this.editableWriteVersion++
416
+ return true
492
417
  }
493
418
 
494
419
  /**
495
- * Moves an item within a has-many relation from one index to another.
420
+ * Moves an item within one view of a has-many relation. Ordering is presentation
421
+ * only — nothing persists it — so it stays per view.
496
422
  */
497
- moveInHasMany(key: string, fromIndex: number, toIndex: number): void {
423
+ moveInHasMany(key: string, alias: string, fromIndex: number, toIndex: number): void {
498
424
  const existing = this.hasManyStates.get(key)
499
425
  if (!existing) return
500
426
 
501
- const currentOrderedIds = existing.orderedIds ?? computeDefaultOrderedIds(existing)
427
+ const currentOrderedIds = viewOrderedIds(existing, alias)
502
428
 
503
429
  if (fromIndex < 0 || fromIndex >= currentOrderedIds.length) return
504
430
  if (toIndex < 0 || toIndex >= currentOrderedIds.length) return
@@ -509,26 +435,19 @@ export class HasManyStore {
509
435
  if (movedItem === undefined) return
510
436
  newOrderedIds.splice(toIndex, 0, movedItem)
511
437
 
512
- this.writeHasMany(key, {
513
- ...existing,
514
- orderedIds: newOrderedIds,
515
- version: existing.version + 1,
516
- })
438
+ const state = this.editableState(key, alias)
439
+ state.views.get(alias)!.orderedIds = newOrderedIds
440
+ this.commitEdit(key, state)
517
441
  this.editableWriteVersion++
518
442
  }
519
443
 
520
444
  /**
521
- * Gets the ordered list of item IDs for a has-many relation.
445
+ * Gets the ordered list of item IDs shown by one view.
522
446
  */
523
- getHasManyOrderedIds(key: string): string[] {
447
+ getHasManyOrderedIds(key: string, alias: string): string[] {
524
448
  const existing = this.hasManyStates.get(key)
525
449
  if (!existing) return []
526
-
527
- if (existing.orderedIds !== null) {
528
- return [...existing.orderedIds]
529
- }
530
-
531
- return computeDefaultOrderedIds(existing)
450
+ return viewOrderedIds(existing, alias)
532
451
  }
533
452
 
534
453
  /**
@@ -583,38 +502,39 @@ export class HasManyStore {
583
502
  *
584
503
  * `pendingItems` (by relation key) names planned additions/removals that were NOT
585
504
  * sent — they stay planned instead of being folded into the server baseline.
505
+ *
506
+ * A confirmed removal leaves every view; a confirmed addition joins the views it
507
+ * renders in. Only the views that actually changed lose their manual ordering —
508
+ * an untouched sibling view keeps the order its user arranged.
586
509
  */
587
510
  commitAllRelations(keyPrefix: string, pendingItems?: ReadonlyMap<string, ReadonlySet<string>>): void {
588
- for (const [key, state] of this.hasManyStates) {
511
+ for (const [key, existing] of this.hasManyStates) {
589
512
  if (!key.startsWith(keyPrefix)) continue
590
513
 
591
514
  const pending = pendingItems?.get(key)
592
- const newServerIds = new Set(state.serverIds)
593
- const keptRemovals = new Map<string, HasManyRemovalType>()
594
- const keptAdditions = new Map<string, HasManyAdditionKind>()
595
-
596
- for (const [removedId, type] of state.plannedRemovals) {
597
- if (pending?.has(removedId)) {
598
- keptRemovals.set(removedId, type)
599
- } else {
600
- newServerIds.delete(removedId)
515
+ const state = cloneHasManyState(existing)
516
+ const touched = new Set<string>()
517
+
518
+ for (const removedId of existing.plannedRemovals.keys()) {
519
+ if (pending?.has(removedId)) continue
520
+ state.plannedRemovals.delete(removedId)
521
+ for (const [alias, view] of state.views) {
522
+ if (view.serverIds.delete(removedId)) touched.add(alias)
601
523
  }
602
524
  }
603
- for (const [addedId, kind] of state.plannedAdditions) {
604
- if (pending?.has(addedId)) {
605
- keptAdditions.set(addedId, kind)
606
- } else {
607
- newServerIds.add(addedId)
525
+ for (const [addedId, addition] of existing.plannedAdditions) {
526
+ if (pending?.has(addedId)) continue
527
+ state.plannedAdditions.delete(addedId)
528
+ for (const alias of additionFoldTargets(existing, addition)) {
529
+ state.views.get(alias)!.serverIds.add(addedId)
530
+ touched.add(alias)
608
531
  }
609
532
  }
610
533
 
611
- this.writeHasMany(key, {
612
- serverIds: newServerIds,
613
- orderedIds: null,
614
- plannedRemovals: keptRemovals,
615
- plannedAdditions: keptAdditions,
616
- version: state.version + 1,
617
- })
534
+ for (const alias of touched) state.views.get(alias)!.orderedIds = null
535
+
536
+ state.version = existing.version + 1
537
+ this.writeHasMany(key, state)
618
538
  }
619
539
  }
620
540
 
@@ -664,13 +584,9 @@ export class HasManyStore {
664
584
  importHasManyStates(states: Map<string, StoredHasManyState>): string[] {
665
585
  const keys: string[] = []
666
586
  for (const [key, state] of states) {
667
- this.writeHasMany(key, {
668
- serverIds: new Set(state.serverIds),
669
- orderedIds: state.orderedIds ? [...state.orderedIds] : null,
670
- plannedRemovals: new Map(state.plannedRemovals),
671
- plannedAdditions: new Map(state.plannedAdditions),
672
- version: state.version + 1,
673
- })
587
+ const imported = cloneHasManyState(state)
588
+ imported.version = state.version + 1
589
+ this.writeHasMany(key, imported)
674
590
  keys.push(key)
675
591
  }
676
592
  return keys
@@ -678,58 +594,47 @@ export class HasManyStore {
678
594
 
679
595
  /**
680
596
  * Replaces all occurrences of oldId with newId across has-many states
681
- * (serverIds, orderedIds, plannedAdditions, plannedRemovals).
597
+ * (every view's serverIds/orderedIds, plannedAdditions, plannedRemovals).
682
598
  */
683
599
  replaceEntityId(oldId: string, newId: string): void {
684
- for (const [key, state] of this.hasManyStates) {
600
+ for (const [key, existing] of this.hasManyStates) {
685
601
  let changed = false
602
+ const state = cloneHasManyState(existing)
686
603
 
687
- let serverIds = state.serverIds
688
- if (serverIds.has(oldId)) {
689
- serverIds = new Set(serverIds)
690
- serverIds.delete(oldId)
691
- serverIds.add(newId)
692
- changed = true
693
- }
694
-
695
- let orderedIds = state.orderedIds
696
- if (orderedIds) {
697
- const idx = orderedIds.indexOf(oldId)
698
- if (idx !== -1) {
699
- orderedIds = [...orderedIds]
700
- orderedIds[idx] = newId
604
+ for (const view of state.views.values()) {
605
+ if (view.serverIds.delete(oldId)) {
606
+ view.serverIds.add(newId)
701
607
  changed = true
702
608
  }
609
+ if (view.orderedIds) {
610
+ const idx = view.orderedIds.indexOf(oldId)
611
+ if (idx !== -1) {
612
+ view.orderedIds[idx] = newId
613
+ changed = true
614
+ }
615
+ }
703
616
  }
704
617
 
705
- let plannedAdditions = state.plannedAdditions
706
- if (plannedAdditions.has(oldId)) {
707
- plannedAdditions = new Map(plannedAdditions)
708
- plannedAdditions.delete(oldId)
618
+ const addition = state.plannedAdditions.get(oldId)
619
+ if (addition) {
620
+ state.plannedAdditions.delete(oldId)
709
621
  // The id only changes when the item was persisted, so a still-planned
710
622
  // 'created' addition (the item went out on its own, not nested in this
711
623
  // parent) must now connect the server row rather than create a second one.
712
- plannedAdditions.set(newId, 'connected')
624
+ state.plannedAdditions.set(newId, { kind: 'connected', origins: addition.origins })
713
625
  changed = true
714
626
  }
715
627
 
716
- let plannedRemovals = state.plannedRemovals
717
- if (plannedRemovals.has(oldId)) {
718
- const removalType = plannedRemovals.get(oldId)!
719
- plannedRemovals = new Map(plannedRemovals)
720
- plannedRemovals.delete(oldId)
721
- plannedRemovals.set(newId, removalType)
628
+ const removalType = state.plannedRemovals.get(oldId)
629
+ if (removalType !== undefined) {
630
+ state.plannedRemovals.delete(oldId)
631
+ state.plannedRemovals.set(newId, removalType)
722
632
  changed = true
723
633
  }
724
634
 
725
635
  if (changed) {
726
- this.writeHasMany(key, {
727
- serverIds,
728
- orderedIds,
729
- plannedRemovals,
730
- plannedAdditions,
731
- version: state.version + 1,
732
- })
636
+ state.version = existing.version + 1
637
+ this.writeHasMany(key, state)
733
638
  }
734
639
  }
735
640
  }
@@ -763,84 +668,3 @@ export class HasManyStore {
763
668
  this.mutationVersion++
764
669
  }
765
670
  }
766
-
767
- /**
768
- * The single liveness predicate for has-many membership: effective members are
769
- * (serverIds ∪ plannedAdditions) minus plannedRemovals. Defined once and consumed
770
- * by the write chokepoint so the forward/reverse index can never drift from it.
771
- */
772
- function liveHasManyChildIds(state: StoredHasManyState | undefined): Set<string> {
773
- const live = new Set<string>()
774
- if (!state) return live
775
- for (const id of state.serverIds) {
776
- if (!state.plannedRemovals.has(id)) live.add(id)
777
- }
778
- for (const id of state.plannedAdditions.keys()) {
779
- if (!state.plannedRemovals.has(id)) live.add(id)
780
- }
781
- return live
782
- }
783
-
784
- function plannedDeleteChildIds(state: StoredHasManyState | undefined): Set<string> {
785
- const ids = new Set<string>()
786
- if (!state) return ids
787
- for (const [id, type] of state.plannedRemovals) {
788
- if (type === 'delete') ids.add(id)
789
- }
790
- return ids
791
- }
792
-
793
- interface HasManyReconciliation {
794
- state: StoredHasManyState
795
- result: ReconciliationResult
796
- }
797
-
798
- function reconcileHasManyState(
799
- existing: StoredHasManyState,
800
- delta: SentHasManyDelta,
801
- ): HasManyReconciliation {
802
- const currentLive = liveHasManyChildIds(existing)
803
- const serverIds = new Set(existing.serverIds)
804
- const plannedAdditions = new Map(existing.plannedAdditions)
805
- const plannedRemovals = new Map(existing.plannedRemovals)
806
- let result: ReconciliationResult = 'applied'
807
-
808
- for (const addition of delta.additions) {
809
- serverIds.add(addition.itemId)
810
- plannedAdditions.delete(addition.itemId)
811
- plannedRemovals.delete(addition.itemId)
812
- if (!currentLive.has(addition.itemId)) {
813
- plannedRemovals.set(
814
- addition.itemId,
815
- addition.kind === 'created' ? 'delete' : 'disconnect',
816
- )
817
- }
818
- }
819
-
820
- for (const removal of delta.removals) {
821
- serverIds.delete(removal.itemId)
822
- const currentRemoval = plannedRemovals.get(removal.itemId)
823
- if (currentRemoval === removal.type) plannedRemovals.delete(removal.itemId)
824
-
825
- if (!currentLive.has(removal.itemId)) {
826
- if (removal.type === 'delete') plannedRemovals.delete(removal.itemId)
827
- continue
828
- }
829
- if (removal.type === 'delete') {
830
- result = 'conflict'
831
- } else if (!plannedAdditions.has(removal.itemId)) {
832
- plannedAdditions.set(removal.itemId, 'connected')
833
- }
834
- }
835
-
836
- return {
837
- state: {
838
- serverIds,
839
- orderedIds: existing.orderedIds ? [...existing.orderedIds] : null,
840
- plannedRemovals,
841
- plannedAdditions,
842
- version: existing.version + 1,
843
- },
844
- result,
845
- }
846
- }