@abloatai/ablo 0.9.0 → 0.9.2

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 (63) hide show
  1. package/AGENTS.md +84 -0
  2. package/CHANGELOG.md +40 -0
  3. package/README.md +15 -7
  4. package/dist/BaseSyncedStore.d.ts +10 -0
  5. package/dist/BaseSyncedStore.js +26 -0
  6. package/dist/SyncClient.d.ts +12 -0
  7. package/dist/SyncClient.js +15 -0
  8. package/dist/agent/index.js +1 -1
  9. package/dist/api/index.d.ts +1 -1
  10. package/dist/client/Ablo.d.ts +9 -51
  11. package/dist/client/Ablo.js +2 -104
  12. package/dist/client/ApiClient.d.ts +3 -115
  13. package/dist/client/ApiClient.js +0 -232
  14. package/dist/client/auth.js +32 -2
  15. package/dist/client/httpClient.d.ts +5 -6
  16. package/dist/client/httpClient.js +2 -3
  17. package/dist/client/index.d.ts +1 -1
  18. package/dist/errorCodes.js +3 -3
  19. package/dist/index.js +1 -1
  20. package/dist/interfaces/index.d.ts +4 -4
  21. package/dist/mutators/UndoManager.d.ts +100 -11
  22. package/dist/mutators/UndoManager.js +282 -13
  23. package/dist/react/AbloProvider.d.ts +18 -8
  24. package/dist/react/context.d.ts +31 -0
  25. package/dist/react/index.d.ts +1 -1
  26. package/dist/react/index.js +1 -1
  27. package/dist/react/useUndoScope.js +7 -0
  28. package/dist/schema/ddl.d.ts +8 -0
  29. package/dist/schema/ddl.js +10 -0
  30. package/dist/schema/index.d.ts +1 -1
  31. package/dist/schema/index.js +1 -1
  32. package/dist/server/commit.d.ts +4 -5
  33. package/dist/source/adapter.d.ts +18 -12
  34. package/dist/source/adapter.js +8 -7
  35. package/dist/source/adapters/drizzle.d.ts +15 -6
  36. package/dist/source/adapters/drizzle.js +87 -49
  37. package/dist/source/adapters/memory.d.ts +1 -1
  38. package/dist/source/adapters/memory.js +2 -2
  39. package/dist/source/adapters/prisma.d.ts +3 -3
  40. package/dist/source/adapters/prisma.js +6 -29
  41. package/dist/source/conformance.d.ts +1 -1
  42. package/dist/source/conformance.js +2 -2
  43. package/dist/source/contract.d.ts +3 -2
  44. package/dist/source/contract.js +3 -2
  45. package/dist/source/index.d.ts +1 -0
  46. package/dist/source/index.js +3 -2
  47. package/dist/source/migrations.d.ts +14 -0
  48. package/dist/source/migrations.js +39 -0
  49. package/dist/types/streams.d.ts +2 -1
  50. package/dist/wire/frames.d.ts +6 -8
  51. package/docs/api.md +1 -1
  52. package/docs/cli.md +18 -5
  53. package/docs/data-sources.md +68 -83
  54. package/docs/examples/ai-sdk-tool.md +11 -5
  55. package/docs/examples/existing-python-backend.md +26 -4
  56. package/docs/examples/nextjs.md +3 -2
  57. package/docs/examples/scoped-agent.md +38 -11
  58. package/docs/identity.md +86 -59
  59. package/docs/index.md +1 -1
  60. package/docs/integration-guide.md +85 -54
  61. package/docs/react.md +39 -28
  62. package/llms.txt +18 -11
  63. package/package.json +2 -2
@@ -34,6 +34,23 @@ export interface UndoScopeOptions {
34
34
  * {@link UndoConflictPolicy}.
35
35
  */
36
36
  conflictPolicy?: UndoConflictPolicy;
37
+ /**
38
+ * Which models this surface owns. The scope only records mutations whose
39
+ * resolved schema key passes this predicate, so a spreadsheet edit never
40
+ * lands on the deck editor's stack (the equivalent of Yjs scoping by
41
+ * shared-type set). Omit to track every model — fine for a single-surface
42
+ * app, wrong when two surfaces with independent Cmd+Z share one store.
43
+ */
44
+ tracksModel?: (schemaKey: string) => boolean;
45
+ /**
46
+ * Opt into recording undo entries by OBSERVING the local-mutation stream
47
+ * (the best-practice model: undo listens where all local writes converge —
48
+ * Yjs/Liveblocks). When false (default), the scope records nothing on its
49
+ * own and relies on legacy manual `record()` calls. Transitional: a scope
50
+ * must not mix the two, or shared writes double-count. Flip a surface to
51
+ * `true` only when its manual-record consumers are removed in the same step.
52
+ */
53
+ recordFromStream?: boolean;
37
54
  }
