@contember/bindx 0.1.0

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 (234) hide show
  1. package/dist/adapter/ContemberAdapter.d.ts +40 -0
  2. package/dist/adapter/ContemberAdapter.d.ts.map +1 -0
  3. package/dist/adapter/MockAdapter.d.ts +81 -0
  4. package/dist/adapter/MockAdapter.d.ts.map +1 -0
  5. package/dist/adapter/MockQueryEngine.d.ts +43 -0
  6. package/dist/adapter/MockQueryEngine.d.ts.map +1 -0
  7. package/dist/adapter/index.d.ts +5 -0
  8. package/dist/adapter/index.d.ts.map +1 -0
  9. package/dist/adapter/types.d.ts +184 -0
  10. package/dist/adapter/types.d.ts.map +1 -0
  11. package/dist/brand/ComponentBrand.d.ts +25 -0
  12. package/dist/brand/ComponentBrand.d.ts.map +1 -0
  13. package/dist/contember/index.d.ts +10 -0
  14. package/dist/contember/index.d.ts.map +1 -0
  15. package/dist/core/ActionDispatcher.d.ts +64 -0
  16. package/dist/core/ActionDispatcher.d.ts.map +1 -0
  17. package/dist/core/Disposable.d.ts +57 -0
  18. package/dist/core/Disposable.d.ts.map +1 -0
  19. package/dist/core/EntityLoader.d.ts +80 -0
  20. package/dist/core/EntityLoader.d.ts.map +1 -0
  21. package/dist/core/PersistenceManager.d.ts +126 -0
  22. package/dist/core/PersistenceManager.d.ts.map +1 -0
  23. package/dist/core/SelectionResolver.d.ts +21 -0
  24. package/dist/core/SelectionResolver.d.ts.map +1 -0
  25. package/dist/core/actionClassification.d.ts +48 -0
  26. package/dist/core/actionClassification.d.ts.map +1 -0
  27. package/dist/core/actions.d.ts +301 -0
  28. package/dist/core/actions.d.ts.map +1 -0
  29. package/dist/core/index.d.ts +4 -0
  30. package/dist/core/index.d.ts.map +1 -0
  31. package/dist/dataview/filterHandlers.d.ts +18 -0
  32. package/dist/dataview/filterHandlers.d.ts.map +1 -0
  33. package/dist/dataview/index.d.ts +3 -0
  34. package/dist/dataview/index.d.ts.map +1 -0
  35. package/dist/dataview/types.d.ts +164 -0
  36. package/dist/dataview/types.d.ts.map +1 -0
  37. package/dist/errors/UnfetchedFieldError.d.ts +11 -0
  38. package/dist/errors/UnfetchedFieldError.d.ts.map +1 -0
  39. package/dist/errors/errorPathResolver.d.ts +52 -0
  40. package/dist/errors/errorPathResolver.d.ts.map +1 -0
  41. package/dist/errors/index.d.ts +5 -0
  42. package/dist/errors/index.d.ts.map +1 -0
  43. package/dist/errors/pathMapper.d.ts +42 -0
  44. package/dist/errors/pathMapper.d.ts.map +1 -0
  45. package/dist/errors/types.d.ts +131 -0
  46. package/dist/errors/types.d.ts.map +1 -0
  47. package/dist/events/EventEmitter.d.ts +97 -0
  48. package/dist/events/EventEmitter.d.ts.map +1 -0
  49. package/dist/events/eventFactory.d.ts +40 -0
  50. package/dist/events/eventFactory.d.ts.map +1 -0
  51. package/dist/events/index.d.ts +15 -0
  52. package/dist/events/index.d.ts.map +1 -0
  53. package/dist/events/types.d.ts +264 -0
  54. package/dist/events/types.d.ts.map +1 -0
  55. package/dist/handles/BaseHandle.d.ts +64 -0
  56. package/dist/handles/BaseHandle.d.ts.map +1 -0
  57. package/dist/handles/EntityHandle.d.ts +208 -0
  58. package/dist/handles/EntityHandle.d.ts.map +1 -0
  59. package/dist/handles/FieldHandle.d.ts +111 -0
  60. package/dist/handles/FieldHandle.d.ts.map +1 -0
  61. package/dist/handles/HasManyListHandle.d.ts +166 -0
  62. package/dist/handles/HasManyListHandle.d.ts.map +1 -0
  63. package/dist/handles/HasOneHandle.d.ts +168 -0
  64. package/dist/handles/HasOneHandle.d.ts.map +1 -0
  65. package/dist/handles/PlaceholderHandle.d.ts +117 -0
  66. package/dist/handles/PlaceholderHandle.d.ts.map +1 -0
  67. package/dist/handles/index.d.ts +8 -0
  68. package/dist/handles/index.d.ts.map +1 -0
  69. package/dist/handles/proxyFactory.d.ts +66 -0
  70. package/dist/handles/proxyFactory.d.ts.map +1 -0
  71. package/dist/handles/types.d.ts +430 -0
  72. package/dist/handles/types.d.ts.map +1 -0
  73. package/dist/index.d.ts +53 -0
  74. package/dist/index.d.ts.map +1 -0
  75. package/dist/internal.d.ts +11 -0
  76. package/dist/internal.d.ts.map +1 -0
  77. package/dist/persistence/BatchPersister.d.ts +142 -0
  78. package/dist/persistence/BatchPersister.d.ts.map +1 -0
  79. package/dist/persistence/ChangeRegistry.d.ts +87 -0
  80. package/dist/persistence/ChangeRegistry.d.ts.map +1 -0
  81. package/dist/persistence/ContemberSchemaMutationAdapter.d.ts +16 -0
  82. package/dist/persistence/ContemberSchemaMutationAdapter.d.ts.map +1 -0
  83. package/dist/persistence/MutationCollector.d.ts +96 -0
  84. package/dist/persistence/MutationCollector.d.ts.map +1 -0
  85. package/dist/persistence/MutationSchemaProvider.d.ts +30 -0
  86. package/dist/persistence/MutationSchemaProvider.d.ts.map +1 -0
  87. package/dist/persistence/index.d.ts +7 -0
  88. package/dist/persistence/index.d.ts.map +1 -0
  89. package/dist/persistence/types.d.ts +189 -0
  90. package/dist/persistence/types.d.ts.map +1 -0
  91. package/dist/roles/RoleSchemaRegistry.d.ts +77 -0
  92. package/dist/roles/RoleSchemaRegistry.d.ts.map +1 -0
  93. package/dist/roles/index.d.ts +11 -0
  94. package/dist/roles/index.d.ts.map +1 -0
  95. package/dist/roles/types.d.ts +242 -0
  96. package/dist/roles/types.d.ts.map +1 -0
  97. package/dist/schema/ContemberSchema.d.ts +98 -0
  98. package/dist/schema/ContemberSchema.d.ts.map +1 -0
  99. package/dist/schema/SchemaLoader.d.ts +28 -0
  100. package/dist/schema/SchemaLoader.d.ts.map +1 -0
  101. package/dist/schema/SchemaRegistry.d.ts +95 -0
  102. package/dist/schema/SchemaRegistry.d.ts.map +1 -0
  103. package/dist/schema/index.d.ts +6 -0
  104. package/dist/schema/index.d.ts.map +1 -0
  105. package/dist/schema/roles.d.ts +62 -0
  106. package/dist/schema/roles.d.ts.map +1 -0
  107. package/dist/schema/types.d.ts +179 -0
  108. package/dist/schema/types.d.ts.map +1 -0
  109. package/dist/selection/SelectionMetaCollector.d.ts +32 -0
  110. package/dist/selection/SelectionMetaCollector.d.ts.map +1 -0
  111. package/dist/selection/SelectionScope.d.ts +77 -0
  112. package/dist/selection/SelectionScope.d.ts.map +1 -0
  113. package/dist/selection/buildQuery.d.ts +45 -0
  114. package/dist/selection/buildQuery.d.ts.map +1 -0
  115. package/dist/selection/createFragment.d.ts +29 -0
  116. package/dist/selection/createFragment.d.ts.map +1 -0
  117. package/dist/selection/createSelectionBuilder.d.ts +18 -0
  118. package/dist/selection/createSelectionBuilder.d.ts.map +1 -0
  119. package/dist/selection/index.d.ts +8 -0
  120. package/dist/selection/index.d.ts.map +1 -0
  121. package/dist/selection/queryTypes.d.ts +84 -0
  122. package/dist/selection/queryTypes.d.ts.map +1 -0
  123. package/dist/selection/types.d.ts +238 -0
  124. package/dist/selection/types.d.ts.map +1 -0
  125. package/dist/store/DirtyTracker.d.ts +26 -0
  126. package/dist/store/DirtyTracker.d.ts.map +1 -0
  127. package/dist/store/EntityMetaStore.d.ts +52 -0
  128. package/dist/store/EntityMetaStore.d.ts.map +1 -0
  129. package/dist/store/EntitySnapshotStore.d.ts +63 -0
  130. package/dist/store/EntitySnapshotStore.d.ts.map +1 -0
  131. package/dist/store/ErrorStore.d.ts +82 -0
  132. package/dist/store/ErrorStore.d.ts.map +1 -0
  133. package/dist/store/RelationStore.d.ts +175 -0
  134. package/dist/store/RelationStore.d.ts.map +1 -0
  135. package/dist/store/SnapshotStore.d.ts +143 -0
  136. package/dist/store/SnapshotStore.d.ts.map +1 -0
  137. package/dist/store/SubscriptionManager.d.ts +90 -0
  138. package/dist/store/SubscriptionManager.d.ts.map +1 -0
  139. package/dist/store/TouchedStore.d.ts +24 -0
  140. package/dist/store/TouchedStore.d.ts.map +1 -0
  141. package/dist/store/entityId.d.ts +29 -0
  142. package/dist/store/entityId.d.ts.map +1 -0
  143. package/dist/store/index.d.ts +1 -0
  144. package/dist/store/index.d.ts.map +1 -0
  145. package/dist/store/snapshots.d.ts +144 -0
  146. package/dist/store/snapshots.d.ts.map +1 -0
  147. package/dist/undo/UndoManager.d.ts +120 -0
  148. package/dist/undo/UndoManager.d.ts.map +1 -0
  149. package/dist/undo/index.d.ts +3 -0
  150. package/dist/undo/index.d.ts.map +1 -0
  151. package/dist/undo/types.d.ts +74 -0
  152. package/dist/undo/types.d.ts.map +1 -0
  153. package/dist/utils/aliasGenerator.d.ts +25 -0
  154. package/dist/utils/aliasGenerator.d.ts.map +1 -0
  155. package/dist/utils/deepEqual.d.ts +6 -0
  156. package/dist/utils/deepEqual.d.ts.map +1 -0
  157. package/dist/utils/index.d.ts +3 -0
  158. package/dist/utils/index.d.ts.map +1 -0
  159. package/package.json +29 -0
  160. package/src/adapter/ContemberAdapter.ts +239 -0
  161. package/src/adapter/MockAdapter.ts +394 -0
  162. package/src/adapter/MockQueryEngine.ts +236 -0
  163. package/src/adapter/index.ts +21 -0
  164. package/src/adapter/types.ts +199 -0
  165. package/src/brand/ComponentBrand.ts +28 -0
  166. package/src/contember/index.ts +17 -0
  167. package/src/core/ActionDispatcher.ts +450 -0
  168. package/src/core/Disposable.ts +126 -0
  169. package/src/core/EntityLoader.ts +169 -0
  170. package/src/core/SelectionResolver.ts +78 -0
  171. package/src/core/actionClassification.ts +196 -0
  172. package/src/core/actions.ts +491 -0
  173. package/src/core/index.ts +20 -0
  174. package/src/dataview/filterHandlers.ts +443 -0
  175. package/src/dataview/index.ts +36 -0
  176. package/src/dataview/types.ts +220 -0
  177. package/src/errors/UnfetchedFieldError.ts +18 -0
  178. package/src/errors/errorPathResolver.ts +239 -0
  179. package/src/errors/index.ts +40 -0
  180. package/src/errors/pathMapper.ts +40 -0
  181. package/src/errors/types.ts +223 -0
  182. package/src/events/EventEmitter.ts +447 -0
  183. package/src/events/eventFactory.ts +430 -0
  184. package/src/events/index.ts +89 -0
  185. package/src/events/types.ts +374 -0
  186. package/src/handles/BaseHandle.ts +100 -0
  187. package/src/handles/EntityHandle.ts +538 -0
  188. package/src/handles/FieldHandle.ts +268 -0
  189. package/src/handles/HasManyListHandle.ts +500 -0
  190. package/src/handles/HasOneHandle.ts +500 -0
  191. package/src/handles/PlaceholderHandle.ts +321 -0
  192. package/src/handles/index.ts +37 -0
  193. package/src/handles/proxyFactory.ts +218 -0
  194. package/src/handles/types.ts +743 -0
  195. package/src/index.ts +384 -0
  196. package/src/internal.ts +19 -0
  197. package/src/persistence/BatchPersister.ts +911 -0
  198. package/src/persistence/ChangeRegistry.ts +150 -0
  199. package/src/persistence/ContemberSchemaMutationAdapter.ts +57 -0
  200. package/src/persistence/MutationCollector.ts +567 -0
  201. package/src/persistence/MutationSchemaProvider.ts +33 -0
  202. package/src/persistence/index.ts +23 -0
  203. package/src/persistence/types.ts +220 -0
  204. package/src/schema/ContemberSchema.ts +132 -0
  205. package/src/schema/SchemaLoader.ts +93 -0
  206. package/src/schema/SchemaRegistry.ts +286 -0
  207. package/src/schema/index.ts +51 -0
  208. package/src/schema/roles.ts +83 -0
  209. package/src/schema/types.ts +238 -0
  210. package/src/selection/SelectionMetaCollector.ts +122 -0
  211. package/src/selection/SelectionScope.ts +208 -0
  212. package/src/selection/buildQuery.ts +134 -0
  213. package/src/selection/createFragment.ts +50 -0
  214. package/src/selection/createSelectionBuilder.ts +283 -0
  215. package/src/selection/index.ts +31 -0
  216. package/src/selection/queryTypes.ts +135 -0
  217. package/src/selection/types.ts +320 -0
  218. package/src/store/DirtyTracker.ts +117 -0
  219. package/src/store/EntityMetaStore.ts +143 -0
  220. package/src/store/EntitySnapshotStore.ts +260 -0
  221. package/src/store/ErrorStore.ts +251 -0
  222. package/src/store/RelationStore.ts +696 -0
  223. package/src/store/SnapshotStore.ts +765 -0
  224. package/src/store/SubscriptionManager.ts +241 -0
  225. package/src/store/TouchedStore.ts +42 -0
  226. package/src/store/entityId.ts +43 -0
  227. package/src/store/index.ts +2 -0
  228. package/src/store/snapshots.ts +203 -0
  229. package/src/undo/UndoManager.ts +418 -0
  230. package/src/undo/index.ts +9 -0
  231. package/src/undo/types.ts +84 -0
  232. package/src/utils/aliasGenerator.ts +56 -0
  233. package/src/utils/deepEqual.ts +33 -0
  234. package/src/utils/index.ts +2 -0
