@abloatai/ablo 0.8.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 (162) hide show
  1. package/CHANGELOG.md +40 -1
  2. package/README.md +32 -27
  3. package/dist/BaseSyncedStore.d.ts +73 -0
  4. package/dist/BaseSyncedStore.js +172 -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 +86 -50
  52. package/dist/mutators/UndoManager.js +129 -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/useAblo.d.ts +2 -2
  63. package/dist/react/useCurrentUserId.d.ts +1 -1
  64. package/dist/react/useCurrentUserId.js +1 -1
  65. package/dist/react/useMutators.js +19 -12
  66. package/dist/schema/ddl.d.ts +26 -3
  67. package/dist/schema/ddl.js +152 -4
  68. package/dist/schema/index.d.ts +4 -0
  69. package/dist/schema/index.js +12 -0
  70. package/dist/schema/model.d.ts +11 -0
  71. package/dist/schema/model.js +2 -0
  72. package/dist/schema/openapi.d.ts +28 -0
  73. package/dist/schema/openapi.js +118 -0
  74. package/dist/schema/plane.d.ts +23 -0
  75. package/dist/schema/plane.js +19 -0
  76. package/dist/schema/relation.d.ts +20 -0
  77. package/dist/schema/serialize.d.ts +4 -0
  78. package/dist/schema/serialize.js +4 -0
  79. package/dist/schema/sync-delta-row.d.ts +157 -0
  80. package/dist/schema/sync-delta-row.js +102 -0
  81. package/dist/schema/sync-delta-wire.d.ts +180 -0
  82. package/dist/schema/sync-delta-wire.js +102 -0
  83. package/dist/server/adapter.d.ts +156 -0
  84. package/dist/server/adapter.js +19 -0
  85. package/dist/server/commit.d.ts +82 -0
  86. package/dist/server/commit.js +1 -0
  87. package/dist/server/index.d.ts +14 -0
  88. package/dist/server/index.js +1 -0
  89. package/dist/server/next.d.ts +51 -0
  90. package/dist/server/next.js +47 -0
  91. package/dist/server/read-config.d.ts +60 -0
  92. package/dist/server/read-config.js +8 -0
  93. package/dist/server/storage-mode.d.ts +17 -0
  94. package/dist/server/storage-mode.js +12 -0
  95. package/dist/source/adapter.d.ts +59 -0
  96. package/dist/source/adapter.js +19 -0
  97. package/dist/source/adapters/drizzle.d.ts +34 -0
  98. package/dist/source/adapters/drizzle.js +147 -0
  99. package/dist/source/adapters/memory.d.ts +12 -0
  100. package/dist/source/adapters/memory.js +114 -0
  101. package/dist/source/adapters/prisma.d.ts +57 -0
  102. package/dist/source/adapters/prisma.js +199 -0
  103. package/dist/source/conformance.d.ts +32 -0
  104. package/dist/source/conformance.js +134 -0
  105. package/dist/source/contract.d.ts +143 -0
  106. package/dist/source/contract.js +98 -0
  107. package/dist/source/index.d.ts +61 -10
  108. package/dist/source/index.js +98 -0
  109. package/dist/source/next.d.ts +33 -0
  110. package/dist/source/next.js +26 -0
  111. package/dist/sync/BootstrapHelper.d.ts +10 -0
  112. package/dist/sync/BootstrapHelper.js +10 -15
  113. package/dist/sync/ConnectionManager.d.ts +55 -1
  114. package/dist/sync/ConnectionManager.js +155 -16
  115. package/dist/sync/HydrationCoordinator.d.ts +93 -17
  116. package/dist/sync/HydrationCoordinator.js +238 -39
  117. package/dist/sync/NetworkProbe.d.ts +58 -24
  118. package/dist/sync/NetworkProbe.js +118 -42
  119. package/dist/sync/SyncWebSocket.d.ts +45 -70
  120. package/dist/sync/SyncWebSocket.js +70 -36
  121. package/dist/sync/createIntentStream.js +10 -1
  122. package/dist/types/streams.d.ts +9 -0
  123. package/dist/utils/mobx-setup.js +1 -0
  124. package/dist/webhooks/events.d.ts +38 -0
  125. package/dist/webhooks/events.js +40 -0
  126. package/dist/webhooks/index.d.ts +10 -0
  127. package/dist/webhooks/index.js +10 -0
  128. package/dist/wire/errorEnvelope.d.ts +34 -0
  129. package/dist/wire/errorEnvelope.js +86 -0
  130. package/dist/wire/frames.d.ts +119 -0
  131. package/dist/wire/frames.js +1 -0
  132. package/dist/wire/index.d.ts +24 -0
  133. package/dist/wire/index.js +21 -0
  134. package/dist/wire/listEnvelope.d.ts +45 -0
  135. package/dist/wire/listEnvelope.js +17 -0
  136. package/docs/api.md +47 -44
  137. package/docs/cli.md +44 -44
  138. package/docs/client-behavior.md +30 -30
  139. package/docs/coordination.md +33 -36
  140. package/docs/data-sources.md +35 -15
  141. package/docs/examples/agent-human.md +45 -43
  142. package/docs/examples/ai-sdk-tool.md +20 -16
  143. package/docs/examples/existing-python-backend.md +16 -12
  144. package/docs/examples/nextjs.md +14 -12
  145. package/docs/examples/scoped-agent.md +1 -1
  146. package/docs/examples/server-agent.md +24 -21
  147. package/docs/guarantees.md +15 -13
  148. package/docs/index.md +1 -1
  149. package/docs/integration-guide.md +30 -30
  150. package/docs/interaction-model.md +19 -23
  151. package/docs/mcp/claude-code.md +3 -3
  152. package/docs/mcp/cursor.md +1 -1
  153. package/docs/mcp/windsurf.md +2 -2
  154. package/docs/mcp.md +6 -6
  155. package/docs/quickstart.md +41 -31
  156. package/docs/react.md +13 -9
  157. package/docs/schema-contract.md +12 -10
  158. package/docs/the-loop.md +21 -0
  159. package/examples/data-source/README.md +4 -5
  160. package/examples/data-source/customer-server.ts +27 -25
  161. package/llms.txt +28 -5
  162. package/package.json +43 -3