38
55
  /**
39
56
  * A single undo stack for one surface. Access via `UndoManager.getScope(name)`.
@@ -58,6 +75,15 @@ export declare class UndoScope<S extends Schema> {
58
75
  * observer can never wedge the editor's recording path.
59
76
  */
60
77
  private readonly recordListeners;
78
+ /**
79
+ * Observers notified after ANY stack change — record, undo, redo, or clear.
80
+ * Distinct from {@link recordListeners} (forward actions only): this fires on
81
+ * reversals too, so React consumers can keep `canUndo`/`canRedo` live. The
82
+ * stream-recording path pushes entries WITHOUT a React render, so without this
83
+ * a freshly-recorded entry leaves `canUndo` stale (snapshot from last render)
84
+ * and a Cmd+Z handler gated on `canUndo !== false` silently no-ops.
85
+ */
86
+ private readonly changeListeners;
61
87
  /**
62
88
  * Serialization tail. Recording, undo, and redo all chain off this single
63
89
  * promise so they run strictly in the order they were *invoked* — never
@@ -72,7 +98,52 @@ export declare class UndoScope<S extends Schema> {
72
98
  * Serializing the whole scope closes both holes with one mechanism.
73
99
  */
74
100
  private tail;
101
+ /** Predicate selecting which models this surface records (see options). */
102
+ private readonly tracksModel?;
103
+ /** registered-name / alias → schema key, built once from the schema. */
104
+ private readonly schemaKeyByAlias;
105
+ /** Unsubscribe from the local-mutation stream. */
106
+ private readonly unsubscribe;
107
+ /**
108
+ * True while `undo()`/`redo()` replays ops. Replays write through the same
109
+ * commit path, so they re-emit on the local-mutation stream; this flag tells
110
+ * our own listener to ignore them (no echo) — the engine equivalent of Yjs's
111
+ * `trackedOrigins` exclusion / Liveblocks pausing history during undo.
112
+ */
113
+ private replaying;
114
+ /** Ops collected during the current tick, flushed as ONE entry. */
115
+ private batch;
116
+ private flushScheduled;
117
+ /**
118
+ * Open grouping session (Liveblocks `history.pause()` / Yjs `stopCapturing`
119
+ * analogue). While set, stream ops accumulate here ACROSS ticks instead of
120
+ * flushing per-tick, so a multi-tick action (a drag, a whole streaming AI
121
+ * response) collapses into ONE Cmd+Z. `endGroup()` flushes it.
122
+ */
123
+ private group;
75
124
  constructor(schema: S, store: SyncStoreContract, organizationId: string, options?: UndoScopeOptions);
125
+ /**
126
+ * Open a grouping session: every stream-recorded op until {@link endGroup}
127
+ * collapses into a single undo entry. Mirrors Liveblocks `history.pause()` —
128
+ * call on gesture start (pointerdown) or AI-response start. Idempotent-ish:
129
+ * a second call closes the previous group first.
130
+ */
131
+ beginGroup(label?: string): void;
132
+ /** Close the grouping session and record the accumulated ops as one entry. */
133
+ endGroup(label?: string): void;
134
+ /** Resolve a stream mutation's registered name to its schema key, or null. */
135
+ private resolveSchemaKey;
136
+ /**
137
+ * Stream listener — the sole place entries are born. Skips replay echoes
138
+ * and out-of-scope models, derives the forward+inverse op from the
139
+ * mutation's `data`/`previousData`, and defers the stack push to a
140
+ * per-tick flush so a burst of writes (e.g. align 5 layers) becomes ONE
141
+ * undo step — riding the same tick boundary the TransactionQueue batches on.
142
+ */
143
+ private onLocalMutation;
144
+ private scheduleFlush;
145
+ /** Coalesce the tick's collected ops into one entry and record it. */
146
+ private flushBatch;
76
147
  /**
77
148
  * Run `work` after every previously-enqueued scope operation has settled,
78
149
  * in invocation order. The internal `tail` always resolves (failures are
@@ -82,20 +153,24 @@ export declare class UndoScope<S extends Schema> {
82
153
  private enqueue;
83
154
  /**
84
155
  * Run a recording mutator exclusively on the scope's serialization chain.
85
- * `useMutators` calls this so the snapshot write → `record()` sequence is
86
- * atomic relative to other invocations, undo, and redo.
156
+ * Used by the legacy manual-record path (`useMutators` + `RecordingTransaction`)
157
+ * so the snapshot → write → `record()` sequence is atomic relative to undo/
158
+ * redo. The stream-recording path doesn't need this (it derives entries from
159
+ * already-committed mutations); kept until all surfaces migrate off manual.
87
160
  */
