@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.
- package/dist/adapter/ContemberAdapter.d.ts +40 -0
- package/dist/adapter/ContemberAdapter.d.ts.map +1 -0
- package/dist/adapter/MockAdapter.d.ts +81 -0
- package/dist/adapter/MockAdapter.d.ts.map +1 -0
- package/dist/adapter/MockQueryEngine.d.ts +43 -0
- package/dist/adapter/MockQueryEngine.d.ts.map +1 -0
- package/dist/adapter/index.d.ts +5 -0
- package/dist/adapter/index.d.ts.map +1 -0
- package/dist/adapter/types.d.ts +184 -0
- package/dist/adapter/types.d.ts.map +1 -0
- package/dist/brand/ComponentBrand.d.ts +25 -0
- package/dist/brand/ComponentBrand.d.ts.map +1 -0
- package/dist/contember/index.d.ts +10 -0
- package/dist/contember/index.d.ts.map +1 -0
- package/dist/core/ActionDispatcher.d.ts +64 -0
- package/dist/core/ActionDispatcher.d.ts.map +1 -0
- package/dist/core/Disposable.d.ts +57 -0
- package/dist/core/Disposable.d.ts.map +1 -0
- package/dist/core/EntityLoader.d.ts +80 -0
- package/dist/core/EntityLoader.d.ts.map +1 -0
- package/dist/core/PersistenceManager.d.ts +126 -0
- package/dist/core/PersistenceManager.d.ts.map +1 -0
- package/dist/core/SelectionResolver.d.ts +21 -0
- package/dist/core/SelectionResolver.d.ts.map +1 -0
- package/dist/core/actionClassification.d.ts +48 -0
- package/dist/core/actionClassification.d.ts.map +1 -0
- package/dist/core/actions.d.ts +301 -0
- package/dist/core/actions.d.ts.map +1 -0
- package/dist/core/index.d.ts +4 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/dataview/filterHandlers.d.ts +18 -0
- package/dist/dataview/filterHandlers.d.ts.map +1 -0
- package/dist/dataview/index.d.ts +3 -0
- package/dist/dataview/index.d.ts.map +1 -0
- package/dist/dataview/types.d.ts +164 -0
- package/dist/dataview/types.d.ts.map +1 -0
- package/dist/errors/UnfetchedFieldError.d.ts +11 -0
- package/dist/errors/UnfetchedFieldError.d.ts.map +1 -0
- package/dist/errors/errorPathResolver.d.ts +52 -0
- package/dist/errors/errorPathResolver.d.ts.map +1 -0
- package/dist/errors/index.d.ts +5 -0
- package/dist/errors/index.d.ts.map +1 -0
- package/dist/errors/pathMapper.d.ts +42 -0
- package/dist/errors/pathMapper.d.ts.map +1 -0
- package/dist/errors/types.d.ts +131 -0
- package/dist/errors/types.d.ts.map +1 -0
- package/dist/events/EventEmitter.d.ts +97 -0
- package/dist/events/EventEmitter.d.ts.map +1 -0
- package/dist/events/eventFactory.d.ts +40 -0
- package/dist/events/eventFactory.d.ts.map +1 -0
- package/dist/events/index.d.ts +15 -0
- package/dist/events/index.d.ts.map +1 -0
- package/dist/events/types.d.ts +264 -0
- package/dist/events/types.d.ts.map +1 -0
- package/dist/handles/BaseHandle.d.ts +64 -0
- package/dist/handles/BaseHandle.d.ts.map +1 -0
- package/dist/handles/EntityHandle.d.ts +208 -0
- package/dist/handles/EntityHandle.d.ts.map +1 -0
- package/dist/handles/FieldHandle.d.ts +111 -0
- package/dist/handles/FieldHandle.d.ts.map +1 -0
- package/dist/handles/HasManyListHandle.d.ts +166 -0
- package/dist/handles/HasManyListHandle.d.ts.map +1 -0
- package/dist/handles/HasOneHandle.d.ts +168 -0
- package/dist/handles/HasOneHandle.d.ts.map +1 -0
- package/dist/handles/PlaceholderHandle.d.ts +117 -0
- package/dist/handles/PlaceholderHandle.d.ts.map +1 -0
- package/dist/handles/index.d.ts +8 -0
- package/dist/handles/index.d.ts.map +1 -0
- package/dist/handles/proxyFactory.d.ts +66 -0
- package/dist/handles/proxyFactory.d.ts.map +1 -0
- package/dist/handles/types.d.ts +430 -0
- package/dist/handles/types.d.ts.map +1 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/internal.d.ts +11 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/persistence/BatchPersister.d.ts +142 -0
- package/dist/persistence/BatchPersister.d.ts.map +1 -0
- package/dist/persistence/ChangeRegistry.d.ts +87 -0
- package/dist/persistence/ChangeRegistry.d.ts.map +1 -0
- package/dist/persistence/ContemberSchemaMutationAdapter.d.ts +16 -0
- package/dist/persistence/ContemberSchemaMutationAdapter.d.ts.map +1 -0
- package/dist/persistence/MutationCollector.d.ts +96 -0
- package/dist/persistence/MutationCollector.d.ts.map +1 -0
- package/dist/persistence/MutationSchemaProvider.d.ts +30 -0
- package/dist/persistence/MutationSchemaProvider.d.ts.map +1 -0
- package/dist/persistence/index.d.ts +7 -0
- package/dist/persistence/index.d.ts.map +1 -0
- package/dist/persistence/types.d.ts +189 -0
- package/dist/persistence/types.d.ts.map +1 -0
- package/dist/roles/RoleSchemaRegistry.d.ts +77 -0
- package/dist/roles/RoleSchemaRegistry.d.ts.map +1 -0
- package/dist/roles/index.d.ts +11 -0
- package/dist/roles/index.d.ts.map +1 -0
- package/dist/roles/types.d.ts +242 -0
- package/dist/roles/types.d.ts.map +1 -0
- package/dist/schema/ContemberSchema.d.ts +98 -0
- package/dist/schema/ContemberSchema.d.ts.map +1 -0
- package/dist/schema/SchemaLoader.d.ts +28 -0
- package/dist/schema/SchemaLoader.d.ts.map +1 -0
- package/dist/schema/SchemaRegistry.d.ts +95 -0
- package/dist/schema/SchemaRegistry.d.ts.map +1 -0
- package/dist/schema/index.d.ts +6 -0
- package/dist/schema/index.d.ts.map +1 -0
- package/dist/schema/roles.d.ts +62 -0
- package/dist/schema/roles.d.ts.map +1 -0
- package/dist/schema/types.d.ts +179 -0
- package/dist/schema/types.d.ts.map +1 -0
- package/dist/selection/SelectionMetaCollector.d.ts +32 -0
- package/dist/selection/SelectionMetaCollector.d.ts.map +1 -0
- package/dist/selection/SelectionScope.d.ts +77 -0
- package/dist/selection/SelectionScope.d.ts.map +1 -0
- package/dist/selection/buildQuery.d.ts +45 -0
- package/dist/selection/buildQuery.d.ts.map +1 -0
- package/dist/selection/createFragment.d.ts +29 -0
- package/dist/selection/createFragment.d.ts.map +1 -0
- package/dist/selection/createSelectionBuilder.d.ts +18 -0
- package/dist/selection/createSelectionBuilder.d.ts.map +1 -0
- package/dist/selection/index.d.ts +8 -0
- package/dist/selection/index.d.ts.map +1 -0
- package/dist/selection/queryTypes.d.ts +84 -0
- package/dist/selection/queryTypes.d.ts.map +1 -0
- package/dist/selection/types.d.ts +238 -0
- package/dist/selection/types.d.ts.map +1 -0
- package/dist/store/DirtyTracker.d.ts +26 -0
- package/dist/store/DirtyTracker.d.ts.map +1 -0
- package/dist/store/EntityMetaStore.d.ts +52 -0
- package/dist/store/EntityMetaStore.d.ts.map +1 -0
- package/dist/store/EntitySnapshotStore.d.ts +63 -0
- package/dist/store/EntitySnapshotStore.d.ts.map +1 -0
- package/dist/store/ErrorStore.d.ts +82 -0
- package/dist/store/ErrorStore.d.ts.map +1 -0
- package/dist/store/RelationStore.d.ts +175 -0
- package/dist/store/RelationStore.d.ts.map +1 -0
- package/dist/store/SnapshotStore.d.ts +143 -0
- package/dist/store/SnapshotStore.d.ts.map +1 -0
- package/dist/store/SubscriptionManager.d.ts +90 -0
- package/dist/store/SubscriptionManager.d.ts.map +1 -0
- package/dist/store/TouchedStore.d.ts +24 -0
- package/dist/store/TouchedStore.d.ts.map +1 -0
- package/dist/store/entityId.d.ts +29 -0
- package/dist/store/entityId.d.ts.map +1 -0
- package/dist/store/index.d.ts +1 -0
- package/dist/store/index.d.ts.map +1 -0
- package/dist/store/snapshots.d.ts +144 -0
- package/dist/store/snapshots.d.ts.map +1 -0
- package/dist/undo/UndoManager.d.ts +120 -0
- package/dist/undo/UndoManager.d.ts.map +1 -0
- package/dist/undo/index.d.ts +3 -0
- package/dist/undo/index.d.ts.map +1 -0
- package/dist/undo/types.d.ts +74 -0
- package/dist/undo/types.d.ts.map +1 -0
- package/dist/utils/aliasGenerator.d.ts +25 -0
- package/dist/utils/aliasGenerator.d.ts.map +1 -0
- package/dist/utils/deepEqual.d.ts +6 -0
- package/dist/utils/deepEqual.d.ts.map +1 -0
- package/dist/utils/index.d.ts +3 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/package.json +29 -0
- package/src/adapter/ContemberAdapter.ts +239 -0
- package/src/adapter/MockAdapter.ts +394 -0
- package/src/adapter/MockQueryEngine.ts +236 -0
- package/src/adapter/index.ts +21 -0
- package/src/adapter/types.ts +199 -0
- package/src/brand/ComponentBrand.ts +28 -0
- package/src/contember/index.ts +17 -0
- package/src/core/ActionDispatcher.ts +450 -0
- package/src/core/Disposable.ts +126 -0
- package/src/core/EntityLoader.ts +169 -0
- package/src/core/SelectionResolver.ts +78 -0
- package/src/core/actionClassification.ts +196 -0
- package/src/core/actions.ts +491 -0
- package/src/core/index.ts +20 -0
- package/src/dataview/filterHandlers.ts +443 -0
- package/src/dataview/index.ts +36 -0
- package/src/dataview/types.ts +220 -0
- package/src/errors/UnfetchedFieldError.ts +18 -0
- package/src/errors/errorPathResolver.ts +239 -0
- package/src/errors/index.ts +40 -0
- package/src/errors/pathMapper.ts +40 -0
- package/src/errors/types.ts +223 -0
- package/src/events/EventEmitter.ts +447 -0
- package/src/events/eventFactory.ts +430 -0
- package/src/events/index.ts +89 -0
- package/src/events/types.ts +374 -0
- package/src/handles/BaseHandle.ts +100 -0
- package/src/handles/EntityHandle.ts +538 -0
- package/src/handles/FieldHandle.ts +268 -0
- package/src/handles/HasManyListHandle.ts +500 -0
- package/src/handles/HasOneHandle.ts +500 -0
- package/src/handles/PlaceholderHandle.ts +321 -0
- package/src/handles/index.ts +37 -0
- package/src/handles/proxyFactory.ts +218 -0
- package/src/handles/types.ts +743 -0
- package/src/index.ts +384 -0
- package/src/internal.ts +19 -0
- package/src/persistence/BatchPersister.ts +911 -0
- package/src/persistence/ChangeRegistry.ts +150 -0
- package/src/persistence/ContemberSchemaMutationAdapter.ts +57 -0
- package/src/persistence/MutationCollector.ts +567 -0
- package/src/persistence/MutationSchemaProvider.ts +33 -0
- package/src/persistence/index.ts +23 -0
- package/src/persistence/types.ts +220 -0
- package/src/schema/ContemberSchema.ts +132 -0
- package/src/schema/SchemaLoader.ts +93 -0
- package/src/schema/SchemaRegistry.ts +286 -0
- package/src/schema/index.ts +51 -0
- package/src/schema/roles.ts +83 -0
- package/src/schema/types.ts +238 -0
- package/src/selection/SelectionMetaCollector.ts +122 -0
- package/src/selection/SelectionScope.ts +208 -0
- package/src/selection/buildQuery.ts +134 -0
- package/src/selection/createFragment.ts +50 -0
- package/src/selection/createSelectionBuilder.ts +283 -0
- package/src/selection/index.ts +31 -0
- package/src/selection/queryTypes.ts +135 -0
- package/src/selection/types.ts +320 -0
- package/src/store/DirtyTracker.ts +117 -0
- package/src/store/EntityMetaStore.ts +143 -0
- package/src/store/EntitySnapshotStore.ts +260 -0
- package/src/store/ErrorStore.ts +251 -0
- package/src/store/RelationStore.ts +696 -0
- package/src/store/SnapshotStore.ts +765 -0
- package/src/store/SubscriptionManager.ts +241 -0
- package/src/store/TouchedStore.ts +42 -0
- package/src/store/entityId.ts +43 -0
- package/src/store/index.ts +2 -0
- package/src/store/snapshots.ts +203 -0
- package/src/undo/UndoManager.ts +418 -0
- package/src/undo/index.ts +9 -0
- package/src/undo/types.ts +84 -0
- package/src/utils/aliasGenerator.ts +56 -0
- package/src/utils/deepEqual.ts +33 -0
- 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,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
|
+
}
|