@contember/bindx 0.1.52 → 0.1.54

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 (49) 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/store/HasManyStore.d.ts +94 -78
  15. package/dist/store/HasManyStore.d.ts.map +1 -1
  16. package/dist/store/RelationStore.d.ts +21 -12
  17. package/dist/store/RelationStore.d.ts.map +1 -1
  18. package/dist/store/ResponseOccurrenceIndex.d.ts +65 -0
  19. package/dist/store/ResponseOccurrenceIndex.d.ts.map +1 -0
  20. package/dist/store/SnapshotStore.d.ts +49 -14
  21. package/dist/store/SnapshotStore.d.ts.map +1 -1
  22. package/dist/store/hasManyState.d.ts +164 -0
  23. package/dist/store/hasManyState.d.ts.map +1 -0
  24. package/dist/store/relationKey.d.ts +11 -0
  25. package/dist/store/relationKey.d.ts.map +1 -1
  26. package/dist/undo/UndoJournal.d.ts.map +1 -1
  27. package/dist/undo/rekeyJournalEntry.d.ts +4 -4
  28. package/dist/undo/rekeyJournalEntry.d.ts.map +1 -1
  29. package/dist/utils/aliasGenerator.d.ts +1 -1
  30. package/dist/utils/aliasGenerator.d.ts.map +1 -1
  31. package/package.json +2 -2
  32. package/src/core/ActionDispatcher.ts +0 -1
  33. package/src/core/EntityLoader.ts +7 -0
  34. package/src/core/actions.ts +1 -3
  35. package/src/dataview/types.ts +6 -6
  36. package/src/handles/EntityHandle.ts +11 -0
  37. package/src/handles/HasManyListHandle.ts +15 -17
  38. package/src/handles/HasOneHandle.ts +4 -2
  39. package/src/index.ts +6 -0
  40. package/src/store/HasManyStore.ts +312 -488
  41. package/src/store/RelationStore.ts +60 -24
  42. package/src/store/ResponseOccurrenceIndex.ts +224 -0
  43. package/src/store/SnapshotStore.ts +144 -73
  44. package/src/store/hasManyState.ts +408 -0
  45. package/src/store/relationKey.ts +17 -0
  46. package/src/undo/UndoJournal.ts +3 -1
  47. package/src/undo/UndoManager.ts +1 -1
  48. package/src/undo/rekeyJournalEntry.ts +31 -43
  49. package/src/utils/aliasGenerator.ts +1 -1
@@ -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
+ }
@@ -9,6 +9,23 @@ export function parentKeyFromRelationKey(relationKey: string): string {
9
9
  return relationKey.slice(0, lastSeparator)
10
10
  }
11
11
 
12
+ /**
13
+ * Derives the parent entity type from a relation key ("parentType:parentId:fieldName").
14
+ * Entity type names never contain ':', so the type is everything before the first separator.
15
+ */
16
+ export function entityTypeFromRelationKey(relationKey: string): string {
17
+ return relationKey.slice(0, relationKey.indexOf(':'))
18
+ }
19
+
20
+ /**
21
+ * Derives the field name from a relation key ("parentType:parentId:fieldName").
22
+ * The counterpart of {@link parentKeyFromRelationKey}: entity ids and field names
23
+ * never contain ':', so the field is everything after the last separator.
24
+ */
25
+ export function fieldFromRelationKey(relationKey: string): string {
26
+ return relationKey.slice(relationKey.lastIndexOf(':') + 1)
27
+ }
28
+
12
29
  /**
13
30
  * Derives the parent composite key ("parentType:parentId") from an owner key
14
31
  * prefix ("parentType:parentId:") by dropping the trailing separator. Callers
@@ -113,7 +113,9 @@ function collectDetachedSeedIds(active: Map<string, JournalCellImage>): Set<stri
113
113
  if (image.kind === 'relation') {
114
114
  if (image.state?.currentId) ids.add(image.state.currentId)
115
115
  } else if (image.kind === 'hasMany' && image.state) {
116
- if (image.state.orderedIds) for (const id of image.state.orderedIds) ids.add(id)
116
+ for (const view of image.state.views.values()) {
117
+ if (view.orderedIds) for (const id of view.orderedIds) ids.add(id)
118
+ }
117
119
  for (const id of image.state.plannedAdditions.keys()) ids.add(id)
118
120
  for (const id of image.state.plannedRemovals.keys()) ids.add(id)
119
121
  }
@@ -214,7 +214,7 @@ export class UndoManager {
214
214
  * so stored cells keep valid keys / id references (and sealed creates drop out).
215
215
  */