@@ -1,10 +1,6 @@
1
1
  import { type ReactNode } from 'react';
2
- import type { Schema, SchemaRecord } from '../schema/schema.js';
2
+ import type { SchemaRecord } from '../schema/schema.js';
3
3
  import { Ablo } from '../client/Ablo.js';
4
- import type { AbloPersistence } from '../client/persistence.js';
5
- import type { SyncEngineConfig, MutationExecutor, MutationDispatcher, SessionErrorDetector, OnlineStatusProvider, SyncLogger, SyncObservabilityProvider } from '../config/index.js';
6
- import type { UseMutatorsOptions } from './useMutators.js';
7
- import type { MutatorDefs } from '../mutators/defineMutators.js';
8
4
  import type { ActiveIntent, Peer } from '../types/streams.js';
9
5
  import type { EngineParticipant, ParticipantScope, ParticipantStatus } from '../sync/participants.js';
10
6
  import { type SyncStoreContract } from './context.js';
@@ -49,140 +45,41 @@ import { type SyncStoreContract } from './context.js';
49
45
  */
50
46
  export interface AbloProviderProps<R extends SchemaRecord = SchemaRecord> {
51
47
  /**
52
- * Schema from `defineSchema()`. Determines the typed hook surface.
53
- * This is the only prop most apps pass — start here.
54
- */
55
- schema: Schema<R>;
56
- /**
57
- * WebSocket URL of the sync server (`wss://...` or `ws://...`).
58
- * Hosted apps omit this.
48
+ * A prebuilt {@link Ablo} client — **the only way to configure the engine.**
49
+ * Construct it yourself with `Ablo({ schema, apiKey, ... })` and pass the
50
+ * instance: the CLIENT owns auth, the credential lifecycle, transport, and
51
+ * connection; this provider is the thin REACTIVE binding over it (context,
52
+ * the bootstrap gate, error/​session forwarding). Mirrors Stripe
53
+ * `<Elements stripe={...}>` and a Supabase client passed into a context.
54
+ *
55
+ * Memoize it (build it once, e.g. with `useMemo` or module scope) — a new
56
+ * instance each render re-keys the bootstrap gate and tears down the socket.
59
57
  */
60
- url?: string;
58
+ client: Ablo<R>;
61
59
  /**
62
- * Optional app user id for app-owned fields. Ablo resolves sync
63
- * participant identity from auth; this is not required to connect.
60
+ * The app user id, surfaced via `useCurrentUserId()` for app-owned fields.
61
+ * Purely informational for the React tree — sync identity is resolved by the
62
+ * client from its auth, not from this. Optional.
64
63
  */
65
64
  userId?: string;
66
- /** Team IDs the user belongs to. Expanded into sync groups. */
67
- teamIds?: string[];
68
- /**
69
- * API key for engine bootstrap auth. Used by the bootstrap fetch
70
- * path; falls back to `credentials: 'include'` (session cookie)
71
- * when unset. Browser apps typically omit this and rely on
72
- * same-origin session cookies.
73
- */
74
- apiKey?: string;
75
- /**
76
- * Static bearer auth token, sent as `Authorization: Bearer <token>` on the
77
- * WebSocket upgrade + HTTP. For a token that must be refreshed (e.g. a
78
- * short-lived JWT), prefer {@link getToken}.
79
- */
80
- authToken?: string | null;
81
65
  /**
82
- * Async resolver for a short-lived bearer token. Called once before the
83
- * engine connects (so the first connection carries a fresh token) and then on
84
- * a refresh interval ahead of expiry — each result is pushed via
85
- * `engine.setAuthToken` without tearing down the connection. Wire this to
86
- * a resolver that mints a short-lived session token (`ek_`/`rk_`) — e.g.
87
- * `getSyncCapabilityToken` — or your own. Takes precedence over
88
- * {@link authEndpoint}.
89
- */
90
- getToken?: () => Promise<string | null>;
91
- /**
92
- * Liveblocks/Stripe-style auth endpoint: a URL on YOUR backend that returns
93
- * `{ token }` — the `ek_` ephemeral key your server minted for the logged-in
94
- * user with `ablo.sessions.create({ user: { id } })`. The provider POSTs to it
95
- * (with cookies) to fetch + refresh the bearer, so the browser carries no
96
- * secret. Shorthand for a {@link getToken} that does the fetch; ignored when
97
- * `getToken` is set.
98
- */
99
- authEndpoint?: string;
100
- /** Optional Zero-style custom mutators. */
101
- mutators?: MutatorDefs<Schema<R>>;
102
- /** Options forwarded to the internal `useMutators` call (e.g., `undoScope`). */
103
- mutatorOptions?: UseMutatorsOptions<Schema<R>>;
104
- /**
105
- * Block browser tab close when there are unsynced local writes.
106
- * Triggers the standard `beforeunload` "Leave site?" prompt.
107
- * Browsers ignore custom messages — do not pass one. Consumers
108
- * who want telemetry should read
109
- * `useSyncStatus().hasUnsyncedChanges` directly.
66
+ * Block tab close while there are unsynced local writes (the standard
67
+ * `beforeunload` prompt). Browsers ignore custom messages — don't pass one.
110
68
  */
111
69
  preventUnsavedChanges?: boolean;
112
70
  /**
113
- * Milliseconds to tolerate connection loss before `useSyncStatus()`
114
- * flips to `disconnected`. Defaults to 5000. Set to 0 to
115
- * disable the grace period (immediate transition).
116
- *
117
- * v0.3.0 scope: reserved for future wiring. Current transition is
118
- * driven by the engine's built-in state machine.
119
- */
120
- lostConnectionTimeout?: number;
121
- /**
122
- * Fired when the server rejects the session. The provider has
123
- * ALREADY called `engine.purge()` (disposed + wiped IndexedDB) by
124
- * the time this runs — the callback is for app-level side effects
125
- * (e.g., redirect to sign-in, clear analytics identity).
71
+ * Fired when the server rejects the session. The provider has ALREADY called
72
+ * `client.purge()` (disposed + wiped IndexedDB) by the time this runs — use it
73
+ * for app side effects (redirect to sign-in, clear analytics identity).
126
74
  */
127
75
  onSessionExpired?: () => void | Promise<void>;
128
76
  /**
129
- * Fired on any error the provider surfaces: engine errors,
130
- * WebSocket errors, uncaught `postBootstrap` exceptions. Use for
131
- * Sentry / Datadog. Consumers who only want errors inside React
132
- * can use the `useErrorListener()` hook instead.
77
+ * Fired on any error the provider surfaces (engine/WebSocket/bootstrap). For
78
+ * Sentry/Datadog. React-only consumers can use `useErrorListener()` instead.
133
79
  */
134
80
  onError?: (error: Error) => void;
135
- observability?: SyncObservabilityProvider;
136
- logger?: SyncLogger;
137
- mutationExecutor?: MutationExecutor;
138
- mutationDispatcher?: MutationDispatcher;
139
- sessionErrorDetector?: SessionErrorDetector;
140
- onlineStatus?: OnlineStatusProvider;
141
- configOverrides?: SyncEngineConfig;
142
- /**
143
- * Raw sync-group strings for the initial connection. Prefer {@link scope} —
144
- * the model form (`{ decks: deckId }`) that the engine resolves through the
145
- * schema's `scope`, so you never hand-write a `deck:<id>` string. Both merge.
146
- */
147
- syncGroups?: string[];
148
- /**
149
- * Model-form connection scope: `{ decks: deckId, documents: documentId }` or
150
- * entity refs. Resolved through the schema's per-model `scope` into group
151
- * strings (so typename `SlideDeck` → `deck:<id>`), unioned with {@link syncGroups}.
152
- * Memoize the object if it's derived, to avoid rotating the engine each render.
153
- */
154
- scope?: ParticipantScope;
155
- bootstrapBaseUrl?: string;
156
- maxPoolSize?: number;
157
- /**
158
- * Local persistence mode for the underlying `Ablo` client. Defaults
159
- * to `volatile` — pass `'indexeddb'` to opt back into offline-queue +
160
- * reload-surviving cache in a browser. See `AbloOptions.persistence`
161
- * for the full semantics.
162
- */
163
- persistence?: AbloPersistence;
164
- /**
165
- * How aggressively this provider pulls baseline state at startup.
166
- *
167
- * - `'full'` (default): pull every delta in the configured sync
168
- * groups before the engine reports ready — a local replica of the
169
- * org's tenant plane. Right for collaborative editors and any page
170
- * that reads a lot of shared state.
171
- * - `'none'`: open the connection and process live deltas only — no
172
- * baseline fetch. Reads round-trip via `ablo.<model>.retrieve(...)`
173
- * and subscriptions populate the pool lazily. Right for read-light
174
- * pages (a mostly-static dashboard, a settings screen) that don't
175
- * want to download the whole org to render.
176
- *
177
- * Note: `'none'` still opens the realtime connection — it skips the
178
- * baseline pull, not the socket. A fully connection-free mode for
179
- * pages that do zero multiplayer is a separate follow-up (the socket
180
- * open lives inside `engine.ready()`, so deferring it needs
181
- * engine-level lazy-connect support, not just a provider prop).
182
- *
183
- * Mirrors `AbloOptions.bootstrapMode`. Changing it rotates the engine.
184
- */
185
- bootstrapMode?: 'full' | 'none';
81
+ /** @internal placeholder so the old WS-URL prop shape doesn't silently leak in. */
82
+ url?: never;
186
83
  /**
187
84
  * Rendered in place of `children` during the *first* bootstrap pass —
188
85
  * while the engine is actively transitioning from `initial` →
@@ -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
@@ -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);