@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.
Files changed (165) hide show
  1. package/CHANGELOG.md +46 -1
  2. package/README.md +33 -28
  3. package/dist/BaseSyncedStore.d.ts +83 -0
  4. package/dist/BaseSyncedStore.js +194 -2
  5. package/dist/Model.d.ts +42 -0
  6. package/dist/Model.js +103 -44
  7. package/dist/agent/session.js +3 -3
  8. package/dist/ai-sdk/coordination-context.js +4 -0
  9. package/dist/ai-sdk/index.d.ts +56 -47
  10. package/dist/ai-sdk/index.js +56 -47
  11. package/dist/ai-sdk/intent-broadcast.d.ts +5 -0
  12. package/dist/ai-sdk/intent-broadcast.js +11 -4
  13. package/dist/ai-sdk/wrap.d.ts +14 -11
  14. package/dist/ai-sdk/wrap.js +11 -13
  15. package/dist/auth/credentialSource.d.ts +34 -0
  16. package/dist/auth/credentialSource.js +63 -0
  17. package/dist/auth/index.d.ts +2 -22
  18. package/dist/auth/index.js +4 -42
  19. package/dist/auth/schemas.d.ts +35 -0
  20. package/dist/auth/schemas.js +53 -0
  21. package/dist/client/Ablo.d.ts +160 -42
  22. package/dist/client/Ablo.js +145 -75
  23. package/dist/client/ApiClient.d.ts +20 -4
  24. package/dist/client/ApiClient.js +166 -28
  25. package/dist/client/auth.d.ts +14 -5
  26. package/dist/client/auth.js +60 -7
  27. package/dist/client/createInternalComponents.d.ts +2 -0
  28. package/dist/client/createInternalComponents.js +8 -1
  29. package/dist/client/createModelProxy.d.ts +130 -66
  30. package/dist/client/createModelProxy.js +152 -49
  31. package/dist/client/httpClient.d.ts +71 -0
  32. package/dist/client/httpClient.js +69 -0
  33. package/dist/client/identity.d.ts +2 -6
  34. package/dist/client/identity.js +49 -11
  35. package/dist/client/index.d.ts +1 -0
  36. package/dist/client/index.js +1 -0
  37. package/dist/client/registerDataSource.d.ts +3 -3
  38. package/dist/client/registerDataSource.js +11 -9
  39. package/dist/client/validateAbloOptions.js +1 -1
  40. package/dist/core/DatabaseManager.js +30 -2
  41. package/dist/core/openIDBWithTimeout.d.ts +36 -0
  42. package/dist/core/openIDBWithTimeout.js +88 -1
  43. package/dist/errorCodes.d.ts +70 -1
  44. package/dist/errorCodes.js +108 -9
  45. package/dist/errors.d.ts +2 -2
  46. package/dist/errors.js +72 -22
  47. package/dist/index.d.ts +17 -8
  48. package/dist/index.js +15 -6
  49. package/dist/keys/index.d.ts +16 -1
  50. package/dist/keys/index.js +26 -6
  51. package/dist/mutators/UndoManager.d.ts +158 -50
  52. package/dist/mutators/UndoManager.js +345 -22
  53. package/dist/mutators/inverseOp.d.ts +129 -0
  54. package/dist/mutators/inverseOp.js +74 -0
  55. package/dist/mutators/readerActions.d.ts +1 -1
  56. package/dist/mutators/undoApply.d.ts +42 -0
  57. package/dist/mutators/undoApply.js +143 -0
  58. package/dist/query/client.d.ts +10 -9
  59. package/dist/query/client.js +3 -6
  60. package/dist/react/AbloProvider.d.ts +23 -126
  61. package/dist/react/AbloProvider.js +62 -199
  62. package/dist/react/context.d.ts +31 -0
  63. package/dist/react/useAblo.d.ts +2 -2
  64. package/dist/react/useCurrentUserId.d.ts +1 -1
  65. package/dist/react/useCurrentUserId.js +1 -1
  66. package/dist/react/useMutators.js +19 -12
  67. package/dist/schema/ddl.d.ts +34 -3
  68. package/dist/schema/ddl.js +162 -4
  69. package/dist/schema/index.d.ts +5 -1
  70. package/dist/schema/index.js +13 -1
  71. package/dist/schema/model.d.ts +11 -0
  72. package/dist/schema/model.js +2 -0
  73. package/dist/schema/openapi.d.ts +28 -0
  74. package/dist/schema/openapi.js +118 -0
  75. package/dist/schema/plane.d.ts +23 -0
  76. package/dist/schema/plane.js +19 -0
  77. package/dist/schema/relation.d.ts +20 -0
  78. package/dist/schema/serialize.d.ts +4 -0
  79. package/dist/schema/serialize.js +4 -0
  80. package/dist/schema/sync-delta-row.d.ts +157 -0
  81. package/dist/schema/sync-delta-row.js +102 -0
  82. package/dist/schema/sync-delta-wire.d.ts +180 -0
  83. package/dist/schema/sync-delta-wire.js +102 -0
  84. package/dist/server/adapter.d.ts +156 -0
  85. package/dist/server/adapter.js +19 -0
  86. package/dist/server/commit.d.ts +82 -0
  87. package/dist/server/commit.js +1 -0
  88. package/dist/server/index.d.ts +14 -0
  89. package/dist/server/index.js +1 -0
  90. package/dist/server/next.d.ts +51 -0
  91. package/dist/server/next.js +47 -0
  92. package/dist/server/read-config.d.ts +60 -0
  93. package/dist/server/read-config.js +8 -0
  94. package/dist/server/storage-mode.d.ts +17 -0
  95. package/dist/server/storage-mode.js +12 -0
  96. package/dist/source/adapter.d.ts +65 -0
  97. package/dist/source/adapter.js +20 -0
  98. package/dist/source/adapters/drizzle.d.ts +43 -0
  99. package/dist/source/adapters/drizzle.js +185 -0
  100. package/dist/source/adapters/memory.d.ts +12 -0
  101. package/dist/source/adapters/memory.js +114 -0
  102. package/dist/source/adapters/prisma.d.ts +57 -0
  103. package/dist/source/adapters/prisma.js +176 -0
  104. package/dist/source/conformance.d.ts +32 -0
  105. package/dist/source/conformance.js +134 -0
  106. package/dist/source/contract.d.ts +144 -0
  107. package/dist/source/contract.js +99 -0
  108. package/dist/source/index.d.ts +62 -10
  109. package/dist/source/index.js +99 -0
  110. package/dist/source/migrations.d.ts +14 -0
  111. package/dist/source/migrations.js +39 -0
  112. package/dist/source/next.d.ts +33 -0
  113. package/dist/source/next.js +26 -0
  114. package/dist/sync/BootstrapHelper.d.ts +10 -0
  115. package/dist/sync/BootstrapHelper.js +10 -15
  116. package/dist/sync/ConnectionManager.d.ts +55 -1
  117. package/dist/sync/ConnectionManager.js +155 -16
  118. package/dist/sync/HydrationCoordinator.d.ts +93 -17
  119. package/dist/sync/HydrationCoordinator.js +238 -39
  120. package/dist/sync/NetworkProbe.d.ts +58 -24
  121. package/dist/sync/NetworkProbe.js +118 -42
  122. package/dist/sync/SyncWebSocket.d.ts +45 -70
  123. package/dist/sync/SyncWebSocket.js +70 -36
  124. package/dist/sync/createIntentStream.js +10 -1
  125. package/dist/types/streams.d.ts +9 -0
  126. package/dist/utils/mobx-setup.js +1 -0
  127. package/dist/webhooks/events.d.ts +38 -0
  128. package/dist/webhooks/events.js +40 -0
  129. package/dist/webhooks/index.d.ts +10 -0
  130. package/dist/webhooks/index.js +10 -0
  131. package/dist/wire/errorEnvelope.d.ts +34 -0
  132. package/dist/wire/errorEnvelope.js +86 -0
  133. package/dist/wire/frames.d.ts +119 -0
  134. package/dist/wire/frames.js +1 -0
  135. package/dist/wire/index.d.ts +24 -0
  136. package/dist/wire/index.js +21 -0
  137. package/dist/wire/listEnvelope.d.ts +45 -0
  138. package/dist/wire/listEnvelope.js +17 -0
  139. package/docs/api.md +47 -44
  140. package/docs/cli.md +44 -44
  141. package/docs/client-behavior.md +30 -30
  142. package/docs/coordination.md +33 -36
  143. package/docs/data-sources.md +35 -15
  144. package/docs/examples/agent-human.md +45 -43
  145. package/docs/examples/ai-sdk-tool.md +20 -16
  146. package/docs/examples/existing-python-backend.md +16 -12
  147. package/docs/examples/nextjs.md +14 -12
  148. package/docs/examples/scoped-agent.md +1 -1
  149. package/docs/examples/server-agent.md +24 -21
  150. package/docs/guarantees.md +15 -13
  151. package/docs/index.md +2 -2
  152. package/docs/integration-guide.md +30 -30
  153. package/docs/interaction-model.md +19 -23
  154. package/docs/mcp/claude-code.md +3 -3
  155. package/docs/mcp/cursor.md +1 -1
  156. package/docs/mcp/windsurf.md +2 -2
  157. package/docs/mcp.md +6 -6
  158. package/docs/quickstart.md +41 -31
  159. package/docs/react.md +13 -9
  160. package/docs/schema-contract.md +12 -10
  161. package/docs/the-loop.md +21 -0
  162. package/examples/data-source/README.md +4 -5
  163. package/examples/data-source/customer-server.ts +27 -25
  164. package/llms.txt +28 -5
  165. package/package.json +43 -3
