@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 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) — bring your own auth; tell Ablo who's connecting and how org / team / user map to sync-group scope.
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.
@@ -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.
@@ -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 sync-server (e.g. the S3 presign endpoint)
690
- * with the very token this client already holds — no extra mint round-trip.
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 requests to the same
244
- * sync-server (e.g. the S3 presign endpoint) without re-minting.
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
- * `useMutators` calls this so the snapshot write → `record()` sequence is
86
- * atomic relative to other invocations, undo, and redo.
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
- * Internal: record a mutator's inverses. Clears the redo stack.
91
- *
92
- * Entries here are produced internally by `RecordingTransaction` (trusted),
93
- * so the schema check is DEV-ONLY: it catches recorder bugs in dev/test
94
- * (rejecting a malformed op at ingestion, with its path, instead of letting
95
- * it crash later inside `applyOps`) without paying a Zod parse on every user
96
- * action in production. The real validation boundary is `parseUndoEntry`,
97
- * applied when entries are deserialized from persistence (untrusted input).
98
- * Best practice: validate at trust boundaries, type-check internal calls.
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
- * `useMutators` calls this so the snapshot write → `record()` sequence is
81
- * atomic relative to other invocations, undo, and redo.
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
- * Internal: record a mutator's inverses. Clears the redo stack.
88
- *
89
- * Entries here are produced internally by `RecordingTransaction` (trusted),
90
- * so the schema check is DEV-ONLY: it catches recorder bugs in dev/test
91
- * (rejecting a malformed op at ingestion, with its path, instead of letting
92
- * it crash later inside `applyOps`) without paying a Zod parse on every user
93
- * action in production. The real validation boundary is `parseUndoEntry`,
94
- * applied when entries are deserialized from persistence (untrusted input).
95
- * Best practice: validate at trust boundaries, type-check internal calls.
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
- await applyOps(tx, ops);
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
- await applyOps(tx, ops);
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
  /**
@@ -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;
@@ -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;
@@ -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, '""')}"`;
@@ -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';
@@ -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)