88
161
  runRecorded<T>(work: () => Promise<T>): Promise<T>;
89
162
  /**
90
- * Internal: record a mutator's inverses. Clears the redo stack.
91
- *
92
- * Entries here are produced internally by `RecordingTransaction` (trusted),
93
- * so the schema check is DEV-ONLY: it catches recorder bugs in dev/test
94
- * (rejecting a malformed op at ingestion, with its path, instead of letting
95
- * it crash later inside `applyOps`) without paying a Zod parse on every user
96
- * action in production. The real validation boundary is `parseUndoEntry`,
97
- * applied when entries are deserialized from persistence (untrusted input).
98
- * Best practice: validate at trust boundaries, type-check internal calls.
163
+ * Record one entry onto the undo stack. Clears the redo stack. Fed by
164
+ * {@link flushBatch}/{@link endGroup} from the local-mutation stream, and
165
+ * still called directly by the legacy manual-record consumers
166
+ * (`useMutators`, the AI mutation pipeline) until they migrate. Entries are
167
+ * built internally (trusted), so the schema check is DEV-ONLY: it catches
168
+ * recorder bugs in dev/test (rejecting a malformed op at ingestion, with its
169
+ * path, instead of letting it crash later inside `applyOps`) without paying a
170
+ * Zod parse on every user action in production. The real validation boundary
171
+ * is `parseUndoEntry`, applied when entries are deserialized from persistence
172
+ * (untrusted input). Best practice: validate at trust boundaries, type-check
173
+ * internal calls.
99
174
  */
100
175
  record(entry: UndoEntry): void;
101
176
  /**
@@ -109,6 +184,14 @@ export declare class UndoScope<S extends Schema> {
109
184
  */
110
185
  onRecord(listener: (entry: UndoEntry) => void): () => void;
111
186
  private emitRecord;
187
+ /**
188
+ * Subscribe to ANY stack change (record/undo/redo/clear). Used by
189
+ * `useUndoScope` to re-render so `canUndo`/`canRedo` stay live across every
190
+ * consumer — not just the component that invoked undo/redo. Returns an
191
+ * unsubscribe function.
192
+ */
193
+ onChange(listener: () => void): () => void;
194
+ private emitChange;
112
195
  canUndo(): boolean;
113
196
  canRedo(): boolean;
114
197
  /**
@@ -134,6 +217,12 @@ export declare class UndoScope<S extends Schema> {
134
217
  undo: number;
135
218
  redo: number;
136
219
  };
220
+ /**
221
+ * Detach from the local-mutation stream and drop listeners. Scopes are
222
+ * cached for the store's lifetime by `UndoManager`, so this is mainly for
223
+ * tests and explicit teardown.
224
+ */
225
+ dispose(): void;
137
226
  }
138
227
  /**
139
228
  * Central registry of named undo scopes. One per-app instance, created once
@@ -20,6 +20,9 @@
20
20
  import { createTransaction } from './Transaction.js';
21
21
  import { parseUndoEntry } from './inverseOp.js';
22
22
  import { resolveOps, DEFAULT_UNDO_CONFLICT_POLICY, } from './undoApply.js';
23
+ /** Normalize a registered model name to the queue's lowercased alias form
24
+ * (mirrors TransactionQueue's `normalizeModelKey`). */
25
+ const normalizeModelAlias = (modelName) => modelName.replace('Model', '').toLowerCase();
23
26
  /**
24
27
  * A single undo stack for one surface. Access via `UndoManager.getScope(name)`.
25
28
  * Consumers call `record(entry)` after each mutator; `undo()` / `redo()` to
@@ -43,6 +46,15 @@ export class UndoScope {
43
46
  * observer can never wedge the editor's recording path.
44
47
  */
45
48
  recordListeners = new Set();