@@ -1,11 +1,10 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useContext, useEffect, useMemo, useRef, useState, } from 'react';
4
- import { Ablo } from '../client/Ablo.js';
5
4
  import { createParticipantClaimId, parseParticipantTtlSeconds, resolveParticipantSyncGroups, } from '../sync/participants.js';
6
5
  import { SyncContext } from './context.js';
7
6
  import { AbloInternalContext } from './internalContext.js';
8
- import { AbloValidationError, AbloAuthenticationError } from '../errors.js';
7
+ import { AbloValidationError } from '../errors.js';
9
8
  import { useSyncStatus } from './useSyncStatus.js';
10
9
  import { DefaultFallback } from './DefaultFallback.js';
11
10
  // ── Implementation ───────────────────────────────────────────────────
@@ -32,95 +31,52 @@ function createErrorEmitter() {
32
31
  };
33
32
  }
34
33
  export function AbloProvider(props) {
35
- const { schema, url = 'wss://mesh.ablo.finance', userId, teamIds, apiKey, authToken, getToken, authEndpoint, preventUnsavedChanges, onSessionExpired, onError, observability, logger, mutationExecutor, mutationDispatcher, sessionErrorDetector, onlineStatus, configOverrides, syncGroups, scope, bootstrapBaseUrl, maxPoolSize, persistence, bootstrapMode, fallback = _jsx(DefaultFallback, {}), children, } = props;
36
- // Account scope is no longer accepted from props. The engine learns
37
- // it from auth (capability token) at bootstrap and we read it back
38
- // out of `_store.orgId` once `engine.ready()` resolves.
34
+ const { client, userId, preventUnsavedChanges, onSessionExpired, onError, fallback = _jsx(DefaultFallback, {}), children, } = props;
35
+ // The client IS the engine synchronous, never null. This provider is a
36
+ // REACTIVE binding over it (context + bootstrap gate + error/session
37
+ // forwarding); it does NOT construct, configure, or own the connection. The
38
+ // client owns auth, the credential lifecycle (first mint, refresh, and
39
+ // wake/online/focus re-mint — see `Ablo({ getToken })`), transport, and
40
+ // `dispose()`. The CONSUMER built the client, so the consumer owns teardown;
41
+ // the provider never disposes it.
42
+ const engine = client;
43
+ const schema = engine.schema;
44
+ // Account scope isn't a prop — read it from `_store.orgId` once `ready()`
45
+ // resolves the identity from the client's auth.
39
46
  const [resolvedAccountScope, setResolvedAccountScope] = useState(null);
40
47
  // ── Error emitter (provider-instance scoped) ─────────────────────
41
- //
42
- // Built once, reused for the lifetime of this provider. Survives
43
- // engine rotations so error listeners don't need to resubscribe.
44
48
  const errorEmitterRef = useRef(null);
45
49
  if (!errorEmitterRef.current) {
46
50
  errorEmitterRef.current = createErrorEmitter();
47
51
  }
48
52
  const errorEmitter = errorEmitterRef.current;
49
- // Stash `onError` in a ref so forwarding it doesn't trigger
50
- // engine rotation. The provider wraps it and calls via ref at fire
51
- // time, matching the `useEventCallback` idiom.
53
+ // Stash callbacks in refs so a new identity each render doesn't re-run the
54
+ // start effect (the `useEventCallback` idiom).
52
55
  const onErrorRef = useRef(onError);
53
56
  onErrorRef.current = onError;
54
57
  useEffect(() => {
55
58
  return errorEmitter.subscribe((err) => onErrorRef.current?.(err));
56
59
  }, [errorEmitter]);
57
- // Stash the token resolver in a ref so a new function identity each render
58
- // does not re-key (tear down) the engine. Read at fire time in the connect +
59
- // refresh paths below same `useEventCallback` idiom as `onError`. `getToken`
60
- // wins; otherwise `authEndpoint` is fetched for `{ token }`.
61
- const tokenFromEndpoint = authEndpoint
62
- ? async () => {
63
- const res = await fetch(authEndpoint, {
64
- method: 'POST',
65
- credentials: 'include',
66
- headers: { 'Content-Type': 'application/json' },
67
- });
68
- if (!res.ok)
69
- return null;
70
- const body = (await res.json());
71
- return body.token ?? null;
72
- }
73
- : undefined;
74
- const getTokenRef = useRef(getToken ?? tokenFromEndpoint);
75
- getTokenRef.current = getToken ?? tokenFromEndpoint;
76
- // ── Engine lifecycle keyed on (userId, url) ─────────────────────
60
+ const onSessionExpiredRef = useRef(onSessionExpired);
61
+ onSessionExpiredRef.current = onSessionExpired;
62
+ // Re-key the bootstrap gate when the client INSTANCE changes — a genuinely new
63
+ // engine is a fresh "first bootstrap". Stable for the common single-client app.
64
+ const clientGenRef = useRef({ client, gen: 0 });
65
+ if (clientGenRef.current.client !== client) {
66
+ clientGenRef.current = { client, gen: clientGenRef.current.gen + 1 };
67
+ }
68
+ const engineKey = String(clientGenRef.current.gen);
69
+ // ── Start + session-error wiring ─────────────────────────────────
77
70
  //
78
- // The engine rotates when either of these change. For everything
79
- // else (mutators, DI, callbacks) we stash in refs so mutations to
80
- // those props don't tear down the engine.
81
- const engineKey = JSON.stringify({
82
- userId: userId ?? null,
83
- url,
84
- bootstrapMode: bootstrapMode ?? null,
85
- });
86
- const [engineState, setEngineState] = useState({ key: engineKey, engine: null });
87
- // Keep a ref to the current engine key so the rotation effect can
88
- // detect late-arriving prop changes without causing React churn.
89
- const currentKeyRef = useRef(engineState.key);
90
- currentKeyRef.current = engineState.key;
71
+ // Two reactive jobs only:
72
+ // 1. Forward a SERVER session-rejection purge (wipe IndexedDB so the next
73
+ // login starts clean) onSessionExpired (the app redirects). The
74
+ // offline/transient-vs-terminal credential logic lives in the CLIENT now.
75
+ // 2. Drive `ready()` (idempotent) so bootstrap starts on mount, then read the
76
+ // resolved org scope for SyncContext.
77
+ // It does NOT dispose the client (consumer-owned) and does NOT touch auth.
91
78
  useEffect(() => {
92
- const abort = new AbortController();
93
- let isStale = false;
94
- setResolvedAccountScope(null);
95
- // Construct engine + multiplayer streams for this key.
96
- const engineOptions = {
97
- baseURL: url,
98
- schema,
99
- ...(userId ? { user: { id: userId, teamIds } } : {}),
100
- apiKey,
101
- ...(authToken ? { authToken } : {}),
102
- logger,
103
- observability,
104
- sessionErrorDetector,
105
- onlineStatus,
106
- mutationExecutor,
107
- mutationDispatcher,
108
- configOverrides,
109
- // Union raw strings with model-form `scope` resolved through the schema,
110
- // so `scope={{ decks: id }}` becomes `deck:<id>` via the model's `scope`.
111
- syncGroups: scope
112
- ? [...(syncGroups ?? []), ...resolveParticipantSyncGroups(scope, schema)]
113
- : syncGroups,
114
- bootstrapBaseUrl,
115
- maxPoolSize,
116
- persistence,
117
- ...(bootstrapMode ? { bootstrapMode } : {}),
118
- autoStart: false,
119
- };
120
- const engine = Ablo(engineOptions);
121
- setEngineState({ key: engineKey, engine });
122
- // Forward session-error events to the consumer. Purge first so
123
- // the IndexedDB is wiped before the app redirects to /signin.
79
+ let stale = false;
124
80
  const unsubscribeSession = engine.onSessionError(async (err) => {
125
81
  errorEmitter.emit(err);
126
82
  try {
@@ -128,130 +84,37 @@ export function AbloProvider(props) {
128
84
  }
129
85
  catch { }
130
86
  try {
131
- await onSessionExpired?.();
87
+ await onSessionExpiredRef.current?.();
132
88
  }
133
89
  catch (hookErr) {
134
90
  errorEmitter.emit(hookErr);
135
91
  }
136
92
  });
137
- // Drive initial bootstrap. Consumer code that wants to run logic
138
- // after the engine is ready calls `useAblo()` and wires its own
139
- // `useEffect` — the SDK no longer holds a registry of "post-
140
- // bootstrap hooks" because the indirection costs more than it
141
- // saves once `useAblo` exists.
142
- (async () => {
143
- try {
144
- // Resolve a fresh bearer token BEFORE `ready()` (which connects —
145
- // the engine is built with `autoStart: false`), so the first WS
146
- // upgrade + bootstrap carry it. No race: nothing has connected yet.
147
- const fetchToken = getTokenRef.current;
148
- if (fetchToken) {
149
- const token = await fetchToken();
150
- if (isStale || abort.signal.aborted)
151
- return;
152
- if (token) {
153
- engine.setAuthToken(token);
154
- }
155
- else {
156
- // A configured `getToken` that resolves to falsy means "no active
157
- // session" — the session-token resolver (e.g. `getSyncCapabilityToken`)
158
- // returns null when logged out, the session hasn't hydrated, or the
159
- // mint endpoint rejects. Its contract is "the provider then surfaces
160
- // the usual unauthenticated path rather than connecting with a bad token."
161
- //
162
- // We MUST short-circuit here rather than fall through to
163
- // `engine.ready()`. On apps/web's identity path (org + user.id are
164
- // both supplied) `resolveParticipantIdentity` takes the
165
- // trust-the-caller Branch 3, which does NOT validate the (absent)
166
- // token — so init would proceed into `store.initialize()` and open
167
- // IndexedDB *before* any auth-bearing bootstrap/WS call. If storage
168
- // is at all wedged the no-session condition then surfaces only as an
169
- // opaque `IndexedDB "ablo_databases" open did not resolve within
170
- // 10000ms` stall, never the real auth error. Surface `session_expired`
171
- // explicitly and let the app redirect to sign-in.
172
- //
173
- // Unlike the server-detected `onSessionError` handler above, we do
174
- // NOT purge: nothing connected or wrote this mount, so any cached
175
- // IndexedDB belongs to a prior session and is reconciled by the next
176
- // valid bootstrap — purging here would drop a still-valid offline
177
- // queue on a merely transient null token.
178
- const authErr = new AbloAuthenticationError('No session token available — getToken() resolved to null. The ' +
179
- 'session is missing or expired; sign in again.', { code: 'session_expired' });
180
- errorEmitter.emit(authErr);
181
- try {
182
- await onSessionExpired?.();
183
- }
184
- catch (hookErr) {
185
- errorEmitter.emit(hookErr);
186
- }
187
- return;
188
- }
189
- }
190
- await engine.ready();
191
- if (isStale || abort.signal.aborted)
192
- return;
193
- setResolvedAccountScope(engine._store.orgId ?? null);
194
- }
195
- catch (err) {
196
- if (isStale || abort.signal.aborted)
197
- return;
198
- errorEmitter.emit(err);
199
- }
200
- })();
93
+ engine
94
+ .ready()
95
+ .then(() => {
96
+ if (stale)
97
+ return;
98
+ setResolvedAccountScope(engine._store.orgId ?? null);
99
+ })
100
+ .catch((err) => {
101
+ if (stale)
102
+ return;
103
+ errorEmitter.emit(err);
104
+ });
201
105
  return () => {
202
- isStale = true;
203
- abort.abort();
106
+ stale = true;
204
107
  unsubscribeSession();
205
- void engine.dispose();
206
- // AbloClient is stateless-ish — participants manage their own
207
- // WebSocket connections via `participant.disconnect()`. No client
208
- // close is needed.
209
108
  };
210
- // We intentionally only re-run on engineKey. All other DI is
211
- // captured at first render; rotating the engine on every
212
- // `mutationExecutor` identity change would destroy the WebSocket.
213
- // eslint-disable-next-line react-hooks/exhaustive-deps
214
- }, [engineKey]);
215
- // ── Bearer-token refresh ─────────────────────────────────────────
216
- //
217
- // Short-lived tokens (the sync-server JWT defaults to 15m) must be rolled
218
- // before they expire or the next reconnect/HTTP call fails auth. Re-resolve
219
- // ahead of expiry and push via `engine.setAuthToken`, which swaps the token
220
- // on the live WebSocket + bootstrap header without tearing down the engine.
221
- // Only runs when a `getToken` resolver is wired (cookie deployments skip it).
222
- useEffect(() => {
223
- const engine = engineState.engine;
224
- if (!engine || !getTokenRef.current)
225
- return;
226
- // Comfortably inside the 15m JWT lifetime; a missed tick is recovered by
227
- // the next one well before expiry.
228
- const REFRESH_INTERVAL_MS = 10 * 60 * 1000;
229
- const id = setInterval(() => {
230
- void (async () => {
231
- try {
232
- const token = await getTokenRef.current?.();
233
- if (token)
234
- engine.setAuthToken(token);
235
- }
236
- catch {
237
- // Transient (offline / session check). The next tick retries; the
238
- // engine keeps using the still-valid current token until then.
239
- }
240
- })();
241
- }, REFRESH_INTERVAL_MS);
242
- return () => clearInterval(id);
243
- }, [engineState.engine]);
109
+ }, [engine, errorEmitter]);
244
110
  // ── beforeunload + preventUnsavedChanges ─────────────────────────