216
216
  private rekeyStacks(ctx: RekeyContext): void {
217
- const liveServerIds = (key: string): Set<string> => this.store.getLiveHasManyServerIds(key)
217
+ const liveServerIds = (key: string): ReadonlyMap<string, ReadonlySet<string>> => this.store.getLiveHasManyServerIds(key)
218
218
  const undoCount = this.undoStack.length
219
219
  const redoCount = this.redoStack.length
220
220
  const hadPending = this.pending !== null && this.pending.size > 0
@@ -1,7 +1,7 @@
1
1
  import type { RekeyContext } from '../store/RekeyOrchestrator.js'
2
2
  import { createEntitySnapshot } from '../store/snapshots.js'
3
3
  import type { EntitySnapshot } from '../store/snapshots.js'
4
- import type { StoredHasManyState } from '../store/RelationStore.js'
4
+ import { cloneHasManyState, type StoredHasManyState } from '../store/hasManyState.js'
5
5
  import type { JournalEntry, JournalCellImage } from './UndoJournal.js'
6
6
 
7
7
  /**
@@ -15,11 +15,11 @@ import type { JournalEntry, JournalCellImage } from './UndoJournal.js'
15
15
  * server-backed row — only later edits to it remain undoable.
16
16
  */
17
17
  /**
18
- * Looks up the live server-member ids of a has-many list by its key. Lets the
19
- * rekey rebase a pre-image when the just-persisted create has become a permanent
20
- * member of that list.
18
+ * Looks up the live server-member ids of a has-many relation by its key, per
19
+ * args-view. Lets the rekey rebase a pre-image when the just-persisted create has
20
+ * become a permanent member of that list.
21
21
  */
22
- export type LiveServerIdsLookup = (relationKey: string) => Set<string>
22
+ export type LiveServerIdsLookup = (relationKey: string) => ReadonlyMap<string, ReadonlySet<string>>
23
23
 
24
24
  export function rekeyJournalEntry(
25
25
  entry: JournalEntry,
@@ -77,19 +77,19 @@ function rekeyCell(
77
77
 
78
78
  const key = rekeyKey(cell.key, ctx)
79
79
  if (!cell.state) return { ...cell, key }
80
- let state = rekeyHasManyState(cell.state, ctx)
80
+ const state = rekeyHasManyState(cell.state, ctx)
81
81
  // Membership rebase: when the just-persisted create became a permanent member of
82
- // this live list, fold it into the (older) pre-image so undo keeps it instead of
83
- // dropping it. Default order picks it up automatically; an explicit order needs
84
- // the id appended.
85
- if (liveServerIds(key).has(ctx.newId) && !state.serverIds.has(ctx.newId)) {
86
- const serverIds = new Set(state.serverIds)
87
- serverIds.add(ctx.newId)
88
- let orderedIds = state.orderedIds
89
- if (orderedIds && !orderedIds.includes(ctx.newId)) {
90
- orderedIds = [...orderedIds, ctx.newId]
82
+ // a live view, fold it into the (older) pre-image of that view so undo keeps it
83
+ // instead of dropping it. Default order picks it up automatically; an explicit
84
+ // order needs the id appended. Views born after the gesture are left alone —
85
+ // the pre-image says nothing about them.
86
+ const live = liveServerIds(key)
87
+ for (const [alias, view] of state.views) {
88
+ if (!live.get(alias)?.has(ctx.newId) || view.serverIds.has(ctx.newId)) continue
89
+ view.serverIds.add(ctx.newId)
90
+ if (view.orderedIds && !view.orderedIds.includes(ctx.newId)) {
91
+ view.orderedIds = [...view.orderedIds, ctx.newId]
91
92
  }
92
- state = { ...state, serverIds, orderedIds }
93
93
  }
94
94
  return { ...cell, key, state }
95
95
  }
@@ -105,41 +105,29 @@ function rekeySnapshotId(snapshot: EntitySnapshot, ctx: RekeyContext): EntitySna
105
105
  )
106
106
  }
107
107
 
108
- function swapInSet(set: Set<string>, oldId: string, newId: string): Set<string> {
109
- if (!set.has(oldId)) return set
110
- const next = new Set(set)
111
- next.delete(oldId)
112
- next.add(newId)
113
- return next
114
- }
115
-
108
+ /** Returns a deep copy with oldId swapped for newId everywhere it appears. */
116
109
  function rekeyHasManyState(state: StoredHasManyState, ctx: RekeyContext): StoredHasManyState {
117
- const serverIds = swapInSet(state.serverIds, ctx.oldId, ctx.newId)
110
+ const next = cloneHasManyState(state)
118
111
 
119
- let orderedIds = state.orderedIds
120
- if (orderedIds) {
121
- const idx = orderedIds.indexOf(ctx.oldId)
122
- if (idx !== -1) {
123
- orderedIds = [...orderedIds]
124
- orderedIds[idx] = ctx.newId
112
+ for (const view of next.views.values()) {
113
+ if (view.serverIds.delete(ctx.oldId)) view.serverIds.add(ctx.newId)
114
+ if (view.orderedIds) {
115
+ const idx = view.orderedIds.indexOf(ctx.oldId)
116
+ if (idx !== -1) view.orderedIds[idx] = ctx.newId
125
117
  }
126
118
  }
127
119
 
128
- let plannedAdditions = state.plannedAdditions
129
- const additionKind = plannedAdditions.get(ctx.oldId)
130
- if (additionKind !== undefined) {
131
- plannedAdditions = new Map(plannedAdditions)
132
- plannedAdditions.delete(ctx.oldId)
133
- plannedAdditions.set(ctx.newId, additionKind)
120
+ const addition = next.plannedAdditions.get(ctx.oldId)
121
+ if (addition) {
122
+ next.plannedAdditions.delete(ctx.oldId)
123
+ next.plannedAdditions.set(ctx.newId, addition)
134
124
  }
135
125
 
136
- let plannedRemovals = state.plannedRemovals
137
- const removalType = plannedRemovals.get(ctx.oldId)
126
+ const removalType = next.plannedRemovals.get(ctx.oldId)
138
127
  if (removalType !== undefined) {
139
- plannedRemovals = new Map(plannedRemovals)
140
- plannedRemovals.delete(ctx.oldId)
141
- plannedRemovals.set(ctx.newId, removalType)
128
+ next.plannedRemovals.delete(ctx.oldId)
129
+ next.plannedRemovals.set(ctx.newId, removalType)
142
130
  }
143
131
 
144
- return { serverIds, orderedIds, plannedRemovals, plannedAdditions, version: state.version }
132
+ return next
145
133
  }
@@ -1 +1 @@
1
- export { generateHasManyAlias } from '@contember/bindx-client'
1
+ export { canHasManyParamsExcludeMembers, generateHasManyAlias } from '@contember/bindx-client'