@abloatai/ablo 0.7.0 → 0.9.0

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 (181) hide show
  1. package/CHANGELOG.md +72 -1
  2. package/README.md +80 -66
  3. package/dist/BaseSyncedStore.d.ts +73 -0
  4. package/dist/BaseSyncedStore.js +179 -5
  5. package/dist/Model.d.ts +42 -0
  6. package/dist/Model.js +103 -44
  7. package/dist/SyncEngineContext.d.ts +2 -1
  8. package/dist/SyncEngineContext.js +5 -3
  9. package/dist/agent/session.js +6 -5
  10. package/dist/ai-sdk/coordination-context.js +4 -0
  11. package/dist/ai-sdk/index.d.ts +56 -47
  12. package/dist/ai-sdk/index.js +56 -47
  13. package/dist/ai-sdk/intent-broadcast.d.ts +5 -0
  14. package/dist/ai-sdk/intent-broadcast.js +11 -4
  15. package/dist/ai-sdk/wrap.d.ts +14 -11
  16. package/dist/ai-sdk/wrap.js +11 -13
  17. package/dist/auth/credentialSource.d.ts +34 -0
  18. package/dist/auth/credentialSource.js +63 -0
  19. package/dist/auth/index.d.ts +2 -22
  20. package/dist/auth/index.js +26 -36
  21. package/dist/auth/schemas.d.ts +35 -0
  22. package/dist/auth/schemas.js +53 -0
  23. package/dist/client/Ablo.d.ts +259 -33
  24. package/dist/client/Ablo.js +276 -73
  25. package/dist/client/ApiClient.d.ts +52 -4
  26. package/dist/client/ApiClient.js +236 -66
  27. package/dist/client/auth.d.ts +21 -2
  28. package/dist/client/auth.js +77 -5
  29. package/dist/client/createInternalComponents.d.ts +2 -0
  30. package/dist/client/createInternalComponents.js +8 -1
  31. package/dist/client/createModelProxy.d.ts +187 -79
  32. package/dist/client/createModelProxy.js +203 -68
  33. package/dist/client/httpClient.d.ts +71 -0
  34. package/dist/client/httpClient.js +69 -0
  35. package/dist/client/identity.d.ts +2 -6
  36. package/dist/client/identity.js +63 -11
  37. package/dist/client/index.d.ts +1 -0
  38. package/dist/client/index.js +1 -0
  39. package/dist/client/registerDataSource.d.ts +19 -0
  40. package/dist/client/registerDataSource.js +59 -0
  41. package/dist/client/validateAbloOptions.d.ts +2 -1
  42. package/dist/client/validateAbloOptions.js +8 -7
  43. package/dist/core/DatabaseManager.js +30 -2
  44. package/dist/core/openIDBWithTimeout.d.ts +36 -0
  45. package/dist/core/openIDBWithTimeout.js +88 -1
  46. package/dist/errorCodes.d.ts +92 -1
  47. package/dist/errorCodes.js +139 -7
  48. package/dist/errors.d.ts +54 -3
  49. package/dist/errors.js +192 -44
  50. package/dist/index.d.ts +23 -10
  51. package/dist/index.js +21 -8
  52. package/dist/keys/index.d.ts +76 -0
  53. package/dist/keys/index.js +171 -0
  54. package/dist/mutators/UndoManager.d.ts +86 -50
  55. package/dist/mutators/UndoManager.js +129 -22
  56. package/dist/mutators/inverseOp.d.ts +129 -0
  57. package/dist/mutators/inverseOp.js +74 -0
  58. package/dist/mutators/readerActions.d.ts +1 -1
  59. package/dist/mutators/undoApply.d.ts +42 -0
  60. package/dist/mutators/undoApply.js +143 -0
  61. package/dist/query/client.d.ts +10 -9
  62. package/dist/query/client.js +22 -14
  63. package/dist/react/AbloProvider.d.ts +23 -101
  64. package/dist/react/AbloProvider.js +61 -103
  65. package/dist/react/ClientSideSuspense.d.ts +1 -1
  66. package/dist/react/DefaultFallback.d.ts +1 -1
  67. package/dist/react/SyncGroupProvider.d.ts +1 -1
  68. package/dist/react/index.d.ts +3 -2
  69. package/dist/react/index.js +3 -2
  70. package/dist/react/useAblo.d.ts +4 -4
  71. package/dist/react/useAblo.js +10 -5
  72. package/dist/react/useCurrentUserId.d.ts +1 -1
  73. package/dist/react/useCurrentUserId.js +1 -1
  74. package/dist/react/useMutators.js +19 -12
  75. package/dist/react/useReactive.js +16 -3
  76. package/dist/schema/ddl.d.ts +26 -3
  77. package/dist/schema/ddl.js +152 -4
  78. package/dist/schema/index.d.ts +4 -0
  79. package/dist/schema/index.js +12 -0
  80. package/dist/schema/model.d.ts +11 -0
  81. package/dist/schema/model.js +2 -0
  82. package/dist/schema/openapi.d.ts +28 -0
  83. package/dist/schema/openapi.js +118 -0
  84. package/dist/schema/plane.d.ts +23 -0
  85. package/dist/schema/plane.js +19 -0
  86. package/dist/schema/relation.d.ts +20 -0
  87. package/dist/schema/serialize.d.ts +7 -3
  88. package/dist/schema/serialize.js +6 -2
  89. package/dist/schema/sync-delta-row.d.ts +157 -0
  90. package/dist/schema/sync-delta-row.js +102 -0
  91. package/dist/schema/sync-delta-wire.d.ts +180 -0
  92. package/dist/schema/sync-delta-wire.js +102 -0
  93. package/dist/server/adapter.d.ts +156 -0
  94. package/dist/server/adapter.js +19 -0
  95. package/dist/server/commit.d.ts +82 -0
  96. package/dist/server/commit.js +1 -0
  97. package/dist/server/index.d.ts +14 -0
  98. package/dist/server/index.js +1 -0
  99. package/dist/server/next.d.ts +51 -0
  100. package/dist/server/next.js +47 -0
  101. package/dist/server/read-config.d.ts +60 -0
  102. package/dist/server/read-config.js +8 -0
  103. package/dist/server/storage-mode.d.ts +17 -0
  104. package/dist/server/storage-mode.js +12 -0
  105. package/dist/source/adapter.d.ts +59 -0
  106. package/dist/source/adapter.js +19 -0
  107. package/dist/source/adapters/drizzle.d.ts +34 -0
  108. package/dist/source/adapters/drizzle.js +147 -0
  109. package/dist/source/adapters/memory.d.ts +12 -0
  110. package/dist/source/adapters/memory.js +114 -0
  111. package/dist/source/adapters/prisma.d.ts +57 -0
  112. package/dist/source/adapters/prisma.js +199 -0
  113. package/dist/source/conformance.d.ts +32 -0
  114. package/dist/source/conformance.js +134 -0
  115. package/dist/source/contract.d.ts +143 -0
  116. package/dist/source/contract.js +98 -0
  117. package/dist/source/index.d.ts +61 -10
  118. package/dist/source/index.js +98 -0
  119. package/dist/source/next.d.ts +33 -0
  120. package/dist/source/next.js +26 -0
  121. package/dist/sync/BootstrapHelper.d.ts +10 -0
  122. package/dist/sync/BootstrapHelper.js +56 -42
  123. package/dist/sync/ConnectionManager.d.ts +57 -1
  124. package/dist/sync/ConnectionManager.js +186 -11
  125. package/dist/sync/HydrationCoordinator.d.ts +93 -17
  126. package/dist/sync/HydrationCoordinator.js +241 -41
  127. package/dist/sync/NetworkProbe.d.ts +60 -18
  128. package/dist/sync/NetworkProbe.js +121 -23
  129. package/dist/sync/SyncWebSocket.d.ts +45 -70
  130. package/dist/sync/SyncWebSocket.js +113 -89
  131. package/dist/sync/createIntentStream.js +10 -1
  132. package/dist/sync/participants.js +5 -2
  133. package/dist/transactions/TransactionQueue.js +13 -1
  134. package/dist/types/streams.d.ts +9 -0
  135. package/dist/utils/mobx-setup.js +1 -0
  136. package/dist/webhooks/events.d.ts +38 -0
  137. package/dist/webhooks/events.js +40 -0
  138. package/dist/webhooks/index.d.ts +10 -0
  139. package/dist/webhooks/index.js +10 -0
  140. package/dist/wire/errorEnvelope.d.ts +34 -0
  141. package/dist/wire/errorEnvelope.js +86 -0
  142. package/dist/wire/frames.d.ts +119 -0
  143. package/dist/wire/frames.js +1 -0
  144. package/dist/wire/index.d.ts +24 -0
  145. package/dist/wire/index.js +21 -0
  146. package/dist/wire/listEnvelope.d.ts +45 -0
  147. package/dist/wire/listEnvelope.js +17 -0
  148. package/docs/api-keys.md +5 -5
  149. package/docs/api.md +125 -65
  150. package/docs/audit.md +16 -9
  151. package/docs/cli.md +57 -47
  152. package/docs/client-behavior.md +54 -40
  153. package/docs/coordination.md +66 -80
  154. package/docs/data-sources.md +56 -34
  155. package/docs/examples/agent-human.md +74 -28
  156. package/docs/examples/ai-sdk-tool.md +29 -22
  157. package/docs/examples/existing-python-backend.md +41 -26
  158. package/docs/examples/nextjs.md +32 -17
  159. package/docs/examples/scoped-agent.md +43 -28
  160. package/docs/examples/server-agent.md +40 -15
  161. package/docs/guarantees.md +38 -27
  162. package/docs/identity.md +65 -59
  163. package/docs/index.md +30 -19
  164. package/docs/integration-guide.md +78 -78
  165. package/docs/interaction-model.md +43 -35
  166. package/docs/mcp/claude-code.md +11 -19
  167. package/docs/mcp/cursor.md +7 -25
  168. package/docs/mcp/windsurf.md +7 -20
  169. package/docs/mcp.md +103 -26
  170. package/docs/quickstart.md +63 -61
  171. package/docs/react.md +24 -16
  172. package/docs/roadmap.md +13 -13
  173. package/docs/schema-contract.md +111 -0
  174. package/docs/the-loop.md +21 -0
  175. package/examples/README.md +8 -4
  176. package/examples/data-source/README.md +10 -7
  177. package/examples/data-source/customer-server.ts +27 -25
  178. package/examples/data-source/run.ts +4 -3
  179. package/examples/quickstart.ts +1 -1
  180. package/llms.txt +55 -21
  181. package/package.json +48 -3