245
111
  useEffect(() => {
246
112
  if (typeof window === 'undefined')
247
113
  return;
248
114
  const handler = (event) => {
249
- const engine = engineState.engine;
250
- if (!engine)
251
- return;
252
- // Best-effort: dispose on unload. The async work may not
253
- // complete before the tab closes — that's fine for IDB, which
254
- // flushes pending writes transactionally.
115
+ // Best-effort IDB flush on TAB CLOSE — the client is going away with the
116
+ // page regardless. This is NOT an unmount teardown: the consumer owns the
117
+ // client's lifecycle and the provider never disposes it on unmount.
255
118
  void engine.dispose();
256
119
  if (preventUnsavedChanges && engine._store.hasUnsyncedChanges) {
257
120
  event.preventDefault();
@@ -260,30 +123,30 @@ export function AbloProvider(props) {
260
123
  };
261
124
  window.addEventListener('beforeunload', handler);
262
125
  return () => window.removeEventListener('beforeunload', handler);
263
- }, [engineState.engine, preventUnsavedChanges]);
126
+ }, [engine, preventUnsavedChanges]);
264
127
  // ── SyncContext value (for useQuery/useOne/useMutate hooks) ──────
128
+ //
129
+ // The engine is always present (it's the `client` prop), but its org scope is
130
+ // unknown until `ready()` resolves identity — so `syncValue` is null until
131
+ // then, which drives the initial fallback below.
265
132
  const syncValue = useMemo(() => {
266
- if (!engineState.engine)
267
- return null;
268
133
  const currentAccountScope = resolvedAccountScope ??
269
- engineState.engine._store.orgId;
134
+ engine._store.orgId;
270
135
  if (!currentAccountScope)
271
136
  return null;
272
137
  return {
273
- store: engineState.engine._store,
138
+ store: engine._store,
274
139
  organizationId: currentAccountScope,
275
140
  schema,
276
141
  };
277
- }, [engineState.engine, resolvedAccountScope, schema]);
142
+ }, [engine, resolvedAccountScope, schema]);
278
143
  // ── Internal context (currentUserId + error subscription) ────────
