@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.
- package/AGENTS.md +84 -0
- package/CHANGELOG.md +40 -0
- package/README.md +15 -7
- package/dist/BaseSyncedStore.d.ts +10 -0
- package/dist/BaseSyncedStore.js +26 -0
- package/dist/SyncClient.d.ts +12 -0
- package/dist/SyncClient.js +15 -0
- package/dist/agent/index.js +1 -1
- package/dist/api/index.d.ts +1 -1
- package/dist/client/Ablo.d.ts +9 -51
- package/dist/client/Ablo.js +2 -104
- package/dist/client/ApiClient.d.ts +3 -115
- package/dist/client/ApiClient.js +0 -232
- package/dist/client/auth.js +32 -2
- package/dist/client/httpClient.d.ts +5 -6
- package/dist/client/httpClient.js +2 -3
- package/dist/client/index.d.ts +1 -1
- package/dist/errorCodes.js +3 -3
- package/dist/index.js +1 -1
- package/dist/interfaces/index.d.ts +4 -4
- package/dist/mutators/UndoManager.d.ts +100 -11
- package/dist/mutators/UndoManager.js +282 -13
- package/dist/react/AbloProvider.d.ts +18 -8
- package/dist/react/context.d.ts +31 -0
- package/dist/react/index.d.ts +1 -1
- package/dist/react/index.js +1 -1
- package/dist/react/useUndoScope.js +7 -0
- package/dist/schema/ddl.d.ts +8 -0
- package/dist/schema/ddl.js +10 -0
- package/dist/schema/index.d.ts +1 -1
- package/dist/schema/index.js +1 -1
- package/dist/server/commit.d.ts +4 -5
- package/dist/source/adapter.d.ts +18 -12
- package/dist/source/adapter.js +8 -7
- package/dist/source/adapters/drizzle.d.ts +15 -6
- package/dist/source/adapters/drizzle.js +87 -49
- package/dist/source/adapters/memory.d.ts +1 -1
- package/dist/source/adapters/memory.js +2 -2
- package/dist/source/adapters/prisma.d.ts +3 -3
- package/dist/source/adapters/prisma.js +6 -29
- package/dist/source/conformance.d.ts +1 -1
- package/dist/source/conformance.js +2 -2
- package/dist/source/contract.d.ts +3 -2
- package/dist/source/contract.js +3 -2
- package/dist/source/index.d.ts +1 -0
- package/dist/source/index.js +3 -2
- package/dist/source/migrations.d.ts +14 -0
- package/dist/source/migrations.js +39 -0
- package/dist/types/streams.d.ts +2 -1
- package/dist/wire/frames.d.ts +6 -8
- package/docs/api.md +1 -1
- package/docs/cli.md +18 -5
- package/docs/data-sources.md +68 -83
- package/docs/examples/ai-sdk-tool.md +11 -5
- package/docs/examples/existing-python-backend.md +26 -4
- package/docs/examples/nextjs.md +3 -2
- package/docs/examples/scoped-agent.md +38 -11
- package/docs/identity.md +86 -59
- package/docs/index.md +1 -1
- package/docs/integration-guide.md +85 -54
- package/docs/react.md +39 -28
- package/llms.txt +18 -11
- 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
|
-
*
|
|
86
|
-
* atomic relative to
|
|
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
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
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
|
-
*
|
|
81
|
-
* atomic relative to
|
|
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
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
/**
|
package/dist/react/context.d.ts
CHANGED
|
@@ -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;
|
package/dist/react/index.d.ts
CHANGED
|
@@ -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.
|
|
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
|
package/dist/react/index.js
CHANGED
|
@@ -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.
|
|
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,
|