@@ -1,7 +1,6 @@
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';
@@ -32,75 +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, 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
- // ── 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 ─────────────────────────────────
58
70
  //
59
- // The engine rotates when either of these change. For everything
60
- // else (mutators, DI, callbacks) we stash in refs so mutations to
61
- // those props don't tear down the engine.
62
- const engineKey = JSON.stringify({
63
- userId: userId ?? null,
64
- url,
65
- bootstrapMode: bootstrapMode ?? null,
66
- });
67
- const [engineState, setEngineState] = useState({ key: engineKey, engine: null });
68
- // Keep a ref to the current engine key so the rotation effect can
69
- // detect late-arriving prop changes without causing React churn.
70
- const currentKeyRef = useRef(engineState.key);
71
- 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.
72
78
  useEffect(() => {
73
- const abort = new AbortController();
74
- let isStale = false;
75
- setResolvedAccountScope(null);
76
- // Construct engine + multiplayer streams for this key.
77
- const engineOptions = {
78
- baseURL: url,
79
- schema,
80
- ...(userId ? { user: { id: userId, teamIds } } : {}),
81
- apiKey,
82
- logger,
83
- observability,
84
- sessionErrorDetector,
85
- onlineStatus,
86
- mutationExecutor,
87
- mutationDispatcher,
88
- configOverrides,
89
- // Union raw strings with model-form `scope` resolved through the schema,
90
- // so `scope={{ decks: id }}` becomes `deck:<id>` via the model's `scope`.
91
- syncGroups: scope
92
- ? [...(syncGroups ?? []), ...resolveParticipantSyncGroups(scope, schema)]
93
- : syncGroups,
94
- bootstrapBaseUrl,
95
- maxPoolSize,
96
- persistence,
97
- ...(bootstrapMode ? { bootstrapMode } : {}),
98
- autoStart: false,
99
- };
100
- const engine = Ablo(engineOptions);
101
- setEngineState({ key: engineKey, engine });
102
- // Forward session-error events to the consumer. Purge first so
103
- // the IndexedDB is wiped before the app redirects to /signin.
79
+ let stale = false;
104
80
  const unsubscribeSession = engine.onSessionError(async (err) => {
105
81
  errorEmitter.emit(err);
106
82
  try {
@@ -108,55 +84,37 @@ export function AbloProvider(props) {
108
84
  }
109
85
  catch { }
110
86
  try {
111
- await onSessionExpired?.();
87
+ await onSessionExpiredRef.current?.();
112
88
  }
113
89
  catch (hookErr) {
114
90
  errorEmitter.emit(hookErr);
115
91
  }
116
92
  });
117
- // Drive initial bootstrap. Consumer code that wants to run logic
118
- // after the engine is ready calls `useAblo()` and wires its own
119
- // `useEffect` — the SDK no longer holds a registry of "post-
120
- // bootstrap hooks" because the indirection costs more than it
121
- // saves once `useAblo` exists.
122
- (async () => {
123
- try {
124
- await engine.ready();
125
- if (isStale || abort.signal.aborted)
126
- return;
127
- setResolvedAccountScope(engine._store.orgId ?? null);
128
- }
129
- catch (err) {
130
- if (isStale || abort.signal.aborted)
131
- return;
132
- errorEmitter.emit(err);
133
- }
134
- })();
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
+ });
135
105
  return () => {
136
- isStale = true;
137
- abort.abort();
106
+ stale = true;
138
107
  unsubscribeSession();
139
- void engine.dispose();
140
- // AbloClient is stateless-ish — participants manage their own
141
- // WebSocket connections via `participant.disconnect()`. No client
142
- // close is needed.
143
108
  };
144
- // We intentionally only re-run on engineKey. All other DI is
145
- // captured at first render; rotating the engine on every
146
- // `mutationExecutor` identity change would destroy the WebSocket.
147
- // eslint-disable-next-line react-hooks/exhaustive-deps
148
- }, [engineKey]);
109
+ }, [engine, errorEmitter]);
149
110
  // ── beforeunload + preventUnsavedChanges ─────────────────────────