49
+ /**
50
+ * Observers notified after ANY stack change — record, undo, redo, or clear.
51
+ * Distinct from {@link recordListeners} (forward actions only): this fires on
52
+ * reversals too, so React consumers can keep `canUndo`/`canRedo` live. The
53
+ * stream-recording path pushes entries WITHOUT a React render, so without this
54
+ * a freshly-recorded entry leaves `canUndo` stale (snapshot from last render)
55
+ * and a Cmd+Z handler gated on `canUndo !== false` silently no-ops.
56
+ */
57
+ changeListeners = new Set();
46
58
  /**
47
59
  * Serialization tail. Recording, undo, and redo all chain off this single
48
60
  * promise so they run strictly in the order they were *invoked* — never
@@ -57,12 +69,148 @@ export class UndoScope {
57
69
  * Serializing the whole scope closes both holes with one mechanism.
58
70
  */
59
71
  tail = Promise.resolve();
72
+ /** Predicate selecting which models this surface records (see options). */
73
+ tracksModel;
74
+ /** registered-name / alias → schema key, built once from the schema. */
75
+ schemaKeyByAlias = new Map();
76
+ /** Unsubscribe from the local-mutation stream. */
77
+ unsubscribe;
78
+ /**
79
+ * True while `undo()`/`redo()` replays ops. Replays write through the same
80
+ * commit path, so they re-emit on the local-mutation stream; this flag tells
81
+ * our own listener to ignore them (no echo) — the engine equivalent of Yjs's
82
+ * `trackedOrigins` exclusion / Liveblocks pausing history during undo.
83
+ */
84
+ replaying = false;
85
+ /** Ops collected during the current tick, flushed as ONE entry. */
86
+ batch = [];
87
+ flushScheduled = false;
88
+ /**
89
+ * Open grouping session (Liveblocks `history.pause()` / Yjs `stopCapturing`
90
+ * analogue). While set, stream ops accumulate here ACROSS ticks instead of
91
+ * flushing per-tick, so a multi-tick action (a drag, a whole streaming AI
92
+ * response) collapses into ONE Cmd+Z. `endGroup()` flushes it.
93
+ */
94
+ group = null;
60
95
  constructor(schema, store, organizationId, options = {}) {
61
96
  this.schema = schema;
62
97
  this.store = store;
63
98
  this.organizationId = organizationId;
64
99
  this.maxHistory = options.maxHistory ?? 100;
65
100
  this.conflictPolicy = options.conflictPolicy ?? DEFAULT_UNDO_CONFLICT_POLICY;
101
+ this.tracksModel = options.tracksModel;
102
+ // Build the registered-name → schema-key alias map. The mutation stream
103
+ // reports `model.getModelName()` (e.g. `'SlideLayer'`), but inverse ops
104
+ // and the replay transaction are keyed by the SCHEMA key (e.g.
105
+ // `'slideLayers'`). Map every reasonable spelling to the schema key.
106
+ for (const schemaKey of Object.keys(this.schema.models)) {
107
+ const def = this.schema.models[schemaKey];
108
+ const typename = def?.typename ?? schemaKey;
109
+ for (const alias of [schemaKey, typename]) {
110
+ this.schemaKeyByAlias.set(alias, schemaKey);
111
+ this.schemaKeyByAlias.set(alias.toLowerCase(), schemaKey);
112
+ this.schemaKeyByAlias.set(normalizeModelAlias(alias), schemaKey);
113
+ }
114
+ }
115
+ // Subscribe to the local-mutation stream ONLY when this scope opts into
116
+ // stream recording. Transitional flag: surfaces still on the legacy
117
+ // manual-record path (mutator `RecordingTransaction`, AI pipeline
118
+ // sessions) keep `recordFromStream: false` so writes aren't double-counted.
119
+ // Once every surface is migrated, stream recording becomes the only path
120
+ // and the flag is removed. Optional on the contract so minimal test
121
+ // doubles can omit it (undo then records nothing).
122
+ this.unsubscribe =
123
+ options.recordFromStream && this.store.subscribeLocalMutations
124
+ ? this.store.subscribeLocalMutations((m) => this.onLocalMutation(m))
125
+ : () => { };
126
+ }
127
+ /**
128
+ * Open a grouping session: every stream-recorded op until {@link endGroup}
129
+ * collapses into a single undo entry. Mirrors Liveblocks `history.pause()` —
130
+ * call on gesture start (pointerdown) or AI-response start. Idempotent-ish:
131
+ * a second call closes the previous group first.
132
+ */
133
+ beginGroup(label) {
134
+ if (this.group)
135
+ this.endGroup();
136
+ this.group = { label, ops: [] };
137
+ }
138
+ /** Close the grouping session and record the accumulated ops as one entry. */
139
+ endGroup(label) {
140
+ const g = this.group;
141
+ if (!g)
142
+ return;
143
+ this.group = null;
144
+ const forwards = g.ops.map((c) => c.forward);
145
+ const inverses = g.ops
146
+ .map((c) => c.inverse)
147
+ .filter((i) => i !== null)
148
+ .reverse();
149
+ if (forwards.length === 0 && inverses.length === 0)
150
+ return;
151
+ this.record({ label: label ?? g.label, inverses, forwards });
152
+ }
153
+ /** Resolve a stream mutation's registered name to its schema key, or null. */
154
+ resolveSchemaKey(modelName) {
155
+ return (this.schemaKeyByAlias.get(modelName) ??
156
+ this.schemaKeyByAlias.get(normalizeModelAlias(modelName)) ??
157
+ null);
158
+ }
159
+ /**
160
+ * Stream listener — the sole place entries are born. Skips replay echoes
161
+ * and out-of-scope models, derives the forward+inverse op from the
162
+ * mutation's `data`/`previousData`, and defers the stack push to a
163
+ * per-tick flush so a burst of writes (e.g. align 5 layers) becomes ONE
164
+ * undo step — riding the same tick boundary the TransactionQueue batches on.
165
+ */
166
+ onLocalMutation(m) {
167
+ if (this.replaying)
168
+ return;
169
+ const schemaKey = this.resolveSchemaKey(m.modelName);
170
+ if (!schemaKey)
171
+ return;
172
+ if (this.tracksModel && !this.tracksModel(schemaKey))
173
+ return;
174
+ const ops = buildUndoOps(m, schemaKey);
175
+ if (!ops)
176
+ return;
177
+ // Inside a grouping session, accumulate across ticks (flushed on
178
+ // endGroup); otherwise coalesce per-tick.
179
+ if (this.group) {
180
+ this.group.ops.push(ops);
181
+ return;
182
+ }
183
+ this.batch.push(ops);
184
+ this.scheduleFlush();
185
+ }
186
+ scheduleFlush() {
187
+ if (this.flushScheduled)
188
+ return;
189
+ this.flushScheduled = true;
190
+ const run = () => {
191
+ this.flushScheduled = false;
192
+ this.flushBatch();
193
+ };
194
+ if (typeof queueMicrotask === 'function')
195
+ queueMicrotask(run);
196
+ else
197
+ void Promise.resolve().then(run);
198
+ }
199
+ /** Coalesce the tick's collected ops into one entry and record it. */
200
+ flushBatch() {
201
+ if (this.batch.length === 0)
202
+ return;
203
+ const collected = this.batch;
204
+ this.batch = [];
205
+ const forwards = collected.map((c) => c.forward);
206
+ // Undo applies inverses in REVERSE order of how the forwards ran.
207
+ const inverses = collected
208
+ .map((c) => c.inverse)
209
+ .filter((i) => i !== null)
210
+ .reverse();
211
+ if (forwards.length === 0 && inverses.length === 0)
212
+ return;
213
+ this.record({ inverses, forwards });
66
214
  }
