@contember/bindx 0.1.46 → 0.1.48
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/adapter/MockAdapter.d.ts +4 -1
- package/dist/adapter/MockAdapter.d.ts.map +1 -1
- package/dist/core/ActionDispatcher.d.ts +4 -1
- package/dist/core/ActionDispatcher.d.ts.map +1 -1
- package/dist/core/actions.d.ts +7 -1
- package/dist/core/actions.d.ts.map +1 -1
- package/dist/events/EventEmitter.d.ts +13 -1
- package/dist/events/EventEmitter.d.ts.map +1 -1
- package/dist/handles/BaseHandle.d.ts +14 -3
- package/dist/handles/BaseHandle.d.ts.map +1 -1
- package/dist/handles/EntityHandle.d.ts +14 -5
- package/dist/handles/EntityHandle.d.ts.map +1 -1
- package/dist/handles/FieldHandle.d.ts +11 -4
- package/dist/handles/FieldHandle.d.ts.map +1 -1
- package/dist/handles/HasManyListHandle.d.ts +36 -2
- package/dist/handles/HasManyListHandle.d.ts.map +1 -1
- package/dist/handles/HasOneHandle.d.ts +52 -9
- package/dist/handles/HasOneHandle.d.ts.map +1 -1
- package/dist/handles/PlaceholderHandle.d.ts +25 -13
- package/dist/handles/PlaceholderHandle.d.ts.map +1 -1
- package/dist/handles/index.d.ts +1 -1
- package/dist/handles/index.d.ts.map +1 -1
- package/dist/handles/proxyFactory.d.ts +7 -1
- package/dist/handles/proxyFactory.d.ts.map +1 -1
- package/dist/handles/types.d.ts +33 -0
- package/dist/handles/types.d.ts.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/persistence/BatchPersister.d.ts +53 -37
- package/dist/persistence/BatchPersister.d.ts.map +1 -1
- package/dist/persistence/ChangeRegistry.d.ts +15 -0
- package/dist/persistence/ChangeRegistry.d.ts.map +1 -1
- package/dist/persistence/MutationCollector.d.ts +106 -3
- package/dist/persistence/MutationCollector.d.ts.map +1 -1
- package/dist/persistence/PersistExecution.d.ts +26 -0
- package/dist/persistence/PersistExecution.d.ts.map +1 -0
- package/dist/persistence/types.d.ts +8 -2
- package/dist/persistence/types.d.ts.map +1 -1
- package/dist/store/EntityMetaStore.d.ts +32 -10
- package/dist/store/EntityMetaStore.d.ts.map +1 -1
- package/dist/store/EntitySnapshotStore.d.ts +50 -3
- package/dist/store/EntitySnapshotStore.d.ts.map +1 -1
- package/dist/store/ErrorStore.d.ts +3 -2
- package/dist/store/ErrorStore.d.ts.map +1 -1
- package/dist/store/HasManyStore.d.ts +214 -0
- package/dist/store/HasManyStore.d.ts.map +1 -0
- package/dist/store/HasOneStore.d.ts +159 -0
- package/dist/store/HasOneStore.d.ts.map +1 -0
- package/dist/store/ReachabilityAnalyzer.d.ts +19 -0
- package/dist/store/ReachabilityAnalyzer.d.ts.map +1 -1
- package/dist/store/RekeyOrchestrator.d.ts +76 -0
- package/dist/store/RekeyOrchestrator.d.ts.map +1 -0
- package/dist/store/RelationEdgeIndex.d.ts +34 -0
- package/dist/store/RelationEdgeIndex.d.ts.map +1 -0
- package/dist/store/RelationStore.d.ts +74 -137
- package/dist/store/RelationStore.d.ts.map +1 -1
- package/dist/store/RootRegistry.d.ts +25 -4
- package/dist/store/RootRegistry.d.ts.map +1 -1
- package/dist/store/SnapshotStore.d.ts +137 -13
- package/dist/store/SnapshotStore.d.ts.map +1 -1
- package/dist/store/SubscriptionManager.d.ts +63 -14
- package/dist/store/SubscriptionManager.d.ts.map +1 -1
- package/dist/store/TouchedStore.d.ts +3 -2
- package/dist/store/TouchedStore.d.ts.map +1 -1
- package/dist/store/relationKey.d.ts +15 -0
- package/dist/store/relationKey.d.ts.map +1 -0
- package/dist/undo/UndoJournal.d.ts +133 -0
- package/dist/undo/UndoJournal.d.ts.map +1 -0
- package/dist/undo/UndoManager.d.ts +25 -82
- package/dist/undo/UndoManager.d.ts.map +1 -1
- package/dist/undo/UnrecordedWriteError.d.ts +16 -0
- package/dist/undo/UnrecordedWriteError.d.ts.map +1 -0
- package/dist/undo/index.d.ts +3 -1
- package/dist/undo/index.d.ts.map +1 -1
- package/dist/undo/rekeyJournalEntry.d.ts +20 -0
- package/dist/undo/rekeyJournalEntry.d.ts.map +1 -0
- package/dist/undo/types.d.ts +0 -48
- package/dist/undo/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/adapter/MockAdapter.ts +15 -5
- package/src/core/ActionDispatcher.ts +56 -36
- package/src/core/actions.ts +8 -1
- package/src/events/EventEmitter.ts +78 -24
- package/src/handles/BaseHandle.ts +19 -2
- package/src/handles/EntityHandle.ts +48 -17
- package/src/handles/FieldHandle.ts +36 -10
- package/src/handles/HasManyListHandle.ts +120 -16
- package/src/handles/HasOneHandle.ts +177 -44
- package/src/handles/PlaceholderHandle.ts +184 -36
- package/src/handles/index.ts +3 -0
- package/src/handles/proxyFactory.ts +27 -0
- package/src/handles/types.ts +39 -0
- package/src/index.ts +4 -4
- package/src/persistence/BatchPersister.ts +810 -446
- package/src/persistence/ChangeRegistry.ts +27 -3
- package/src/persistence/MutationCollector.ts +265 -31
- package/src/persistence/PersistExecution.ts +217 -0
- package/src/persistence/types.ts +8 -2
- package/src/store/EntityMetaStore.ts +89 -45
- package/src/store/EntitySnapshotStore.ts +102 -19
- package/src/store/ErrorStore.ts +6 -5
- package/src/store/HasManyStore.ts +810 -0
- package/src/store/HasOneStore.ts +454 -0
- package/src/store/ReachabilityAnalyzer.ts +37 -0
- package/src/store/RekeyOrchestrator.ts +123 -0
- package/src/store/RelationEdgeIndex.ts +79 -0
- package/src/store/RelationStore.ts +176 -725
- package/src/store/RootRegistry.ts +50 -8
- package/src/store/SnapshotStore.ts +572 -101
- package/src/store/SubscriptionManager.ts +122 -75
- package/src/store/TouchedStore.ts +5 -2
- package/src/store/relationKey.ts +20 -0
- package/src/undo/UndoJournal.ts +258 -0
- package/src/undo/UndoManager.ts +151 -281
- package/src/undo/UnrecordedWriteError.ts +23 -0
- package/src/undo/index.ts +8 -7
- package/src/undo/rekeyJournalEntry.ts +145 -0
- package/src/undo/types.ts +0 -58
- package/dist/core/actionClassification.d.ts +0 -48
- package/dist/core/actionClassification.d.ts.map +0 -1
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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.
|
|
537
|
+
this.registerNestedEntity(targetType, currentId)
|
|
361
538
|
const createData = this.collectCreateData(targetType, currentId)
|
|
362
|
-
|
|
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.
|
|
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
|
|
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
|
-
//
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
this.
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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.
|
|
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 &&
|
|
960
|
+
if (existing && existing.plannedAdditions.size > 0) {
|
|
727
961
|
return
|
|
728
962
|
}
|
|
729
963
|
|