@arcmantle/chronicle 0.0.4
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/README.md +563 -0
- package/dist/api-methods.d.ts +28 -0
- package/dist/api-methods.d.ts.map +1 -0
- package/dist/api-methods.js +206 -0
- package/dist/api-methods.js.map +1 -0
- package/dist/api.d.ts +12 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +30 -0
- package/dist/api.js.map +1 -0
- package/dist/array-mutations.d.ts +31 -0
- package/dist/array-mutations.d.ts.map +1 -0
- package/dist/array-mutations.js +50 -0
- package/dist/array-mutations.js.map +1 -0
- package/dist/batch-transaction.d.ts +25 -0
- package/dist/batch-transaction.d.ts.map +1 -0
- package/dist/batch-transaction.js +138 -0
- package/dist/batch-transaction.js.map +1 -0
- package/dist/chronicle.d.ts +41 -0
- package/dist/chronicle.d.ts.map +1 -0
- package/dist/chronicle.js +40 -0
- package/dist/chronicle.js.map +1 -0
- package/dist/collection-adapters.d.ts +29 -0
- package/dist/collection-adapters.d.ts.map +1 -0
- package/dist/collection-adapters.js +184 -0
- package/dist/collection-adapters.js.map +1 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +11 -0
- package/dist/config.js.map +1 -0
- package/dist/grouping.d.ts +17 -0
- package/dist/grouping.d.ts.map +1 -0
- package/dist/grouping.js +35 -0
- package/dist/grouping.js.map +1 -0
- package/dist/history-recorder.d.ts +39 -0
- package/dist/history-recorder.d.ts.map +1 -0
- package/dist/history-recorder.js +112 -0
- package/dist/history-recorder.js.map +1 -0
- package/dist/history.d.ts +29 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +47 -0
- package/dist/history.js.map +1 -0
- package/dist/listener-affinity.d.ts +16 -0
- package/dist/listener-affinity.d.ts.map +1 -0
- package/dist/listener-affinity.js +58 -0
- package/dist/listener-affinity.js.map +1 -0
- package/dist/listener-trie.d.ts +10 -0
- package/dist/listener-trie.d.ts.map +1 -0
- package/dist/listener-trie.js +83 -0
- package/dist/listener-trie.js.map +1 -0
- package/dist/nameof.d.ts +12 -0
- package/dist/nameof.d.ts.map +1 -0
- package/dist/nameof.js +30 -0
- package/dist/nameof.js.map +1 -0
- package/dist/path-key.d.ts +11 -0
- package/dist/path-key.d.ts.map +1 -0
- package/dist/path-key.js +11 -0
- package/dist/path-key.js.map +1 -0
- package/dist/path.d.ts +7 -0
- package/dist/path.d.ts.map +1 -0
- package/dist/path.js +53 -0
- package/dist/path.js.map +1 -0
- package/dist/proxy-cache.d.ts +32 -0
- package/dist/proxy-cache.d.ts.map +1 -0
- package/dist/proxy-cache.js +72 -0
- package/dist/proxy-cache.js.map +1 -0
- package/dist/proxy-factory.d.ts +17 -0
- package/dist/proxy-factory.d.ts.map +1 -0
- package/dist/proxy-factory.js +124 -0
- package/dist/proxy-factory.js.map +1 -0
- package/dist/schedule-queue.d.ts +10 -0
- package/dist/schedule-queue.d.ts.map +1 -0
- package/dist/schedule-queue.js +112 -0
- package/dist/schedule-queue.js.map +1 -0
- package/dist/snapshot-diff.d.ts +6 -0
- package/dist/snapshot-diff.d.ts.map +1 -0
- package/dist/snapshot-diff.js +67 -0
- package/dist/snapshot-diff.js.map +1 -0
- package/dist/symbol-id.d.ts +3 -0
- package/dist/symbol-id.d.ts.map +1 -0
- package/dist/symbol-id.js +16 -0
- package/dist/symbol-id.js.map +1 -0
- package/dist/types.d.ts +48 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/undo-redo.d.ts +12 -0
- package/dist/undo-redo.d.ts.map +1 -0
- package/dist/undo-redo.js +216 -0
- package/dist/undo-redo.js.map +1 -0
- package/package.json +45 -0
- package/src/api-methods.ts +292 -0
- package/src/api.ts +53 -0
- package/src/array-mutations.ts +64 -0
- package/src/batch-transaction.ts +183 -0
- package/src/chronicle.ts +100 -0
- package/src/collection-adapters.ts +224 -0
- package/src/config.ts +16 -0
- package/src/grouping.ts +47 -0
- package/src/history-recorder.ts +145 -0
- package/src/history.ts +75 -0
- package/src/listener-affinity.ts +69 -0
- package/src/listener-trie.ts +103 -0
- package/src/nameof.ts +42 -0
- package/src/path-key.ts +10 -0
- package/src/path.ts +69 -0
- package/src/proxy-cache.ts +86 -0
- package/src/proxy-factory.ts +168 -0
- package/src/schedule-queue.ts +144 -0
- package/src/snapshot-diff.ts +90 -0
- package/src/symbol-id.ts +20 -0
- package/src/tsconfig.json +3 -0
- package/src/types.ts +59 -0
- package/src/undo-redo.ts +249 -0
package/README.md
ADDED
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
# Chronicle - Deep Observable State with Time-Travel
|
|
2
|
+
|
|
3
|
+
Chronicle is a powerful state observation library that provides deep proxy-based tracking, history recording, undo/redo capabilities, and time-travel debugging for JavaScript objects.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Deep Observation**: Automatically tracks changes to nested objects, arrays, Maps, and Sets
|
|
8
|
+
- **Time-Travel Debugging**: Full undo/redo with group-based operations
|
|
9
|
+
- **Flexible Listeners**: Listen to specific paths with exact, descendant, or ancestor modes
|
|
10
|
+
- **Batching & Transactions**: Group multiple changes into atomic, undoable operations
|
|
11
|
+
- **Smart History**: Configurable history size, filtering, and compaction
|
|
12
|
+
- **Diff & Snapshots**: Compare current state to original, reset to pristine
|
|
13
|
+
- **Quality of Life**: Debounce, throttle, once listeners, pause/resume notifications
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
import { chronicle } from './chronicle.ts';
|
|
19
|
+
|
|
20
|
+
// Observe an object
|
|
21
|
+
const state = chronicle({ count: 0, user: { name: 'Alice' } });
|
|
22
|
+
|
|
23
|
+
// Listen to changes (string selector)
|
|
24
|
+
chronicle.listen(state, 'count', (path, newValue, oldValue) => {
|
|
25
|
+
console.log(`Count changed from ${oldValue} to ${newValue}`);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Or use a function selector for better type safety
|
|
29
|
+
chronicle.listen(state, s => s.count, (path, newValue, oldValue) => {
|
|
30
|
+
console.log(`Count changed from ${oldValue} to ${newValue}`);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Make changes
|
|
34
|
+
state.count = 1; // Listener fires: "Count changed from 0 to 1"
|
|
35
|
+
|
|
36
|
+
// Undo
|
|
37
|
+
chronicle.undo(state);
|
|
38
|
+
console.log(state.count); // 0
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Core API
|
|
42
|
+
|
|
43
|
+
### `chronicle(object)`
|
|
44
|
+
|
|
45
|
+
Wraps an object with deep observation. Returns a proxy that tracks all changes.
|
|
46
|
+
|
|
47
|
+
```typescript
|
|
48
|
+
const observed = chronicle({ items: [], settings: { theme: 'dark' } });
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Listeners
|
|
52
|
+
|
|
53
|
+
#### `chronicle.listen(object, selector, listener, mode?, options?)`
|
|
54
|
+
|
|
55
|
+
Listen to changes at a specific path.
|
|
56
|
+
|
|
57
|
+
**Modes:**
|
|
58
|
+
|
|
59
|
+
- `'exact'` (default): Only changes to this exact path
|
|
60
|
+
- `'down'`: Changes to this path and all descendants
|
|
61
|
+
- `'up'`: Changes to any ancestor of this path
|
|
62
|
+
|
|
63
|
+
**Selector types:**
|
|
64
|
+
|
|
65
|
+
- String: `'user.name'` or `'items.0'`
|
|
66
|
+
- Array: `['user', 'name']` or `['items', 0]`
|
|
67
|
+
- Function: `obj => obj.user.name` (uses `nameof` utility)
|
|
68
|
+
|
|
69
|
+
**Options:**
|
|
70
|
+
|
|
71
|
+
- `once: boolean` - Auto-unsubscribe after first call
|
|
72
|
+
- `debounceMs: number` - Coalesce rapid changes
|
|
73
|
+
- `throttleMs: number` - Limit call frequency
|
|
74
|
+
- `schedule: 'sync' | 'microtask'` - When to deliver notifications
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
// Listen to exact path (string selector)
|
|
78
|
+
chronicle.listen(state, 'count', (path, newVal, oldVal, meta) => {
|
|
79
|
+
console.log('Count changed:', newVal);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Or use a function selector for type safety
|
|
83
|
+
chronicle.listen(state, s => s.count, (path, newVal, oldVal, meta) => {
|
|
84
|
+
console.log('Count changed:', newVal);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Listen to all descendants
|
|
88
|
+
chronicle.listen(state, 'user', (path) => {
|
|
89
|
+
console.log('User changed at:', path);
|
|
90
|
+
}, 'down');
|
|
91
|
+
|
|
92
|
+
// Function selector with descendant mode
|
|
93
|
+
chronicle.listen(state, s => s.user, (path) => {
|
|
94
|
+
console.log('User changed at:', path);
|
|
95
|
+
}, 'down');
|
|
96
|
+
|
|
97
|
+
// Debounced listener
|
|
98
|
+
chronicle.listen(state, s => s.searchQuery, handleSearch, {
|
|
99
|
+
debounceMs: 300
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Throttled listener
|
|
103
|
+
chronicle.listen(state, s => s.mousePosition, updateUI, {
|
|
104
|
+
throttleMs: 16 // ~60fps
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// One-time listener
|
|
108
|
+
chronicle.listen(state, s => s.initialized, () => {
|
|
109
|
+
console.log('App initialized!');
|
|
110
|
+
}, { once: true });
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
#### `chronicle.onAny(object, listener, options?)`
|
|
114
|
+
|
|
115
|
+
Listen to all changes on the object.
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
chronicle.onAny(state, (path, newVal, oldVal, meta) => {
|
|
119
|
+
console.log('Changed:', path, 'type:', meta.type);
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Pause/Resume
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
// Pause notifications (queues them)
|
|
127
|
+
chronicle.pause(state);
|
|
128
|
+
|
|
129
|
+
state.count = 1;
|
|
130
|
+
state.count = 2;
|
|
131
|
+
state.count = 3; // No listeners fired yet
|
|
132
|
+
|
|
133
|
+
// Resume and deliver all queued notifications
|
|
134
|
+
chronicle.resume(state);
|
|
135
|
+
|
|
136
|
+
// Or just flush without resuming
|
|
137
|
+
chronicle.flush(state);
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### History
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
// Get full history
|
|
144
|
+
const history = chronicle.getHistory(state);
|
|
145
|
+
// [{ path: ['count'], type: 'set', oldValue: 0, newValue: 1, ... }]
|
|
146
|
+
|
|
147
|
+
// Clear history
|
|
148
|
+
chronicle.clearHistory(state);
|
|
149
|
+
|
|
150
|
+
// Mark current point for undo
|
|
151
|
+
const marker = chronicle.mark(state);
|
|
152
|
+
// ... make changes ...
|
|
153
|
+
chronicle.undoSince(state, marker);
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Undo/Redo
|
|
157
|
+
|
|
158
|
+
```typescript
|
|
159
|
+
// Undo individual steps
|
|
160
|
+
chronicle.undo(state, 3); // Undo last 3 changes
|
|
161
|
+
|
|
162
|
+
// Undo by groups (batches/transactions)
|
|
163
|
+
chronicle.undoGroups(state, 1); // Undo last batch
|
|
164
|
+
|
|
165
|
+
// Redo
|
|
166
|
+
chronicle.redo(state, 2);
|
|
167
|
+
chronicle.redoGroups(state, 1);
|
|
168
|
+
|
|
169
|
+
// Check availability
|
|
170
|
+
if (chronicle.canUndo(state)) {
|
|
171
|
+
chronicle.undo(state);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (chronicle.canRedo(state)) {
|
|
175
|
+
chronicle.redo(state);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Clear redo stack
|
|
179
|
+
chronicle.clearRedo(state);
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### Batching
|
|
183
|
+
|
|
184
|
+
Group multiple changes into a single undoable operation.
|
|
185
|
+
|
|
186
|
+
```typescript
|
|
187
|
+
// Manual batching
|
|
188
|
+
chronicle.beginBatch(state);
|
|
189
|
+
state.items.push('item1');
|
|
190
|
+
state.items.push('item2');
|
|
191
|
+
state.count = 2;
|
|
192
|
+
chronicle.commitBatch(state);
|
|
193
|
+
|
|
194
|
+
// Now undo reverts all 3 changes as one
|
|
195
|
+
chronicle.undoGroups(state, 1);
|
|
196
|
+
|
|
197
|
+
// Or rollback to discard changes
|
|
198
|
+
chronicle.beginBatch(state);
|
|
199
|
+
state.count = 999;
|
|
200
|
+
chronicle.rollbackBatch(state); // Changes discarded
|
|
201
|
+
|
|
202
|
+
// Convenience wrapper
|
|
203
|
+
chronicle.batch(state, (s) => {
|
|
204
|
+
s.items.push('item1');
|
|
205
|
+
s.items.push('item2');
|
|
206
|
+
s.count = 2;
|
|
207
|
+
}); // Auto-commits
|
|
208
|
+
|
|
209
|
+
// Batch with error handling
|
|
210
|
+
try {
|
|
211
|
+
chronicle.batch(state, (s) => {
|
|
212
|
+
s.count = 1;
|
|
213
|
+
throw new Error('Something went wrong');
|
|
214
|
+
});
|
|
215
|
+
} catch (e) {
|
|
216
|
+
// Batch auto-rolled back on error
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### Transactions
|
|
221
|
+
|
|
222
|
+
Transactions are batches with convenient undo helpers.
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
// Sync transaction
|
|
226
|
+
const { result, marker, undo } = chronicle.transaction(state, (s) => {
|
|
227
|
+
s.user.name = 'Bob';
|
|
228
|
+
s.user.email = 'bob@example.com';
|
|
229
|
+
return s.user;
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Later, undo this specific transaction
|
|
233
|
+
undo();
|
|
234
|
+
|
|
235
|
+
// Async transaction
|
|
236
|
+
const { result, undo } = await chronicle.transactionAsync(state, async (s) => {
|
|
237
|
+
s.loading = true;
|
|
238
|
+
const data = await fetchData();
|
|
239
|
+
s.data = data;
|
|
240
|
+
s.loading = false;
|
|
241
|
+
return data;
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Nested transactions coalesce
|
|
245
|
+
chronicle.transaction(state, (s) => {
|
|
246
|
+
s.count = 1;
|
|
247
|
+
chronicle.transaction(s, (s2) => {
|
|
248
|
+
s2.count = 2; // Both changes in one group
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
// Undo undoes both changes
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### Diff & Reset
|
|
255
|
+
|
|
256
|
+
```typescript
|
|
257
|
+
const original = { count: 0, items: ['a'] };
|
|
258
|
+
const state = chronicle(original);
|
|
259
|
+
|
|
260
|
+
state.count = 5;
|
|
261
|
+
state.items.push('b');
|
|
262
|
+
|
|
263
|
+
// Get differences
|
|
264
|
+
const diff = chronicle.diff(state);
|
|
265
|
+
// [
|
|
266
|
+
// { path: ['count'], kind: 'changed', oldValue: 0, newValue: 5 },
|
|
267
|
+
// { path: ['items', '1'], kind: 'added', newValue: 'b' }
|
|
268
|
+
// ]
|
|
269
|
+
|
|
270
|
+
// Check if pristine
|
|
271
|
+
console.log(chronicle.isPristine(state)); // false
|
|
272
|
+
|
|
273
|
+
// Reset to original
|
|
274
|
+
chronicle.reset(state);
|
|
275
|
+
console.log(state.count); // 0
|
|
276
|
+
console.log(state.items); // ['a']
|
|
277
|
+
|
|
278
|
+
// Mark new pristine point
|
|
279
|
+
state.count = 10;
|
|
280
|
+
chronicle.markPristine(state);
|
|
281
|
+
console.log(chronicle.isPristine(state)); // true
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### Configuration
|
|
285
|
+
|
|
286
|
+
```typescript
|
|
287
|
+
chronicle.configure(state, {
|
|
288
|
+
// Limit history size (trims by whole groups)
|
|
289
|
+
maxHistory: 100,
|
|
290
|
+
|
|
291
|
+
// Filter which changes to record
|
|
292
|
+
filter: (record) => !record.path.includes('_temp'),
|
|
293
|
+
|
|
294
|
+
// Merge ungrouped changes within time window
|
|
295
|
+
mergeUngrouped: true,
|
|
296
|
+
mergeWindowMs: 100,
|
|
297
|
+
|
|
298
|
+
// Compact consecutive sets to same path
|
|
299
|
+
compactConsecutiveSamePath: true,
|
|
300
|
+
|
|
301
|
+
// Enable proxy caching for stable identity
|
|
302
|
+
cacheProxies: true,
|
|
303
|
+
|
|
304
|
+
// Custom clone function (default: structuredClone)
|
|
305
|
+
clone: (value) => JSON.parse(JSON.stringify(value)),
|
|
306
|
+
|
|
307
|
+
// Custom equality check (default: Object.is)
|
|
308
|
+
compare: (a, b) => a === b,
|
|
309
|
+
|
|
310
|
+
// Filter diff traversal
|
|
311
|
+
diffFilter: (path) => {
|
|
312
|
+
if (path[0] === '_internal') return false; // Skip
|
|
313
|
+
if (path[0] === 'large') return 'shallow'; // Don't recurse
|
|
314
|
+
return true; // Recurse normally
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
## Working with Collections
|
|
320
|
+
|
|
321
|
+
### Arrays
|
|
322
|
+
|
|
323
|
+
Arrays work seamlessly with all features. Deleting by index uses splice to avoid holes.
|
|
324
|
+
|
|
325
|
+
```typescript
|
|
326
|
+
const state = chronicle({ items: ['a', 'b', 'c'] });
|
|
327
|
+
|
|
328
|
+
state.items.push('d');
|
|
329
|
+
state.items[1] = 'B';
|
|
330
|
+
delete state.items[2]; // Uses splice internally
|
|
331
|
+
|
|
332
|
+
chronicle.undo(state); // Restores 'c' at index 2
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
### Maps
|
|
336
|
+
|
|
337
|
+
```typescript
|
|
338
|
+
const state = chronicle({ cache: new Map() });
|
|
339
|
+
|
|
340
|
+
state.cache.set('key1', 'value1');
|
|
341
|
+
state.cache.set('key2', 'value2');
|
|
342
|
+
state.cache.delete('key1');
|
|
343
|
+
state.cache.clear();
|
|
344
|
+
|
|
345
|
+
// Listen to map changes
|
|
346
|
+
chronicle.listen(state, 'cache', (path, newVal, oldVal, meta) => {
|
|
347
|
+
console.log('Map operation:', meta.type);
|
|
348
|
+
// meta contains: { collection: 'map', key: 'key1' }
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// Undo works correctly
|
|
352
|
+
chronicle.undoGroups(state, 1); // Undoes entire clear
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
### Sets
|
|
356
|
+
|
|
357
|
+
```typescript
|
|
358
|
+
const state = chronicle({ tags: new Set() });
|
|
359
|
+
|
|
360
|
+
state.tags.add('javascript');
|
|
361
|
+
state.tags.add('typescript');
|
|
362
|
+
state.tags.delete('javascript');
|
|
363
|
+
|
|
364
|
+
chronicle.undo(state); // Restores 'javascript'
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
## Common Patterns
|
|
368
|
+
|
|
369
|
+
### Todo List with Undo
|
|
370
|
+
|
|
371
|
+
```typescript
|
|
372
|
+
const todos = chronicle({
|
|
373
|
+
items: [],
|
|
374
|
+
filter: 'all'
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
function addTodo(text) {
|
|
378
|
+
chronicle.batch(todos, (state) => {
|
|
379
|
+
state.items.push({
|
|
380
|
+
id: Date.now(),
|
|
381
|
+
text,
|
|
382
|
+
completed: false
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function toggleTodo(id) {
|
|
388
|
+
const todo = todos.items.find(t => t.id === id);
|
|
389
|
+
if (todo) todo.completed = !todo.completed;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function deleteTodo(id) {
|
|
393
|
+
const index = todos.items.findIndex(t => t.id === id);
|
|
394
|
+
if (index !== -1) todos.items.splice(index, 1);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Undo last action
|
|
398
|
+
chronicle.undoGroups(todos, 1);
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
### Form State with Validation
|
|
402
|
+
|
|
403
|
+
```typescript
|
|
404
|
+
const form = chronicle({
|
|
405
|
+
values: { email: '', password: '' },
|
|
406
|
+
errors: {},
|
|
407
|
+
touched: {},
|
|
408
|
+
isValid: true
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// Debounced validation
|
|
412
|
+
chronicle.listen(form, 'values', (path) => {
|
|
413
|
+
validateForm();
|
|
414
|
+
}, 'down', { debounceMs: 300 });
|
|
415
|
+
|
|
416
|
+
function validateForm() {
|
|
417
|
+
const errors = {};
|
|
418
|
+
if (!form.values.email.includes('@')) {
|
|
419
|
+
errors.email = 'Invalid email';
|
|
420
|
+
}
|
|
421
|
+
form.errors = errors;
|
|
422
|
+
form.isValid = Object.keys(errors).length === 0;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Transaction for submit
|
|
426
|
+
async function submitForm() {
|
|
427
|
+
const { result, undo } = await chronicle.transactionAsync(form, async (f) => {
|
|
428
|
+
f.submitting = true;
|
|
429
|
+
try {
|
|
430
|
+
const result = await api.post('/submit', f.values);
|
|
431
|
+
f.submitSuccess = true;
|
|
432
|
+
return result;
|
|
433
|
+
} catch (error) {
|
|
434
|
+
f.submitError = error.message;
|
|
435
|
+
throw error;
|
|
436
|
+
} finally {
|
|
437
|
+
f.submitting = false;
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
return result;
|
|
441
|
+
}
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
### Collaborative Editor
|
|
445
|
+
|
|
446
|
+
```typescript
|
|
447
|
+
const doc = chronicle({
|
|
448
|
+
content: '',
|
|
449
|
+
cursors: new Map(),
|
|
450
|
+
version: 0
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// Batch local edits
|
|
454
|
+
let editBatch = null;
|
|
455
|
+
function startEdit() {
|
|
456
|
+
if (!editBatch) {
|
|
457
|
+
chronicle.beginBatch(doc);
|
|
458
|
+
editBatch = setTimeout(() => {
|
|
459
|
+
chronicle.commitBatch(doc);
|
|
460
|
+
editBatch = null;
|
|
461
|
+
}, 1000);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function insert(pos, text) {
|
|
466
|
+
startEdit();
|
|
467
|
+
doc.content = doc.content.slice(0, pos) + text + doc.content.slice(pos);
|
|
468
|
+
doc.version++;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Listen for remote changes
|
|
472
|
+
chronicle.listen(doc, 'content', (path, newVal) => {
|
|
473
|
+
broadcastToRemote({ content: newVal, version: doc.version });
|
|
474
|
+
}, { debounceMs: 100 });
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
## Performance Tips
|
|
478
|
+
|
|
479
|
+
1. **Use batching** for bulk operations to reduce listener overhead
|
|
480
|
+
2. **Enable proxy caching** for frequently accessed nested objects
|
|
481
|
+
3. **Use debounce/throttle** for high-frequency updates
|
|
482
|
+
4. **Filter history** to exclude temporary/internal state
|
|
483
|
+
5. **Set maxHistory** to prevent unbounded growth
|
|
484
|
+
6. **Use 'exact' mode** when possible (faster than 'down'/'up')
|
|
485
|
+
|
|
486
|
+
## Gotchas & Best Practices
|
|
487
|
+
|
|
488
|
+
### Listener Path Modes
|
|
489
|
+
|
|
490
|
+
```typescript
|
|
491
|
+
const state = chronicle({ user: { profile: { name: 'Alice' } } });
|
|
492
|
+
|
|
493
|
+
// 'exact': Only fires when 'user' is reassigned
|
|
494
|
+
chronicle.listen(state, 'user', handler, 'exact');
|
|
495
|
+
state.user = {}; // Fires
|
|
496
|
+
state.user.profile.name = 'Bob'; // Does NOT fire
|
|
497
|
+
|
|
498
|
+
// 'down': Fires for user and all nested changes
|
|
499
|
+
chronicle.listen(state, 'user', handler, 'down');
|
|
500
|
+
state.user = {}; // Fires
|
|
501
|
+
state.user.profile.name = 'Bob'; // Fires
|
|
502
|
+
|
|
503
|
+
// 'up': Fires when any ancestor changes
|
|
504
|
+
chronicle.listen(state, ['user', 'profile', 'name'], handler, 'up');
|
|
505
|
+
state.user.profile.name = 'Bob'; // Does NOT fire (not an ancestor)
|
|
506
|
+
state.user.profile = {}; // Fires (ancestor)
|
|
507
|
+
state.user = {}; // Fires (ancestor)
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
### Array Length Changes
|
|
511
|
+
|
|
512
|
+
When shrinking arrays, deletes are synthesized for removed elements:
|
|
513
|
+
|
|
514
|
+
```typescript
|
|
515
|
+
const state = chronicle({ items: [1, 2, 3, 4] });
|
|
516
|
+
state.items.length = 2; // Generates delete records for indices 2 and 3
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
### Redo is Cleared
|
|
520
|
+
|
|
521
|
+
Making any forward change clears the redo stack:
|
|
522
|
+
|
|
523
|
+
```typescript
|
|
524
|
+
chronicle.undo(state); // Can now redo
|
|
525
|
+
state.count = 5; // Clears redo stack
|
|
526
|
+
chronicle.redo(state); // Does nothing
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
### Avoid Recording Internal Operations
|
|
530
|
+
|
|
531
|
+
```typescript
|
|
532
|
+
// Bad: Will record intermediate array operations
|
|
533
|
+
state.items.push(...largeArray);
|
|
534
|
+
|
|
535
|
+
// Better: Use batch to group
|
|
536
|
+
chronicle.batch(state, (s) => {
|
|
537
|
+
s.items.push(...largeArray);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
// Best: Filter out internal paths
|
|
541
|
+
chronicle.configure(state, {
|
|
542
|
+
filter: (rec) => !rec.path[0].startsWith('_')
|
|
543
|
+
});
|
|
544
|
+
state._tempData = []; // Not recorded
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
## TypeScript Support
|
|
548
|
+
|
|
549
|
+
Chronicle is fully typed and preserves object types:
|
|
550
|
+
|
|
551
|
+
```typescript
|
|
552
|
+
interface User {
|
|
553
|
+
name: string;
|
|
554
|
+
age: number;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const user: User = chronicle({ name: 'Alice', age: 30 });
|
|
558
|
+
// user is still typed as User, all properties autocomplete
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
## License
|
|
562
|
+
|
|
563
|
+
MIT
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ChangeListener, ChangeRecord, DiffRecord, ListenerOptions, PathMode, PathSelector } from './types.ts';
|
|
2
|
+
export interface ApiDeps {
|
|
3
|
+
getRoot: (obj: object) => object;
|
|
4
|
+
}
|
|
5
|
+
export interface ChronicleApiMethods {
|
|
6
|
+
listen: <T extends object>(object: T, selector: PathSelector<T>, listener: ChangeListener, modeOrOptions?: PathMode | ListenerOptions, maybeOptions?: ListenerOptions) => () => void;
|
|
7
|
+
onAny: (obj: object, listener: ChangeListener, options?: ListenerOptions) => () => void;
|
|
8
|
+
pause: (obj: object) => void;
|
|
9
|
+
resume: (obj: object) => void;
|
|
10
|
+
flush: (obj: object) => void;
|
|
11
|
+
getHistory: (obj: object) => ChangeRecord[];
|
|
12
|
+
clearHistory: (obj: object) => void;
|
|
13
|
+
reset: (obj: object) => void;
|
|
14
|
+
markPristine: (obj: object) => void;
|
|
15
|
+
diff: (obj: object) => DiffRecord[];
|
|
16
|
+
isPristine: (obj: object) => boolean;
|
|
17
|
+
mark: (obj: object) => number;
|
|
18
|
+
undo: (obj: object, steps?: number) => void;
|
|
19
|
+
undoSince: (obj: object, historyLengthBefore: number) => void;
|
|
20
|
+
undoGroups: (obj: object, groups?: number) => void;
|
|
21
|
+
canUndo: (obj: object) => boolean;
|
|
22
|
+
canRedo: (obj: object) => boolean;
|
|
23
|
+
clearRedo: (obj: object) => void;
|
|
24
|
+
redo: (obj: object, steps?: number) => void;
|
|
25
|
+
redoGroups: (obj: object, groups?: number) => void;
|
|
26
|
+
}
|
|
27
|
+
export declare const createApiMethods: (deps: ApiDeps) => ChronicleApiMethods;
|
|
28
|
+
//# sourceMappingURL=api-methods.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api-methods.d.ts","sourceRoot":"","sources":["../src/api-methods.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,UAAU,EAAE,eAAe,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAIpH,MAAM,WAAW,OAAO;IACvB,OAAO,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC;CACjC;AAMD,MAAM,WAAW,mBAAmB;IACnC,MAAM,EAAE,CAAC,CAAC,SAAS,MAAM,EACxB,MAAM,EAAE,CAAC,EACT,QAAQ,EAAE,YAAY,CAAC,CAAC,CAAC,EACzB,QAAQ,EAAE,cAAc,EACxB,aAAa,CAAC,EAAE,QAAQ,GAAG,eAAe,EAC1C,YAAY,CAAC,EAAE,eAAe,KAC1B,MAAM,IAAI,CAAC;IAChB,KAAK,EAAS,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,cAAc,EAAE,OAAO,CAAC,EAAE,eAAe,KAAK,MAAM,IAAI,CAAC;IAC/F,KAAK,EAAS,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,MAAM,EAAQ,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,KAAK,EAAS,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,UAAU,EAAI,CAAC,GAAG,EAAE,MAAM,KAAK,YAAY,EAAE,CAAC;IAC9C,YAAY,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,KAAK,EAAS,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,YAAY,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,IAAI,EAAU,CAAC,GAAG,EAAE,MAAM,KAAK,UAAU,EAAE,CAAC;IAC5C,UAAU,EAAI,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;IACvC,IAAI,EAAU,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC;IACtC,IAAI,EAAU,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;IACpD,SAAS,EAAK,CAAC,GAAG,EAAE,MAAM,EAAE,mBAAmB,EAAE,MAAM,KAAK,IAAI,CAAC;IACjE,UAAU,EAAI,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;IACrD,OAAO,EAAO,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;IACvC,OAAO,EAAO,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;IACvC,SAAS,EAAK,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,IAAI,EAAU,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;IACpD,UAAU,EAAI,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;CACrD;AAED,eAAO,MAAM,gBAAgB,GAAI,MAAM,OAAO,KAAG,mBAoPhD,CAAC"}
|