67
215
  /**
68
216
  * Run `work` after every previously-enqueued scope operation has settled,
@@ -77,22 +225,26 @@ export class UndoScope {
77
225
  }
78
226
  /**
79
227
  * Run a recording mutator exclusively on the scope's serialization chain.
80
- * `useMutators` calls this so the snapshot write → `record()` sequence is
81
- * atomic relative to other invocations, undo, and redo.
228
+ * Used by the legacy manual-record path (`useMutators` + `RecordingTransaction`)
229
+ * so the snapshot → write → `record()` sequence is atomic relative to undo/
230
+ * redo. The stream-recording path doesn't need this (it derives entries from
231
+ * already-committed mutations); kept until all surfaces migrate off manual.
82
232
  */
83
233
  runRecorded(work) {
84
234
  return this.enqueue(work);
85
235
  }
86
236
  /**
87
- * Internal: record a mutator's inverses. Clears the redo stack.
88
- *
89
- * Entries here are produced internally by `RecordingTransaction` (trusted),
90
- * so the schema check is DEV-ONLY: it catches recorder bugs in dev/test
91
- * (rejecting a malformed op at ingestion, with its path, instead of letting
92
- * it crash later inside `applyOps`) without paying a Zod parse on every user
93
- * action in production. The real validation boundary is `parseUndoEntry`,
94
- * applied when entries are deserialized from persistence (untrusted input).
95
- * Best practice: validate at trust boundaries, type-check internal calls.
237
+ * Record one entry onto the undo stack. Clears the redo stack. Fed by
238
+ * {@link flushBatch}/{@link endGroup} from the local-mutation stream, and
239
+ * still called directly by the legacy manual-record consumers
240
+ * (`useMutators`, the AI mutation pipeline) until they migrate. Entries are
241
+ * built internally (trusted), so the schema check is DEV-ONLY: it catches
242
+ * recorder bugs in dev/test (rejecting a malformed op at ingestion, with its
243
+ * path, instead of letting it crash later inside `applyOps`) without paying a
244
+ * Zod parse on every user action in production. The real validation boundary
245
+ * is `parseUndoEntry`, applied when entries are deserialized from persistence
246
+ * (untrusted input). Best practice: validate at trust boundaries, type-check
247
+ * internal calls.
96
248
  */