279
144
  const internalValue = useMemo(() => ({
280
145
  currentUserId: userId ?? null,
281
146
  subscribeError: errorEmitter.subscribe,
282
147
  emitError: errorEmitter.emit,
283
- // `engine` is null until bootstrap finishes; `useSync()` throws
284
- // on null so callers are forced to gate with <ClientSideSuspense>.
285
- engine: engineState.engine,
286
- }), [userId, errorEmitter, engineState.engine]);
148
+ engine: engine,
149
+ }), [userId, errorEmitter, engine]);
287
150
  // ── Render ───────────────────────────────────────────────────────
288
151
  //
289
152
  // Two-phase gate (see `BootstrapGate` below for the latch logic):
@@ -307,7 +170,7 @@ export function AbloProvider(props) {
307
170
  if (!syncValue) {
308
171
  return (_jsx(AbloInternalContext.Provider, { value: internalValue, children: initialFallback }));
309
172
  }
310
- return (_jsx(AbloInternalContext.Provider, { value: internalValue, children: _jsx(SyncContext.Provider, { value: syncValue, children: passthrough ? (children) : (_jsx(BootstrapGate, { fallback: fallback, children: children }, engineState.key)) }) }));
173
+ return (_jsx(AbloInternalContext.Provider, { value: internalValue, children: _jsx(SyncContext.Provider, { value: syncValue, children: passthrough ? (children) : (_jsx(BootstrapGate, { fallback: fallback, children: children }, engineKey)) }) }));
311
174
  }
