@contember/bindx 0.1.46 → 0.1.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/dist/core/ActionDispatcher.d.ts +4 -1
  2. package/dist/core/ActionDispatcher.d.ts.map +1 -1
  3. package/dist/core/actions.d.ts +7 -1
  4. package/dist/core/actions.d.ts.map +1 -1
  5. package/dist/events/EventEmitter.d.ts +13 -1
  6. package/dist/events/EventEmitter.d.ts.map +1 -1
  7. package/dist/handles/BaseHandle.d.ts +14 -3
  8. package/dist/handles/BaseHandle.d.ts.map +1 -1
  9. package/dist/handles/EntityHandle.d.ts +14 -5
  10. package/dist/handles/EntityHandle.d.ts.map +1 -1
  11. package/dist/handles/FieldHandle.d.ts +11 -4
  12. package/dist/handles/FieldHandle.d.ts.map +1 -1
  13. package/dist/handles/HasManyListHandle.d.ts +36 -2
  14. package/dist/handles/HasManyListHandle.d.ts.map +1 -1
  15. package/dist/handles/HasOneHandle.d.ts +52 -9
  16. package/dist/handles/HasOneHandle.d.ts.map +1 -1
  17. package/dist/handles/PlaceholderHandle.d.ts +25 -13
  18. package/dist/handles/PlaceholderHandle.d.ts.map +1 -1
  19. package/dist/handles/index.d.ts +1 -1
  20. package/dist/handles/index.d.ts.map +1 -1
  21. package/dist/handles/proxyFactory.d.ts +7 -1
  22. package/dist/handles/proxyFactory.d.ts.map +1 -1
  23. package/dist/handles/types.d.ts +33 -0
  24. package/dist/handles/types.d.ts.map +1 -1
  25. package/dist/index.d.ts +3 -3
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/persistence/BatchPersister.d.ts +53 -37
  28. package/dist/persistence/BatchPersister.d.ts.map +1 -1
  29. package/dist/persistence/ChangeRegistry.d.ts +15 -0
  30. package/dist/persistence/ChangeRegistry.d.ts.map +1 -1
  31. package/dist/persistence/MutationCollector.d.ts +106 -3
  32. package/dist/persistence/MutationCollector.d.ts.map +1 -1
  33. package/dist/persistence/PersistExecution.d.ts +26 -0
  34. package/dist/persistence/PersistExecution.d.ts.map +1 -0
  35. package/dist/persistence/types.d.ts +8 -2
  36. package/dist/persistence/types.d.ts.map +1 -1
  37. package/dist/store/EntityMetaStore.d.ts +32 -10
  38. package/dist/store/EntityMetaStore.d.ts.map +1 -1
  39. package/dist/store/EntitySnapshotStore.d.ts +50 -3
  40. package/dist/store/EntitySnapshotStore.d.ts.map +1 -1
  41. package/dist/store/ErrorStore.d.ts +3 -2
  42. package/dist/store/ErrorStore.d.ts.map +1 -1
  43. package/dist/store/HasManyStore.d.ts +214 -0
  44. package/dist/store/HasManyStore.d.ts.map +1 -0
  45. package/dist/store/HasOneStore.d.ts +159 -0
  46. package/dist/store/HasOneStore.d.ts.map +1 -0
  47. package/dist/store/ReachabilityAnalyzer.d.ts +19 -0
  48. package/dist/store/ReachabilityAnalyzer.d.ts.map +1 -1
  49. package/dist/store/RekeyOrchestrator.d.ts +76 -0
  50. package/dist/store/RekeyOrchestrator.d.ts.map +1 -0
  51. package/dist/store/RelationEdgeIndex.d.ts +34 -0
  52. package/dist/store/RelationEdgeIndex.d.ts.map +1 -0
  53. package/dist/store/RelationStore.d.ts +74 -137
  54. package/dist/store/RelationStore.d.ts.map +1 -1
  55. package/dist/store/RootRegistry.d.ts +25 -4
  56. package/dist/store/RootRegistry.d.ts.map +1 -1
  57. package/dist/store/SnapshotStore.d.ts +137 -13
  58. package/dist/store/SnapshotStore.d.ts.map +1 -1
  59. package/dist/store/SubscriptionManager.d.ts +63 -14
  60. package/dist/store/SubscriptionManager.d.ts.map +1 -1
  61. package/dist/store/TouchedStore.d.ts +3 -2
  62. package/dist/store/TouchedStore.d.ts.map +1 -1
  63. package/dist/store/relationKey.d.ts +15 -0
  64. package/dist/store/relationKey.d.ts.map +1 -0
  65. package/dist/undo/UndoJournal.d.ts +133 -0
  66. package/dist/undo/UndoJournal.d.ts.map +1 -0
  67. package/dist/undo/UndoManager.d.ts +25 -82
  68. package/dist/undo/UndoManager.d.ts.map +1 -1
  69. package/dist/undo/UnrecordedWriteError.d.ts +16 -0
  70. package/dist/undo/UnrecordedWriteError.d.ts.map +1 -0
  71. package/dist/undo/index.d.ts +3 -1
  72. package/dist/undo/index.d.ts.map +1 -1
  73. package/dist/undo/rekeyJournalEntry.d.ts +20 -0
  74. package/dist/undo/rekeyJournalEntry.d.ts.map +1 -0
  75. package/dist/undo/types.d.ts +0 -48
  76. package/dist/undo/types.d.ts.map +1 -1
  77. package/package.json +2 -2
  78. package/src/core/ActionDispatcher.ts +56 -36
  79. package/src/core/actions.ts +8 -1
  80. package/src/events/EventEmitter.ts +78 -24
  81. package/src/handles/BaseHandle.ts +19 -2
  82. package/src/handles/EntityHandle.ts +48 -17
  83. package/src/handles/FieldHandle.ts +36 -10
  84. package/src/handles/HasManyListHandle.ts +120 -16
  85. package/src/handles/HasOneHandle.ts +177 -44
  86. package/src/handles/PlaceholderHandle.ts +184 -36
  87. package/src/handles/index.ts +3 -0
  88. package/src/handles/proxyFactory.ts +27 -0
  89. package/src/handles/types.ts +39 -0
  90. package/src/index.ts +4 -4
  91. package/src/persistence/BatchPersister.ts +810 -446
  92. package/src/persistence/ChangeRegistry.ts +27 -3
  93. package/src/persistence/MutationCollector.ts +265 -31
  94. package/src/persistence/PersistExecution.ts +217 -0
  95. package/src/persistence/types.ts +8 -2
  96. package/src/store/EntityMetaStore.ts +89 -45
  97. package/src/store/EntitySnapshotStore.ts +102 -19
  98. package/src/store/ErrorStore.ts +6 -5
  99. package/src/store/HasManyStore.ts +810 -0
  100. package/src/store/HasOneStore.ts +454 -0
  101. package/src/store/ReachabilityAnalyzer.ts +37 -0
  102. package/src/store/RekeyOrchestrator.ts +123 -0
  103. package/src/store/RelationEdgeIndex.ts +79 -0
  104. package/src/store/RelationStore.ts +176 -725
  105. package/src/store/RootRegistry.ts +50 -8
  106. package/src/store/SnapshotStore.ts +572 -101
  107. package/src/store/SubscriptionManager.ts +122 -75
  108. package/src/store/TouchedStore.ts +5 -2
  109. package/src/store/relationKey.ts +20 -0
  110. package/src/undo/UndoJournal.ts +258 -0
  111. package/src/undo/UndoManager.ts +151 -281
  112. package/src/undo/UnrecordedWriteError.ts +23 -0
  113. package/src/undo/index.ts +8 -7
  114. package/src/undo/rekeyJournalEntry.ts +145 -0
  115. package/src/undo/types.ts +0 -58
  116. package/dist/core/actionClassification.d.ts +0 -48
  117. package/dist/core/actionClassification.d.ts.map +0 -1
  118. package/src/core/actionClassification.ts +0 -199
