@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.
- package/dist/core/ActionDispatcher.d.ts.map +1 -1
- package/dist/core/EntityLoader.d.ts +7 -0
- package/dist/core/EntityLoader.d.ts.map +1 -1
- package/dist/core/actions.d.ts +1 -2
- package/dist/core/actions.d.ts.map +1 -1
- package/dist/dataview/types.d.ts +6 -6
- package/dist/dataview/types.d.ts.map +1 -1
- package/dist/handles/EntityHandle.d.ts.map +1 -1
- package/dist/handles/HasManyListHandle.d.ts +8 -0
- package/dist/handles/HasManyListHandle.d.ts.map +1 -1
- package/dist/handles/HasOneHandle.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/persistence/BatchPersister.d.ts.map +1 -1
- package/dist/store/HasManyStore.d.ts +94 -78
- package/dist/store/HasManyStore.d.ts.map +1 -1
- package/dist/store/RelationStore.d.ts +21 -12
- package/dist/store/RelationStore.d.ts.map +1 -1
- package/dist/store/ResponseOccurrenceIndex.d.ts +65 -0
- package/dist/store/ResponseOccurrenceIndex.d.ts.map +1 -0
- package/dist/store/SnapshotStore.d.ts +53 -15
- package/dist/store/SnapshotStore.d.ts.map +1 -1
- package/dist/store/SubscriptionManager.d.ts +19 -4
- package/dist/store/SubscriptionManager.d.ts.map +1 -1
- package/dist/store/hasManyState.d.ts +164 -0
- package/dist/store/hasManyState.d.ts.map +1 -0
- package/dist/store/relationKey.d.ts +11 -0
- package/dist/store/relationKey.d.ts.map +1 -1
- package/dist/undo/UndoJournal.d.ts.map +1 -1
- package/dist/undo/rekeyJournalEntry.d.ts +4 -4
- package/dist/undo/rekeyJournalEntry.d.ts.map +1 -1
- package/dist/utils/aliasGenerator.d.ts +1 -1
- package/dist/utils/aliasGenerator.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/core/ActionDispatcher.ts +0 -1
- package/src/core/EntityLoader.ts +13 -6
- package/src/core/actions.ts +1 -3
- package/src/dataview/types.ts +6 -6
- package/src/handles/EntityHandle.ts +11 -0
- package/src/handles/HasManyListHandle.ts +15 -17
- package/src/handles/HasOneHandle.ts +4 -2
- package/src/index.ts +6 -0
- package/src/persistence/BatchPersister.ts +15 -11
- package/src/store/HasManyStore.ts +312 -488
- package/src/store/RelationStore.ts +60 -24
- package/src/store/ResponseOccurrenceIndex.ts +224 -0
- package/src/store/SnapshotStore.ts +183 -109
- package/src/store/SubscriptionManager.ts +116 -58
- package/src/store/hasManyState.ts +408 -0
- package/src/store/relationKey.ts +17 -0
- package/src/undo/UndoJournal.ts +3 -1
- package/src/undo/UndoManager.ts +1 -1
- package/src/undo/rekeyJournalEntry.ts +31 -43
- 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
|
-
|
|
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
|
|
149
|
-
|
|
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,
|
|
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
|
-
|
|
278
|
+
walkedKeys: Set<string>,
|
|
213
279
|
): void {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
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 (
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
+
}
|