97
249
  record(entry) {
98
250
  if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
@@ -103,6 +255,7 @@ export class UndoScope {
103
255
  this.undoStack.shift();
104
256
  this.redoStack = [];
105
257
  this.emitRecord(entry);
258
+ this.emitChange();
106
259
  }
107
260
  /**
108
261
  * Subscribe to every recorded mutation. Fires synchronously at the tail of
@@ -132,6 +285,30 @@ export class UndoScope {
132
285
  }
133
286
  }
134
287
  }
288
+ /**
289
+ * Subscribe to ANY stack change (record/undo/redo/clear). Used by
290
+ * `useUndoScope` to re-render so `canUndo`/`canRedo` stay live across every
291
+ * consumer — not just the component that invoked undo/redo. Returns an
292
+ * unsubscribe function.
293
+ */
294
+ onChange(listener) {
295
+ this.changeListeners.add(listener);
296
+ return () => {
297
+ this.changeListeners.delete(listener);
298
+ };
299
+ }
300
+ emitChange() {
301
+ for (const listener of this.changeListeners) {
302
+ try {
303
+ listener();
304
+ }
305
+ catch (err) {
306
+ if (typeof console !== 'undefined') {
307
+ console.error('[UndoScope] onChange listener threw', err);
308
+ }
309
+ }
310
+ }
311
+ }
135
312
  canUndo() {
136
313
  return this.undoStack.length > 0;
137
314
  }
@@ -153,10 +330,27 @@ export class UndoScope {
153
330
  return;
154
331
  const tx = createTransaction(this.schema, this.store, this.organizationId);
155
332
  const ops = resolveOps(entry.inverses, entry.forwards, this.store, this.conflictPolicy);
156
- await applyOps(tx, ops);
333
+ // Suppress our own stream listener so replayed writes don't record as
334
+ // new undo entries. Cleared in `finally` even if a replay op throws.
335
+ this.replaying = true;
336
+ try {
337
+ await applyOps(tx, ops);
338
+ }
339
+ catch (err) {
340
+ // The replay was rejected (e.g. a server 409): the world didn't change,
341
+ // so restore the entry to the undo stack rather than silently dropping
342
+ // it (which would also strand it off the redo stack — invisible undo).
343
+ this.undoStack.push(entry);
344
+ this.emitChange();
345
+ throw err;
346
+ }
347
+ finally {
348
+ this.replaying = false;
349
+ }
157
350
  this.redoStack.push(entry);
158
351
  if (this.redoStack.length > this.maxHistory)
159
352
  this.redoStack.shift();
353
+ this.emitChange();
160
354
  });
161
355
  }
162
356
  /**
@@ -172,21 +366,96 @@ export class UndoScope {
172
366
  return;
173
367
  const tx = createTransaction(this.schema, this.store, this.organizationId);
174
368
  const ops = resolveOps(entry.forwards, entry.inverses, this.store, this.conflictPolicy);
175
- await applyOps(tx, ops);
369
+ this.replaying = true;
370
+ try {
371
+ await applyOps(tx, ops);
372
+ }
373
+ catch (err) {
374
+ // Symmetric to undo: a rejected re-apply leaves state unchanged, so put
375
+ // the entry back on the redo stack instead of losing it.
376
+ this.redoStack.push(entry);
377
+ this.emitChange();
378
+ throw err;
379
+ }
380
+ finally {
381
+ this.replaying = false;
382
+ }
176
383
  this.undoStack.push(entry);
177
384
  if (this.undoStack.length > this.maxHistory)
178
385
  this.undoStack.shift();
386
+ this.emitChange();
179
387
  });
180
388
  }
181
389
  /** Drop all history. Use after bootstrap / sync group change / sync error. */
182
390
  clear() {
183
391
  this.undoStack = [];
184
392
  this.redoStack = [];
393
+ this.batch = [];
394
+ this.emitChange();
185
395
  }
186
396
  /** Introspection — for debug panels / e2e tests. */
