@abloatai/ablo 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -1
- package/README.md +33 -28
- package/dist/BaseSyncedStore.d.ts +83 -0
- package/dist/BaseSyncedStore.js +194 -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 +158 -50
- package/dist/mutators/UndoManager.js +345 -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/context.d.ts +31 -0
- 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 +34 -3
- package/dist/schema/ddl.js +162 -4
- package/dist/schema/index.d.ts +5 -1
- package/dist/schema/index.js +13 -1
- 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 +65 -0
- package/dist/source/adapter.js +20 -0
- package/dist/source/adapters/drizzle.d.ts +43 -0
- package/dist/source/adapters/drizzle.js +185 -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 +176 -0
- package/dist/source/conformance.d.ts +32 -0
- package/dist/source/conformance.js +134 -0
- package/dist/source/contract.d.ts +144 -0
- package/dist/source/contract.js +99 -0
- package/dist/source/index.d.ts +62 -10
- package/dist/source/index.js +99 -0
- package/dist/source/migrations.d.ts +14 -0
- package/dist/source/migrations.js +39 -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 +2 -2
- 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,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/context.d.ts
CHANGED
|
@@ -5,11 +5,42 @@ import type { QueryView, QueryViewOptions } from '../core/QueryView.js';
|
|
|
5
5
|
import type { ViewRegistry } from '../core/ViewRegistry.js';
|
|
6
6
|
import type { Schema } from '../schema/schema.js';
|
|
7
7
|
import type { SyncStatus } from '../BaseSyncedStore.js';
|
|
8
|
+
/**
|
|
9
|
+
* A single LOCAL mutation as observed off the commit stream — the substrate
|
|
10
|
+
* the undo system records from. One is emitted per local create/update/
|
|
11
|
+
* delete/archive (remote/collaborator deltas never appear here: they apply
|
|
12
|
+
* through a separate pool path that doesn't queue mutations). `previousData`
|
|
13
|
+
* holds the pre-edit field values (captured from the model's
|
|
14
|
+
* `modifiedProperties` first-old-wins baseline), so an inverse op is fully
|
|
15
|
+
* derivable from the event alone — no separate snapshot pass.
|
|
16
|
+
*
|
|
17
|
+
* This mirrors how Yjs's `UndoManager` derives reverse-ops by observing the
|
|
18
|
+
* doc and Liveblocks' `room.history` records room ops: undo listens to the
|
|
19
|
+
* one place all local writes converge, rather than wrapping the write call.
|
|
20
|
+
*/
|
|
21
|
+
export interface LocalMutation {
|
|
22
|
+
type: 'create' | 'update' | 'delete' | 'archive' | 'unarchive';
|
|
23
|
+
/** Registered model name (e.g. `'SlideLayer'`); resolved to a schema key by the recorder. */
|
|
24
|
+
modelName: string;
|
|
25
|
+
modelId: string;
|
|
26
|
+
/** New field values (create/update). */
|
|
27
|
+
data?: Record<string, unknown> | null;
|
|
28
|
+
/** Pre-edit field values (update → inverse patch; delete → full re-create row). */
|
|
29
|
+
previousData?: Record<string, unknown> | null;
|
|
30
|
+
}
|
|
8
31
|
/**
|
|
9
32
|
* Minimal store interface that the SDK hooks need.
|
|
10
33
|
* Consumers provide their concrete store (e.g., SyncedStore) that implements this.
|
|
11
34
|
*/
|
|
12
35
|
export interface SyncStoreContract {
|
|
36
|
+
/**
|
|
37
|
+
* Subscribe to the LOCAL mutation stream (optimistic, pre-ack) for undo
|
|
38
|
+
* recording. Optional so minimal test doubles can omit it — when absent,
|
|
39
|
+
* undo scopes simply record nothing. The concrete store
|
|
40
|
+
* (`BaseSyncedStore`) wires this to the TransactionQueue's
|
|
41
|
+
* `transaction:created` event. Returns an unsubscribe function.
|
|
42
|
+
*/
|
|
43
|
+
subscribeLocalMutations?(handler: (mutation: LocalMutation) => void): () => void;
|
|
13
44
|
retrieve(modelClass: abstract new (...args: never[]) => Model, id: string): Model | undefined;
|
|
14
45
|
queryByClass(modelClass: abstract new (...args: never[]) => Model, options?: {
|
|
15
46
|
predicate?: (model: Model) => boolean;
|
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);
|
package/dist/schema/ddl.d.ts
CHANGED
|
@@ -22,18 +22,46 @@ import type { MigrationStep, BackfillValue } from './diff.js';
|
|
|
22
22
|
export interface ProvisionPlan {
|
|
23
23
|
/** The Postgres schema the tables live in (`app_<id>` or `public`). */
|
|
24
24
|
readonly appSchema: string;
|
|
25
|
-
/** Ordered, idempotent DDL statements. Safe to run repeatedly.
|
|
25
|
+
/** Ordered, idempotent DDL statements. Safe to run repeatedly. Executors run
|
|
26
|
+
* these together in ONE transaction. */
|
|
26
27
|
readonly statements: readonly string[];
|
|
28
|
+
/** Post-commit, NON-transactional DDL (`VALIDATE CONSTRAINT`, `CREATE INDEX
|
|
29
|
+
* CONCURRENTLY`) — run AFTER {@link statements} commit, each outside any
|
|
30
|
+
* transaction, best-effort. Keeps the lock-heavy / scan-heavy work off the
|
|
31
|
+
* main transaction so adding a foreign key never freezes a large, live BYO
|
|
32
|
+
* table. Optional + back-compat: absent = nothing to run. */
|
|
33
|
+
readonly concurrent?: readonly string[];
|
|
34
|
+
}
|
|
35
|
+
export interface ProvisionOptions {
|
|
36
|
+
/**
|
|
37
|
+
* Emit `DEFERRABLE INITIALLY DEFERRED` FOREIGN KEY constraints for every
|
|
38
|
+
* `parent: true` belongsTo relation (true ownership edges only — see
|
|
39
|
+
* {@link foreignKeyStatements}). Off by default: the soft-reference model keeps
|
|
40
|
+
* out-of-order sync robust on Ablo-managed tables. Turn on for a customer's own
|
|
41
|
+
* (BYO / dedicated) database, where a clean, navigable relational schema is
|
|
42
|
+
* wanted and the DB starts empty (nothing for the constraint to fail against).
|
|
43
|
+
*/
|
|
44
|
+
readonly foreignKeys?: boolean;
|
|
27
45
|
}
|
|
28
46
|
export interface MigrationPlan {
|
|
29
47
|
/** The app Postgres schema the DDL targets (`app_<id>` or `public`). */
|
|
30
48
|
readonly appSchema: string;
|
|
31
|
-
/** Ordered DDL statements (expand → contract). */
|
|
49
|
+
/** Ordered DDL statements (expand → contract). Run in ONE transaction. */
|
|
32
50
|
readonly statements: readonly string[];
|
|
51
|
+
/** Post-commit, non-transactional DDL — see {@link ProvisionPlan.concurrent}. */
|
|
52
|
+
readonly concurrent?: readonly string[];
|
|
33
53
|
}
|
|
34
54
|
/** Per-app schema name for an app (organization) id. */
|
|
35
55
|
export declare function appSchemaName(organizationId: string): string;
|
|
36
56
|
export declare function camelToSnake(identifier: string): string;
|
|
57
|
+
/**
|
|
58
|
+
* Pure snake_case → camelCase — the inverse of {@link camelToSnake}, matching
|
|
59
|
+
* `postgres.toCamel` semantics. Read-side translation: a column read back from a
|
|
60
|
+
* BYO database (e.g. via `drizzleDataSource`) maps to the same JS field the SDK
|
|
61
|
+
* wrote, so `camelToSnake('operatorId') === 'operator_id'` and
|
|
62
|
+
* `snakeToCamel('operator_id') === 'operatorId'` round-trip.
|
|
63
|
+
*/
|
|
64
|
+
export declare function snakeToCamel(identifier: string): string;
|
|
37
65
|
/** Quote an identifier (defense-in-depth; inputs are already slug/snake). */
|
|
38
66
|
export declare function q(identifier: string): string;
|
|
39
67
|
export declare function sqlType(fieldType: ModelJSON['fields'][string]['type']): string;
|
|
@@ -46,7 +74,7 @@ export declare function sqlType(fieldType: ModelJSON['fields'][string]['type']):
|
|
|
46
74
|
* itself is the isolation boundary). For `public` the `CREATE SCHEMA` is
|
|
47
75
|
* skipped (it always exists).
|
|
48
76
|
*/
|
|
49
|
-
export declare function generateProvisionPlan(schema: SchemaJSON, targetSchema: string): ProvisionPlan;
|
|
77
|
+
export declare function generateProvisionPlan(schema: SchemaJSON, targetSchema: string, opts?: ProvisionOptions): ProvisionPlan;
|
|
50
78
|
/**
|
|
51
79
|
* Lower an ordered migration step list to DDL. `next` is the schema being pushed
|
|
52
80
|
* (the target column shapes are read from it), `prev` the active one (used to
|
|
@@ -59,4 +87,7 @@ export declare function generateMigrationPlan(steps: readonly MigrationStep[], o
|
|
|
59
87
|
/** Constant seed values that let a required-field add / made-required step
|
|
60
88
|
* set NOT NULL on a non-empty table. Keyed by (model, field). */
|
|
61
89
|
readonly backfills?: readonly BackfillValue[];
|
|
90
|
+
/** Emit DEFERRABLE FK constraints for `parent: true` edges of newly-created
|
|
91
|
+
* models. Off by default — see {@link ProvisionOptions.foreignKeys}. */
|
|
92
|
+
readonly foreignKeys?: boolean;
|
|
62
93
|
}): MigrationPlan;
|