312
175
  /**
313
176
  * Internal gate that renders `fallback` only during the very first
@@ -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;
@@ -45,11 +45,11 @@ export type UseAbloHydratedModelResult<T> = Omit<UseAbloModelResult<T>, 'data'>
45
45
  * // With Register augmentation (recommended):
46
46
  * const ablo = useAblo();
47
47
  * if (!ablo) return <Loading />;
48
- * const doc = await ablo.documents.retrieve(id); // async server read
48
+ * const doc = await ablo.documents.retrieve({ id }); // async server read
49
49
  *
50
50
  * // Reactive selector (sync local-graph snapshot):
51
51
  * const doc = useAblo((ablo) => ablo.documents.get(id)) ?? serverDoc;
52
- * const active = useAblo((ablo) => ablo.documents.claim.state(id));
52
+ * const active = useAblo((ablo) => ablo.documents.claim.state({ id }));
53
53
  *
54
54
  * // Without augmentation, pass the schema generic:
55
55
  * const ablo = useAblo<(typeof schema)['models']>();
@@ -13,7 +13,7 @@
13
13
  * const userId = useCurrentUserId();
14
14
  * const ablo = useAblo();
15
15
  * if (!userId) return null;
16
- * return <button onClick={() => ablo?.tasks.update(id, { assigneeId: userId })}>
16
+ * return <button onClick={() => ablo?.tasks.update({ id, data: { assigneeId: userId } })}>
17
17
  * Assign to me
18
18
  * </button>;
19
19
  * }
@@ -17,7 +17,7 @@ import { AbloValidationError } from '../errors.js';
17
17
  * const userId = useCurrentUserId();
18
18
  * const ablo = useAblo();
19
19
  * if (!userId) return null;
20
- * return <button onClick={() => ablo?.tasks.update(id, { assigneeId: userId })}>
20
+ * return <button onClick={() => ablo?.tasks.update({ id, data: { assigneeId: userId } })}>
21
21
  * Assign to me
22
22
  * </button>;
23
23
  * }
@@ -34,19 +34,26 @@ export function useMutators(schemaOrMutators, mutatorsOrOptions, maybeOptions) {
34
34
  invokers[mutatorName] = async (args) => {
35
35
  // Recording path: wrap the transaction so each write snapshots its
36
36
  // inverse. On success, push the captured entry to the scope.
37
+ //
38
+ // The whole snapshot → write → record sequence runs on the scope's
39
+ // serialization chain so concurrent invocations (the slides UI fires
40
+ // writes un-awaited) record in *invocation* order and never
41
+ // interleave their shared-model snapshots. See UndoScope.runRecorded.
37
42
  if (undoScope) {
38
- const recording = createRecordingTransaction(schema, store, organizationId);
39
- try {
40
- const result = await fn({ tx: recording.tx, args });
41
- const entry = recording.getEntry(label);
42
- if (entry)
43
- undoScope.record(entry);
44
- return result;
45
- }
46
- catch (err) {
47
- getContext().logger.error(`[useMutators] mutator "${label}" threw`, { error: err });
48
- throw err;
49
- }
43
+ return undoScope.runRecorded(async () => {
44
+ const recording = createRecordingTransaction(schema, store, organizationId);
45
+ try {
46
+ const result = await fn({ tx: recording.tx, args });
47
+ const entry = recording.getEntry(label);
48
+ if (entry)
49
+ undoScope.record(entry);
50
+ return result;
51
+ }
52
+ catch (err) {
53
+ getContext().logger.error(`[useMutators] mutator "${label}" threw`, { error: err });
54
+ throw err;
55
+ }
56
+ });
50
57
  }
51
58
  // Non-recording path — plain transaction, identical to pre-undo V1.
52
59
  const tx = createTransaction(schema, store, organizationId);
@@ -22,18 +22,46 @@ import type { MigrationStep, BackfillValue } from './diff.js';
22
22
  export interface ProvisionPlan {
23
23
  /** The Postgres schema the tables live in (`app_<id>` or `public`). */