187
397
  size() {
188
398
  return { undo: this.undoStack.length, redo: this.redoStack.length };
189
399
  }
400
+ /**
401
+ * Detach from the local-mutation stream and drop listeners. Scopes are
402
+ * cached for the store's lifetime by `UndoManager`, so this is mainly for
403
+ * tests and explicit teardown.
404
+ */
405
+ dispose() {
406
+ this.unsubscribe();
407
+ this.recordListeners.clear();
408
+ this.changeListeners.clear();
409
+ this.batch = [];
410
+ }
411
+ }
412
+ /**
413
+ * Derive the forward + inverse op for a single local mutation. Returns null
414
+ * when the mutation can't be reversed (e.g. an update with no captured
415
+ * previous values), so the caller can drop it rather than push a half-entry.
416
+ */
417
+ function buildUndoOps(m, modelKey) {
418
+ const id = m.modelId;
419
+ const stripId = (o) => {
420
+ const out = { ...(o ?? {}) };
421
+ delete out.id;
422
+ return out;
423
+ };
424
+ switch (m.type) {
425
+ case 'create':
426
+ return {
427
+ forward: { kind: 'create', modelKey, data: { ...stripId(m.data), id } },
428
+ inverse: { kind: 'delete', modelKey, id },
429
+ };
430
+ case 'update': {
431
+ const next = stripId(m.data);
432
+ const prev = stripId(m.previousData);
433
+ return {
434
+ forward: { kind: 'update', modelKey, patch: { id, ...next } },
435
+ // No previous values captured → not reversible; drop the inverse.
436
+ inverse: Object.keys(prev).length > 0
437
+ ? { kind: 'update', modelKey, patch: { id, ...prev } }
438
+ : null,
439
+ };
440
+ }
441
+ case 'delete':
442
+ return {
443
+ forward: { kind: 'delete', modelKey, id },
444
+ inverse: { kind: 'create', modelKey, data: { ...stripId(m.previousData), id } },
445
+ };
446
+ case 'archive':
447
+ return {
448
+ forward: { kind: 'update', modelKey, patch: { id, archivedAt: new Date() } },
449
+ inverse: { kind: 'update', modelKey, patch: { id, archivedAt: null } },
450
+ };
451
+ case 'unarchive':
452
+ return {
453
+ forward: { kind: 'update', modelKey, patch: { id, archivedAt: null } },
454
+ inverse: { kind: 'update', modelKey, patch: { id, archivedAt: new Date() } },
455
+ };
456
+ default:
457
+ return null;
458
+ }
190
459
  }
191
460
  // ── Manager ────────────────────────────────────────────────────────────────
192
461
  /**
@@ -28,20 +28,30 @@ import { type SyncStoreContract } from './context.js';
28
28
  /**
29
29
  * Props for `<AbloProvider>`.
30
30
  *
31
- * The default path is one prop:
31
+ * The one required prop is a prebuilt {@link Ablo} client — the client
32
+ * owns auth and the credential lifecycle; this provider is the reactive
33
+ * binding over it (Stripe's `<Elements stripe={...}>` model):
32
34
  *
33
35
  * ```tsx
34
- * <AbloProvider schema={schema}>
36
+ * // Build once at module scope — a new instance per render tears down the socket.
37
+ * const ablo = Ablo({
38
+ * schema,
39
+ * getToken: () =>
40
+ * fetch('/api/ablo-session', { method: 'POST' })
41
+ * .then((r) => r.json())
42
+ * .then((d) => d.token),
43
+ * });
44
+ *
45
+ * <AbloProvider client={ablo}>
35
46
  * <App />
36
47
  * </AbloProvider>
37
48
  * ```
38
49
  *
39
- * That's it for most apps the provider resolves identity, account
40
- * scope, and realtime permissions from auth. `userId`/`apiKey`/`url`
41
- * are situational; the `bootstrapMode`, `persistence`, and `fallback`
42
- * props are opt-in tuning; and the block tagged "Optional DI (advanced)"
43
- * below is escape-hatch wiring for tests and platform builders — if you
44
- * don't recognize a prop there, you don't need it.
50
+ * That's it for most apps. `userId` is informational; the `fallback`,
51
+ * `preventUnsavedChanges`, and `on*` props are opt-in app glue; and the
52
+ * block tagged "Optional DI (advanced)" below is escape-hatch wiring for
53
+ * tests and platform builders if you don't recognize a prop there, you
54
+ * don't need it.
45
55
  */