150
111
  useEffect(() => {
151
112
  if (typeof window === 'undefined')
152
113
  return;
153
114
  const handler = (event) => {
154
- const engine = engineState.engine;
155
- if (!engine)
156
- return;
157
- // Best-effort: dispose on unload. The async work may not
158
- // complete before the tab closes — that's fine for IDB, which
159
- // 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.
160
118
  void engine.dispose();
161
119
  if (preventUnsavedChanges && engine._store.hasUnsyncedChanges) {
162
120
  event.preventDefault();
@@ -165,30 +123,30 @@ export function AbloProvider(props) {
165
123
  };
166
124
  window.addEventListener('beforeunload', handler);
167
125
  return () => window.removeEventListener('beforeunload', handler);
168
- }, [engineState.engine, preventUnsavedChanges]);
126
+ }, [engine, preventUnsavedChanges]);
169
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.
170
132
  const syncValue = useMemo(() => {
171
- if (!engineState.engine)
172
- return null;
173
133
  const currentAccountScope = resolvedAccountScope ??
174
- engineState.engine._store.orgId;
134
+ engine._store.orgId;
175
135
  if (!currentAccountScope)
176
136
  return null;
177
137
  return {
178
- store: engineState.engine._store,
138
+ store: engine._store,
179
139
  organizationId: currentAccountScope,
180
140
  schema,
181
141
  };
182
- }, [engineState.engine, resolvedAccountScope, schema]);
142
+ }, [engine, resolvedAccountScope, schema]);
183
143
  // ── Internal context (currentUserId + error subscription) ────────
