@abloatai/ablo 0.8.0 → 0.9.1
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/CHANGELOG.md +46 -1
- package/README.md +33 -28
- package/dist/BaseSyncedStore.d.ts +83 -0
- package/dist/BaseSyncedStore.js +194 -2
- package/dist/Model.d.ts +42 -0
- package/dist/Model.js +103 -44
- package/dist/agent/session.js +3 -3
- package/dist/ai-sdk/coordination-context.js +4 -0
- package/dist/ai-sdk/index.d.ts +56 -47
- package/dist/ai-sdk/index.js +56 -47
- package/dist/ai-sdk/intent-broadcast.d.ts +5 -0
- package/dist/ai-sdk/intent-broadcast.js +11 -4
- package/dist/ai-sdk/wrap.d.ts +14 -11
- package/dist/ai-sdk/wrap.js +11 -13
- package/dist/auth/credentialSource.d.ts +34 -0
- package/dist/auth/credentialSource.js +63 -0
- package/dist/auth/index.d.ts +2 -22
- package/dist/auth/index.js +4 -42
- package/dist/auth/schemas.d.ts +35 -0
- package/dist/auth/schemas.js +53 -0
- package/dist/client/Ablo.d.ts +160 -42
- package/dist/client/Ablo.js +145 -75
- package/dist/client/ApiClient.d.ts +20 -4
- package/dist/client/ApiClient.js +166 -28
- package/dist/client/auth.d.ts +14 -5
- package/dist/client/auth.js +60 -7
- package/dist/client/createInternalComponents.d.ts +2 -0
- package/dist/client/createInternalComponents.js +8 -1
- package/dist/client/createModelProxy.d.ts +130 -66
- package/dist/client/createModelProxy.js +152 -49
- package/dist/client/httpClient.d.ts +71 -0
- package/dist/client/httpClient.js +69 -0
- package/dist/client/identity.d.ts +2 -6
- package/dist/client/identity.js +49 -11
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/client/registerDataSource.d.ts +3 -3
- package/dist/client/registerDataSource.js +11 -9
- package/dist/client/validateAbloOptions.js +1 -1
- package/dist/core/DatabaseManager.js +30 -2
- package/dist/core/openIDBWithTimeout.d.ts +36 -0
- package/dist/core/openIDBWithTimeout.js +88 -1
- package/dist/errorCodes.d.ts +70 -1
- package/dist/errorCodes.js +108 -9
- package/dist/errors.d.ts +2 -2
- package/dist/errors.js +72 -22
- package/dist/index.d.ts +17 -8
- package/dist/index.js +15 -6
- package/dist/keys/index.d.ts +16 -1
- package/dist/keys/index.js +26 -6
- package/dist/mutators/UndoManager.d.ts +158 -50
- package/dist/mutators/UndoManager.js +345 -22
- package/dist/mutators/inverseOp.d.ts +129 -0
- package/dist/mutators/inverseOp.js +74 -0
- package/dist/mutators/readerActions.d.ts +1 -1
- package/dist/mutators/undoApply.d.ts +42 -0
- package/dist/mutators/undoApply.js +143 -0
- package/dist/query/client.d.ts +10 -9
- package/dist/query/client.js +3 -6
- package/dist/react/AbloProvider.d.ts +23 -126
- package/dist/react/AbloProvider.js +62 -199
- package/dist/react/context.d.ts +31 -0
- package/dist/react/useAblo.d.ts +2 -2
- package/dist/react/useCurrentUserId.d.ts +1 -1
- package/dist/react/useCurrentUserId.js +1 -1
- package/dist/react/useMutators.js +19 -12
- package/dist/schema/ddl.d.ts +34 -3
- package/dist/schema/ddl.js +162 -4
- package/dist/schema/index.d.ts +5 -1
- package/dist/schema/index.js +13 -1
- package/dist/schema/model.d.ts +11 -0
- package/dist/schema/model.js +2 -0
- package/dist/schema/openapi.d.ts +28 -0
- package/dist/schema/openapi.js +118 -0
- package/dist/schema/plane.d.ts +23 -0
- package/dist/schema/plane.js +19 -0
- package/dist/schema/relation.d.ts +20 -0
- package/dist/schema/serialize.d.ts +4 -0
- package/dist/schema/serialize.js +4 -0
- package/dist/schema/sync-delta-row.d.ts +157 -0
- package/dist/schema/sync-delta-row.js +102 -0
- package/dist/schema/sync-delta-wire.d.ts +180 -0
- package/dist/schema/sync-delta-wire.js +102 -0
- package/dist/server/adapter.d.ts +156 -0
- package/dist/server/adapter.js +19 -0
- package/dist/server/commit.d.ts +82 -0
- package/dist/server/commit.js +1 -0
- package/dist/server/index.d.ts +14 -0
- package/dist/server/index.js +1 -0
- package/dist/server/next.d.ts +51 -0
- package/dist/server/next.js +47 -0
- package/dist/server/read-config.d.ts +60 -0
- package/dist/server/read-config.js +8 -0
- package/dist/server/storage-mode.d.ts +17 -0
- package/dist/server/storage-mode.js +12 -0
- package/dist/source/adapter.d.ts +65 -0
- package/dist/source/adapter.js +20 -0
- package/dist/source/adapters/drizzle.d.ts +43 -0
- package/dist/source/adapters/drizzle.js +185 -0
- package/dist/source/adapters/memory.d.ts +12 -0
- package/dist/source/adapters/memory.js +114 -0
- package/dist/source/adapters/prisma.d.ts +57 -0
- package/dist/source/adapters/prisma.js +176 -0
- package/dist/source/conformance.d.ts +32 -0
- package/dist/source/conformance.js +134 -0
- package/dist/source/contract.d.ts +144 -0
- package/dist/source/contract.js +99 -0
- package/dist/source/index.d.ts +62 -10
- package/dist/source/index.js +99 -0
- package/dist/source/migrations.d.ts +14 -0
- package/dist/source/migrations.js +39 -0
- package/dist/source/next.d.ts +33 -0
- package/dist/source/next.js +26 -0
- package/dist/sync/BootstrapHelper.d.ts +10 -0
- package/dist/sync/BootstrapHelper.js +10 -15
- package/dist/sync/ConnectionManager.d.ts +55 -1
- package/dist/sync/ConnectionManager.js +155 -16
- package/dist/sync/HydrationCoordinator.d.ts +93 -17
- package/dist/sync/HydrationCoordinator.js +238 -39
- package/dist/sync/NetworkProbe.d.ts +58 -24
- package/dist/sync/NetworkProbe.js +118 -42
- package/dist/sync/SyncWebSocket.d.ts +45 -70
- package/dist/sync/SyncWebSocket.js +70 -36
- package/dist/sync/createIntentStream.js +10 -1
- package/dist/types/streams.d.ts +9 -0
- package/dist/utils/mobx-setup.js +1 -0
- package/dist/webhooks/events.d.ts +38 -0
- package/dist/webhooks/events.js +40 -0
- package/dist/webhooks/index.d.ts +10 -0
- package/dist/webhooks/index.js +10 -0
- package/dist/wire/errorEnvelope.d.ts +34 -0
- package/dist/wire/errorEnvelope.js +86 -0
- package/dist/wire/frames.d.ts +119 -0
- package/dist/wire/frames.js +1 -0
- package/dist/wire/index.d.ts +24 -0
- package/dist/wire/index.js +21 -0
- package/dist/wire/listEnvelope.d.ts +45 -0
- package/dist/wire/listEnvelope.js +17 -0
- package/docs/api.md +47 -44
- package/docs/cli.md +44 -44
- package/docs/client-behavior.md +30 -30
- package/docs/coordination.md +33 -36
- package/docs/data-sources.md +35 -15
- package/docs/examples/agent-human.md +45 -43
- package/docs/examples/ai-sdk-tool.md +20 -16
- package/docs/examples/existing-python-backend.md +16 -12
- package/docs/examples/nextjs.md +14 -12
- package/docs/examples/scoped-agent.md +1 -1
- package/docs/examples/server-agent.md +24 -21
- package/docs/guarantees.md +15 -13
- package/docs/index.md +2 -2
- package/docs/integration-guide.md +30 -30
- package/docs/interaction-model.md +19 -23
- package/docs/mcp/claude-code.md +3 -3
- package/docs/mcp/cursor.md +1 -1
- package/docs/mcp/windsurf.md +2 -2
- package/docs/mcp.md +6 -6
- package/docs/quickstart.md +41 -31
- package/docs/react.md +13 -9
- package/docs/schema-contract.md +12 -10
- package/docs/the-loop.md +21 -0
- package/examples/data-source/README.md +4 -5
- package/examples/data-source/customer-server.ts +27 -25
- package/llms.txt +28 -5
- package/package.json +43 -3
|
@@ -18,6 +18,11 @@
|
|
|
18
18
|
* the scope on sync error if they want strict correctness.
|
|
19
19
|
*/
|
|
20
20
|
import { createTransaction } from './Transaction.js';
|
|
21
|
+
import { parseUndoEntry } from './inverseOp.js';
|
|
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();
|
|
21
26
|
/**
|
|
22
27
|
* A single undo stack for one surface. Access via `UndoManager.getScope(name)`.
|
|
23
28
|
* Consumers call `record(entry)` after each mutator; `undo()` / `redo()` to
|
|
@@ -30,18 +35,245 @@ export class UndoScope {
|
|
|
30
35
|
undoStack = [];
|
|
31
36
|
redoStack = [];
|
|
32
37
|
maxHistory;
|
|
38
|
+
conflictPolicy;
|
|
39
|
+
/**
|
|
40
|
+
* Observers notified after each successful {@link record}. These see FORWARD
|
|
41
|
+
* user actions only — `undo()`/`redo()` replays move entries between stacks
|
|
42
|
+
* without calling `record()`, so a listener never observes a reversal. This
|
|
43
|
+
* is a deliberately domain-agnostic seam: analytics, gamification, and audit
|
|
44
|
+
* can tap the committed-mutation stream without the scope knowing about them.
|
|
45
|
+
* A throwing listener is isolated (see {@link emitRecord}) so a faulty
|
|
46
|
+
* observer can never wedge the editor's recording path.
|
|
47
|
+
*/
|
|
48
|
+
recordListeners = new Set();
|
|
49
|
+
/**
|
|
50
|
+
* Serialization tail. Recording, undo, and redo all chain off this single
|
|
51
|
+
* promise so they run strictly in the order they were *invoked* — never
|
|
52
|
+
* interleaved. This is load-bearing for correctness, not just throughput:
|
|
53
|
+
* - Ordering: callers fire writes un-awaited (`void mutations.x.update`).
|
|
54
|
+
* Without serialization, an entry lands on the stack when its mutator
|
|
55
|
+
* *resolves*, so a fast second write can record before a slow first one
|
|
56
|
+
* → undo replays in the wrong order.
|
|
57
|
+
* - Snapshot integrity: every recording reads/clears the shared models'
|
|
58
|
+
* `modifiedProperties` (the undo "before" baseline). Two recordings
|
|
59
|
+
* interleaving on the same model corrupt each other's inverse snapshot.
|
|
60
|
+
* Serializing the whole scope closes both holes with one mechanism.
|
|
61
|
+
*/
|
|
62
|
+
tail = Promise.resolve();
|
|
63
|
+
/** Predicate selecting which models this surface records (see options). */
|
|
64
|
+
tracksModel;
|
|
65
|
+
/** registered-name / alias → schema key, built once from the schema. */
|
|
66
|
+
schemaKeyByAlias = new Map();
|
|
67
|
+
/** Unsubscribe from the local-mutation stream. */
|
|
68
|
+
unsubscribe;
|
|
69
|
+
/**
|
|
70
|
+
* True while `undo()`/`redo()` replays ops. Replays write through the same
|
|
71
|
+
* commit path, so they re-emit on the local-mutation stream; this flag tells
|
|
72
|
+
* our own listener to ignore them (no echo) — the engine equivalent of Yjs's
|
|
73
|
+
* `trackedOrigins` exclusion / Liveblocks pausing history during undo.
|
|
74
|
+
*/
|
|
75
|
+
replaying = false;
|
|
76
|
+
/** Ops collected during the current tick, flushed as ONE entry. */
|
|
77
|
+
batch = [];
|
|
78
|
+
flushScheduled = false;
|
|
79
|
+
/**
|
|
80
|
+
* Open grouping session (Liveblocks `history.pause()` / Yjs `stopCapturing`
|
|
81
|
+
* analogue). While set, stream ops accumulate here ACROSS ticks instead of
|
|
82
|
+
* flushing per-tick, so a multi-tick action (a drag, a whole streaming AI
|
|
83
|
+
* response) collapses into ONE Cmd+Z. `endGroup()` flushes it.
|
|
84
|
+
*/
|
|
85
|
+
group = null;
|
|
33
86
|
constructor(schema, store, organizationId, options = {}) {
|
|
34
87
|
this.schema = schema;
|
|
35
88
|
this.store = store;
|
|
36
89
|
this.organizationId = organizationId;
|
|
37
90
|
this.maxHistory = options.maxHistory ?? 100;
|
|
91
|
+
this.conflictPolicy = options.conflictPolicy ?? DEFAULT_UNDO_CONFLICT_POLICY;
|
|
92
|
+
this.tracksModel = options.tracksModel;
|
|
93
|
+
// Build the registered-name → schema-key alias map. The mutation stream
|
|
94
|
+
// reports `model.getModelName()` (e.g. `'SlideLayer'`), but inverse ops
|
|
95
|
+
// and the replay transaction are keyed by the SCHEMA key (e.g.
|
|
96
|
+
// `'slideLayers'`). Map every reasonable spelling to the schema key.
|
|
97
|
+
for (const schemaKey of Object.keys(this.schema.models)) {
|
|
98
|
+
const def = this.schema.models[schemaKey];
|
|
99
|
+
const typename = def?.typename ?? schemaKey;
|
|
100
|
+
for (const alias of [schemaKey, typename]) {
|
|
101
|
+
this.schemaKeyByAlias.set(alias, schemaKey);
|
|
102
|
+
this.schemaKeyByAlias.set(alias.toLowerCase(), schemaKey);
|
|
103
|
+
this.schemaKeyByAlias.set(normalizeModelAlias(alias), schemaKey);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Subscribe to the local-mutation stream ONLY when this scope opts into
|
|
107
|
+
// stream recording. Transitional flag: surfaces still on the legacy
|
|
108
|
+
// manual-record path (mutator `RecordingTransaction`, AI pipeline
|
|
109
|
+
// sessions) keep `recordFromStream: false` so writes aren't double-counted.
|
|
110
|
+
// Once every surface is migrated, stream recording becomes the only path
|
|
111
|
+
// and the flag is removed. Optional on the contract so minimal test
|
|
112
|
+
// doubles can omit it (undo then records nothing).
|
|
113
|
+
this.unsubscribe =
|
|
114
|
+
options.recordFromStream && this.store.subscribeLocalMutations
|
|
115
|
+
? this.store.subscribeLocalMutations((m) => this.onLocalMutation(m))
|
|
116
|
+
: () => { };
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Open a grouping session: every stream-recorded op until {@link endGroup}
|
|
120
|
+
* collapses into a single undo entry. Mirrors Liveblocks `history.pause()` —
|
|
121
|
+
* call on gesture start (pointerdown) or AI-response start. Idempotent-ish:
|
|
122
|
+
* a second call closes the previous group first.
|
|
123
|
+
*/
|
|
124
|
+
beginGroup(label) {
|
|
125
|
+
if (this.group)
|
|
126
|
+
this.endGroup();
|
|
127
|
+
this.group = { label, ops: [] };
|
|
128
|
+
}
|
|
129
|
+
/** Close the grouping session and record the accumulated ops as one entry. */
|
|
130
|
+
endGroup(label) {
|
|
131
|
+
const g = this.group;
|
|
132
|
+
if (!g)
|
|
133
|
+
return;
|
|
134
|
+
this.group = null;
|
|
135
|
+
const forwards = g.ops.map((c) => c.forward);
|
|
136
|
+
const inverses = g.ops
|
|
137
|
+
.map((c) => c.inverse)
|
|
138
|
+
.filter((i) => i !== null)
|
|
139
|
+
.reverse();
|
|
140
|
+
if (forwards.length === 0 && inverses.length === 0)
|
|
141
|
+
return;
|
|
142
|
+
this.record({ label: label ?? g.label, inverses, forwards });
|
|
143
|
+
}
|
|
144
|
+
/** Resolve a stream mutation's registered name to its schema key, or null. */
|
|
145
|
+
resolveSchemaKey(modelName) {
|
|
146
|
+
return (this.schemaKeyByAlias.get(modelName) ??
|
|
147
|
+
this.schemaKeyByAlias.get(normalizeModelAlias(modelName)) ??
|
|
148
|
+
null);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Stream listener — the sole place entries are born. Skips replay echoes
|
|
152
|
+
* and out-of-scope models, derives the forward+inverse op from the
|
|
153
|
+
* mutation's `data`/`previousData`, and defers the stack push to a
|
|
154
|
+
* per-tick flush so a burst of writes (e.g. align 5 layers) becomes ONE
|
|
155
|
+
* undo step — riding the same tick boundary the TransactionQueue batches on.
|
|
156
|
+
*/
|
|
157
|
+
onLocalMutation(m) {
|
|
158
|
+
if (this.replaying)
|
|
159
|
+
return;
|
|
160
|
+
const schemaKey = this.resolveSchemaKey(m.modelName);
|
|
161
|
+
if (!schemaKey)
|
|
162
|
+
return;
|
|
163
|
+
if (this.tracksModel && !this.tracksModel(schemaKey))
|
|
164
|
+
return;
|
|
165
|
+
const ops = buildUndoOps(m, schemaKey);
|
|
166
|
+
if (!ops)
|
|
167
|
+
return;
|
|
168
|
+
// Inside a grouping session, accumulate across ticks (flushed on
|
|
169
|
+
// endGroup); otherwise coalesce per-tick.
|
|
170
|
+
if (this.group) {
|
|
171
|
+
this.group.ops.push(ops);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
this.batch.push(ops);
|
|
175
|
+
this.scheduleFlush();
|
|
176
|
+
}
|
|
177
|
+
scheduleFlush() {
|
|
178
|
+
if (this.flushScheduled)
|
|
179
|
+
return;
|
|
180
|
+
this.flushScheduled = true;
|
|
181
|
+
const run = () => {
|
|
182
|
+
this.flushScheduled = false;
|
|
183
|
+
this.flushBatch();
|
|
184
|
+
};
|
|
185
|
+
if (typeof queueMicrotask === 'function')
|
|
186
|
+
queueMicrotask(run);
|
|
187
|
+
else
|
|
188
|
+
void Promise.resolve().then(run);
|
|
189
|
+
}
|
|
190
|
+
/** Coalesce the tick's collected ops into one entry and record it. */
|
|
191
|
+
flushBatch() {
|
|
192
|
+
if (this.batch.length === 0)
|
|
193
|
+
return;
|
|
194
|
+
const collected = this.batch;
|
|
195
|
+
this.batch = [];
|
|
196
|
+
const forwards = collected.map((c) => c.forward);
|
|
197
|
+
// Undo applies inverses in REVERSE order of how the forwards ran.
|
|
198
|
+
const inverses = collected
|
|
199
|
+
.map((c) => c.inverse)
|
|
200
|
+
.filter((i) => i !== null)
|
|
201
|
+
.reverse();
|
|
202
|
+
if (forwards.length === 0 && inverses.length === 0)
|
|
203
|
+
return;
|
|
204
|
+
this.record({ inverses, forwards });
|
|
38
205
|
}
|
|
39
|
-
/**
|
|
206
|
+
/**
|
|
207
|
+
* Run `work` after every previously-enqueued scope operation has settled,
|
|
208
|
+
* in invocation order. The internal `tail` always resolves (failures are
|
|
209
|
+
* swallowed *for the chain only*) so one rejected mutator can't wedge the
|
|
210
|
+
* queue; the original settlement is still surfaced to this call's caller.
|
|
211
|
+
*/
|
|
212
|
+
enqueue(work) {
|
|
213
|
+
const result = this.tail.then(work, work);
|
|
214
|
+
this.tail = result.then(() => undefined, () => undefined);
|
|
215
|
+
return result;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Run a recording mutator exclusively on the scope's serialization chain.
|
|
219
|
+
* Used by the legacy manual-record path (`useMutators` + `RecordingTransaction`)
|
|
220
|
+
* so the snapshot → write → `record()` sequence is atomic relative to undo/
|
|
221
|
+
* redo. The stream-recording path doesn't need this (it derives entries from
|
|
222
|
+
* already-committed mutations); kept until all surfaces migrate off manual.
|
|
223
|
+
*/
|
|
224
|
+
runRecorded(work) {
|
|
225
|
+
return this.enqueue(work);
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Record one entry onto the undo stack. Clears the redo stack. Fed by
|
|
229
|
+
* {@link flushBatch}/{@link endGroup} from the local-mutation stream, and
|
|
230
|
+
* still called directly by the legacy manual-record consumers
|
|
231
|
+
* (`useMutators`, the AI mutation pipeline) until they migrate. Entries are
|
|
232
|
+
* built internally (trusted), so the schema check is DEV-ONLY: it catches
|
|
233
|
+
* recorder bugs in dev/test (rejecting a malformed op at ingestion, with its
|
|
234
|
+
* path, instead of letting it crash later inside `applyOps`) without paying a
|
|
235
|
+
* Zod parse on every user action in production. The real validation boundary
|
|
236
|
+
* is `parseUndoEntry`, applied when entries are deserialized from persistence
|
|
237
|
+
* (untrusted input). Best practice: validate at trust boundaries, type-check
|
|
238
|
+
* internal calls.
|
|
239
|
+
*/
|
|
40
240
|
record(entry) {
|
|
241
|
+
if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
|
|
242
|
+
parseUndoEntry(entry);
|
|
243
|
+
}
|
|
41
244
|
this.undoStack.push(entry);
|
|
42
245
|
if (this.undoStack.length > this.maxHistory)
|
|
43
246
|
this.undoStack.shift();
|
|
44
247
|
this.redoStack = [];
|
|
248
|
+
this.emitRecord(entry);
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Subscribe to every recorded mutation. Fires synchronously at the tail of
|
|
252
|
+
* each {@link record} call, after the entry is on the undo stack. Returns an
|
|
253
|
+
* unsubscribe function — call it on teardown.
|
|
254
|
+
*
|
|
255
|
+
* Listeners receive the full {@link UndoEntry} (its `forwards` carry the
|
|
256
|
+
* `{ kind, modelKey, data }` ops), so a consumer can derive what changed
|
|
257
|
+
* (e.g. "a slideLayers row of type 'chart' was created") without re-querying.
|
|
258
|
+
*/
|
|
259
|
+
onRecord(listener) {
|
|
260
|
+
this.recordListeners.add(listener);
|
|
261
|
+
return () => {
|
|
262
|
+
this.recordListeners.delete(listener);
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
emitRecord(entry) {
|
|
266
|
+
for (const listener of this.recordListeners) {
|
|
267
|
+
try {
|
|
268
|
+
listener(entry);
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
// A faulty observer must never break the editor's recording path.
|
|
272
|
+
if (typeof console !== 'undefined') {
|
|
273
|
+
console.error('[UndoScope] onRecord listener threw', err);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
45
277
|
}
|
|
46
278
|
canUndo() {
|
|
47
279
|
return this.undoStack.length > 0;
|
|
@@ -49,37 +281,128 @@ export class UndoScope {
|
|
|
49
281
|
canRedo() {
|
|
50
282
|
return this.redoStack.length > 0;
|
|
51
283
|
}
|
|
52
|
-
/**
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
284
|
+
/**
|
|
285
|
+
* Pop the last mutator and apply its inverses. Pushes to redo.
|
|
286
|
+
*
|
|
287
|
+
* Under the default `skip-stale` policy the inverses are filtered against
|
|
288
|
+
* live state first (paired with the entry's forwards = "what I set"), so a
|
|
289
|
+
* field a collaborator changed after my op is left untouched — undo reverts
|
|
290
|
+
* my change only where it still stands.
|
|
291
|
+
*/
|
|
292
|
+
undo() {
|
|
293
|
+
return this.enqueue(async () => {
|
|
294
|
+
const entry = this.undoStack.pop();
|
|
295
|
+
if (!entry)
|
|
296
|
+
return;
|
|
297
|
+
const tx = createTransaction(this.schema, this.store, this.organizationId);
|
|
298
|
+
const ops = resolveOps(entry.inverses, entry.forwards, this.store, this.conflictPolicy);
|
|
299
|
+
// Suppress our own stream listener so replayed writes don't record as
|
|
300
|
+
// new undo entries. Cleared in `finally` even if a replay op throws.
|
|
301
|
+
this.replaying = true;
|
|
302
|
+
try {
|
|
303
|
+
await applyOps(tx, ops);
|
|
304
|
+
}
|
|
305
|
+
finally {
|
|
306
|
+
this.replaying = false;
|
|
307
|
+
}
|
|
308
|
+
this.redoStack.push(entry);
|
|
309
|
+
if (this.redoStack.length > this.maxHistory)
|
|
310
|
+
this.redoStack.shift();
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Pop the last undone entry and re-apply the forward ops. Pushes to undo.
|
|
315
|
+
* Symmetric to {@link undo}: forwards are filtered against live state
|
|
316
|
+
* (paired with the entry's inverses = "what undo restored"), so redo
|
|
317
|
+
* re-asserts my change only where the undone value still stands.
|
|
318
|
+
*/
|
|
319
|
+
redo() {
|
|
320
|
+
return this.enqueue(async () => {
|
|
321
|
+
const entry = this.redoStack.pop();
|
|
322
|
+
if (!entry)
|
|
323
|
+
return;
|
|
324
|
+
const tx = createTransaction(this.schema, this.store, this.organizationId);
|
|
325
|
+
const ops = resolveOps(entry.forwards, entry.inverses, this.store, this.conflictPolicy);
|
|
326
|
+
this.replaying = true;
|
|
327
|
+
try {
|
|
328
|
+
await applyOps(tx, ops);
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
this.replaying = false;
|
|
332
|
+
}
|
|
333
|
+
this.undoStack.push(entry);
|
|
334
|
+
if (this.undoStack.length > this.maxHistory)
|
|
335
|
+
this.undoStack.shift();
|
|
336
|
+
});
|
|
73
337
|
}
|
|
74
338
|
/** Drop all history. Use after bootstrap / sync group change / sync error. */
|
|
75
339
|
clear() {
|
|
76
340
|
this.undoStack = [];
|
|
77
341
|
this.redoStack = [];
|
|
342
|
+
this.batch = [];
|
|
78
343
|
}
|
|
79
344
|
/** Introspection — for debug panels / e2e tests. */
|
|
80
345
|
size() {
|
|
81
346
|
return { undo: this.undoStack.length, redo: this.redoStack.length };
|
|
82
347
|
}
|
|
348
|
+
/**
|
|
349
|
+
* Detach from the local-mutation stream and drop listeners. Scopes are
|
|
350
|
+
* cached for the store's lifetime by `UndoManager`, so this is mainly for
|
|
351
|
+
* tests and explicit teardown.
|
|
352
|
+
*/
|
|
353
|
+
dispose() {
|
|
354
|
+
this.unsubscribe();
|
|
355
|
+
this.recordListeners.clear();
|
|
356
|
+
this.batch = [];
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Derive the forward + inverse op for a single local mutation. Returns null
|
|
361
|
+
* when the mutation can't be reversed (e.g. an update with no captured
|
|
362
|
+
* previous values), so the caller can drop it rather than push a half-entry.
|
|
363
|
+
*/
|
|
364
|
+
function buildUndoOps(m, modelKey) {
|
|
365
|
+
const id = m.modelId;
|
|
366
|
+
const stripId = (o) => {
|
|
367
|
+
const out = { ...(o ?? {}) };
|
|
368
|
+
delete out.id;
|
|
369
|
+
return out;
|
|
370
|
+
};
|
|
371
|
+
switch (m.type) {
|
|
372
|
+
case 'create':
|
|
373
|
+
return {
|
|
374
|
+
forward: { kind: 'create', modelKey, data: { ...stripId(m.data), id } },
|
|
375
|
+
inverse: { kind: 'delete', modelKey, id },
|
|
376
|
+
};
|
|
377
|
+
case 'update': {
|
|
378
|
+
const next = stripId(m.data);
|
|
379
|
+
const prev = stripId(m.previousData);
|
|
380
|
+
return {
|
|
381
|
+
forward: { kind: 'update', modelKey, patch: { id, ...next } },
|
|
382
|
+
// No previous values captured → not reversible; drop the inverse.
|
|
383
|
+
inverse: Object.keys(prev).length > 0
|
|
384
|
+
? { kind: 'update', modelKey, patch: { id, ...prev } }
|
|
385
|
+
: null,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
case 'delete':
|
|
389
|
+
return {
|
|
390
|
+
forward: { kind: 'delete', modelKey, id },
|
|
391
|
+
inverse: { kind: 'create', modelKey, data: { ...stripId(m.previousData), id } },
|
|
392
|
+
};
|
|
393
|
+
case 'archive':
|
|
394
|
+
return {
|
|
395
|
+
forward: { kind: 'update', modelKey, patch: { id, archivedAt: new Date() } },
|
|
396
|
+
inverse: { kind: 'update', modelKey, patch: { id, archivedAt: null } },
|
|
397
|
+
};
|
|
398
|
+
case 'unarchive':
|
|
399
|
+
return {
|
|
400
|
+
forward: { kind: 'update', modelKey, patch: { id, archivedAt: null } },
|
|
401
|
+
inverse: { kind: 'update', modelKey, patch: { id, archivedAt: new Date() } },
|
|
402
|
+
};
|
|
403
|
+
default:
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
83
406
|
}
|
|
84
407
|
// ── Manager ────────────────────────────────────────────────────────────────
|
|
85
408
|
/**
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* inverseOp.ts — the reversible-operation model for the undo system,
|
|
3
|
+
* expressed as Zod schemas.
|
|
4
|
+
*
|
|
5
|
+
* Why schemas (not bare TS types):
|
|
6
|
+
* - Single source of truth. The `InverseOp` / `UndoEntry` TypeScript types
|
|
7
|
+
* are *derived* from these schemas (`z.infer`), so the wire shape and the
|
|
8
|
+
* static type can't drift.
|
|
9
|
+
* - A real validation boundary. Inverse ops are stored as plain JSON-shaped
|
|
10
|
+
* records (model keys + row data) so the undo manager stays schema-agnostic
|
|
11
|
+
* — it replays them through a strongly-typed transaction it doesn't own.
|
|
12
|
+
* That JSON boundary is exactly where a runtime check belongs, and is the
|
|
13
|
+
* seam a future cross-session persistence layer (IndexedDB-backed history)
|
|
14
|
+
* would deserialize through. `parseUndoEntry` is that gate.
|
|
15
|
+
*
|
|
16
|
+
* The op kinds mirror the mutator surface 1:1 — single (`create`/`update`/
|
|
17
|
+
* `delete`) and batch (`createMany`/`updateMany`/`deleteMany`) — so a recorded
|
|
18
|
+
* entry is symmetric with what was originally invoked.
|
|
19
|
+
*/
|
|
20
|
+
import { z } from 'zod';
|
|
21
|
+
/**
|
|
22
|
+
* A single reversible operation. Discriminated on `kind` so a malformed op
|
|
23
|
+
* fails fast with a precise path (e.g. `inverses[2].patch.id`) rather than a
|
|
24
|
+
* vague union mismatch. Model keys/data are strings/records — the manager is
|
|
25
|
+
* schema-agnostic; the transaction it replays through is schema-typed.
|
|
26
|
+
*/
|
|
27
|
+
export declare const inverseOpSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
28
|
+
kind: z.ZodLiteral<"create">;
|
|
29
|
+
modelKey: z.ZodString;
|
|
30
|
+
data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
31
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
32
|
+
kind: z.ZodLiteral<"update">;
|
|
33
|
+
modelKey: z.ZodString;
|
|
34
|
+
patch: z.ZodObject<{
|
|
35
|
+
id: z.ZodString;
|
|
36
|
+
}, z.core.$catchall<z.ZodUnknown>>;
|
|
37
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
38
|
+
kind: z.ZodLiteral<"delete">;
|
|
39
|
+
modelKey: z.ZodString;
|
|
40
|
+
id: z.ZodString;
|
|
41
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
42
|
+
kind: z.ZodLiteral<"createMany">;
|
|
43
|
+
modelKey: z.ZodString;
|
|
44
|
+
data: z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
45
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
46
|
+
kind: z.ZodLiteral<"updateMany">;
|
|
47
|
+
modelKey: z.ZodString;
|
|
48
|
+
patches: z.ZodArray<z.ZodObject<{
|
|
49
|
+
id: z.ZodString;
|
|
50
|
+
}, z.core.$catchall<z.ZodUnknown>>>;
|
|
51
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
52
|
+
kind: z.ZodLiteral<"deleteMany">;
|
|
53
|
+
modelKey: z.ZodString;
|
|
54
|
+
ids: z.ZodArray<z.ZodString>;
|
|
55
|
+
}, z.core.$strip>], "kind">;
|
|
56
|
+
/** One undo entry = one mutator invocation's inverses + paired forwards. */
|
|
57
|
+
export declare const undoEntrySchema: z.ZodObject<{
|
|
58
|
+
label: z.ZodOptional<z.ZodString>;
|
|
59
|
+
inverses: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
60
|
+
kind: z.ZodLiteral<"create">;
|
|
61
|
+
modelKey: z.ZodString;
|
|
62
|
+
data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
63
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
64
|
+
kind: z.ZodLiteral<"update">;
|
|
65
|
+
modelKey: z.ZodString;
|
|
66
|
+
patch: z.ZodObject<{
|
|
67
|
+
id: z.ZodString;
|
|
68
|
+
}, z.core.$catchall<z.ZodUnknown>>;
|
|
69
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
70
|
+
kind: z.ZodLiteral<"delete">;
|
|
71
|
+
modelKey: z.ZodString;
|
|
72
|
+
id: z.ZodString;
|
|
73
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
74
|
+
kind: z.ZodLiteral<"createMany">;
|
|
75
|
+
modelKey: z.ZodString;
|
|
76
|
+
data: z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
77
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
78
|
+
kind: z.ZodLiteral<"updateMany">;
|
|
79
|
+
modelKey: z.ZodString;
|
|
80
|
+
patches: z.ZodArray<z.ZodObject<{
|
|
81
|
+
id: z.ZodString;
|
|
82
|
+
}, z.core.$catchall<z.ZodUnknown>>>;
|
|
83
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
84
|
+
kind: z.ZodLiteral<"deleteMany">;
|
|
85
|
+
modelKey: z.ZodString;
|
|
86
|
+
ids: z.ZodArray<z.ZodString>;
|
|
87
|
+
}, z.core.$strip>], "kind">>;
|
|
88
|
+
forwards: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
89
|
+
kind: z.ZodLiteral<"create">;
|
|
90
|
+
modelKey: z.ZodString;
|
|
91
|
+
data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
92
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
93
|
+
kind: z.ZodLiteral<"update">;
|
|
94
|
+
modelKey: z.ZodString;
|
|
95
|
+
patch: z.ZodObject<{
|
|
96
|
+
id: z.ZodString;
|
|
97
|
+
}, z.core.$catchall<z.ZodUnknown>>;
|
|
98
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
99
|
+
kind: z.ZodLiteral<"delete">;
|
|
100
|
+
modelKey: z.ZodString;
|
|
101
|
+
id: z.ZodString;
|
|
102
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
103
|
+
kind: z.ZodLiteral<"createMany">;
|
|
104
|
+
modelKey: z.ZodString;
|
|
105
|
+
data: z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
106
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
107
|
+
kind: z.ZodLiteral<"updateMany">;
|
|
108
|
+
modelKey: z.ZodString;
|
|
109
|
+
patches: z.ZodArray<z.ZodObject<{
|
|
110
|
+
id: z.ZodString;
|
|
111
|
+
}, z.core.$catchall<z.ZodUnknown>>>;
|
|
112
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
113
|
+
kind: z.ZodLiteral<"deleteMany">;
|
|
114
|
+
modelKey: z.ZodString;
|
|
115
|
+
ids: z.ZodArray<z.ZodString>;
|
|
116
|
+
}, z.core.$strip>], "kind">>;
|
|
117
|
+
}, z.core.$strip>;
|
|
118
|
+
/** A single reversible operation (schema-derived). */
|
|
119
|
+
export type InverseOp = z.infer<typeof inverseOpSchema>;
|
|
120
|
+
/** One undo/redo stack entry (schema-derived). */
|
|
121
|
+
export type UndoEntry = z.infer<typeof undoEntrySchema>;
|
|
122
|
+
/**
|
|
123
|
+
* Validate an untrusted value as an `UndoEntry`. Use at any boundary where an
|
|
124
|
+
* entry crosses out of internal construction — deserialization from
|
|
125
|
+
* persistence, or a defensive check at the recording ingestion point. Throws
|
|
126
|
+
* `AbloValidationError` (code `undo_entry_invalid`) with the failing Zod path
|
|
127
|
+
* in `details` so the offending op is obvious.
|
|
128
|
+
*/
|
|
129
|
+
export declare function parseUndoEntry(value: unknown): UndoEntry;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* inverseOp.ts — the reversible-operation model for the undo system,
|
|
3
|
+
* expressed as Zod schemas.
|
|
4
|
+
*
|
|
5
|
+
* Why schemas (not bare TS types):
|
|
6
|
+
* - Single source of truth. The `InverseOp` / `UndoEntry` TypeScript types
|
|
7
|
+
* are *derived* from these schemas (`z.infer`), so the wire shape and the
|
|
8
|
+
* static type can't drift.
|
|
9
|
+
* - A real validation boundary. Inverse ops are stored as plain JSON-shaped
|
|
10
|
+
* records (model keys + row data) so the undo manager stays schema-agnostic
|
|
11
|
+
* — it replays them through a strongly-typed transaction it doesn't own.
|
|
12
|
+
* That JSON boundary is exactly where a runtime check belongs, and is the
|
|
13
|
+
* seam a future cross-session persistence layer (IndexedDB-backed history)
|
|
14
|
+
* would deserialize through. `parseUndoEntry` is that gate.
|
|
15
|
+
*
|
|
16
|
+
* The op kinds mirror the mutator surface 1:1 — single (`create`/`update`/
|
|
17
|
+
* `delete`) and batch (`createMany`/`updateMany`/`deleteMany`) — so a recorded
|
|
18
|
+
* entry is symmetric with what was originally invoked.
|
|
19
|
+
*/
|
|
20
|
+
import { z } from 'zod';
|
|
21
|
+
import { AbloValidationError } from '../errors.js';
|
|
22
|
+
/** A row payload — JSON-shaped record used by create/createMany inverses. */
|
|
23
|
+
const rowDataSchema = z.record(z.string(), z.unknown());
|
|
24
|
+
/**
|
|
25
|
+
* An update patch: an `id` plus the changed fields. Modeled as an object with
|
|
26
|
+
* a required `id` and an open `catchall`, so `z.infer` yields
|
|
27
|
+
* `{ id: string } & { [k: string]: unknown }` — the exact shape the recorder
|
|
28
|
+
* builds and the replayer consumes.
|
|
29
|
+
*/
|
|
30
|
+
const patchSchema = z.object({ id: z.string() }).catchall(z.unknown());
|
|
31
|
+
/**
|
|
32
|
+
* A single reversible operation. Discriminated on `kind` so a malformed op
|
|
33
|
+
* fails fast with a precise path (e.g. `inverses[2].patch.id`) rather than a
|
|
34
|
+
* vague union mismatch. Model keys/data are strings/records — the manager is
|
|
35
|
+
* schema-agnostic; the transaction it replays through is schema-typed.
|
|
36
|
+
*/
|
|
37
|
+
export const inverseOpSchema = z.discriminatedUnion('kind', [
|
|
38
|
+
z.object({ kind: z.literal('create'), modelKey: z.string(), data: rowDataSchema }),
|
|
39
|
+
z.object({ kind: z.literal('update'), modelKey: z.string(), patch: patchSchema }),
|
|
40
|
+
z.object({ kind: z.literal('delete'), modelKey: z.string(), id: z.string() }),
|
|
41
|
+
z.object({ kind: z.literal('createMany'), modelKey: z.string(), data: z.array(rowDataSchema) }),
|
|
42
|
+
z.object({ kind: z.literal('updateMany'), modelKey: z.string(), patches: z.array(patchSchema) }),
|
|
43
|
+
z.object({ kind: z.literal('deleteMany'), modelKey: z.string(), ids: z.array(z.string()) }),
|
|
44
|
+
]);
|
|
45
|
+
/** One undo entry = one mutator invocation's inverses + paired forwards. */
|
|
46
|
+
export const undoEntrySchema = z.object({
|
|
47
|
+
/** Optional label for diagnostics / UI ("Move layer", "Delete slide", etc). */
|
|
48
|
+
label: z.string().optional(),
|
|
49
|
+
/** Applied (in array order) to reverse the invocation. */
|
|
50
|
+
inverses: z.array(inverseOpSchema),
|
|
51
|
+
/**
|
|
52
|
+
* Paired forward ops, captured at record time so redo can replay them
|
|
53
|
+
* without re-running the user's mutator (which may have non-idempotent
|
|
54
|
+
* side effects like generating new IDs).
|
|
55
|
+
*/
|
|
56
|
+
forwards: z.array(inverseOpSchema),
|
|
57
|
+
});
|
|
58
|
+
/**
|
|
59
|
+
* Validate an untrusted value as an `UndoEntry`. Use at any boundary where an
|
|
60
|
+
* entry crosses out of internal construction — deserialization from
|
|
61
|
+
* persistence, or a defensive check at the recording ingestion point. Throws
|
|
62
|
+
* `AbloValidationError` (code `undo_entry_invalid`) with the failing Zod path
|
|
63
|
+
* in `details` so the offending op is obvious.
|
|
64
|
+
*/
|
|
65
|
+
export function parseUndoEntry(value) {
|
|
66
|
+
const result = undoEntrySchema.safeParse(value);
|
|
67
|
+
if (!result.success) {
|
|
68
|
+
throw new AbloValidationError('Undo entry failed inverse-op schema validation.', {
|
|
69
|
+
code: 'undo_entry_invalid',
|
|
70
|
+
details: { issues: result.error.issues },
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return result.data;
|
|
74
|
+
}
|
|
@@ -4,7 +4,7 @@ import type { SyncStoreContract } from '../react/context.js';
|
|
|
4
4
|
* React-free imperative reads over a store: one-off `retrieve`/`list`/`count`
|
|
5
5
|
* snapshots that do NOT subscribe to changes. Used by the transaction system
|
|
6
6
|
* and `BaseSyncedStore`. For reactive reads in components use
|
|
7
|
-
* `useAblo((ablo) => ablo.<model>.retrieve(id) / .list(opts))`.
|
|
7
|
+
* `useAblo((ablo) => ablo.<model>.retrieve({ id }) / .list(opts))`.
|
|
8
8
|
*/
|
|
9
9
|
export interface ReaderFindOptions<T> {
|
|
10
10
|
/** Equality filter — uses FK index when the field is registered. */
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* undoApply.ts — conflict-aware resolution of undo/redo ops (per-user undo).
|
|
3
|
+
*
|
|
4
|
+
* The undo stack is already per-client (only local mutator invocations call
|
|
5
|
+
* `UndoScope.record`; a collaborator's edits arrive as inbound sync deltas and
|
|
6
|
+
* never land here). What this module adds is the second half of "undo per
|
|
7
|
+
* user": when replaying a recorded op, only touch a field whose CURRENT value
|
|
8
|
+
* still equals what THIS op established — so undo reverts your own change only
|
|
9
|
+
* where it still stands, and never clobbers a field a collaborator changed
|
|
10
|
+
* after you (the Yjs/CRDT "selective undo" principle, adapted to our
|
|
11
|
+
* field-level last-writer-wins model).
|
|
12
|
+
*
|
|
13
|
+
* `resolveOps(apply, paired, store, policy)`:
|
|
14
|
+
* - `apply` — the ops we're about to replay (inverses on undo, forwards on redo).
|
|
15
|
+
* - `paired` — their counterparts, carrying the value this op established
|
|
16
|
+
* (forwards on undo = "what I set"; inverses on redo = "what undo restored").
|
|
17
|
+
* - For `update`/`updateMany` ops it drops fields whose live value no longer
|
|
18
|
+
* matches the established value. `create`/`delete` families are structural
|
|
19
|
+
* and applied unconditionally (undoing your create removes the row you
|
|
20
|
+
* added; undoing your delete restores it).
|
|
21
|
+
*
|
|
22
|
+
* With no collaborator, the live value always equals what you set, so nothing
|
|
23
|
+
* is dropped — single-user undo is byte-for-byte unchanged.
|
|
24
|
+
*/
|
|
25
|
+
import type { SyncStoreContract } from '../react/context.js';
|
|
26
|
+
import type { InverseOp } from './inverseOp.js';
|
|
27
|
+
/**
|
|
28
|
+
* How undo/redo handles a field a collaborator changed after your op:
|
|
29
|
+
* - `skip-stale` (default): leave it — your change is already superseded, so
|
|
30
|
+
* reverting it would clobber theirs. This is the per-user guarantee.
|
|
31
|
+
* - `last-writer-wins`: apply the op verbatim (legacy behavior). Your undo
|
|
32
|
+
* overwrites their change.
|
|
33
|
+
*/
|
|
34
|
+
export type UndoConflictPolicy = 'skip-stale' | 'last-writer-wins';
|
|
35
|
+
export declare const DEFAULT_UNDO_CONFLICT_POLICY: UndoConflictPolicy;
|
|
36
|
+
/** Structural equality for JSON-shaped values (scalars, arrays, plain objects). */
|
|
37
|
+
export declare function deepEqual(a: unknown, b: unknown): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Filter the ops to apply so they don't clobber concurrent collaborator edits.
|
|
40
|
+
* See the module docblock. `last-writer-wins` returns the ops unchanged.
|
|
41
|
+
*/
|
|
42
|
+
export declare function resolveOps(apply: InverseOp[], paired: InverseOp[], store: SyncStoreContract, policy: UndoConflictPolicy): InverseOp[];
|