@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.
- package/CHANGELOG.md +40 -1
- package/README.md +32 -27
- package/dist/BaseSyncedStore.d.ts +73 -0
- package/dist/BaseSyncedStore.js +172 -2
- package/dist/Model.d.ts +42 -0
- package/dist/Model.js +103 -44
- package/dist/agent/session.js +3 -3
- package/dist/ai-sdk/coordination-context.js +4 -0
- package/dist/ai-sdk/index.d.ts +56 -47
- package/dist/ai-sdk/index.js +56 -47
- package/dist/ai-sdk/intent-broadcast.d.ts +5 -0
- package/dist/ai-sdk/intent-broadcast.js +11 -4
- package/dist/ai-sdk/wrap.d.ts +14 -11
- package/dist/ai-sdk/wrap.js +11 -13
- package/dist/auth/credentialSource.d.ts +34 -0
- package/dist/auth/credentialSource.js +63 -0
- package/dist/auth/index.d.ts +2 -22
- package/dist/auth/index.js +4 -42
- package/dist/auth/schemas.d.ts +35 -0
- package/dist/auth/schemas.js +53 -0
- package/dist/client/Ablo.d.ts +160 -42
- package/dist/client/Ablo.js +145 -75
- package/dist/client/ApiClient.d.ts +20 -4
- package/dist/client/ApiClient.js +166 -28
- package/dist/client/auth.d.ts +14 -5
- package/dist/client/auth.js +60 -7
- package/dist/client/createInternalComponents.d.ts +2 -0
- package/dist/client/createInternalComponents.js +8 -1
- package/dist/client/createModelProxy.d.ts +130 -66
- package/dist/client/createModelProxy.js +152 -49
- package/dist/client/httpClient.d.ts +71 -0
- package/dist/client/httpClient.js +69 -0
- package/dist/client/identity.d.ts +2 -6
- package/dist/client/identity.js +49 -11
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/client/registerDataSource.d.ts +3 -3
- package/dist/client/registerDataSource.js +11 -9
- package/dist/client/validateAbloOptions.js +1 -1
- package/dist/core/DatabaseManager.js +30 -2
- package/dist/core/openIDBWithTimeout.d.ts +36 -0
- package/dist/core/openIDBWithTimeout.js +88 -1
- package/dist/errorCodes.d.ts +70 -1
- package/dist/errorCodes.js +108 -9
- package/dist/errors.d.ts +2 -2
- package/dist/errors.js +72 -22
- package/dist/index.d.ts +17 -8
- package/dist/index.js +15 -6
- package/dist/keys/index.d.ts +16 -1
- package/dist/keys/index.js +26 -6
- package/dist/mutators/UndoManager.d.ts +86 -50
- package/dist/mutators/UndoManager.js +129 -22
- package/dist/mutators/inverseOp.d.ts +129 -0
- package/dist/mutators/inverseOp.js +74 -0
- package/dist/mutators/readerActions.d.ts +1 -1
- package/dist/mutators/undoApply.d.ts +42 -0
- package/dist/mutators/undoApply.js +143 -0
- package/dist/query/client.d.ts +10 -9
- package/dist/query/client.js +3 -6
- package/dist/react/AbloProvider.d.ts +23 -126
- package/dist/react/AbloProvider.js +62 -199
- package/dist/react/useAblo.d.ts +2 -2
- package/dist/react/useCurrentUserId.d.ts +1 -1
- package/dist/react/useCurrentUserId.js +1 -1
- package/dist/react/useMutators.js +19 -12
- package/dist/schema/ddl.d.ts +26 -3
- package/dist/schema/ddl.js +152 -4
- package/dist/schema/index.d.ts +4 -0
- package/dist/schema/index.js +12 -0
- package/dist/schema/model.d.ts +11 -0
- package/dist/schema/model.js +2 -0
- package/dist/schema/openapi.d.ts +28 -0
- package/dist/schema/openapi.js +118 -0
- package/dist/schema/plane.d.ts +23 -0
- package/dist/schema/plane.js +19 -0
- package/dist/schema/relation.d.ts +20 -0
- package/dist/schema/serialize.d.ts +4 -0
- package/dist/schema/serialize.js +4 -0
- package/dist/schema/sync-delta-row.d.ts +157 -0
- package/dist/schema/sync-delta-row.js +102 -0
- package/dist/schema/sync-delta-wire.d.ts +180 -0
- package/dist/schema/sync-delta-wire.js +102 -0
- package/dist/server/adapter.d.ts +156 -0
- package/dist/server/adapter.js +19 -0
- package/dist/server/commit.d.ts +82 -0
- package/dist/server/commit.js +1 -0
- package/dist/server/index.d.ts +14 -0
- package/dist/server/index.js +1 -0
- package/dist/server/next.d.ts +51 -0
- package/dist/server/next.js +47 -0
- package/dist/server/read-config.d.ts +60 -0
- package/dist/server/read-config.js +8 -0
- package/dist/server/storage-mode.d.ts +17 -0
- package/dist/server/storage-mode.js +12 -0
- package/dist/source/adapter.d.ts +59 -0
- package/dist/source/adapter.js +19 -0
- package/dist/source/adapters/drizzle.d.ts +34 -0
- package/dist/source/adapters/drizzle.js +147 -0
- package/dist/source/adapters/memory.d.ts +12 -0
- package/dist/source/adapters/memory.js +114 -0
- package/dist/source/adapters/prisma.d.ts +57 -0
- package/dist/source/adapters/prisma.js +199 -0
- package/dist/source/conformance.d.ts +32 -0
- package/dist/source/conformance.js +134 -0
- package/dist/source/contract.d.ts +143 -0
- package/dist/source/contract.js +98 -0
- package/dist/source/index.d.ts +61 -10
- package/dist/source/index.js +98 -0
- package/dist/source/next.d.ts +33 -0
- package/dist/source/next.js +26 -0
- package/dist/sync/BootstrapHelper.d.ts +10 -0
- package/dist/sync/BootstrapHelper.js +10 -15
- package/dist/sync/ConnectionManager.d.ts +55 -1
- package/dist/sync/ConnectionManager.js +155 -16
- package/dist/sync/HydrationCoordinator.d.ts +93 -17
- package/dist/sync/HydrationCoordinator.js +238 -39
- package/dist/sync/NetworkProbe.d.ts +58 -24
- package/dist/sync/NetworkProbe.js +118 -42
- package/dist/sync/SyncWebSocket.d.ts +45 -70
- package/dist/sync/SyncWebSocket.js +70 -36
- package/dist/sync/createIntentStream.js +10 -1
- package/dist/types/streams.d.ts +9 -0
- package/dist/utils/mobx-setup.js +1 -0
- package/dist/webhooks/events.d.ts +38 -0
- package/dist/webhooks/events.js +40 -0
- package/dist/webhooks/index.d.ts +10 -0
- package/dist/webhooks/index.js +10 -0
- package/dist/wire/errorEnvelope.d.ts +34 -0
- package/dist/wire/errorEnvelope.js +86 -0
- package/dist/wire/frames.d.ts +119 -0
- package/dist/wire/frames.js +1 -0
- package/dist/wire/index.d.ts +24 -0
- package/dist/wire/index.js +21 -0
- package/dist/wire/listEnvelope.d.ts +45 -0
- package/dist/wire/listEnvelope.js +17 -0
- package/docs/api.md +47 -44
- package/docs/cli.md +44 -44
- package/docs/client-behavior.md +30 -30
- package/docs/coordination.md +33 -36
- package/docs/data-sources.md +35 -15
- package/docs/examples/agent-human.md +45 -43
- package/docs/examples/ai-sdk-tool.md +20 -16
- package/docs/examples/existing-python-backend.md +16 -12
- package/docs/examples/nextjs.md +14 -12
- package/docs/examples/scoped-agent.md +1 -1
- package/docs/examples/server-agent.md +24 -21
- package/docs/guarantees.md +15 -13
- package/docs/index.md +1 -1
- package/docs/integration-guide.md +30 -30
- package/docs/interaction-model.md +19 -23
- package/docs/mcp/claude-code.md +3 -3
- package/docs/mcp/cursor.md +1 -1
- package/docs/mcp/windsurf.md +2 -2
- package/docs/mcp.md +6 -6
- package/docs/quickstart.md +41 -31
- package/docs/react.md +13 -9
- package/docs/schema-contract.md +12 -10
- package/docs/the-loop.md +21 -0
- package/examples/data-source/README.md +4 -5
- package/examples/data-source/customer-server.ts +27 -25
- package/llms.txt +28 -5
- package/package.json +43 -3
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
|
-
import type {
|
|
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
|
-
*
|
|
53
|
-
*
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
-
|
|
58
|
+
client: Ablo<R>;
|
|
61
59
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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
|
-
*
|
|
83
|
-
*
|
|
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
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
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
|
|
130
|
-
*
|
|
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
|
-
|
|
136
|
-
|
|
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
|
|
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 {
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
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
|
|
50
|
-
//
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
|
87
|
+
await onSessionExpiredRef.current?.();
|
|
132
88
|
}
|
|
133
89
|
catch (hookErr) {
|
|
134
90
|
errorEmitter.emit(hookErr);
|
|
135
91
|
}
|
|
136
92
|
});
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
}, [
|
|
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
|
-
|
|
134
|
+
engine._store.orgId;
|
|
270
135
|
if (!currentAccountScope)
|
|
271
136
|
return null;
|
|
272
137
|
return {
|
|
273
|
-
store:
|
|
138
|
+
store: engine._store,
|
|
274
139
|
organizationId: currentAccountScope,
|
|
275
140
|
schema,
|
|
276
141
|
};
|
|
277
|
-
}, [
|
|
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
|
-
|
|
284
|
-
|
|
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 },
|
|
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
|
package/dist/react/useAblo.d.ts
CHANGED
|
@@ -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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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);
|