184
144
  const internalValue = useMemo(() => ({
185
145
  currentUserId: userId ?? null,
186
146
  subscribeError: errorEmitter.subscribe,
187
147
  emitError: errorEmitter.emit,
188
- // `engine` is null until bootstrap finishes; `useSync()` throws
189
- // on null so callers are forced to gate with <ClientSideSuspense>.
190
- engine: engineState.engine,
191
- }), [userId, errorEmitter, engineState.engine]);
148
+ engine: engine,
149
+ }), [userId, errorEmitter, engine]);
192
150
  // ── Render ───────────────────────────────────────────────────────
193
151
  //
194
152
  // Two-phase gate (see `BootstrapGate` below for the latch logic):
@@ -212,7 +170,7 @@ export function AbloProvider(props) {
212
170
  if (!syncValue) {
213
171
  return (_jsx(AbloInternalContext.Provider, { value: internalValue, children: initialFallback }));
214
172
  }
215
- 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)) }) }));
216
174
  }
217
175
  /**
218
176
  * Internal gate that renders `fallback` only during the very first
@@ -33,4 +33,4 @@ export interface ClientSideSuspenseProps {
33
33
  /** What to render once the subtree is cleared to render. */
34
34
  children: ReactNode;
35
35
  }
36
- export declare function ClientSideSuspense({ fallback, children }: ClientSideSuspenseProps): import("react/jsx-runtime").JSX.Element;
36
+ export declare function ClientSideSuspense({ fallback, children }: ClientSideSuspenseProps): import("react").JSX.Element;
@@ -21,4 +21,4 @@
21
21
  * pass `fallback={null}`. Consumers who want to skip the gate entirely