24
24
  readonly appSchema: string;
25
- /** Ordered, idempotent DDL statements. Safe to run repeatedly. */
25
+ /** Ordered, idempotent DDL statements. Safe to run repeatedly. Executors run
26
+ * these together in ONE transaction. */
26
27
  readonly statements: readonly string[];
28
+ /** Post-commit, NON-transactional DDL (`VALIDATE CONSTRAINT`, `CREATE INDEX
29
+ * CONCURRENTLY`) — run AFTER {@link statements} commit, each outside any
30
+ * transaction, best-effort. Keeps the lock-heavy / scan-heavy work off the
31
+ * main transaction so adding a foreign key never freezes a large, live BYO
32
+ * table. Optional + back-compat: absent = nothing to run. */
33
+ readonly concurrent?: readonly string[];
34
+ }
35
+ export interface ProvisionOptions {
36
+ /**
37
+ * Emit `DEFERRABLE INITIALLY DEFERRED` FOREIGN KEY constraints for every
38
+ * `parent: true` belongsTo relation (true ownership edges only — see
39
+ * {@link foreignKeyStatements}). Off by default: the soft-reference model keeps
40
+ * out-of-order sync robust on Ablo-managed tables. Turn on for a customer's own
41
+ * (BYO / dedicated) database, where a clean, navigable relational schema is
42
+ * wanted and the DB starts empty (nothing for the constraint to fail against).
43
+ */
44
+ readonly foreignKeys?: boolean;
27
45
  }
