@abloatai/ablo 0.9.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 +6 -0
- package/README.md +1 -1
- package/dist/BaseSyncedStore.d.ts +10 -0
- package/dist/BaseSyncedStore.js +22 -0
- package/dist/client/Ablo.d.ts +2 -2
- package/dist/client/ApiClient.d.ts +2 -2
- package/dist/mutators/UndoManager.d.ts +83 -11
- package/dist/mutators/UndoManager.js +229 -13
- package/dist/react/context.d.ts +31 -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/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/docs/cli.md +3 -3
- package/docs/index.md +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 90b656c: `drizzleDataSource` now takes `(db, schema)` and derives snake_case columns from your schema, so it composes with `ablo migrate` with no parallel Drizzle table. Update calls from `drizzleDataSource(db, tables)` → `drizzleDataSource(db, schema)`. Also adds the `snakeToCamel` export and provisions the adapter's `ablo_outbox` / `ablo_idempotency` tables via `ablo migrate`.
|
|
8
|
+
|
|
3
9
|
## 0.9.0
|
|
4
10
|
|
|
5
11
|
A single options object for every model verb, and a disposable `claim` handle.
|
package/README.md
CHANGED
|
@@ -384,7 +384,7 @@ contract; there are no retry or timeout knobs to tune.
|
|
|
384
384
|
|
|
385
385
|
## Production Reference
|
|
386
386
|
|
|
387
|
-
- [Identity & Sync Groups](./docs/identity.md) —
|
|
387
|
+
- [Identity & Sync Groups](./docs/identity.md) — use your own authentication; tell Ablo who's connecting and how org / team / user map to sync-group scope.
|
|
388
388
|
- [Schema Contract](./docs/schema-contract.md) — one schema becomes typed model clients, React reads, agent writes, Data Source shape, and schema push.
|
|
389
389
|
- [Guarantees](./docs/guarantees.md) — confirmed writes, stale-write protection, claim coordination, and agent lifecycle.
|
|
390
390
|
- [Integration Guide](./docs/integration-guide.md) — pick the backing mode and integrate React, Data Source, multiplayer, and agents.
|
|
@@ -22,6 +22,7 @@ import { Model } from './Model.js';
|
|
|
22
22
|
import { ModelScope } from './ObjectPool.js';
|
|
23
23
|
import type { Schema } from './schema/schema.js';
|
|
24
24
|
import { type ReaderActions } from './mutators/readerActions.js';
|
|
25
|
+
import type { LocalMutation } from './react/context.js';
|
|
25
26
|
import type { AuthCredentialSource } from './auth/credentialSource.js';
|
|
26
27
|
/** Constructor type for Model subclasses (accepts abstract classes) */
|
|
27
28
|
export type ModelConstructor<T extends Model> = abstract new (...args: never[]) => T;
|
|
@@ -415,6 +416,15 @@ export declare class BaseSyncedStore<TCollaboration extends EventMap<TCollaborat
|
|
|
415
416
|
* lookup contract; resolves immediately if nothing is in flight.
|
|
416
417
|
*/
|
|
417
418
|
waitForConfirmation(modelName: string, modelId: string): Promise<void>;
|
|
419
|
+
/**
|
|
420
|
+
* Observe the LOCAL mutation stream for undo recording (see
|
|
421
|
+
* {@link import('./react/context.js').LocalMutation}). Taps the
|
|
422
|
+
* TransactionQueue's `transaction:created` event — fired once per local
|
|
423
|
+
* create/update/delete/archive with `previousData` already captured.
|
|
424
|
+
* Remote/collaborator deltas apply via `applyDeltaBatchToPool` and never
|
|
425
|
+
* emit here, so undo is naturally local-only (you can't undo a teammate).
|
|
426
|
+
*/
|
|
427
|
+
subscribeLocalMutations(handler: (mutation: LocalMutation) => void): () => void;
|
|
418
428
|
/**
|
|
419
429
|
* Execute a bootstrap function with timeout protection and automatic retry.
|
|
420
430
|
* Prevents the common issue where bootstrap hangs on startup.
|
package/dist/BaseSyncedStore.js
CHANGED
|
@@ -412,6 +412,28 @@ export class BaseSyncedStore {
|
|
|
412
412
|
waitForConfirmation(modelName, modelId) {
|
|
413
413
|
return this.syncClient.waitForConfirmation(modelName, modelId);
|
|
414
414
|
}
|
|
415
|
+
/**
|
|
416
|
+
* Observe the LOCAL mutation stream for undo recording (see
|
|
417
|
+
* {@link import('./react/context.js').LocalMutation}). Taps the
|
|
418
|
+
* TransactionQueue's `transaction:created` event — fired once per local
|
|
419
|
+
* create/update/delete/archive with `previousData` already captured.
|
|
420
|
+
* Remote/collaborator deltas apply via `applyDeltaBatchToPool` and never
|
|
421
|
+
* emit here, so undo is naturally local-only (you can't undo a teammate).
|
|
422
|
+
*/
|
|
423
|
+
subscribeLocalMutations(handler) {
|
|
424
|
+
return this.syncClient.subscribe('transaction:created', (data) => {
|
|
425
|
+
const tx = data;
|
|
426
|
+
if (!tx || !tx.type || !tx.modelName || !tx.modelId)
|
|
427
|
+
return;
|
|
428
|
+
handler({
|
|
429
|
+
type: tx.type,
|
|
430
|
+
modelName: tx.modelName,
|
|
431
|
+
modelId: tx.modelId,
|
|
432
|
+
data: tx.data ?? null,
|
|
433
|
+
previousData: tx.previousData ?? null,
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
}
|
|
415
437
|
// ── Bootstrap + Retry ────────────────────────────────────────────────────
|
|
416
438
|
/**
|
|
417
439
|
* Execute a bootstrap function with timeout protection and automatic retry.
|
package/dist/client/Ablo.d.ts
CHANGED
|
@@ -686,8 +686,8 @@ export type Ablo<S extends SchemaRecord> = {
|
|
|
686
686
|
* live `ek_`/`rk_` the WebSocket and HTTP transports currently carry (kept
|
|
687
687
|
* fresh by the `getToken` refresh loop), falling back to a configured API
|
|
688
688
|
* key. Returns `null` when no credential is set yet. Use it to authenticate
|
|
689
|
-
* a side-band request to the same
|
|
690
|
-
*
|
|
689
|
+
* a side-band request to the same server with the very token this client
|
|
690
|
+
* already holds — no extra mint round-trip.
|
|
691
691
|
*/
|
|
692
692
|
getAuthToken(): Promise<string | null>;
|
|
693
693
|
/**
|
|
@@ -240,8 +240,8 @@ export interface AbloApi {
|
|
|
240
240
|
* Resolve the active bearer credential this client authenticates with — the
|
|
241
241
|
* same token its own requests carry in `Authorization`. Returns `null` when
|
|
242
242
|
* no credential is configured. Async because the API key may be supplied as
|
|
243
|
-
* an async setter. Use it to authenticate side-band
|
|
244
|
-
*
|
|
243
|
+
* an async setter. Use it to authenticate a side-band request to the same
|
|
244
|
+
* server with the credential this client already holds — no re-mint.
|
|
245
245
|
*/
|
|
246
246
|
getAuthToken(): Promise<string | null>;
|
|
247
247
|
}
|
|
@@ -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)`.
|
|
@@ -72,7 +89,52 @@ export declare class UndoScope<S extends Schema> {
|
|
|
72
89
|
* Serializing the whole scope closes both holes with one mechanism.
|
|
73
90
|
*/
|
|
74
91
|
private tail;
|
|
92
|
+
/** Predicate selecting which models this surface records (see options). */
|
|
93
|
+
private readonly tracksModel?;
|
|
94
|
+
/** registered-name / alias → schema key, built once from the schema. */
|
|
95
|
+
private readonly schemaKeyByAlias;
|
|
96
|
+
/** Unsubscribe from the local-mutation stream. */
|
|
97
|
+
private readonly unsubscribe;
|
|
98
|
+
/**
|
|
99
|
+
* True while `undo()`/`redo()` replays ops. Replays write through the same
|
|
100
|
+
* commit path, so they re-emit on the local-mutation stream; this flag tells
|
|
101
|
+
* our own listener to ignore them (no echo) — the engine equivalent of Yjs's
|
|
102
|
+
* `trackedOrigins` exclusion / Liveblocks pausing history during undo.
|
|
103
|
+
*/
|
|
104
|
+
private replaying;
|
|
105
|
+
/** Ops collected during the current tick, flushed as ONE entry. */
|
|
106
|
+
private batch;
|
|
107
|
+
private flushScheduled;
|
|
108
|
+
/**
|
|
109
|
+
* Open grouping session (Liveblocks `history.pause()` / Yjs `stopCapturing`
|
|
110
|
+
* analogue). While set, stream ops accumulate here ACROSS ticks instead of
|
|
111
|
+
* flushing per-tick, so a multi-tick action (a drag, a whole streaming AI
|
|
112
|
+
* response) collapses into ONE Cmd+Z. `endGroup()` flushes it.
|
|
113
|
+
*/
|
|
114
|
+
private group;
|
|
75
115
|
constructor(schema: S, store: SyncStoreContract, organizationId: string, options?: UndoScopeOptions);
|
|
116
|
+
/**
|
|
117
|
+
* Open a grouping session: every stream-recorded op until {@link endGroup}
|
|
118
|
+
* collapses into a single undo entry. Mirrors Liveblocks `history.pause()` —
|
|
119
|
+
* call on gesture start (pointerdown) or AI-response start. Idempotent-ish:
|
|
120
|
+
* a second call closes the previous group first.
|
|
121
|
+
*/
|
|
122
|
+
beginGroup(label?: string): void;
|
|
123
|
+
/** Close the grouping session and record the accumulated ops as one entry. */
|
|
124
|
+
endGroup(label?: string): void;
|
|
125
|
+
/** Resolve a stream mutation's registered name to its schema key, or null. */
|
|
126
|
+
private resolveSchemaKey;
|
|
127
|
+
/**
|
|
128
|
+
* Stream listener — the sole place entries are born. Skips replay echoes
|
|
129
|
+
* and out-of-scope models, derives the forward+inverse op from the
|
|
130
|
+
* mutation's `data`/`previousData`, and defers the stack push to a
|
|
131
|
+
* per-tick flush so a burst of writes (e.g. align 5 layers) becomes ONE
|
|
132
|
+
* undo step — riding the same tick boundary the TransactionQueue batches on.
|
|
133
|
+
*/
|
|
134
|
+
private onLocalMutation;
|
|
135
|
+
private scheduleFlush;
|
|
136
|
+
/** Coalesce the tick's collected ops into one entry and record it. */
|
|
137
|
+
private flushBatch;
|
|
76
138
|
/**
|
|
77
139
|
* Run `work` after every previously-enqueued scope operation has settled,
|
|
78
140
|
* in invocation order. The internal `tail` always resolves (failures are
|
|
@@ -82,20 +144,24 @@ export declare class UndoScope<S extends Schema> {
|
|
|
82
144
|
private enqueue;
|
|
83
145
|
/**
|
|
84
146
|
* Run a recording mutator exclusively on the scope's serialization chain.
|
|
85
|
-
*
|
|
86
|
-
* atomic relative to
|
|
147
|
+
* Used by the legacy manual-record path (`useMutators` + `RecordingTransaction`)
|
|
148
|
+
* so the snapshot → write → `record()` sequence is atomic relative to undo/
|
|
149
|
+
* redo. The stream-recording path doesn't need this (it derives entries from
|
|
150
|
+
* already-committed mutations); kept until all surfaces migrate off manual.
|
|
87
151
|
*/
|
|
88
152
|
runRecorded<T>(work: () => Promise<T>): Promise<T>;
|
|
89
153
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
154
|
+
* Record one entry onto the undo stack. Clears the redo stack. Fed by
|
|
155
|
+
* {@link flushBatch}/{@link endGroup} from the local-mutation stream, and
|
|
156
|
+
* still called directly by the legacy manual-record consumers
|
|
157
|
+
* (`useMutators`, the AI mutation pipeline) until they migrate. Entries are
|
|
158
|
+
* built internally (trusted), so the schema check is DEV-ONLY: it catches
|
|
159
|
+
* recorder bugs in dev/test (rejecting a malformed op at ingestion, with its
|
|
160
|
+
* path, instead of letting it crash later inside `applyOps`) without paying a
|
|
161
|
+
* Zod parse on every user action in production. The real validation boundary
|
|
162
|
+
* is `parseUndoEntry`, applied when entries are deserialized from persistence
|
|
163
|
+
* (untrusted input). Best practice: validate at trust boundaries, type-check
|
|
164
|
+
* internal calls.
|
|
99
165
|
*/
|
|
100
166
|
record(entry: UndoEntry): void;
|
|
101
167
|
/**
|
|
@@ -134,6 +200,12 @@ export declare class UndoScope<S extends Schema> {
|
|
|
134
200
|
undo: number;
|
|
135
201
|
redo: number;
|
|
136
202
|
};
|
|
203
|
+
/**
|
|
204
|
+
* Detach from the local-mutation stream and drop listeners. Scopes are
|
|
205
|
+
* cached for the store's lifetime by `UndoManager`, so this is mainly for
|
|
206
|
+
* tests and explicit teardown.
|
|
207
|
+
*/
|
|
208
|
+
dispose(): void;
|
|
137
209
|
}
|
|
138
210
|
/**
|
|
139
211
|
* 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
|
|
@@ -57,12 +60,148 @@ export class UndoScope {
|
|
|
57
60
|
* Serializing the whole scope closes both holes with one mechanism.
|
|
58
61
|
*/
|
|
59
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;
|
|
60
86
|
constructor(schema, store, organizationId, options = {}) {
|
|
61
87
|
this.schema = schema;
|
|
62
88
|
this.store = store;
|
|
63
89
|
this.organizationId = organizationId;
|
|
64
90
|
this.maxHistory = options.maxHistory ?? 100;
|
|
65
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 });
|
|
66
205
|
}
|
|
67
206
|
/**
|
|
68
207
|
* Run `work` after every previously-enqueued scope operation has settled,
|
|
@@ -77,22 +216,26 @@ export class UndoScope {
|
|
|
77
216
|
}
|
|
78
217
|
/**
|
|
79
218
|
* Run a recording mutator exclusively on the scope's serialization chain.
|
|
80
|
-
*
|
|
81
|
-
* atomic relative to
|
|
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.
|
|
82
223
|
*/
|
|
83
224
|
runRecorded(work) {
|
|
84
225
|
return this.enqueue(work);
|
|
85
226
|
}
|
|
86
227
|
/**
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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.
|
|
96
239
|
*/
|
|
97
240
|
record(entry) {
|
|
98
241
|
if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
|
|
@@ -153,7 +296,15 @@ export class UndoScope {
|
|
|
153
296
|
return;
|
|
154
297
|
const tx = createTransaction(this.schema, this.store, this.organizationId);
|
|
155
298
|
const ops = resolveOps(entry.inverses, entry.forwards, this.store, this.conflictPolicy);
|
|
156
|
-
|
|
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
|
+
}
|
|
157
308
|
this.redoStack.push(entry);
|
|
158
309
|
if (this.redoStack.length > this.maxHistory)
|
|
159
310
|
this.redoStack.shift();
|
|
@@ -172,7 +323,13 @@ export class UndoScope {
|
|
|
172
323
|
return;
|
|
173
324
|
const tx = createTransaction(this.schema, this.store, this.organizationId);
|
|
174
325
|
const ops = resolveOps(entry.forwards, entry.inverses, this.store, this.conflictPolicy);
|
|
175
|
-
|
|
326
|
+
this.replaying = true;
|
|
327
|
+
try {
|
|
328
|
+
await applyOps(tx, ops);
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
this.replaying = false;
|
|
332
|
+
}
|
|
176
333
|
this.undoStack.push(entry);
|
|
177
334
|
if (this.undoStack.length > this.maxHistory)
|
|
178
335
|
this.undoStack.shift();
|
|
@@ -182,11 +339,70 @@ export class UndoScope {
|
|
|
182
339
|
clear() {
|
|
183
340
|
this.undoStack = [];
|
|
184
341
|
this.redoStack = [];
|
|
342
|
+
this.batch = [];
|
|
185
343
|
}
|
|
186
344
|
/** Introspection — for debug panels / e2e tests. */
|
|
187
345
|
size() {
|
|
188
346
|
return { undo: this.undoStack.length, redo: this.redoStack.length };
|
|
189
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
|
+
}
|
|
190
406
|
}
|
|
191
407
|
// ── Manager ────────────────────────────────────────────────────────────────
|
|
192
408
|
/**
|
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/schema/ddl.d.ts
CHANGED
|
@@ -54,6 +54,14 @@ export interface MigrationPlan {
|
|
|
54
54
|
/** Per-app schema name for an app (organization) id. */
|
|
55
55
|
export declare function appSchemaName(organizationId: string): string;
|
|
56
56
|
export declare function camelToSnake(identifier: string): string;
|
|
57
|
+
/**
|
|
58
|
+
* Pure snake_case → camelCase — the inverse of {@link camelToSnake}, matching
|
|
59
|
+
* `postgres.toCamel` semantics. Read-side translation: a column read back from a
|
|
60
|
+
* BYO database (e.g. via `drizzleDataSource`) maps to the same JS field the SDK
|
|
61
|
+
* wrote, so `camelToSnake('operatorId') === 'operator_id'` and
|
|
62
|
+
* `snakeToCamel('operator_id') === 'operatorId'` round-trip.
|
|
63
|
+
*/
|
|
64
|
+
export declare function snakeToCamel(identifier: string): string;
|
|
57
65
|
/** Quote an identifier (defense-in-depth; inputs are already slug/snake). */
|
|
58
66
|
export declare function q(identifier: string): string;
|
|
59
67
|
export declare function sqlType(fieldType: ModelJSON['fields'][string]['type']): string;
|
package/dist/schema/ddl.js
CHANGED
|
@@ -31,6 +31,16 @@ export function appSchemaName(organizationId) {
|
|
|
31
31
|
export function camelToSnake(identifier) {
|
|
32
32
|
return identifier.replace(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`);
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Pure snake_case → camelCase — the inverse of {@link camelToSnake}, matching
|
|
36
|
+
* `postgres.toCamel` semantics. Read-side translation: a column read back from a
|
|
37
|
+
* BYO database (e.g. via `drizzleDataSource`) maps to the same JS field the SDK
|
|
38
|
+
* wrote, so `camelToSnake('operatorId') === 'operator_id'` and
|
|
39
|
+
* `snakeToCamel('operator_id') === 'operatorId'` round-trip.
|
|
40
|
+
*/
|
|
41
|
+
export function snakeToCamel(identifier) {
|
|
42
|
+
return identifier.replace(/_+([a-z0-9])/g, (_, ch) => ch.toUpperCase());
|
|
43
|
+
}
|
|
34
44
|
/** Quote an identifier (defense-in-depth; inputs are already slug/snake). */
|
|
35
45
|
export function q(identifier) {
|
|
36
46
|
return `"${identifier.replace(/"/g, '""')}"`;
|
package/dist/schema/index.d.ts
CHANGED
|
@@ -32,7 +32,7 @@ export { mutable, readOnly, type SugarOptions } from './sugar.js';
|
|
|
32
32
|
export { defineSchema, composeIdentitySyncGroups, type Schema, type SchemaRecord, type InferModel, type InferCreate, type InferModelNames, type BaseModelFields, type InsertValue, type UpsertValue, type UpdateValue, type DeleteId, type DefineSchemaOptions, type Casing, type CasingConvention, type CasingFn, composeEntitySyncGroups, type IdentityRole, type IdentityContext, type IdentityRoleSource, type EntityRole, type EntityContext, type EntityRoleSource, type RoleSource, type RoleContext, type SyncGroup, identityRole, entityRole, extractIdentityIds, extractEntityIds, syncGroup, syncGroupSchema, identityRoleSchema, entityRoleSchema, roleSchema, roleSourceSchema, scopeSchema, grantsRefSchema, } from './schema.js';
|
|
33
33
|
export { serializeSchema, parseSchema, toSchemaJSON, fromSchemaJSON, schemaHash, type SchemaJSON, type ModelJSON, type RelationJSON, } from './serialize.js';
|
|
34
34
|
export { selectModels } from './select.js';
|
|
35
|
-
export { generateProvisionPlan, generateMigrationPlan, appSchemaName, camelToSnake, q, sqlType, type ProvisionPlan, type MigrationPlan, } from './ddl.js';
|
|
35
|
+
export { generateProvisionPlan, generateMigrationPlan, appSchemaName, camelToSnake, snakeToCamel, q, sqlType, type ProvisionPlan, type MigrationPlan, } from './ddl.js';
|
|
36
36
|
export { diffSchema, classifyMigration, classifyCast, isAutoApplicable, isBlockerResolved, unresolvedBlockers, type BackfillValue, type MigrationStep, type FieldChanges, type FieldColumnChange, type FieldTypeChange, type NullabilityChange, type EnumValuesChange, type IndexChange, type CastSafety, type FieldType, type RenameHints, type MigrationSignal, type MigrationClassification, type WarningCode, type BlockerCode, } from './diff.js';
|
|
37
37
|
export { generateTypes } from './generate.js';
|
|
38
38
|
export { query, defineQueries, type QueryDef, type QueryRecord, type Queries, type InferQueryInput, type InferQueryResult, } from './queries.js';
|
package/dist/schema/index.js
CHANGED
|
@@ -52,7 +52,7 @@ export { serializeSchema, parseSchema, toSchemaJSON, fromSchemaJSON, schemaHash,
|
|
|
52
52
|
// Schema projection — derive an app's subset from one canonical schema.
|
|
53
53
|
export { selectModels } from './select.js';
|
|
54
54
|
// Schema → Postgres DDL (pure; shared by the hosted server and the CLI)
|
|
55
|
-
export { generateProvisionPlan, generateMigrationPlan, appSchemaName, camelToSnake, q, sqlType, } from './ddl.js';
|
|
55
|
+
export { generateProvisionPlan, generateMigrationPlan, appSchemaName, camelToSnake, snakeToCamel, q, sqlType, } from './ddl.js';
|
|
56
56
|
// Schema diff + migration planning (pure; SQL emission lowered by ddl.ts)
|
|
57
57
|
export { diffSchema, classifyMigration, classifyCast, isAutoApplicable, isBlockerResolved, unresolvedBlockers, } from './diff.js';
|
|
58
58
|
// Schema → TypeScript type emission (the `generate` half; pure)
|