46
56
  export interface AbloProviderProps<R extends SchemaRecord = SchemaRecord> {
47
57
  /**
@@ -5,11 +5,42 @@ import type { QueryView, QueryViewOptions } from '../core/QueryView.js';
5
5
  import type { ViewRegistry } from '../core/ViewRegistry.js';
6
6
  import type { Schema } from '../schema/schema.js';
7
7
  import type { SyncStatus } from '../BaseSyncedStore.js';
8
+ /**
9
+ * A single LOCAL mutation as observed off the commit stream — the substrate
10
+ * the undo system records from. One is emitted per local create/update/
11
+ * delete/archive (remote/collaborator deltas never appear here: they apply
12
+ * through a separate pool path that doesn't queue mutations). `previousData`
13
+ * holds the pre-edit field values (captured from the model's
14
+ * `modifiedProperties` first-old-wins baseline), so an inverse op is fully
15
+ * derivable from the event alone — no separate snapshot pass.
16
+ *
17
+ * This mirrors how Yjs's `UndoManager` derives reverse-ops by observing the
18
+ * doc and Liveblocks' `room.history` records room ops: undo listens to the
19
+ * one place all local writes converge, rather than wrapping the write call.
20
+ */
21
+ export interface LocalMutation {
22
+ type: 'create' | 'update' | 'delete' | 'archive' | 'unarchive';
23
+ /** Registered model name (e.g. `'SlideLayer'`); resolved to a schema key by the recorder. */
24
+ modelName: string;
25
+ modelId: string;
26
+ /** New field values (create/update). */
27
+ data?: Record<string, unknown> | null;
28
+ /** Pre-edit field values (update → inverse patch; delete → full re-create row). */
29
+ previousData?: Record<string, unknown> | null;
30
+ }
8
31
  /**
9
32
  * Minimal store interface that the SDK hooks need.
10
33
  * Consumers provide their concrete store (e.g., SyncedStore) that implements this.
11
34
  */
12
35
  export interface SyncStoreContract {
36
+ /**
37
+ * Subscribe to the LOCAL mutation stream (optimistic, pre-ack) for undo
38
+ * recording. Optional so minimal test doubles can omit it — when absent,
39
+ * undo scopes simply record nothing. The concrete store
40
+ * (`BaseSyncedStore`) wires this to the TransactionQueue's
41
+ * `transaction:created` event. Returns an unsubscribe function.
42
+ */
43
+ subscribeLocalMutations?(handler: (mutation: LocalMutation) => void): () => void;
13
44
  retrieve(modelClass: abstract new (...args: never[]) => Model, id: string): Model | undefined;
14
45
  queryByClass(modelClass: abstract new (...args: never[]) => Model, options?: {
15
46
  predicate?: (model: Model) => boolean;
@@ -27,7 +27,7 @@
27
27
  * useCurrentUserId() — the provider's userId prop
28
28
  *
29
29
  * Multiplayer (always available — `<AbloProvider>` always constructs a client):
30
- * useAblo((ablo) => ablo.intents.list(...)) — reactive coordination reads
30
+ * useAblo((ablo) => ablo.<model>.claim.state(...)) — reactive coordination reads
31
31
  * useParticipant({ scope }) — join multiplayer for a scope, get peers/claims
32
32
  * usePresence() — typed presence view
33
33
  * useIntent(name) — typed intent dispatcher
@@ -27,7 +27,7 @@
27
27
  * useCurrentUserId() — the provider's userId prop
28
28
  *
29
29
  * Multiplayer (always available — `<AbloProvider>` always constructs a client):
30
- * useAblo((ablo) => ablo.intents.list(...)) — reactive coordination reads
30
+ * useAblo((ablo) => ablo.<model>.claim.state(...)) — reactive coordination reads
31
31
  * useParticipant({ scope }) — join multiplayer for a scope, get peers/claims
32
32
  * usePresence() — typed presence view
33
33
  * useIntent(name) — typed intent dispatcher
@@ -52,6 +52,13 @@ export function useUndoScope(schemaOrName, nameOrOptions, maybeOptions) {
52
52
  useEffect(() => {
53
53
  setTick(0);
54
54
  }, [scope]);
55
+ // Re-render on ANY stack change — including entries recorded from the local-
56
+ // mutation stream, which don't otherwise trigger a React update. Without this
57
+ // `canUndo`/`canRedo` go stale in every consumer that didn't itself call
58
+ // undo/redo (e.g. a keyboard handler whose Cmd+Z gate then never fires).
59
+ useEffect(() => {
60
+ return scope.onChange(() => setTick((t) => t + 1));
61
+ }, [scope]);
55
62
  const size = scope.size();
56
63
  return {
57
64
  scope,