28
46
  export interface MigrationPlan {
29
47
  /** The app Postgres schema the DDL targets (`app_<id>` or `public`). */
30
48
  readonly appSchema: string;
31
- /** Ordered DDL statements (expand → contract). */
49
+ /** Ordered DDL statements (expand → contract). Run in ONE transaction. */
32
50
  readonly statements: readonly string[];
51
+ /** Post-commit, non-transactional DDL — see {@link ProvisionPlan.concurrent}. */
52
+ readonly concurrent?: readonly string[];
33
53
  }
34
54
  /** Per-app schema name for an app (organization) id. */
35
55
  export declare function appSchemaName(organizationId: string): string;
36
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;
37
65
  /** Quote an identifier (defense-in-depth; inputs are already slug/snake). */
38
66
  export declare function q(identifier: string): string;
39
67
  export declare function sqlType(fieldType: ModelJSON['fields'][string]['type']): string;
@@ -46,7 +74,7 @@ export declare function sqlType(fieldType: ModelJSON['fields'][string]['type']):
46
74
  * itself is the isolation boundary). For `public` the `CREATE SCHEMA` is
47
75
  * skipped (it always exists).
48
76
  */
49
- export declare function generateProvisionPlan(schema: SchemaJSON, targetSchema: string): ProvisionPlan;
77
+ export declare function generateProvisionPlan(schema: SchemaJSON, targetSchema: string, opts?: ProvisionOptions): ProvisionPlan;
50
78
  /**
51
79
  * Lower an ordered migration step list to DDL. `next` is the schema being pushed
52
80
  * (the target column shapes are read from it), `prev` the active one (used to
@@ -59,4 +87,7 @@ export declare function generateMigrationPlan(steps: readonly MigrationStep[], o
59
87
  /** Constant seed values that let a required-field add / made-required step
60
88
  * set NOT NULL on a non-empty table. Keyed by (model, field). */
61
89
  readonly backfills?: readonly BackfillValue[];
90
+ /** Emit DEFERRABLE FK constraints for `parent: true` edges of newly-created
91
+ * models. Off by default — see {@link ProvisionOptions.foreignKeys}. */
92
+ readonly foreignKeys?: boolean;
62
93
  }): MigrationPlan;