@@ -0,0 +1,696 @@
1
+ import type { HasOneRelationState } from '../handles/types.js'
2
+ import type { EntitySnapshot } from './snapshots.js'
3
+
4
+ /**
5
+ * Removal type for has-many items
6
+ */
7
+ export type HasManyRemovalType = 'disconnect' | 'delete'
8
+
9
+ /**
10
+ * Has-many list state stored in SnapshotStore
11
+ */
12
+ export interface StoredHasManyState {
13
+ /** IDs of items from server */
14
+ serverIds: Set<string>
15
+ /** Explicit ordered list of item IDs, null means use default order (serverIds + plannedConnections) */
16
+ orderedIds: string[] | null
17
+ /** Planned removals (disconnect or delete) keyed by entity ID */
18
+ plannedRemovals: Map<string, HasManyRemovalType>
19
+ /** Planned connections (IDs to add to the list) */
20
+ plannedConnections: Set<string>
21
+ /** Entity IDs created via add() - tracked for proper remove() semantics and mutation generation */
22
+ createdEntities: Set<string>
23
+ version: number
24
+ }
25
+
26
+ /**
27
+ * Relation state stored in SnapshotStore
28
+ */
29
+ export interface StoredRelationState {
30
+ currentId: string | null
31
+ serverId: string | null
32
+ state: HasOneRelationState
33
+ serverState: HasOneRelationState
34
+ placeholderData: Record<string, unknown>
35
+ version: number
36
+ }
37
+
38
+ /**
39
+ * Manages has-one and has-many relation state.
40
+ *
41
+ * Relation keys use the format "parentType:parentId:fieldName".
42
+ * Notification is handled by callers via callback returns.
43
+ */
44
+ export class RelationStore {
45
+ /** Relation states keyed by "parentType:parentId:fieldName" */
46
+ private readonly relationStates = new Map<string, StoredRelationState>()
47
+
48
+ /** Has-many list states keyed by "parentType:parentId:fieldName" */
49
+ private readonly hasManyStates = new Map<string, StoredHasManyState>()
50
+
51
+ // ==================== Has-One Relations ====================
52
+
53
+ /**
54
+ * Gets or creates relation state.
55
+ */
56
+ getOrCreateRelation(
57
+ key: string,
58
+ initial: Omit<StoredRelationState, 'version'>,
59
+ ): StoredRelationState {
60
+ if (!this.relationStates.has(key)) {
61
+ this.relationStates.set(key, { ...initial, version: 0 })
62
+ }
63
+
64
+ return this.relationStates.get(key)!
65
+ }
66
+
67
+ /**
68
+ * Gets relation state.
69
+ */
70
+ getRelation(key: string): StoredRelationState | undefined {
71
+ return this.relationStates.get(key)
72
+ }
73
+
74
+ /**
75
+ * Updates relation state.
76
+ * If the relation state doesn't exist, creates it using entity snapshot for server data.
77
+ */
78
+ setRelation(
79
+ key: string,
80
+ updates: Partial<Omit<StoredRelationState, 'version'>>,
81
+ entitySnapshot: EntitySnapshot | undefined,
82
+ fieldName: string,
83
+ ): void {
84
+ const existing = this.relationStates.get(key)
85
+
86
+ if (!existing) {
87
+ let serverId: string | null = null
88
+ let serverState: HasOneRelationState = 'disconnected'
89
+
90
+ if (entitySnapshot?.serverData) {
91
+ const relatedData = (entitySnapshot.serverData as Record<string, unknown>)[fieldName]
92
+ if (relatedData && typeof relatedData === 'object' && 'id' in relatedData) {
93
+ serverId = (relatedData as { id: string }).id
94
+ serverState = 'connected'
95
+ }
96
+ }
97
+
98
+ this.relationStates.set(key, {
99
+ currentId: 'currentId' in updates ? updates.currentId! : serverId,
100
+ serverId,
101
+ state: 'state' in updates ? updates.state! : serverState,
102
+ serverState,
103
+ placeholderData: updates.placeholderData ?? {},
104
+ version: 0,
105
+ })
106
+ } else {
107
+ this.relationStates.set(key, {
108
+ ...existing,
109
+ ...updates,
110
+ version: existing.version + 1,
111
+ })
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Commits relation state (server = current).
117
+ */
118
+ commitRelation(key: string): void {
119
+ const existing = this.relationStates.get(key)
120
+ if (!existing) return
121
+
122
+ this.relationStates.set(key, {
123
+ ...existing,
124
+ serverId: existing.currentId,
125
+ serverState: existing.state === 'creating' ? 'connected' : existing.state,
126
+ placeholderData: {},
127
+ version: existing.version + 1,
128
+ })
129
+ }
130
+
131
+ /**
132
+ * Resets relation to server state.
133
+ */
134
+ resetRelation(key: string): void {
135
+ const existing = this.relationStates.get(key)
136
+ if (!existing) return
137
+
138
+ this.relationStates.set(key, {
139
+ ...existing,
140
+ currentId: existing.serverId,
141
+ state: existing.serverState,
142
+ placeholderData: {},
143
+ version: existing.version + 1,
144
+ })
145
+ }
146
+
147
+ // ==================== Has-Many Relations ====================
148
+
149
+ /**
150
+ * Gets or creates has-many list state.
151
+ */
152
+ getOrCreateHasMany(key: string, serverIds?: string[]): StoredHasManyState {
153
+ if (!this.hasManyStates.has(key)) {
154
+ this.hasManyStates.set(key, {
155
+ serverIds: new Set(serverIds ?? []),
156
+ orderedIds: null,
157
+ plannedRemovals: new Map(),
158
+ plannedConnections: new Set(),
159
+ createdEntities: new Set(),
160
+ version: 0,
161
+ })
162
+ }
163
+
164
+ return this.hasManyStates.get(key)!
165
+ }
166
+
167
+ /**
168
+ * Gets has-many list state.
169
+ */
170
+ getHasMany(key: string): StoredHasManyState | undefined {
171
+ return this.hasManyStates.get(key)
172
+ }
173
+
174
+ /**
175
+ * Sets server IDs for a has-many relation.
176
+ */
177
+ setHasManyServerIds(key: string, serverIds: string[]): void {
178
+ const existing = this.hasManyStates.get(key)
179
+
180
+ if (!existing) {
181
+ this.hasManyStates.set(key, {
182
+ serverIds: new Set(serverIds),
183
+ orderedIds: null,
184
+ plannedRemovals: new Map(),
185
+ plannedConnections: new Set(),
186
+ createdEntities: new Set(),
187
+ version: 0,
188
+ })
189
+ } else {
190
+ this.hasManyStates.set(key, {
191
+ ...existing,
192
+ serverIds: new Set(serverIds),
193
+ orderedIds: null,
194
+ version: existing.version + 1,
195
+ })
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Plans a removal for a has-many item.
201
+ */
202
+ planHasManyRemoval(key: string, itemId: string, type: HasManyRemovalType): void {
203
+ const existing = this.hasManyStates.get(key)
204
+
205
+ if (!existing) {
206
+ this.hasManyStates.set(key, {
207
+ serverIds: new Set(),
208
+ orderedIds: null,
209
+ plannedRemovals: new Map([[itemId, type]]),
210
+ plannedConnections: new Set(),
211
+ createdEntities: new Set(),
212
+ version: 0,
213
+ })
214
+ } else {
215
+ const newPlannedRemovals = new Map(existing.plannedRemovals)
216
+ newPlannedRemovals.set(itemId, type)
217
+ const newPlannedConnections = new Set(existing.plannedConnections)
218
+ newPlannedConnections.delete(itemId)
219
+ let newOrderedIds = existing.orderedIds
220
+ if (newOrderedIds !== null) {
221
+ newOrderedIds = newOrderedIds.filter(id => id !== itemId)
222
+ }
223
+ const newCreatedEntities = new Set(existing.createdEntities)
224
+ newCreatedEntities.delete(itemId)
225
+ this.hasManyStates.set(key, {
226
+ ...existing,
227
+ orderedIds: newOrderedIds,
228
+ plannedRemovals: newPlannedRemovals,
229
+ plannedConnections: newPlannedConnections,
230
+ createdEntities: newCreatedEntities,
231
+ version: existing.version + 1,
232
+ })
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Cancels a planned removal for a has-many item.
238
+ */
239
+ cancelHasManyRemoval(key: string, itemId: string): void {
240
+ const existing = this.hasManyStates.get(key)
241
+ if (!existing) return
242
+
243
+ const newPlannedRemovals = new Map(existing.plannedRemovals)
244
+ newPlannedRemovals.delete(itemId)
245
+ this.hasManyStates.set(key, {
246
+ ...existing,
247
+ plannedRemovals: newPlannedRemovals,
248
+ version: existing.version + 1,
249
+ })
250
+ }
251
+
252
+ /**
253
+ * Plans a connection for a has-many item.
254
+ */
255
+ planHasManyConnection(key: string, itemId: string): void {
256
+ const existing = this.hasManyStates.get(key)
257
+
258
+ if (!existing) {
259
+ this.hasManyStates.set(key, {
260
+ serverIds: new Set(),
261
+ orderedIds: null,
262
+ plannedRemovals: new Map(),
263
+ plannedConnections: new Set([itemId]),
264
+ createdEntities: new Set(),
265
+ version: 0,
266
+ })
267
+ } else {
268
+ const newPlannedConnections = new Set(existing.plannedConnections)
269
+ newPlannedConnections.add(itemId)
270
+ const newPlannedRemovals = new Map(existing.plannedRemovals)
271
+ newPlannedRemovals.delete(itemId)
272
+ let newOrderedIds = existing.orderedIds
273
+ if (newOrderedIds !== null && !newOrderedIds.includes(itemId)) {
274
+ newOrderedIds = [...newOrderedIds, itemId]
275
+ }
276
+ this.hasManyStates.set(key, {
277
+ ...existing,
278
+ orderedIds: newOrderedIds,
279
+ plannedConnections: newPlannedConnections,
280
+ plannedRemovals: newPlannedRemovals,
281
+ version: existing.version + 1,
282
+ })
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Cancels a planned connection for a has-many item.
288
+ */
289
+ cancelHasManyConnection(key: string, itemId: string): void {
290
+ const existing = this.hasManyStates.get(key)
291
+ if (!existing) return
292
+
293
+ const newPlannedConnections = new Set(existing.plannedConnections)
294
+ newPlannedConnections.delete(itemId)
295
+ this.hasManyStates.set(key, {
296
+ ...existing,
297
+ plannedConnections: newPlannedConnections,
298
+ version: existing.version + 1,
299
+ })
300
+ }
301
+
302
+ /**
303
+ * Commits has-many state after successful persist.
304
+ */
305
+ commitHasMany(key: string, newServerIds: string[]): void {
306
+ const existing = this.hasManyStates.get(key)
307
+
308
+ this.hasManyStates.set(key, {
309
+ serverIds: new Set(newServerIds),
310
+ orderedIds: null,
311
+ plannedRemovals: new Map(),
312
+ plannedConnections: new Set(),
313
+ createdEntities: new Set(),
314
+ version: (existing?.version ?? 0) + 1,
315
+ })
316
+ }
317
+
318
+ /**
319
+ * Resets has-many state to server state (clears planned operations).
320
+ */
321
+ resetHasMany(key: string): void {
322
+ const existing = this.hasManyStates.get(key)
323
+ if (!existing) return
324
+
325
+ this.hasManyStates.set(key, {
326
+ serverIds: existing.serverIds,
327
+ orderedIds: null,
328
+ plannedRemovals: new Map(),
329
+ plannedConnections: new Set(),
330
+ createdEntities: new Set(),
331
+ version: existing.version + 1,
332
+ })
333
+ }
334
+
335
+ /**
336
+ * Adds a newly created entity to a has-many relation.
337
+ * Used by HasManyListHandle.add() for inline entity creation.
338
+ */
339
+ addToHasMany(key: string, itemId: string): void {
340
+ const existing = this.hasManyStates.get(key)
341
+
342
+ if (!existing) {
343
+ this.hasManyStates.set(key, {
344
+ serverIds: new Set(),
345
+ orderedIds: [itemId],
346
+ plannedRemovals: new Map(),
347
+ plannedConnections: new Set([itemId]),
348
+ createdEntities: new Set([itemId]),
349
+ version: 0,
350
+ })
351
+ } else {
352
+ const newPlannedConnections = new Set(existing.plannedConnections)
353
+ newPlannedConnections.add(itemId)
354
+ const newCreatedEntities = new Set(existing.createdEntities)
355
+ newCreatedEntities.add(itemId)
356
+ const currentOrderedIds = existing.orderedIds ?? computeDefaultOrderedIds(existing)
357
+ const newOrderedIds = [...currentOrderedIds, itemId]
358
+ this.hasManyStates.set(key, {
359
+ ...existing,
360
+ orderedIds: newOrderedIds,
361
+ plannedConnections: newPlannedConnections,
362
+ createdEntities: newCreatedEntities,
363
+ version: existing.version + 1,
364
+ })
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Removes an entity from a has-many relation.
370
+ * For newly created entities (via add()), cancels the connection.
371
+ * For existing server entities, plans a disconnect.
372
+ * Returns the key if a removal was planned (caller should notify), or null if handled inline.
373
+ */
374
+ removeFromHasMany(key: string, itemId: string): 'planned_removal' | 'cancelled_connection' | null {
375
+ const existing = this.hasManyStates.get(key)
376
+ if (!existing) return null
377
+
378
+ const isCreatedEntity = existing.createdEntities.has(itemId)
379
+
380
+ if (isCreatedEntity) {
381
+ const newPlannedConnections = new Set(existing.plannedConnections)
382
+ newPlannedConnections.delete(itemId)
383
+ const newCreatedEntities = new Set(existing.createdEntities)
384
+ newCreatedEntities.delete(itemId)
385
+ let newOrderedIds = existing.orderedIds
386
+ if (newOrderedIds !== null) {
387
+ newOrderedIds = newOrderedIds.filter(id => id !== itemId)
388
+ }
389
+
390
+ const newState: StoredHasManyState = {
391
+ ...existing,
392
+ orderedIds: newOrderedIds,
393
+ plannedConnections: newPlannedConnections,
394
+ createdEntities: newCreatedEntities,
395
+ version: existing.version + 1,
396
+ }
397
+
398
+ if (
399
+ newPlannedConnections.size === 0 &&
400
+ newCreatedEntities.size === 0 &&
401
+ existing.plannedRemovals.size === 0 &&
402
+ newOrderedIds !== null
403
+ ) {
404
+ const defaultOrder = computeDefaultOrderedIds(newState)
405
+ if (arraysEqual(newOrderedIds, defaultOrder)) {
406
+ newState.orderedIds = null
407
+ }
408
+ }
409
+
410
+ this.hasManyStates.set(key, newState)
411
+ return 'cancelled_connection'
412
+ } else {
413
+ this.planHasManyRemoval(key, itemId, 'disconnect')
414
+ return 'planned_removal'
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Moves an item within a has-many relation from one index to another.
420
+ */
421
+ moveInHasMany(key: string, fromIndex: number, toIndex: number): void {
422
+ const existing = this.hasManyStates.get(key)
423
+ if (!existing) return
424
+
425
+ const currentOrderedIds = existing.orderedIds ?? computeDefaultOrderedIds(existing)
426
+
427
+ if (fromIndex < 0 || fromIndex >= currentOrderedIds.length) return
428
+ if (toIndex < 0 || toIndex >= currentOrderedIds.length) return
429
+ if (fromIndex === toIndex) return
430
+
431
+ const newOrderedIds = [...currentOrderedIds]
432
+ const movedItem = newOrderedIds.splice(fromIndex, 1)[0]
433
+ if (movedItem === undefined) return
434
+ newOrderedIds.splice(toIndex, 0, movedItem)
435
+
436
+ this.hasManyStates.set(key, {
437
+ ...existing,
438
+ orderedIds: newOrderedIds,
439
+ version: existing.version + 1,
440
+ })
441
+ }
442
+
443
+ /**
444
+ * Gets the ordered list of item IDs for a has-many relation.
445
+ */
446
+ getHasManyOrderedIds(key: string): string[] {
447
+ const existing = this.hasManyStates.get(key)
448
+ if (!existing) return []
449
+
450
+ if (existing.orderedIds !== null) {
451
+ return existing.orderedIds
452
+ }
453
+
454
+ return computeDefaultOrderedIds(existing)
455
+ }
456
+
457
+ /**
458
+ * Restores a has-many state from a captured snapshot.
459
+ * Used in pessimistic mode after successful server confirmation.
460
+ */
461
+ restoreHasManyState(key: string, state: StoredHasManyState): void {
462
+ this.hasManyStates.set(key, {
463
+ serverIds: new Set(state.serverIds),
464
+ orderedIds: state.orderedIds ? [...state.orderedIds] : null,
465
+ plannedRemovals: new Map(state.plannedRemovals),
466
+ plannedConnections: new Set(state.plannedConnections),
467
+ createdEntities: new Set(state.createdEntities),
468
+ version: state.version + 1,
469
+ })
470
+ }
471
+
472
+ // ==================== Bulk Operations ====================
473
+
474
+ /**
475
+ * Commits all relations (hasOne and hasMany) for an entity.
476
+ */
477
+ commitAllRelations(keyPrefix: string): void {
478
+ for (const key of this.relationStates.keys()) {
479
+ if (key.startsWith(keyPrefix)) {
480
+ this.commitRelation(key)
481
+ }
482
+ }
483
+
484
+ for (const [key, state] of this.hasManyStates) {
485
+ if (key.startsWith(keyPrefix)) {
486
+ const newServerIds = new Set(state.serverIds)
487
+ for (const removedId of state.plannedRemovals.keys()) {
488
+ newServerIds.delete(removedId)
489
+ }
490
+ for (const connectedId of state.plannedConnections) {
491
+ newServerIds.add(connectedId)
492
+ }
493
+ this.commitHasMany(key, Array.from(newServerIds))
494
+ }
495
+ }
496
+ }
497
+
498
+ /**
499
+ * Resets all relations (hasOne and hasMany) for an entity to server state.
500
+ */
501
+ resetAllRelations(keyPrefix: string): void {
502
+ for (const key of this.relationStates.keys()) {
503
+ if (key.startsWith(keyPrefix)) {
504
+ this.resetRelation(key)
505
+ }
506
+ }
507
+
508
+ for (const key of this.hasManyStates.keys()) {
509
+ if (key.startsWith(keyPrefix)) {
510
+ this.resetHasMany(key)
511
+ }
512
+ }
513
+ }
514
+
515
+ /**
516
+ * Gets all relation states for an entity.
517
+ */
518
+ getAllRelationsForEntity(keyPrefix: string): Map<string, StoredRelationState> {
519
+ const result = new Map<string, StoredRelationState>()
520
+ for (const [key, state] of this.relationStates) {
521
+ if (key.startsWith(keyPrefix)) {
522
+ result.set(key, { ...state })
523
+ }
524
+ }
525
+ return result
526
+ }
527
+
528
+ /**
529
+ * Gets all has-many states for an entity.
530
+ */
531
+ getAllHasManyForEntity(keyPrefix: string): Map<string, StoredHasManyState> {
532
+ const result = new Map<string, StoredHasManyState>()
533
+ for (const [key, state] of this.hasManyStates) {
534
+ if (key.startsWith(keyPrefix)) {
535
+ result.set(key, {
536
+ serverIds: new Set(state.serverIds),
537
+ orderedIds: state.orderedIds ? [...state.orderedIds] : null,
538
+ plannedRemovals: new Map(state.plannedRemovals),
539
+ plannedConnections: new Set(state.plannedConnections),
540
+ createdEntities: new Set(state.createdEntities),
541
+ version: state.version,
542
+ })
543
+ }
544
+ }
545
+ return result
546
+ }
547
+
548
+ // ==================== Dirty Tracking ====================
549
+
550
+ /**
551
+ * Gets the list of dirty relations for an entity.
552
+ */
553
+ getDirtyRelations(keyPrefix: string): string[] {
554
+ const dirtyRelations: string[] = []
555
+
556
+ for (const [key, state] of this.relationStates) {
557
+ if (!key.startsWith(keyPrefix)) continue
558
+ const fieldName = key.slice(keyPrefix.length)
559
+
560
+ if (
561
+ state.currentId !== state.serverId ||
562
+ state.state !== state.serverState ||
563
+ Object.keys(state.placeholderData).length > 0
564
+ ) {
565
+ dirtyRelations.push(fieldName)
566
+ }
567
+ }
568
+
569
+ for (const [key, state] of this.hasManyStates) {
570
+ if (!key.startsWith(keyPrefix)) continue
571
+ const fieldName = key.slice(keyPrefix.length)
572
+
573
+ if (state.plannedRemovals.size > 0 || state.plannedConnections.size > 0) {
574
+ dirtyRelations.push(fieldName)
575
+ }
576
+ }
577
+
578
+ return dirtyRelations
579
+ }
580
+
581
+ // ==================== Export/Import ====================
582
+
583
+ /**
584
+ * Exports relation states for given keys.
585
+ */
586
+ exportRelationStates(keys: string[]): Map<string, StoredRelationState> {
587
+ const result = new Map<string, StoredRelationState>()
588
+ for (const key of keys) {
589
+ const state = this.relationStates.get(key)
590
+ if (state) {
591
+ result.set(key, {
592
+ ...state,
593
+ placeholderData: { ...state.placeholderData },
594
+ })
595
+ }
596
+ }
597
+ return result
598
+ }
599
+
600
+ /**
601
+ * Exports has-many states for given keys.
602
+ */
603
+ exportHasManyStates(keys: string[]): Map<string, StoredHasManyState> {
604
+ const result = new Map<string, StoredHasManyState>()
605
+ for (const key of keys) {
606
+ const state = this.hasManyStates.get(key)
607
+ if (state) {
608
+ result.set(key, {
609
+ serverIds: new Set(state.serverIds),
610
+ orderedIds: state.orderedIds ? [...state.orderedIds] : null,
611
+ plannedRemovals: new Map(state.plannedRemovals),
612
+ plannedConnections: new Set(state.plannedConnections),
613
+ createdEntities: new Set(state.createdEntities),
614
+ version: state.version,
615
+ })
616
+ }
617
+ }
618
+ return result
619
+ }
620
+
621
+ /**
622
+ * Imports relation states from a snapshot.
623
+ * Returns the keys that were imported for notification.
624
+ */
625
+ importRelationStates(states: Map<string, StoredRelationState>): string[] {
626
+ const keys: string[] = []
627
+ for (const [key, state] of states) {
628
+ this.relationStates.set(key, {
629
+ ...state,
630
+ placeholderData: { ...state.placeholderData },
631
+ })
632
+ keys.push(key)
633
+ }
634
+ return keys
635
+ }
636
+
637
+ /**
638
+ * Imports has-many states from a snapshot.
639
+ * Returns the keys that were imported for notification.
640
+ */
641
+ importHasManyStates(states: Map<string, StoredHasManyState>): string[] {
642
+ const keys: string[] = []
643
+ for (const [key, state] of states) {
644
+ this.hasManyStates.set(key, {
645
+ serverIds: new Set(state.serverIds),
646
+ orderedIds: state.orderedIds ? [...state.orderedIds] : null,
647
+ plannedRemovals: new Map(state.plannedRemovals),
648
+ plannedConnections: new Set(state.plannedConnections),
649
+ createdEntities: new Set(state.createdEntities),
650
+ version: state.version + 1,
651
+ })
652
+ keys.push(key)
653
+ }
654
+ return keys
655
+ }
656
+
657
+ /**
658
+ * Clears all relation data.
659
+ */
660
+ clear(): void {
661
+ this.relationStates.clear()
662
+ this.hasManyStates.clear()
663
+ }
664
+ }
665
+
666
+ // ==================== Helper Functions ====================
667
+
668
+ /**
669
+ * Computes the default ordered IDs for a has-many relation.
670
+ * Order is: serverIds (minus removals) + plannedConnections
671
+ */
672
+ export function computeDefaultOrderedIds(state: StoredHasManyState): string[] {
673
+ const result: string[] = []
674
+
675
+ for (const id of state.serverIds) {
676
+ if (!state.plannedRemovals.has(id)) {
677
+ result.push(id)
678
+ }
679
+ }
680
+
681
+ for (const id of state.plannedConnections) {
682
+ if (!result.includes(id)) {
683
+ result.push(id)
684
+ }
685
+ }
686
+
687
+ return result
688
+ }
689
+
690
+ function arraysEqual(a: string[], b: string[]): boolean {
691
+ if (a.length !== b.length) return false
692
+ for (let i = 0; i < a.length; i++) {
693
+ if (a[i] !== b[i]) return false
694
+ }
695
+ return true
696
+ }