@contember/bindx 0.1.45 → 0.1.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/dist/core/ActionDispatcher.d.ts +4 -1
  2. package/dist/core/ActionDispatcher.d.ts.map +1 -1
  3. package/dist/core/actions.d.ts +7 -1
  4. package/dist/core/actions.d.ts.map +1 -1
  5. package/dist/events/EventEmitter.d.ts +13 -1
  6. package/dist/events/EventEmitter.d.ts.map +1 -1
  7. package/dist/handles/BaseHandle.d.ts +14 -3
  8. package/dist/handles/BaseHandle.d.ts.map +1 -1
  9. package/dist/handles/EntityHandle.d.ts +14 -5
  10. package/dist/handles/EntityHandle.d.ts.map +1 -1
  11. package/dist/handles/FieldHandle.d.ts +11 -4
  12. package/dist/handles/FieldHandle.d.ts.map +1 -1
  13. package/dist/handles/HasManyListHandle.d.ts +36 -2
  14. package/dist/handles/HasManyListHandle.d.ts.map +1 -1
  15. package/dist/handles/HasOneHandle.d.ts +52 -9
  16. package/dist/handles/HasOneHandle.d.ts.map +1 -1
  17. package/dist/handles/PlaceholderHandle.d.ts +25 -13
  18. package/dist/handles/PlaceholderHandle.d.ts.map +1 -1
  19. package/dist/handles/index.d.ts +1 -1
  20. package/dist/handles/index.d.ts.map +1 -1
  21. package/dist/handles/proxyFactory.d.ts +7 -1
  22. package/dist/handles/proxyFactory.d.ts.map +1 -1
  23. package/dist/handles/types.d.ts +33 -0
  24. package/dist/handles/types.d.ts.map +1 -1
  25. package/dist/index.d.ts +3 -3
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/persistence/BatchPersister.d.ts +53 -37
  28. package/dist/persistence/BatchPersister.d.ts.map +1 -1
  29. package/dist/persistence/ChangeRegistry.d.ts +15 -0
  30. package/dist/persistence/ChangeRegistry.d.ts.map +1 -1
  31. package/dist/persistence/MutationCollector.d.ts +106 -3
  32. package/dist/persistence/MutationCollector.d.ts.map +1 -1
  33. package/dist/persistence/PersistExecution.d.ts +26 -0
  34. package/dist/persistence/PersistExecution.d.ts.map +1 -0
  35. package/dist/persistence/types.d.ts +8 -2
  36. package/dist/persistence/types.d.ts.map +1 -1
  37. package/dist/store/EntityMetaStore.d.ts +32 -10
  38. package/dist/store/EntityMetaStore.d.ts.map +1 -1
  39. package/dist/store/EntitySnapshotStore.d.ts +50 -3
  40. package/dist/store/EntitySnapshotStore.d.ts.map +1 -1
  41. package/dist/store/ErrorStore.d.ts +3 -2
  42. package/dist/store/ErrorStore.d.ts.map +1 -1
  43. package/dist/store/HasManyStore.d.ts +214 -0
  44. package/dist/store/HasManyStore.d.ts.map +1 -0
  45. package/dist/store/HasOneStore.d.ts +159 -0
  46. package/dist/store/HasOneStore.d.ts.map +1 -0
  47. package/dist/store/ReachabilityAnalyzer.d.ts +19 -0
  48. package/dist/store/ReachabilityAnalyzer.d.ts.map +1 -1
  49. package/dist/store/RekeyOrchestrator.d.ts +76 -0
  50. package/dist/store/RekeyOrchestrator.d.ts.map +1 -0
  51. package/dist/store/RelationEdgeIndex.d.ts +34 -0
  52. package/dist/store/RelationEdgeIndex.d.ts.map +1 -0
  53. package/dist/store/RelationStore.d.ts +74 -137
  54. package/dist/store/RelationStore.d.ts.map +1 -1
  55. package/dist/store/RootRegistry.d.ts +25 -4
  56. package/dist/store/RootRegistry.d.ts.map +1 -1
  57. package/dist/store/SnapshotStore.d.ts +137 -13
  58. package/dist/store/SnapshotStore.d.ts.map +1 -1
  59. package/dist/store/SubscriptionManager.d.ts +63 -14
  60. package/dist/store/SubscriptionManager.d.ts.map +1 -1
  61. package/dist/store/TouchedStore.d.ts +3 -2
  62. package/dist/store/TouchedStore.d.ts.map +1 -1
  63. package/dist/store/relationKey.d.ts +15 -0
  64. package/dist/store/relationKey.d.ts.map +1 -0
  65. package/dist/undo/UndoJournal.d.ts +133 -0
  66. package/dist/undo/UndoJournal.d.ts.map +1 -0
  67. package/dist/undo/UndoManager.d.ts +25 -82
  68. package/dist/undo/UndoManager.d.ts.map +1 -1
  69. package/dist/undo/UnrecordedWriteError.d.ts +16 -0
  70. package/dist/undo/UnrecordedWriteError.d.ts.map +1 -0
  71. package/dist/undo/index.d.ts +3 -1
  72. package/dist/undo/index.d.ts.map +1 -1
  73. package/dist/undo/rekeyJournalEntry.d.ts +20 -0
  74. package/dist/undo/rekeyJournalEntry.d.ts.map +1 -0
  75. package/dist/undo/types.d.ts +0 -48
  76. package/dist/undo/types.d.ts.map +1 -1
  77. package/package.json +2 -2
  78. package/src/core/ActionDispatcher.ts +56 -36
  79. package/src/core/actions.ts +8 -1
  80. package/src/events/EventEmitter.ts +78 -24
  81. package/src/handles/BaseHandle.ts +19 -2
  82. package/src/handles/EntityHandle.ts +48 -17
  83. package/src/handles/FieldHandle.ts +36 -10
  84. package/src/handles/HasManyListHandle.ts +121 -17
  85. package/src/handles/HasOneHandle.ts +177 -44
  86. package/src/handles/PlaceholderHandle.ts +184 -36
  87. package/src/handles/index.ts +3 -0
  88. package/src/handles/proxyFactory.ts +27 -0
  89. package/src/handles/types.ts +39 -0
  90. package/src/index.ts +4 -4
  91. package/src/persistence/BatchPersister.ts +810 -446
  92. package/src/persistence/ChangeRegistry.ts +27 -3
  93. package/src/persistence/MutationCollector.ts +265 -31
  94. package/src/persistence/PersistExecution.ts +217 -0
  95. package/src/persistence/types.ts +8 -2
  96. package/src/store/EntityMetaStore.ts +89 -45
  97. package/src/store/EntitySnapshotStore.ts +102 -19
  98. package/src/store/ErrorStore.ts +6 -5
  99. package/src/store/HasManyStore.ts +810 -0
  100. package/src/store/HasOneStore.ts +454 -0
  101. package/src/store/ReachabilityAnalyzer.ts +37 -0
  102. package/src/store/RekeyOrchestrator.ts +123 -0
  103. package/src/store/RelationEdgeIndex.ts +79 -0
  104. package/src/store/RelationStore.ts +176 -725
  105. package/src/store/RootRegistry.ts +50 -8
  106. package/src/store/SnapshotStore.ts +572 -101
  107. package/src/store/SubscriptionManager.ts +122 -75
  108. package/src/store/TouchedStore.ts +5 -2
  109. package/src/store/relationKey.ts +20 -0
  110. package/src/undo/UndoJournal.ts +258 -0
  111. package/src/undo/UndoManager.ts +151 -281
  112. package/src/undo/UnrecordedWriteError.ts +23 -0
  113. package/src/undo/index.ts +8 -7
  114. package/src/undo/rekeyJournalEntry.ts +145 -0
  115. package/src/undo/types.ts +0 -58
  116. package/dist/core/actionClassification.d.ts +0 -48
  117. package/dist/core/actionClassification.d.ts.map +0 -1
  118. package/src/core/actionClassification.ts +0 -199