@@ -23,6 +23,9 @@ export class ChangeRegistry {
23
23
  /** Subscribers to in-flight state changes */
24
24
  private readonly subscribers = new Set<() => void>()
25
25
 
26
+ /** Memoized {@link getDirtyEntities} result, keyed by the store's dirty version. */
27
+ private dirtyCache: { version: number; result: readonly DirtyEntity[] } | null = null
28
+
26
29
  constructor(private readonly store: SnapshotStore) {}
27
30
 
28
31
  /**
@@ -34,21 +37,41 @@ export class ChangeRegistry {
34
37
 
35
38
  /**
36
39
  * Gets all dirty entities with their change types and dirty fields/relations.
40
+ *
41
+ * The underlying scan walks every entity snapshot, so it is memoized on
42
+ * {@link SnapshotStore.getDirtyVersion} — a global store subscriber reads this
43
+ * synchronously from inside every notification, and list helpers emit one
44
+ * notification per touched item. Within a version the same array instance is
45
+ * returned; callers must treat it as read-only (the type already says so).
46
+ *
47
+ * The key only sees writes that go through the store, so snapshot values must be
48
+ * replaced rather than mutated in place — snapshots are frozen only at the top
49
+ * level. See {@link SnapshotStore.getDirtyVersion}.
37
50
  */
38
51
  getDirtyEntities(): readonly DirtyEntity[] {
39
- const rawDirty = this.store.getAllDirtyEntities()
52
+ const version = this.store.getDirtyVersion()
53
+ const cached = this.dirtyCache
54
+ if (cached !== null && cached.version === version) {
55
+ return cached.result
56
+ }
40
57
 
41
- return rawDirty.map(entity => ({
58
+ const result = this.store.getAllDirtyEntities().map(entity => ({
42
59
  entityType: entity.entityType,
43
60
  entityId: entity.entityId,
44
61
  changeType: entity.changeType,
45
62
  dirtyFields: this.store.getDirtyFields(entity.entityType, entity.entityId),
46
63
  dirtyRelations: this.store.getDirtyRelations(entity.entityType, entity.entityId),
47
64
  }))
65
+
66
+ this.dirtyCache = { version, result }
67
+ return result
48
68
  }
49
69
 
50
70
  /**
51
71
  * Gets dirty entities that are not currently in-flight.
72
+ *
73
+ * Filtered on every call, never memoized: in-flight membership lives on this
74
+ * registry and changes with no store write, so it does not move the dirty version.
52
75
  */
53
76
  getDirtyEntitiesNotInFlight(): readonly DirtyEntity[] {
54
77
  return this.getDirtyEntities().filter(
@@ -126,7 +149,8 @@ export class ChangeRegistry {
126
149
  * Checks if there are any dirty entities.
127
150
  */
128
151
  hasDirtyEntities(): boolean {
129
- return this.store.getAllDirtyEntities().length > 0
152
+ // Shares the memo: this is public API a save button may be built on.
153
+ return this.getDirtyEntities().length > 0
130
154
  }
131
155
 
132
156
  /**
@@ -18,6 +18,59 @@ export interface EntityMutationResult {
18
18
  data?: Record<string, unknown>
19
19
  }
20
20
 
21
+ export interface CollectedNestedEntity {
22
+ readonly entityType: string
23
+ readonly entityId: string
24
+ }
25
+
26
+ export interface CollectedHasOneChange {
27
+ readonly entityType: string
28
+ readonly entityId: string
29
+ readonly fieldName: string
30
+ readonly transition:
31
+ | { readonly operation: 'connect'; readonly targetId: string }
32
+ | { readonly operation: 'create'; readonly targetId: string }
33
+ | { readonly operation: 'disconnect'; readonly targetId: string }
34
+ | { readonly operation: 'delete'; readonly targetId: string }
35
+ }
36
+
37
+ export interface CollectedHasManyChange {
38
+ readonly entityType: string
39
+ readonly entityId: string
40
+ readonly fieldName: string
41
+ readonly additions: readonly { readonly itemId: string; readonly kind: 'created' | 'connected' }[]
42
+ readonly removals: readonly { readonly itemId: string; readonly type: 'delete' | 'disconnect' }[]
43
+ }
44
+
45
+ export interface CollectedRelationField {
46
+ readonly entityType: string
47
+ readonly entityId: string
48
+ readonly fieldName: string
49
+ readonly relationType: 'hasOne' | 'hasMany'
50
+ readonly targetEntityType: string
51
+ }
52
+
53
+ export interface CollectedNestedCreate {
54
+ readonly parentEntityType: string
55
+ readonly parentEntityId: string
56
+ readonly fieldName: string
57
+ readonly relationType: 'hasOne' | 'hasMany'
58
+ readonly entityType: string
59
+ readonly entityId: string
60
+ readonly createData: Readonly<Record<string, unknown>>
61
+ readonly knownServerIds: readonly string[]
62
+ }
63
+
64
+ export interface CollectedNestedUpdate {
65
+ readonly parentEntityType: string
66
+ readonly parentEntityId: string
67
+ readonly fieldName: string
68
+ readonly relationType: 'hasOne' | 'hasMany'
69
+ readonly entityType: string
70
+ readonly entityId: string
71
+ readonly data: Readonly<Record<string, unknown>>
72
+ }
73
+
21
74
  /**
22
75
  * MutationCollector builds Contember-compatible mutation input
23
76
  * by collecting changes from SnapshotStore including:
@@ -30,25 +83,73 @@ export interface EntityMutationResult {
30
83
  * implementing MutationSchemaProvider interface (SchemaRegistry, Contember SchemaNames via adapter).
31
84
  */
32
85
  export class MutationCollector implements MutationDataCollector {
86
+ /** Entities with their own top-level mutation; only their nested update is skipped. */
33
87
  private excludedEntityIds: ReadonlySet<string> = new Set()
88
+ /** Entities vetoed by an `entity:persisting` interceptor; nothing is emitted for them. */
89
+ private vetoedEntityIds: ReadonlySet<string> = new Set()
90
+ /**
91
+ * Relation items whose op was dropped because the item is vetoed, keyed by relation
92
+ * key (`Type:id:field`). The commit step leaves exactly these pending, so the planned
93
+ * create/delete is sent once the veto is lifted instead of being committed unsent.
94
+ */
95
+ private readonly _suppressedRelationItems = new Map<string, Set<string>>()
34
96
  private readonly _nestedEntityIds: Set<string> = new Set()
35
97
  /** Maps nested entity temp IDs to their entity types for post-persist processing */
36
98
  private readonly _nestedEntityTypes: Map<string, string> = new Map()
99
+ private readonly _nestedEntities = new Map<string, CollectedNestedEntity>()
100
+ private readonly _hasOneChanges: CollectedHasOneChange[] = []
101
+ private readonly _hasManyChanges: CollectedHasManyChange[] = []
102
+ private readonly _relationFields = new Map<string, CollectedRelationField>()
103
+ private readonly _nestedCreates: CollectedNestedCreate[] = []
104
+ private readonly _nestedUpdates: CollectedNestedUpdate[] = []
37
105
 
38
106
  constructor(
39
107
  private readonly store: SnapshotStore,
40
108
  private readonly schemaProvider: MutationSchemaProvider,
41
109
  ) {}
42
110
 
111
+ /** Creates an isolated collection session for one persist operation. */
112
+ forkSession(): MutationCollector {
113
+ return new MutationCollector(this.store, this.schemaProvider)
114
+ }
115
+
116
+ private entityKey(entityType: string, entityId: string): string {
117
+ return `${entityType}:${entityId}`
118
+ }
119
+
43
120
  /**
44
- * Sets entity IDs that should be excluded from nested mutation generation.
45
- * These entities get their own top-level mutations, so nested updates are skipped
46
- * to avoid duplicate changes.
121
+ * Sets entity IDs that get their own top-level mutation, so their nested
122
+ * update is skipped to avoid duplicate changes. Relation operations that only
123
+ * exist on the parent (delete, disconnect) are still emitted for them.
47
124
  */
48
125
  setExcludedEntities(ids: ReadonlySet<string>): void {
49
126
  this.excludedEntityIds = ids
50
127
  this._nestedEntityIds.clear()
51
128
  this._nestedEntityTypes.clear()
129
+ this._suppressedRelationItems.clear()
130
+ this._nestedEntities.clear()
131
+ this._hasOneChanges.length = 0
132
+ this._hasManyChanges.length = 0
133
+ this._relationFields.clear()
134
+ this._nestedCreates.length = 0
135
+ this._nestedUpdates.length = 0
136
+ }
137
+
138
+ setExcludedEntityKeys(keys: ReadonlySet<string>): void {
139
+ this.setExcludedEntities(keys)
140
+ }
141
+
142
+ /**
143
+ * Sets entity IDs vetoed by an `entity:persisting` interceptor. Unlike excluded
144
+ * entities they have no top-level mutation either, so every nested operation
145
+ * that would write them — create, update or delete — is dropped.
146
+ */
147
+ setVetoedEntities(ids: ReadonlySet<string>): void {
148
+ this.vetoedEntityIds = ids
149
+ }
150
+
151
+ setVetoedEntityKeys(keys: ReadonlySet<string>): void {
152
+ this.vetoedEntityIds = keys
52
153
  }
53
154
 
54
155
  /**
@@ -68,6 +169,74 @@ export class MutationCollector implements MutationDataCollector {
68
169
  return this._nestedEntityTypes
69
170
  }
70
171
 
172
+ getNestedEntities(): readonly CollectedNestedEntity[] {
173
+ return [...this._nestedEntities.values()]
174
+ }
175
+
176
+ getCollectedHasOneChanges(): readonly CollectedHasOneChange[] {
177
+ return this._hasOneChanges
178
+ }
179
+
180
+ getCollectedHasManyChanges(): readonly CollectedHasManyChange[] {
181
+ return this._hasManyChanges
182
+ }
183
+
184
+ getCollectedRelationFields(): readonly CollectedRelationField[] {
185
+ return [...this._relationFields.values()]
186
+ }
187
+
188
+ getCollectedNestedCreates(): readonly CollectedNestedCreate[] {
189
+ return this._nestedCreates
190
+ }
191
+
192
+ getCollectedNestedUpdates(): readonly CollectedNestedUpdate[] {
193
+ return this._nestedUpdates
194
+ }
195
+
196
+ private recordRelationField(
197
+ entityType: string,
198
+ entityId: string,
199
+ fieldName: string,
200
+ relationType: 'hasOne' | 'hasMany',
201
+ targetEntityType: string | undefined,
202
+ ): void {
203
+ if (!targetEntityType) return
204
+ const key = `${this.entityKey(entityType, entityId)}:${fieldName}`
205
+ this._relationFields.set(key, { entityType, entityId, fieldName, relationType, targetEntityType })
206
+ }
207
+
208
+ private registerNestedEntity(entityType: string, entityId: string): void {
209
+ this._nestedEntityIds.add(entityId)
210
+ this._nestedEntityTypes.set(entityId, entityType)
211
+ this._nestedEntities.set(this.entityKey(entityType, entityId), { entityType, entityId })
212
+ }
213
+
214
+ private isExcluded(entityType: string, entityId: string): boolean {
215
+ return this.excludedEntityIds.has(this.entityKey(entityType, entityId)) || this.excludedEntityIds.has(entityId)
216
+ }
217
+
218
+ private isVetoed(entityType: string, entityId: string): boolean {
219
+ return this.vetoedEntityIds.has(this.entityKey(entityType, entityId)) || this.vetoedEntityIds.has(entityId)
220
+ }
221
+
222
+ /**
223
+ * Returns relation items (by relation key) whose planned op was not emitted because
224
+ * the item is vetoed. BatchPersister keeps these pending when it commits relations.
225
+ */
226
+ getSuppressedRelationItems(): ReadonlyMap<string, ReadonlySet<string>> {
227
+ return this._suppressedRelationItems
228
+ }
229
+
230
+ private suppressRelationItem(entityType: string, entityId: string, fieldName: string, itemId: string): void {
231
+ const key = `${entityType}:${entityId}:${fieldName}`
232
+ const items = this._suppressedRelationItems.get(key)
233
+ if (items) {
234
+ items.add(itemId)
235
+ } else {
236
+ this._suppressedRelationItems.set(key, new Set([itemId]))
237
+ }
238
+ }
239
+
71
240
  // ==================== Main Collection Methods ====================
72
241
 
73
242
  /**
@@ -340,41 +509,67 @@ export class MutationCollector implements MutationDataCollector {
340
509
  fieldName: string,
341
510
  ): Record<string, unknown> | null {
342
511
  const relationState = this.store.getRelation(entityType, entityId, fieldName)
512
+ const targetType = this.schemaProvider.getRelationTarget(entityType, fieldName)
513
+ this.recordRelationField(entityType, entityId, fieldName, 'hasOne', targetType)
343
514
  if (!relationState) {
344
515
  return null
345
516
  }
346
517
 
347
518
  const { state, serverState, currentId, serverId, placeholderData } = relationState
519
+ const record = (transition: CollectedHasOneChange['transition'], data: Record<string, unknown>): Record<string, unknown> => {
520
+ this._hasOneChanges.push({ entityType, entityId, fieldName, transition })
521
+ return data
522
+ }
348
523
 
349
524
  switch (state) {
350
525
  case 'connected':
351
526
  if (currentId !== serverId) {
352
527
  // Check if current entity exists on server
353
528
  if (currentId && this.isExistingEntity(currentId)) {
354
- return { connect: { id: currentId } }
529
+ return record({ operation: 'connect', targetId: currentId }, { connect: { id: currentId } })
355
530
  } else if (currentId && isTempId(currentId)) {
531
+ if (targetType && this.isVetoed(targetType, currentId)) {
532
+ this.suppressRelationItem(entityType, entityId, fieldName, currentId)
533
+ return null
534
+ }
356
535
  // Temp entity — generate inline create with its collected data
357
- this._nestedEntityIds.add(currentId)
358
- const targetType = this.schemaProvider.getRelationTarget(entityType, fieldName)
359
536
  if (targetType) {
360
- this._nestedEntityTypes.set(currentId, targetType)
537
+ this.registerNestedEntity(targetType, currentId)
361
538
  const createData = this.collectCreateData(targetType, currentId)
362
- return { create: createData ?? {} }
539
+ this._nestedCreates.push({
540
+ parentEntityType: entityType,
541
+ parentEntityId: entityId,
542
+ fieldName,
543
+ relationType: 'hasOne',
544
+ entityType: targetType,
545
+ entityId: currentId,
546
+ createData: createData ?? {},
547
+ knownServerIds: [],
548
+ })
549
+ return record({ operation: 'create', targetId: currentId }, { create: createData ?? {} })
363
550
  }
364
- return { create: {} }
551
+ return record({ operation: 'create', targetId: currentId }, { create: {} })
365
552
  } else if (currentId) {
366
- return { connect: { id: currentId } }
553
+ return record({ operation: 'connect', targetId: currentId }, { connect: { id: currentId } })
367
554
  }
368
555
  } else if (currentId && serverId && currentId === serverId) {
369
- // Skip if entity has its own top-level mutation
370
- if (this.excludedEntityIds.has(currentId)) {
556
+ // Skip if entity has its own top-level mutation or was vetoed
557
+ if (targetType && (this.isExcluded(targetType, currentId) || this.isVetoed(targetType, currentId))) {
371
558
  return null
372
559
  }
373
560
  // Same entity - check if we need to update it
374
- const targetType = this.schemaProvider.getRelationTarget(entityType, fieldName)
375
561
  if (targetType) {
376
562
  const nestedChanges = this.collectUpdateData(targetType, currentId)
377
563
  if (nestedChanges) {
564
+ this._nestedUpdates.push({
565
+ parentEntityType: entityType,
566
+ parentEntityId: entityId,
567
+ fieldName,
568
+ relationType: 'hasOne',
569
+ entityType: targetType,
570
+ entityId: currentId,
571
+ data: nestedChanges,
572
+ })
378
573
  return { update: nestedChanges }
379
574
  }
380
575
  }
@@ -384,13 +579,19 @@ export class MutationCollector implements MutationDataCollector {
384
579
  case 'disconnected':
385
580
  // Only emit disconnect if server had a connection
386
581
  if (serverState === 'connected' && serverId !== null) {
387
- return { disconnect: true }
582
+ return record({ operation: 'disconnect', targetId: serverId }, { disconnect: true })
388
583
  }
389
584
  return null
390
585
 
391
586
  case 'deleted':
587
+ if (serverId !== null) {
588
+ if (targetType && this.isVetoed(targetType, serverId)) {
589
+ this.suppressRelationItem(entityType, entityId, fieldName, serverId)
590
+ return null
591
+ }
592
+ }
392
593
  // Delete the related entity
393
- return { delete: true }
594
+ return serverId === null ? null : record({ operation: 'delete', targetId: serverId }, { delete: true })
394
595
 
395
596
  case 'creating':
396
597
  // Empty placeholderData is a legitimate no-op (user opened a create
@@ -458,41 +659,62 @@ export class MutationCollector implements MutationDataCollector {
458
659
  fieldName: string,
459
660
  ): Array<Record<string, unknown>> | null {
460
661
  const hasManyState = this.store.getHasMany(entityType, entityId, fieldName)
662
+ const targetType = this.schemaProvider.getRelationTarget(entityType, fieldName)
663
+ this.recordRelationField(entityType, entityId, fieldName, 'hasMany', targetType)
461
664
  if (!hasManyState) return null
462
665
 
463
666
  const operations: Array<Record<string, unknown>> = []
464
- const targetType = this.schemaProvider.getRelationTarget(entityType, fieldName)
667
+ const additions: Array<{ itemId: string; kind: 'created' | 'connected' }> = []
668
+ const removals: Array<{ itemId: string; type: 'delete' | 'disconnect' }> = []
465
669
 
466
670
  // Planned removals -> disconnect/delete
467
671
  for (const [removedId, removalType] of hasManyState.plannedRemovals) {
468
672
  if (removalType === 'delete') {
673
+ if (targetType && this.isVetoed(targetType, removedId)) {
674
+ this.suppressRelationItem(entityType, entityId, fieldName, removedId)
675
+ continue
676
+ }
469
677
  operations.push({ delete: { id: removedId }, alias: removedId })
678
+ removals.push({ itemId: removedId, type: 'delete' })
470
679
  } else {
471
680
  operations.push({ disconnect: { id: removedId }, alias: removedId })
681
+ removals.push({ itemId: removedId, type: 'disconnect' })
472
682
  }
473
683
  }
474
684
 
475
- // Created entities -> create (using collectCreateData for full recursive collection)
476
- if (targetType) {
477
- for (const tempId of hasManyState.createdEntities) {
478
- this._nestedEntityIds.add(tempId)
479
- this._nestedEntityTypes.set(tempId, targetType)
480
- const createData = this.collectCreateData(targetType, tempId)
481
- operations.push({ create: createData ?? {}, alias: tempId })
685
+ // Planned additions -> create (newly created) or connect (existing persisted)
686
+ for (const [additionId, kind] of hasManyState.plannedAdditions) {
687
+ if (kind === 'created') {
688
+ if (targetType && this.isVetoed(targetType, additionId)) {
689
+ this.suppressRelationItem(entityType, entityId, fieldName, additionId)
690
+ continue
691
+ }
692
+ if (!targetType) continue
693
+ this.registerNestedEntity(targetType, additionId)
694
+ const createData = this.collectCreateData(targetType, additionId)
695
+ this._nestedCreates.push({
696
+ parentEntityType: entityType,
697
+ parentEntityId: entityId,
698
+ fieldName,
699
+ relationType: 'hasMany',
700
+ entityType: targetType,
701
+ entityId: additionId,
702
+ createData: createData ?? {},
703
+ knownServerIds: [...hasManyState.serverIds],
704
+ })
705
+ operations.push({ create: createData ?? {}, alias: additionId })
706
+ additions.push({ itemId: additionId, kind: 'created' })
707
+ } else {
708
+ operations.push({ connect: { id: additionId }, alias: additionId })
709
+ additions.push({ itemId: additionId, kind: 'connected' })
482
710
  }
483
711
  }
484
712
 
485
- // Planned connections (minus created entities) -> connect
486
- for (const connectedId of hasManyState.plannedConnections) {
487
- if (hasManyState.createdEntities.has(connectedId)) continue
488
- operations.push({ connect: { id: connectedId }, alias: connectedId })
489
- }
490
-
491
713
  // Server items that aren't removed -> check for updates via entity snapshots
492
714
  if (targetType) {
493
715
  for (const itemId of hasManyState.serverIds) {
494
716
  if (hasManyState.plannedRemovals.has(itemId)) continue
495
- if (this.excludedEntityIds.has(itemId)) continue
717
+ if (this.isExcluded(targetType, itemId) || this.isVetoed(targetType, itemId)) continue
496
718
 
497
719
  const itemSnapshot = this.store.getEntitySnapshot(targetType, itemId)
498
720
  if (!itemSnapshot) continue
@@ -509,6 +731,15 @@ export class MutationCollector implements MutationDataCollector {
509
731
  }
510
732
 
511
733
  if (Object.keys(changes).length > 0) {
734
+ this._nestedUpdates.push({
735
+ parentEntityType: entityType,
736
+ parentEntityId: entityId,
737
+ fieldName,
738
+ relationType: 'hasMany',
739
+ entityType: targetType,
740
+ entityId: itemId,
741
+ data: changes,
742
+ })
512
743
  operations.push({
513
744
  update: {
514
745
  by: { id: itemId },
@@ -520,6 +751,9 @@ export class MutationCollector implements MutationDataCollector {
520
751
  }
521
752
  }
522
753
 
754
+ if (additions.length > 0 || removals.length > 0) {
755
+ this._hasManyChanges.push({ entityType, entityId, fieldName, additions, removals })
756
+ }
523
757
  return operations.length > 0 ? operations : null
524
758
  }
525
759
 
@@ -723,7 +957,7 @@ export class MutationCollector implements MutationDataCollector {
723
957
 
724
958
  // Skip if store already has managed entities for this relation
725
959
  const existing = this.store.getHasMany(entityType, entityId, fieldName)
726
- if (existing && (existing.createdEntities.size > 0 || existing.plannedConnections.size > 0)) {
960
+ if (existing && existing.plannedAdditions.size > 0) {
727
961
  return
728
962
  }
729
963