@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,418 @@
1
+ import type { ActionMiddleware } from '../core/ActionDispatcher.js'
2
+ import type { Action } from '../core/actions.js'
3
+ import {
4
+ isTrackableAction,
5
+ getAffectedKeys,
6
+ mergeAffectedKeys,
7
+ createEmptyAffectedKeys,
8
+ type StoreAffectedKeys,
9
+ } from '../core/actionClassification.js'
10
+ import type { SnapshotStore } from '../store/SnapshotStore.js'
11
+ import type {
12
+ PartialStoreSnapshot,
13
+ PendingUndoEntry,
14
+ UndoEntry,
15
+ UndoManagerConfig,
16
+ UndoState,
17
+ } from './types.js'
18
+
19
+ type Subscriber = () => void
20
+
21
+ /**
22
+ * Generates a unique ID for undo entries.
23
+ */
24
+ function generateId(): string {
25
+ return `undo-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
26
+ }
27
+
28
+ /**
29
+ * UndoManager provides undo/redo functionality for the Bindx store.
30
+ *
31
+ * Features:
32
+ * - Automatic debounced grouping of rapid changes
33
+ * - Manual grouping via beginGroup/endGroup
34
+ * - Configurable history size
35
+ * - Block/unblock during persist operations
36
+ * - React integration via subscribe/getState
37
+ */
38
+ export class UndoManager {
39
+ private undoStack: UndoEntry[] = []
40
+ private redoStack: UndoEntry[] = []
41
+ private pendingEntry: PendingUndoEntry | null = null
42
+ private debounceTimer: ReturnType<typeof setTimeout> | null = null
43
+ private isBlocked = false
44
+ private manualGroupId: string | null = null
45
+ private manualGroupLabel: string | undefined = undefined
46
+ private subscribers = new Set<Subscriber>()
47
+ private cachedState: UndoState | null = null
48
+
49
+ private readonly maxHistorySize: number
50
+ private readonly debounceMs: number
51
+
52
+ constructor(
53
+ private readonly store: SnapshotStore,
54
+ config: UndoManagerConfig = {},
55
+ ) {
56
+ this.maxHistorySize = config.maxHistorySize ?? 100
57
+ this.debounceMs = config.debounceMs ?? 300
58
+ }
59
+
60
+ /**
61
+ * Creates middleware for ActionDispatcher.
62
+ * Must be added to dispatcher to enable undo tracking.
63
+ */
64
+ createMiddleware(): ActionMiddleware {
65
+ return (action: Action) => {
66
+ this.handleAction(action)
67
+ // Always allow action to proceed
68
+ return true
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Handles an action, capturing state for undo if trackable.
74
+ */
75
+ private handleAction(action: Action): void {
76
+ // Skip if blocked or not trackable
77
+ if (this.isBlocked || !isTrackableAction(action)) {
78
+ return
79
+ }
80
+
81
+ const keys = getAffectedKeys(action)
82
+
83
+ // Clear redo stack on new action
84
+ if (this.redoStack.length > 0) {
85
+ this.redoStack = []
86
+ this.notifySubscribers()
87
+ }
88
+
89
+ if (this.manualGroupId !== null) {
90
+ // Manual grouping mode
91
+ this.handleManualGroupAction(keys)
92
+ } else {
93
+ // Auto-debounce mode
94
+ this.handleDebouncedAction(keys)
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Handles action in manual group mode.
100
+ */
101
+ private handleManualGroupAction(keys: StoreAffectedKeys): void {
102
+ if (!this.pendingEntry) {
103
+ // First action in group - capture snapshot
104
+ const snapshot = this.store.exportPartialSnapshot(keys)
105
+ this.pendingEntry = {
106
+ beforeSnapshot: snapshot,
107
+ affectedKeys: { ...keys, entityKeys: [...keys.entityKeys], relationKeys: [...keys.relationKeys], hasManyKeys: [...keys.hasManyKeys] },
108
+ timestamp: Date.now(),
109
+ label: this.manualGroupLabel,
110
+ }
111
+ } else {
112
+ // Merge keys and expand snapshot if needed
113
+ const newKeys = this.getNewKeys(this.pendingEntry.affectedKeys, keys)
114
+ if (!this.isEmptyKeys(newKeys)) {
115
+ // Capture additional state for new keys
116
+ const additionalSnapshot = this.store.exportPartialSnapshot(newKeys)
117
+ this.mergeSnapshots(this.pendingEntry.beforeSnapshot, additionalSnapshot)
118
+ mergeAffectedKeys(this.pendingEntry.affectedKeys, keys)
119
+ }
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Handles action in debounced auto-group mode.
125
+ */
126
+ private handleDebouncedAction(keys: StoreAffectedKeys): void {
127
+ // If debounceMs is 0, immediately create individual entries (no grouping)
128
+ if (this.debounceMs === 0) {
129
+ const snapshot = this.store.exportPartialSnapshot(keys)
130
+ this.undoStack.push({
131
+ id: generateId(),
132
+ beforeSnapshot: snapshot,
133
+ affectedKeys: { ...keys, entityKeys: [...keys.entityKeys], relationKeys: [...keys.relationKeys], hasManyKeys: [...keys.hasManyKeys] },
134
+ timestamp: Date.now(),
135
+ })
136
+ this.trimHistory()
137
+ this.notifySubscribers()
138
+ return
139
+ }
140
+
141
+ if (this.pendingEntry) {
142
+ // Extend existing pending entry
143
+ const newKeys = this.getNewKeys(this.pendingEntry.affectedKeys, keys)
144
+ if (!this.isEmptyKeys(newKeys)) {
145
+ const additionalSnapshot = this.store.exportPartialSnapshot(newKeys)
146
+ this.mergeSnapshots(this.pendingEntry.beforeSnapshot, additionalSnapshot)
147
+ mergeAffectedKeys(this.pendingEntry.affectedKeys, keys)
148
+ }
149
+ this.resetDebounceTimer()
150
+ } else {
151
+ // First action - capture snapshot and start timer
152
+ const snapshot = this.store.exportPartialSnapshot(keys)
153
+ this.pendingEntry = {
154
+ beforeSnapshot: snapshot,
155
+ affectedKeys: { ...keys, entityKeys: [...keys.entityKeys], relationKeys: [...keys.relationKeys], hasManyKeys: [...keys.hasManyKeys] },
156
+ timestamp: Date.now(),
157
+ }
158
+ this.startDebounceTimer()
159
+ this.notifySubscribers()
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Gets keys that are in source but not in target.
165
+ */
166
+ private getNewKeys(target: StoreAffectedKeys, source: StoreAffectedKeys): StoreAffectedKeys {
167
+ return {
168
+ entityKeys: source.entityKeys.filter(k => !target.entityKeys.includes(k)),
169
+ relationKeys: source.relationKeys.filter(k => !target.relationKeys.includes(k)),
170
+ hasManyKeys: source.hasManyKeys.filter(k => !target.hasManyKeys.includes(k)),
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Checks if keys object is empty.
176
+ */
177
+ private isEmptyKeys(keys: StoreAffectedKeys): boolean {
178
+ return keys.entityKeys.length === 0 && keys.relationKeys.length === 0 && keys.hasManyKeys.length === 0
179
+ }
180
+
181
+ /**
182
+ * Merges additional snapshot into target.
183
+ */
184
+ private mergeSnapshots(target: PartialStoreSnapshot, source: PartialStoreSnapshot): void {
185
+ for (const [key, value] of source.entitySnapshots) {
186
+ if (!target.entitySnapshots.has(key)) {
187
+ target.entitySnapshots.set(key, value)
188
+ }
189
+ }
190
+ for (const [key, value] of source.relationStates) {
191
+ if (!target.relationStates.has(key)) {
192
+ target.relationStates.set(key, value)
193
+ }
194
+ }
195
+ for (const [key, value] of source.hasManyStates) {
196
+ if (!target.hasManyStates.has(key)) {
197
+ target.hasManyStates.set(key, value)
198
+ }
199
+ }
200
+ for (const [key, value] of source.entityMetas) {
201
+ if (!target.entityMetas.has(key)) {
202
+ target.entityMetas.set(key, value)
203
+ }
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Starts the debounce timer.
209
+ */
210
+ private startDebounceTimer(): void {
211
+ this.debounceTimer = setTimeout(() => {
212
+ this.flushPendingEntry()
213
+ }, this.debounceMs)
214
+ }
215
+
216
+ /**
217
+ * Resets the debounce timer.
218
+ */
219
+ private resetDebounceTimer(): void {
220
+ if (this.debounceTimer) {
221
+ clearTimeout(this.debounceTimer)
222
+ }
223
+ this.startDebounceTimer()
224
+ }
225
+
226
+ /**
227
+ * Flushes pending entry to undo stack.
228
+ */
229
+ private flushPendingEntry(): void {
230
+ if (!this.pendingEntry) return
231
+
232
+ if (this.debounceTimer) {
233
+ clearTimeout(this.debounceTimer)
234
+ this.debounceTimer = null
235
+ }
236
+
237
+ this.undoStack.push({
238
+ id: generateId(),
239
+ label: this.pendingEntry.label,
240
+ beforeSnapshot: this.pendingEntry.beforeSnapshot,
241
+ affectedKeys: this.pendingEntry.affectedKeys,
242
+ timestamp: this.pendingEntry.timestamp,
243
+ })
244
+
245
+ this.pendingEntry = null
246
+ this.trimHistory()
247
+ this.notifySubscribers()
248
+ }
249
+
250
+ /**
251
+ * Trims history to max size.
252
+ */
253
+ private trimHistory(): void {
254
+ while (this.undoStack.length > this.maxHistorySize) {
255
+ this.undoStack.shift()
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Starts a manual group. All actions until endGroup are grouped as one undo entry.
261
+ * Returns group ID that must be passed to endGroup.
262
+ */
263
+ beginGroup(label?: string): string {
264
+ // Flush any pending debounced entry first
265
+ this.flushPendingEntry()
266
+
267
+ const groupId = generateId()
268
+ this.manualGroupId = groupId
269
+ this.manualGroupLabel = label
270
+ return groupId
271
+ }
272
+
273
+ /**
274
+ * Ends a manual group and pushes to undo stack.
275
+ */
276
+ endGroup(groupId: string): void {
277
+ if (this.manualGroupId !== groupId) {
278
+ console.warn(`UndoManager: endGroup called with wrong groupId. Expected ${this.manualGroupId}, got ${groupId}`)
279
+ return
280
+ }
281
+
282
+ this.manualGroupId = null
283
+ this.manualGroupLabel = undefined
284
+ this.flushPendingEntry()
285
+ }
286
+
287
+ /**
288
+ * Undoes the last entry.
289
+ */
290
+ undo(): void {
291
+ // Flush any pending entry first
292
+ this.flushPendingEntry()
293
+
294
+ if (this.isBlocked || this.undoStack.length === 0) {
295
+ return
296
+ }
297
+
298
+ const entry = this.undoStack.pop()!
299
+
300
+ // Capture current state for redo
301
+ const currentSnapshot = this.store.exportPartialSnapshot(entry.affectedKeys)
302
+
303
+ // Push to redo stack with current state
304
+ this.redoStack.push({
305
+ id: entry.id,
306
+ label: entry.label,
307
+ beforeSnapshot: currentSnapshot,
308
+ affectedKeys: entry.affectedKeys,
309
+ timestamp: Date.now(),
310
+ })
311
+
312
+ // Restore the before snapshot
313
+ this.store.importPartialSnapshot(entry.beforeSnapshot)
314
+
315
+ this.notifySubscribers()
316
+ }
317
+
318
+ /**
319
+ * Redoes the last undone entry.
320
+ */
321
+ redo(): void {
322
+ if (this.isBlocked || this.redoStack.length === 0) {
323
+ return
324
+ }
325
+
326
+ const entry = this.redoStack.pop()!
327
+
328
+ // Capture current state for undo
329
+ const currentSnapshot = this.store.exportPartialSnapshot(entry.affectedKeys)
330
+
331
+ // Push back to undo stack
332
+ this.undoStack.push({
333
+ id: entry.id,
334
+ label: entry.label,
335
+ beforeSnapshot: currentSnapshot,
336
+ affectedKeys: entry.affectedKeys,
337
+ timestamp: Date.now(),
338
+ })
339
+
340
+ // Restore the redo snapshot
341
+ this.store.importPartialSnapshot(entry.beforeSnapshot)
342
+
343
+ this.notifySubscribers()
344
+ }
345
+
346
+ /**
347
+ * Blocks undo/redo operations.
348
+ * Use during persist to prevent inconsistent state.
349
+ */
350
+ block(): void {
351
+ // Flush any pending entry before blocking
352
+ this.flushPendingEntry()
353
+ this.isBlocked = true
354
+ this.notifySubscribers()
355
+ }
356
+
357
+ /**
358
+ * Unblocks undo/redo operations.
359
+ */
360
+ unblock(): void {
361
+ this.isBlocked = false
362
+ this.notifySubscribers()
363
+ }
364
+
365
+ /**
366
+ * Clears all undo/redo history.
367
+ */
368
+ clear(): void {
369
+ if (this.debounceTimer) {
370
+ clearTimeout(this.debounceTimer)
371
+ this.debounceTimer = null
372
+ }
373
+ this.pendingEntry = null
374
+ this.undoStack = []
375
+ this.redoStack = []
376
+ this.manualGroupId = null
377
+ this.manualGroupLabel = undefined
378
+ this.notifySubscribers()
379
+ }
380
+
381
+ /**
382
+ * Subscribes to state changes.
383
+ * Returns unsubscribe function.
384
+ */
385
+ subscribe(callback: Subscriber): () => void {
386
+ this.subscribers.add(callback)
387
+ return () => {
388
+ this.subscribers.delete(callback)
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Gets current state for React integration.
394
+ * Returns a cached object to satisfy useSyncExternalStore's referential equality requirement.
395
+ */
396
+ getState(): UndoState {
397
+ if (!this.cachedState) {
398
+ this.cachedState = {
399
+ canUndo: this.undoStack.length > 0 || this.pendingEntry !== null,
400
+ canRedo: this.redoStack.length > 0,
401
+ isBlocked: this.isBlocked,
402
+ undoCount: this.undoStack.length + (this.pendingEntry ? 1 : 0),
403
+ redoCount: this.redoStack.length,
404
+ }
405
+ }
406
+ return this.cachedState
407
+ }
408
+
409
+ /**
410
+ * Notifies all subscribers of state change.
411
+ */
412
+ private notifySubscribers(): void {
413
+ this.cachedState = null
414
+ for (const sub of this.subscribers) {
415
+ sub()
416
+ }
417
+ }
418
+ }
@@ -0,0 +1,9 @@
1
+ export { UndoManager } from './UndoManager.js'
2
+ export type {
3
+ PartialStoreSnapshot,
4
+ PendingUndoEntry,
5
+ UndoEntry,
6
+ UndoManagerConfig,
7
+ UndoState,
8
+ StoreAffectedKeys,
9
+ } from './types.js'
@@ -0,0 +1,84 @@
1
+ import type { StoreAffectedKeys } from '../core/actionClassification.js'
2
+ import type { EntitySnapshot } from '../store/snapshots.js'
3
+ import type {
4
+ StoredRelationState,
5
+ StoredHasManyState,
6
+ EntityMeta,
7
+ } from '../store/SnapshotStore.js'
8
+
9
+ /**
10
+ * Partial snapshot of store state.
11
+ * Contains only the data affected by an action or group of actions.
12
+ */
13
+ export interface PartialStoreSnapshot {
14
+ /** Entity snapshots (frozen, immutable) */
15
+ entitySnapshots: Map<string, EntitySnapshot>
16
+ /** Has-one relation states */
17
+ relationStates: Map<string, StoredRelationState>
18
+ /** Has-many list states */
19
+ hasManyStates: Map<string, StoredHasManyState>
20
+ /** Entity metadata (existsOnServer, deletion flags) */
21
+ entityMetas: Map<string, EntityMeta>
22
+ }
23
+
24
+ /**
25
+ * A single undo/redo entry.
26
+ * Contains the snapshot of state BEFORE the action(s) were applied.
27
+ */
28
+ export interface UndoEntry {
29
+ /** Unique identifier for this entry */
30
+ id: string
31
+ /** Optional human-readable label */
32
+ label?: string
33
+ /** Snapshot of state BEFORE the action(s) */
34
+ beforeSnapshot: PartialStoreSnapshot
35
+ /** Keys that were affected (for redo snapshot capture) */
36
+ affectedKeys: StoreAffectedKeys
37
+ /** When this entry was created */
38
+ timestamp: number
39
+ }
40
+
41
+ /**
42
+ * Pending entry during debounce window.
43
+ * Accumulates affected keys while waiting for debounce to complete.
44
+ */
45
+ export interface PendingUndoEntry {
46
+ /** Snapshot captured before the first action in the group */
47
+ beforeSnapshot: PartialStoreSnapshot
48
+ /** Accumulated affected keys */
49
+ affectedKeys: StoreAffectedKeys
50
+ /** When the first action occurred */
51
+ timestamp: number
52
+ /** Optional label for manual grouping */
53
+ label?: string
54
+ }
55
+
56
+ /**
57
+ * Configuration options for UndoManager.
58
+ */
59
+ export interface UndoManagerConfig {
60
+ /** Maximum number of undo entries to keep (default: 100) */
61
+ maxHistorySize?: number
62
+ /** Debounce time in milliseconds for auto-grouping (default: 300) */
63
+ debounceMs?: number
64
+ }
65
+
66
+ /**
67
+ * Current state of the undo manager.
68
+ * Used for React integration via useSyncExternalStore.
69
+ */
70
+ export interface UndoState {
71
+ /** Whether undo is available */
72
+ canUndo: boolean
73
+ /** Whether redo is available */
74
+ canRedo: boolean
75
+ /** Whether undo/redo is currently blocked (e.g., during persist) */
76
+ isBlocked: boolean
77
+ /** Number of entries in undo stack */
78
+ undoCount: number
79
+ /** Number of entries in redo stack */
80
+ redoCount: number
81
+ }
82
+
83
+ // Re-export StoreAffectedKeys for convenience
84
+ export type { StoreAffectedKeys } from '../core/actionClassification.js'
@@ -0,0 +1,56 @@
1
+ import type { HasManyParams } from '../selection/SelectionScope.js'
2
+
3
+ /**
4
+ * Generates an alias for a has-many relation based on field name and parameters.
5
+ *
6
+ * If no parameters are provided or all are undefined, returns the fieldName as-is.
7
+ * Otherwise, generates a deterministic alias by hashing the parameters.
8
+ *
9
+ * @param fieldName - The original field name
10
+ * @param params - Optional has-many parameters (filter, orderBy, limit, offset)
11
+ * @returns The alias to use for this has-many relation
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * // No params - returns fieldName
16
+ * generateHasManyAlias('tags') // => 'tags'
17
+ *
18
+ * // With filter - generates unique alias
19
+ * generateHasManyAlias('tags', { filter: { active: true } }) // => 'tags_a7x9k2'
20
+ *
21
+ * // Different params - different aliases
22
+ * generateHasManyAlias('tags', { filter: { active: false } }) // => 'tags_b3m1p8'
23
+ * ```
24
+ */
25
+ export function generateHasManyAlias(fieldName: string, params?: HasManyParams): string {
26
+ if (!params) {
27
+ return fieldName
28
+ }
29
+
30
+ const { filter, orderBy, limit, offset } = params
31
+
32
+ // If all params are undefined, return fieldName
33
+ if (filter === undefined && orderBy === undefined && limit === undefined && offset === undefined) {
34
+ return fieldName
35
+ }
36
+
37
+ // Serialize parameters deterministically
38
+ const serialized = JSON.stringify({
39
+ f: filter,
40
+ o: orderBy,
41
+ l: limit,
42
+ s: offset,
43
+ })
44
+
45
+ // Simple djb2 hash algorithm
46
+ let hash = 5381
47
+ for (let i = 0; i < serialized.length; i++) {
48
+ hash = ((hash << 5) + hash) + serialized.charCodeAt(i)
49
+ hash = hash & hash // Convert to 32-bit integer
50
+ }
51
+
52
+ // Convert to base36 and take first 6 characters for compact representation
53
+ const hashStr = Math.abs(hash).toString(36).slice(0, 6)
54
+
55
+ return `${fieldName}_${hashStr}`
56
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Deep equality check for values.
3
+ * Handles objects, arrays, and primitives.
4
+ */
5
+ export function deepEqual(a: unknown, b: unknown): boolean {
6
+ if (a === b) return true
7
+ if (a === null || b === null) return false
8
+ if (typeof a !== 'object' || typeof b !== 'object') return false
9
+
10
+ if (Array.isArray(a) && Array.isArray(b)) {
11
+ if (a.length !== b.length) return false
12
+ for (let i = 0; i < a.length; i++) {
13
+ if (!deepEqual(a[i], b[i])) return false
14
+ }
15
+ return true
16
+ }
17
+
18
+ if (Array.isArray(a) || Array.isArray(b)) return false
19
+
20
+ const keysA = Object.keys(a)
21
+ const keysB = Object.keys(b)
22
+
23
+ if (keysA.length !== keysB.length) return false
24
+
25
+ for (const key of keysA) {
26
+ if (!keysB.includes(key)) return false
27
+ if (!deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) {
28
+ return false
29
+ }
30
+ }
31
+
32
+ return true
33
+ }
@@ -0,0 +1,2 @@
1
+ export { deepEqual } from './deepEqual.js'
2
+ export { generateHasManyAlias } from './aliasGenerator.js'