@@ -1,48 +1,41 @@
1
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
2
  import type { SnapshotStore } from '../store/SnapshotStore.js'
11
- import type {
12
- PartialStoreSnapshot,
13
- PendingUndoEntry,
14
- UndoEntry,
15
- UndoManagerConfig,
16
- UndoState,
17
- } from './types.js'
3
+ import type { RekeyContext } from '../store/RekeyOrchestrator.js'
4
+ import { UndoJournal, cellRefKey, type JournalEntry, type JournalCellImage } from './UndoJournal.js'
5
+ import { rekeyJournalEntry } from './rekeyJournalEntry.js'
6
+ import type { UndoManagerConfig, UndoState } from './types.js'
18
7
 
19
8
  type Subscriber = () => void
20
9
 
21
10
  /**
22
- * Generates a unique ID for undo entries.
11
+ * Generates a unique id for manual group handles.
23
12
  */
24
13
  function generateId(): string {
25
14
  return `undo-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
26
15
  }
27
16
 
28
17
  /**
29
- * UndoManager provides undo/redo functionality for the Bindx store.
18
+ * UndoManager — the policy layer over the {@link UndoJournal}.
30
19
  *
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
20
+ * The journal records each gesture (one dispatch / one handle transaction) as a
21
+ * {@link JournalEntry} of editable-layer pre-images. UndoManager owns the undo /
22
+ * redo stacks and the grouping policy (debounced auto-grouping + manual
23
+ * begin/endGroup), blocks during persist, and keeps the stacks aligned across a
24
+ * temp→persisted rekey. Restore goes straight through the store's write paths, so
25
+ * the edge index and reachability cache rebuild themselves; the event/interceptor
26
+ * pipeline is NOT replayed.
37
27
  */
38
28
  export class UndoManager {
39
- private undoStack: UndoEntry[] = []
40
- private redoStack: UndoEntry[] = []
41
- private pendingEntry: PendingUndoEntry | null = null
29
+ private readonly journal: UndoJournal
30
+ private undoStack: JournalEntry[] = []
31
+ private redoStack: JournalEntry[] = []
32
+
33
+ /** Cells accumulated while a debounce window or manual group is open (first-writer-wins). */
34
+ private pending: Map<string, JournalCellImage> | null = null
42
35
  private debounceTimer: ReturnType<typeof setTimeout> | null = null
43
- private isBlocked = false
44
36
  private manualGroupId: string | null = null
45
- private manualGroupLabel: string | undefined = undefined
37
+
38
+ private blockDepth = 0
46
39
  private subscribers = new Set<Subscriber>()
47
40
  private cachedState: UndoState | null = null
48
41
 
@@ -55,333 +48,216 @@ export class UndoManager {
55
48
  ) {
56
49
  this.maxHistorySize = config.maxHistorySize ?? 100
57
50
  this.debounceMs = config.debounceMs ?? 300
51
+ this.journal = new UndoJournal(
52
+ store,
53
+ entry => this.onEntry(entry),
54
+ ctx => this.rekeyStacks(ctx),
55
+ () => this.clear(),
56
+ )
58
57
  }
59
58
 
60
59
  /**
61
- * Creates middleware for ActionDispatcher.
62
- * Must be added to dispatcher to enable undo tracking.
60
+ * Wires the journal into the store so its write paths record gestures. The
61
+ * returned middleware is a backward-compatible pass-through (recording is native
62
+ * to the store transaction, not the middleware).
63
63
  */
64
64
  createMiddleware(): ActionMiddleware {
65
- return (action: Action) => {
66
- this.handleAction(action)
67
- // Always allow action to proceed
68
- return true
65
+ this.store.setJournal(this.journal)
66
+ const middleware: ActionMiddleware = () => true
67
+ middleware.dispose = () => {
68
+ this.store.clearJournal(this.journal)
69
69
  }
70
+ return middleware
70
71
  }
71
72
 
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
- }
73
+ // ==================== Recording (journal commit sink) ====================
80
74
 
81
- const keys = getAffectedKeys(action)
75
+ private onEntry(entry: JournalEntry): void {
76
+ if (this.blockDepth > 0 || entry.cells.length === 0) return
82
77
 
83
- // Clear redo stack on new action
78
+ // A fresh user action invalidates the redo stack.
84
79
  if (this.redoStack.length > 0) {
85
80
  this.redoStack = []
86
- this.notifySubscribers()
87
81
  }
88
82
 
89
83
  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()
84
+ this.mergeIntoPending(entry)
137
85
  this.notifySubscribers()
138
86
  return
139
87
  }
140
88
 
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()
89
+ if (this.debounceMs === 0) {
90
+ this.pushEntry(entry)
91
+ return
160
92
  }
161
- }
162
93
 
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
94
+ this.mergeIntoPending(entry)
95
+ this.resetDebounceTimer()
96
+ this.notifySubscribers()
179
97
  }
180
98
 
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
- }
99
+ private mergeIntoPending(entry: JournalEntry): void {
100
+ if (!this.pending) {
101
+ this.pending = new Map()
189
102
  }
190
- for (const [key, value] of source.relationStates) {
191
- if (!target.relationStates.has(key)) {
192
- target.relationStates.set(key, value)
103
+ for (const cell of entry.cells) {
104
+ const key = cellRefKey(cell)
105
+ // First-writer-wins: keep the state from before the group began.
106
+ if (!this.pending.has(key)) {
107
+ this.pending.set(key, cell)
193
108
  }
194
109
  }
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
110
  }
215
111
 
216
- /**
217
- * Resets the debounce timer.
218
- */
219
- private resetDebounceTimer(): void {
112
+ private flushPending(shouldNotify = true): void {
220
113
  if (this.debounceTimer) {
221
114
  clearTimeout(this.debounceTimer)
115
+ this.debounceTimer = null
116
+ }
117
+ const pending = this.pending
118
+ this.pending = null
119
+ if (pending && pending.size > 0) {
120
+ this.pushEntry({ cells: [...pending.values()] }, shouldNotify)
222
121
  }
223
- this.startDebounceTimer()
224
122
  }
225
123
 
226
- /**
227
- * Flushes pending entry to undo stack.
228
- */
229
- private flushPendingEntry(): void {
230
- if (!this.pendingEntry) return
124
+ private pushEntry(entry: JournalEntry, shouldNotify = true): void {
125
+ this.undoStack.push(entry)
126
+ while (this.undoStack.length > this.maxHistorySize) {
127
+ this.undoStack.shift()
128
+ }
129
+ if (shouldNotify) {
130
+ this.notifySubscribers()
131
+ }
132
+ }
231
133
 
134
+ private resetDebounceTimer(): void {
232
135
  if (this.debounceTimer) {
233
136
  clearTimeout(this.debounceTimer)
234
- this.debounceTimer = null
235
137
  }
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()
138
+ this.debounceTimer = setTimeout(() => this.flushPending(), this.debounceMs)
248
139
  }
249
140
 
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
- }
141
+ // ==================== Grouping ====================
258
142
 
259
143
  /**
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.
144
+ * Starts a manual group: every gesture until {@link endGroup} folds into one
145
+ * undo entry. Returns a handle that must be passed back to endGroup.
262
146
  */
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
147
+ beginGroup(_label?: string): string {
148
+ this.flushPending()
149
+ const id = generateId()
150
+ this.manualGroupId = id
151
+ return id
271
152
  }
272
153
 
273
- /**
274
- * Ends a manual group and pushes to undo stack.
275
- */
276
154
  endGroup(groupId: string): void {
277
155
  if (this.manualGroupId !== groupId) {
278
156
  console.warn(`UndoManager: endGroup called with wrong groupId. Expected ${this.manualGroupId}, got ${groupId}`)
279
157
  return
280
158
  }
281
-
282
159
  this.manualGroupId = null
283
- this.manualGroupLabel = undefined
284
- this.flushPendingEntry()
160
+ this.flushPending()
285
161
  }
286
162
 
287
- /**
288
- * Undoes the last entry.
289
- */
290
- undo(): void {
291
- // Flush any pending entry first
292
- this.flushPendingEntry()
163
+ // ==================== Undo / Redo ====================
293
164
 
294
- if (this.isBlocked || this.undoStack.length === 0) {
295
- return
296
- }
165
+ undo(): void {
166
+ this.flushPending()
167
+ if (this.blockDepth > 0 || this.undoStack.length === 0) return
297
168
 
298
169
  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)
170
+ // Capture the current state of the same cells as the inverse (redo) entry,
171
+ // then restore the pre-images.
172
+ const inverse = this.journal.captureCurrent(entry.cells)
173
+ this.journal.apply(entry.cells)
174
+ this.redoStack.push({ cells: inverse })
314
175
 
315
176
  this.notifySubscribers()
316
177
  }
317
178
 
318
- /**
319
- * Redoes the last undone entry.
320
- */
321
179
  redo(): void {
322
- if (this.isBlocked || this.redoStack.length === 0) {
323
- return
324
- }
180
+ if (this.blockDepth > 0 || this.redoStack.length === 0) return
325
181
 
326
182
  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)
183
+ const inverse = this.journal.captureCurrent(entry.cells)
184
+ this.journal.apply(entry.cells)
185
+ this.undoStack.push({ cells: inverse })
342
186
 
343
187
  this.notifySubscribers()
344
188
  }
345
189
 
346
- /**
347
- * Blocks undo/redo operations.
348
- * Use during persist to prevent inconsistent state.
349
- */
190
+ // ==================== Blocking (during persist) ====================
191
+
350
192
  block(): void {
351
- // Flush any pending entry before blocking
352
- this.flushPendingEntry()
353
- this.isBlocked = true
354
- this.notifySubscribers()
193
+ if (this.blockDepth === 0) {
194
+ this.blockDepth++
195
+ this.flushPending(false)
196
+ this.notifySubscribers()
197
+ return
198
+ }
199
+ this.blockDepth++
355
200
  }
356
201
 
357
- /**
358
- * Unblocks undo/redo operations.
359
- */
360
202
  unblock(): void {
361
- this.isBlocked = false
362
- this.notifySubscribers()
203
+ if (this.blockDepth === 0) return
204
+ this.blockDepth--
205
+ if (this.blockDepth === 0) {
206
+ this.notifySubscribers()
207
+ }
363
208
  }
364
209
 
210
+ // ==================== Persist rekey ====================
211
+
365
212
  /**
366
- * Clears all undo/redo history.
213
+ * Rewrites every stacked entry when a temp id is replaced by its persisted id,
214
+ * so stored cells keep valid keys / id references (and sealed creates drop out).
367
215
  */
216
+ private rekeyStacks(ctx: RekeyContext): void {
217
+ const liveServerIds = (key: string): Set<string> => this.store.getLiveHasManyServerIds(key)
218
+ const undoCount = this.undoStack.length
219
+ const redoCount = this.redoStack.length
220
+ const hadPending = this.pending !== null && this.pending.size > 0
221
+
222
+ this.undoStack = this.undoStack
223
+ .map(entry => rekeyJournalEntry(entry, ctx, liveServerIds))
224
+ .filter(entry => entry.cells.length > 0)
225
+ this.redoStack = this.redoStack
226
+ .map(entry => rekeyJournalEntry(entry, ctx, liveServerIds))
227
+ .filter(entry => entry.cells.length > 0)
228
+ if (this.pending) {
229
+ const rekeyed = rekeyJournalEntry({ cells: [...this.pending.values()] }, ctx, liveServerIds)
230
+ if (rekeyed.cells.length > 0) {
231
+ this.pending = new Map(rekeyed.cells.map(cell => [cellRefKey(cell), cell]))
232
+ } else {
233
+ this.pending = null
234
+ if (this.debounceTimer) {
235
+ clearTimeout(this.debounceTimer)
236
+ this.debounceTimer = null
237
+ }
238
+ }
239
+ }
240
+
241
+ const hasPending = this.pending !== null && this.pending.size > 0
242
+ if (this.undoStack.length !== undoCount || this.redoStack.length !== redoCount || hasPending !== hadPending) {
243
+ this.notifySubscribers()
244
+ }
245
+ }
246
+
247
+ // ==================== History / State ====================
248
+
368
249
  clear(): void {
369
250
  if (this.debounceTimer) {
370
251
  clearTimeout(this.debounceTimer)
371
252
  this.debounceTimer = null
372
253
  }
373
- this.pendingEntry = null
254
+ this.pending = null
255
+ this.manualGroupId = null
374
256
  this.undoStack = []
375
257
  this.redoStack = []
376
- this.manualGroupId = null
377
- this.manualGroupLabel = undefined
378
258
  this.notifySubscribers()
379
259
  }
380
260
 
381
- /**
382
- * Subscribes to state changes.
383
- * Returns unsubscribe function.
384
- */
385
261
  subscribe(callback: Subscriber): () => void {
386
262
  this.subscribers.add(callback)
387
263
  return () => {
@@ -389,26 +265,20 @@ export class UndoManager {
389
265
  }
390
266
  }
391
267
 
392
- /**
393
- * Gets current state for React integration.
394
- * Returns a cached object to satisfy useSyncExternalStore's referential equality requirement.
395
- */
396
268
  getState(): UndoState {
397
269
  if (!this.cachedState) {
270
+ const hasPending = this.pending !== null && this.pending.size > 0
398
271
  this.cachedState = {
399
- canUndo: this.undoStack.length > 0 || this.pendingEntry !== null,
272
+ canUndo: this.undoStack.length > 0 || hasPending,
400
273
  canRedo: this.redoStack.length > 0,
401
- isBlocked: this.isBlocked,
402
- undoCount: this.undoStack.length + (this.pendingEntry ? 1 : 0),
274
+ isBlocked: this.blockDepth > 0,
275
+ undoCount: this.undoStack.length + (hasPending ? 1 : 0),
403
276
  redoCount: this.redoStack.length,
404
277
  }
405
278
  }
406
279
  return this.cachedState
407
280
  }
408
281
 
409
- /**
410
- * Notifies all subscribers of state change.
411
- */
412
282
  private notifySubscribers(): void {
413
283
  this.cachedState = null
414
284
  for (const sub of this.subscribers) {
@@ -0,0 +1,23 @@
1
+ import type { JournalCellKind } from './UndoJournal.js'
2
+
3
+ /**
4
+ * Thrown when a journal transaction closes after an editable-layer store write
5
+ * that was NOT preceded by a matching `journal.record*` call — i.e. a mutating
6
+ * SnapshotStore method wrote primary state a gesture can no longer undo.
7
+ *
8
+ * This turns the "every mutating method must record before writing" convention
9
+ * into a checked invariant: the sub-store editable-write counters advanced for a
10
+ * kind the transaction never recorded. The named kinds pinpoint which record call
11
+ * is missing (recordEntity / recordRelation / recordHasMany).
12
+ */
13
+ export class UnrecordedWriteError extends Error {
14
+ constructor(public readonly kinds: ReadonlyArray<JournalCellKind>) {
15
+ super(
16
+ `Undo journal invariant violated: editable-layer ${kinds.join(', ')} write(s) happened ` +
17
+ `inside a journal transaction without a preceding record call. Every mutating SnapshotStore ` +
18
+ `method must call journal.recordEntity/recordRelation/recordHasMany before writing so the ` +
19
+ `gesture can be undone. Unrecorded kind(s): ${kinds.join(', ')}.`,
20
+ )
21
+ this.name = 'UnrecordedWriteError'
22
+ }
23
+ }
package/src/undo/index.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  export { UndoManager } from './UndoManager.js'
2
+ export { UnrecordedWriteError } from './UnrecordedWriteError.js'
3
+ export type { UndoManagerConfig, UndoState } from './types.js'
2
4
  export type {
3
- PartialStoreSnapshot,
4
- PendingUndoEntry,
5
- UndoEntry,
6
- UndoManagerConfig,
7
- UndoState,
8
- StoreAffectedKeys,
9
- } from './types.js'
5
+ JournalEntry,
6
+ JournalCellImage,
7
+ EntityCellImage,
8
+ RelationCellImage,
9
+ HasManyCellImage,
10
+ } from './UndoJournal.js'