22
22
  * pass `fallback="passthrough"`.
23
23
  */
24
- export declare function DefaultFallback(): import("react/jsx-runtime").JSX.Element;
24
+ export declare function DefaultFallback(): import("react").JSX.Element;
@@ -4,7 +4,7 @@ export interface SyncGroupProviderProps {
4
4
  id: string;
5
5
  children: ReactNode;
6
6
  }
7
- export declare function SyncGroupProvider({ id, children }: SyncGroupProviderProps): import("react/jsx-runtime").JSX.Element;
7
+ export declare function SyncGroupProvider({ id, children }: SyncGroupProviderProps): import("react").JSX.Element;
8
8
  /**
9
9
  * Returns the ID of the nearest `<SyncGroupProvider>`. Throws if
10
10
  * called outside one — sync-group awareness is mandatory by design,
@@ -13,9 +13,10 @@
13
13
  * immediately. The provider-level `fallback` is the default path.
14
14
  *
15
15
  * Data hooks:
16
- * useAblo((ablo) => ablo.tasks.retrieve(id)) — primary React read API
16
+ * useAblo((ablo) => ablo.tasks.get(id)) — primary React read API (sync local snapshot)
17
17
  * useAblo() — typed client for callbacks/effects
18
- * (reads: ablo.<model>.retrieve/list;
18
+ * (sync local reads: ablo.<model>.get/getAll;
19
+ * async server reads: ablo.<model>.retrieve/list;
19
20
  * writes: ablo.<model>.create/update/delete)
20
21
  * useMutators(defs, opts?) — Zero-style custom mutators
21
22
  * useUndoScope(name) — per-surface undo/redo
@@ -13,9 +13,10 @@
13
13
  * immediately. The provider-level `fallback` is the default path.
14
14
  *
15
15
  * Data hooks:
16
- * useAblo((ablo) => ablo.tasks.retrieve(id)) — primary React read API
16
+ * useAblo((ablo) => ablo.tasks.get(id)) — primary React read API (sync local snapshot)
17
17
  * useAblo() — typed client for callbacks/effects
18
- * (reads: ablo.<model>.retrieve/list;
18
+ * (sync local reads: ablo.<model>.get/getAll;
19
+ * async server reads: ablo.<model>.retrieve/list;
19
20
  * writes: ablo.<model>.create/update/delete)
20
21
  * useMutators(defs, opts?) — Zero-style custom mutators
21
22
  * useUndoScope(name) — per-surface undo/redo
@@ -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 docs = await ablo.documents.load({ where: { id } });
48
+ * const doc = await ablo.documents.retrieve({ id }); // async server read
49
49
  *
50
- * // Reactive selector:
51
- * const doc = useAblo((ablo) => ablo.documents.retrieve(id)) ?? serverDoc;
52
- * const active = useAblo((ablo) => ablo.documents.claimState(id));
50
+ * // Reactive selector (sync local-graph snapshot):
51
+ * const doc = useAblo((ablo) => ablo.documents.get(id)) ?? serverDoc;
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']>();
@@ -9,7 +9,7 @@ function readModelResult(engine, modelClient, id, initial) {
9
9
  if (!modelClient || id === undefined) {
10
10
  return { data: initial, claims: EMPTY_CLAIMS, claimed: false };
11
11
  }
12
- const data = snapshotValue(modelClient.retrieve(id) ?? initial);
12
+ const data = snapshotValue(modelClient.get(id) ?? initial);
13
13
  const meta = getModelClientMeta(modelClient);
14
14
  const claims = meta && engine
15
15
  ? engine.intents.list({ model: meta.key, id })
@@ -29,7 +29,6 @@ export function useAblo(modelOrSelect, id, options) {
29
29
  const ctx = useContext(AbloInternalContext);
30
30
  const engine = ctx?.engine ?? null;
31
31
  const initial = options?.initial;
32
- const hasSelection = modelOrSelect !== undefined;
33
32
  const isSelectorOnly = typeof modelOrSelect === 'function' && id === undefined;
34
33
  const modelClient = typeof modelOrSelect === 'function' && id !== undefined
35
34
  ? engine
@@ -38,14 +37,20 @@ export function useAblo(modelOrSelect, id, options) {
38
37
  : typeof modelOrSelect === 'function'
39
38
  ? undefined
40
39
  : modelOrSelect;
40
+ // Claims live on a non-MobX event emitter (engine.intents), so the useReactive
41
+ // reactions below cannot track them — we bridge changes through a setState bump.
42
+ // ONLY the model-row form (`id !== undefined`) actually reads claims, so gate the
43
+ // subscription on `id`. The selector-only form (`useAblo((a) => a.x.get/getAll)`)
44
+ // never reads claims; subscribing it to the workspace-global intent stream would
45
+ // re-render + double-compute it on every intent/presence delta anywhere (a real
46
+ // storm during AI editing / live collaboration) for a value that can't change.
41
47
  const [claimVersion, setClaimVersion] = useState(0);
42
48
  useEffect(() => {
43
- if (!engine || !hasSelection)
49
+ if (!engine || id === undefined)
44
50
  return;
45
51
  return engine.intents.onChange(() => setClaimVersion((version) => version + 1));
46
- }, [engine, hasSelection]);
52
+ }, [engine, id]);
47
53
  const selected = useReactive(() => {
48
- void claimVersion;
49
54
  if (!engine || !isSelectorOnly || typeof modelOrSelect !== 'function') {
50
55
  return undefined;
51
56
  }
@@ -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);
@@ -66,16 +66,29 @@ export function useReactive(compute, equals = defaultEquals) {
66
66
  const subscribeVersionRef = useRef(0);
67
67
  if (snapshotRef.current === null) {
68
68
  snapshotRef.current = { value: compute() };
69
- computeRef.current = compute;
70
69
  }
71
70
  else if (computeRef.current !== compute) {
71
+ // `compute` is a fresh inline arrow at virtually every call site, so this
72
+ // branch runs on essentially every render. Reconcile the snapshot against
73
+ // the latest closure, but only force a re-subscription when the value
74
+ // ACTUALLY changed. For the dominant case (same observable source, new
75
+ // arrow identity, unchanged value) this avoids tearing down + recreating
76
+ // the MobX reaction — and its double-compute — on every render. A genuine
77
+ // source swap (a memoized compute closing over a new observable source)
78
+ // changes the value, which both updates the snapshot and bumps
79
+ // `subscribeVersion` so the reaction below re-subscribes and re-tracks the
80
+ // new source's observables.
72
81
  const next = compute();
73
82
  if (!equals(snapshotRef.current.value, next)) {
74
83
  snapshotRef.current = { value: next };
84
+ subscribeVersionRef.current++;
75
85
  }
76
- computeRef.current = compute;
77
- subscribeVersionRef.current++;
78
86
  }
87
+ // Point the long-lived reaction at the latest closure every render. The
88
+ // reaction expression reads `computeRef.current` at fire time, so it always
89
+ // runs the newest compute (and re-tracks its observables) even when we did
90
+ // not re-subscribe above.
91
+ computeRef.current = compute;
79
92
  const subscribeVersion = subscribeVersionRef.current;
80
93
  const subscribe = useCallback((onChange) => {
81
94
  return reaction(() => computeRef.current(), (next) => {
@@ -22,14 +22,34 @@ 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;
@@ -46,7 +66,7 @@ export declare function sqlType(fieldType: ModelJSON['fields'][string]['type']):
46
66
  * itself is the isolation boundary). For `public` the `CREATE SCHEMA` is
47
67
  * skipped (it always exists).
48
68
  */
49
- export declare function generateProvisionPlan(schema: SchemaJSON, targetSchema: string): ProvisionPlan;
69
+ export declare function generateProvisionPlan(schema: SchemaJSON, targetSchema: string, opts?: ProvisionOptions): ProvisionPlan;
50
70
  /**
51
71
  * Lower an ordered migration step list to DDL. `next` is the schema being pushed
52
72
  * (the target column shapes are read from it), `prev` the active one (used to
@@ -59,4 +79,7 @@ export declare function generateMigrationPlan(steps: readonly MigrationStep[], o
59
79
  /** Constant seed values that let a required-field add / made-required step
60
80
  * set NOT NULL on a non-empty table. Keyed by (model, field). */
61
81
  readonly backfills?: readonly BackfillValue[];
82
+ /** Emit DEFERRABLE FK constraints for `parent: true` edges of newly-created
83
+ * models. Off by default — see {@link ProvisionOptions.foreignKeys}. */
84
+ readonly foreignKeys?: boolean;
62
85
  }): MigrationPlan;