@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
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js App Router adapter for Data Source. The core `dataSource()` already
|
|
3
|
+
* returns a Web-standard `(Request) => Promise<Response>`, which Next App Router
|
|
4
|
+
* accepts directly — so this is pure ergonomics: wire an ORM `adapter` in via the
|
|
5
|
+
* bridge and hand back a named `POST` so the customer's route file is the minimum:
|
|
6
|
+
*
|
|
7
|
+
* // app/api/ablo/source/route.ts
|
|
8
|
+
* import { dataSourceNext } from '@abloatai/ablo/source/next';
|
|
9
|
+
* import { prismaDataSource } from '@abloatai/ablo/source';
|
|
10
|
+
* import { schema } from '@/ablo/schema';
|
|
11
|
+
* import { prisma } from '@/lib/prisma';
|
|
12
|
+
*
|
|
13
|
+
* export const { POST } = dataSourceNext({
|
|
14
|
+
* schema,
|
|
15
|
+
* apiKey: process.env.ABLO_API_KEY!,
|
|
16
|
+
* adapter: prismaDataSource(prisma, schema),
|
|
17
|
+
* });
|
|
18
|
+
*
|
|
19
|
+
* Day-one scope: Next + the adapter form only. Hand-written handlers use the core
|
|
20
|
+
* `dataSource()` directly; Hono/Express are the same one-liner and land on demand
|
|
21
|
+
* — not pre-built.
|
|
22
|
+
*/
|
|
23
|
+
import { dataSource } from './index.js';
|
|
24
|
+
export function dataSourceNext(options) {
|
|
25
|
+
return { POST: dataSource(options) };
|
|
26
|
+
}
|
|
@@ -61,7 +61,13 @@ export interface BootstrapOptions {
|
|
|
61
61
|
* old clients that don't send a models param).
|
|
62
62
|
*/
|
|
63
63
|
instantModels?: string[];
|
|
64
|
+
/**
|
|
65
|
+
* Shared SDK credential getter. Preferred over `setAuthToken`; read at
|
|
66
|
+
* request time so token refreshes apply without recreating BootstrapHelper.
|
|
67
|
+
*/
|
|
68
|
+
getAuthToken?: AuthTokenGetter;
|
|
64
69
|
}
|
|
70
|
+
import { type AuthTokenGetter } from '../auth/credentialSource.js';
|
|
65
71
|
import { type ValidatedServerDelta } from './schemas.js';
|
|
66
72
|
export declare class BootstrapHelper {
|
|
67
73
|
private options;
|
|
@@ -74,6 +80,10 @@ export declare class BootstrapHelper {
|
|
|
74
80
|
*/
|
|
75
81
|
setCacheScope(cacheScope: string): void;
|
|
76
82
|
setSyncGroups(syncGroups: readonly string[] | undefined): void;
|
|
83
|
+
/**
|
|
84
|
+
* Compatibility setter for direct BootstrapHelper users. The SDK-owned
|
|
85
|
+
* `Ablo()` path passes `getAuthToken` and does not mutate this helper.
|
|
86
|
+
*/
|
|
77
87
|
setAuthToken(authToken: string | undefined): void;
|
|
78
88
|
/**
|
|
79
89
|
* Create a promise that rejects after a timeout
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { getContext } from '../context.js';
|
|
6
6
|
import { SyncSessionError, AbloConnectionError, translateHttpError, toAbloError, isRetryableCode } from '../errors.js';
|
|
7
|
+
import { withAuthHeaders } from '../auth/credentialSource.js';
|
|
7
8
|
// SyncObservability replaced by getContext().observability
|
|
8
9
|
import { parseBootstrapResponse } from './schemas.js';
|
|
9
10
|
export class BootstrapHelper {
|
|
@@ -46,6 +47,10 @@ export class BootstrapHelper {
|
|
|
46
47
|
setSyncGroups(syncGroups) {
|
|
47
48
|
this.options.syncGroups = [...(syncGroups ?? [])];
|
|
48
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Compatibility setter for direct BootstrapHelper users. The SDK-owned
|
|
52
|
+
* `Ablo()` path passes `getAuthToken` and does not mutate this helper.
|
|
53
|
+
*/
|
|
49
54
|
setAuthToken(authToken) {
|
|
50
55
|
if (!authToken) {
|
|
51
56
|
delete this.options.authToken;
|
|
@@ -197,15 +202,11 @@ export class BootstrapHelper {
|
|
|
197
202
|
// conditional revalidation (If-None-Match) implement it at their own
|
|
198
203
|
// level where they own the cache-key namespace. The 304 branch below
|
|
199
204
|
// remains defensively in place for when a caller enables revalidation.
|
|
200
|
-
const headers = { 'Content-Type': 'application/json' };
|
|
201
|
-
if (this.options.authToken) {
|
|
202
|
-
headers.Authorization = `Bearer ${this.options.authToken}`;
|
|
203
|
-
}
|
|
205
|
+
const headers = withAuthHeaders(this.options.getAuthToken, { 'Content-Type': 'application/json' }, this.options.authToken);
|
|
204
206
|
this.abortController = new AbortController();
|
|
205
207
|
const res = await fetch(url, {
|
|
206
208
|
method: 'GET',
|
|
207
209
|
headers,
|
|
208
|
-
credentials: 'include',
|
|
209
210
|
signal: this.abortController.signal,
|
|
210
211
|
});
|
|
211
212
|
const etag = res.headers.get('ETag');
|
|
@@ -272,15 +273,11 @@ export class BootstrapHelper {
|
|
|
272
273
|
try {
|
|
273
274
|
response = await fetch(url, {
|
|
274
275
|
method: 'GET',
|
|
275
|
-
headers: {
|
|
276
|
+
headers: withAuthHeaders(this.options.getAuthToken, {
|
|
276
277
|
'Content-Type': 'application/json',
|
|
277
278
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
278
279
|
Pragma: 'no-cache',
|
|
279
|
-
|
|
280
|
-
? { Authorization: `Bearer ${this.options.authToken}` }
|
|
281
|
-
: {}),
|
|
282
|
-
},
|
|
283
|
-
credentials: 'include',
|
|
280
|
+
}, this.options.authToken),
|
|
284
281
|
signal: this.abortController.signal,
|
|
285
282
|
cache: 'no-store', // Force browser to not cache
|
|
286
283
|
});
|
|
@@ -337,10 +334,9 @@ export class BootstrapHelper {
|
|
|
337
334
|
const url = `${this.options.baseUrl}/sync/entity/${modelName}/${id}`;
|
|
338
335
|
const response = await fetch(url, {
|
|
339
336
|
method: 'GET',
|
|
340
|
-
headers: {
|
|
337
|
+
headers: withAuthHeaders(this.options.getAuthToken, {
|
|
341
338
|
'Content-Type': 'application/json',
|
|
342
|
-
},
|
|
343
|
-
credentials: 'include',
|
|
339
|
+
}, this.options.authToken),
|
|
344
340
|
});
|
|
345
341
|
if (response.status === 404) {
|
|
346
342
|
return null;
|
|
@@ -436,7 +432,6 @@ export class BootstrapHelper {
|
|
|
436
432
|
try {
|
|
437
433
|
const response = await fetch(`${this.options.baseUrl}/health`, {
|
|
438
434
|
method: 'GET',
|
|
439
|
-
credentials: 'include',
|
|
440
435
|
signal: AbortSignal.timeout(5000),
|
|
441
436
|
cache: 'no-store',
|
|
442
437
|
});
|
|
@@ -34,7 +34,8 @@
|
|
|
34
34
|
* instead of hard-reloading an already-offline browser.
|
|
35
35
|
*/
|
|
36
36
|
import { type ProbeResult } from './NetworkProbe.js';
|
|
37
|
-
|
|
37
|
+
import type { AuthTokenGetter } from '../auth/credentialSource.js';
|
|
38
|
+
export type ConnectionState = 'connected' | 'offline' | 'probing_network' | 'validating_session' | 'refreshing_credential' | 'reconnecting' | 'backoff' | 'waiting_for_network' | 'auth_blocked' | 'session_expired';
|
|
38
39
|
export type ConnectionEvent = {
|
|
39
40
|
type: 'NETWORK_LOST';
|
|
40
41
|
} | {
|
|
@@ -54,8 +55,18 @@ export type ConnectionEvent = {
|
|
|
54
55
|
sessionValid: boolean;
|
|
55
56
|
} | {
|
|
56
57
|
type: 'PROBE_AUTH_BLOCKED';
|
|
58
|
+
}
|
|
59
|
+
/** The probe saw an expired ephemeral access key (`access_credential_expiry`).
|
|
60
|
+
* Recoverable: re-mint a fresh `ek_`/`rk_` and re-probe — never a sign-out. */
|
|
61
|
+
| {
|
|
62
|
+
type: 'PROBE_CREDENTIAL_STALE';
|
|
57
63
|
} | {
|
|
58
64
|
type: 'PROBE_FAILED';
|
|
65
|
+
}
|
|
66
|
+
/** A fresh access credential is available (the re-mint succeeded, or one was
|
|
67
|
+
* pushed in via `setAuthToken`). Re-probe so a parked connection picks it up. */
|
|
68
|
+
| {
|
|
69
|
+
type: 'CREDENTIAL_REFRESHED';
|
|
59
70
|
} | {
|
|
60
71
|
type: 'RECONNECT_SUCCESS';
|
|
61
72
|
} | {
|
|
@@ -70,6 +81,20 @@ export type ConnectionEvent = {
|
|
|
70
81
|
export interface ConnectionCallbacks {
|
|
71
82
|
/** Run bootstrap + WebSocket reconnect. Returns the outcome. */
|
|
72
83
|
onReconnect: () => Promise<'success' | 'session_error' | 'network_error'>;
|
|
84
|
+
/**
|
|
85
|
+
* Re-mint the short-lived access credential (the Stripe-style `ek_`/`rk_`)
|
|
86
|
+
* and push it into the credential source, then report the outcome. Invoked
|
|
87
|
+
* on `refreshing_credential` — i.e. when a probe found the access key stale
|
|
88
|
+
* (`PROBE_CREDENTIAL_STALE`). Mirrors the `getToken` contract:
|
|
89
|
+
* - `'refreshed'` → a fresh credential is in place; re-probe & reconnect.
|
|
90
|
+
* - `'session_error'` → the LONG-LIVED login is gone (mint returned null →
|
|
91
|
+
* 401/403); terminal → sign out.
|
|
92
|
+
* - `'network_error'` → couldn't reach the mint endpoint (offline/5xx/throw);
|
|
93
|
+
* transient → back off and retry, never sign out.
|
|
94
|
+
* Optional: a deployment with no re-mint path (e.g. a static `apiKey`) omits
|
|
95
|
+
* it, and the FSM falls back to a plain re-probe.
|
|
96
|
+
*/
|
|
97
|
+
onRefreshCredential?: () => Promise<'refreshed' | 'session_error' | 'network_error'>;
|
|
73
98
|
/** Called when the session is confirmed expired — route to signin. */
|
|
74
99
|
onSessionExpired: () => void;
|
|
75
100
|
/** Called to tear down the WebSocket when entering a dead state. */
|
|
@@ -90,6 +115,12 @@ export interface ConnectionManagerOptions {
|
|
|
90
115
|
* default of `probeNetwork`.
|
|
91
116
|
*/
|
|
92
117
|
baseUrl?: string;
|
|
118
|
+
/**
|
|
119
|
+
* Current bearer credential for authenticated probes. Read lazily so token
|
|
120
|
+
* refreshes pushed through `Ablo.setAuthToken()` are used by the next probe
|
|
121
|
+
* without recreating the manager.
|
|
122
|
+
*/
|
|
123
|
+
getAuthToken?: AuthTokenGetter;
|
|
93
124
|
/** Override retry ceilings / jitter. Production should leave defaults. */
|
|
94
125
|
backoff?: Partial<typeof DEFAULT_BACKOFF>;
|
|
95
126
|
}
|
|
@@ -109,8 +140,12 @@ export declare class ConnectionManager {
|
|
|
109
140
|
private debounceTimer;
|
|
110
141
|
private watchdogTimer;
|
|
111
142
|
private stuckCycles;
|
|
143
|
+
/** Consecutive access-key re-mints in the current recovery cycle; reset on
|
|
144
|
+
* reaching `connected`. See {@link MAX_CREDENTIAL_REFRESH_ATTEMPTS}. */
|
|
145
|
+
private credentialRefreshAttempts;
|
|
112
146
|
private disposed;
|
|
113
147
|
private readonly baseUrl?;
|
|
148
|
+
private readonly getAuthToken?;
|
|
114
149
|
private readonly backoff;
|
|
115
150
|
private handleBrowserOnline;
|
|
116
151
|
private handleBrowserOffline;
|
|
@@ -122,6 +157,25 @@ export declare class ConnectionManager {
|
|
|
122
157
|
private transition;
|
|
123
158
|
private onEnterState;
|
|
124
159
|
private runProbe;
|
|
160
|
+
/**
|
|
161
|
+
* Re-mint the short-lived access key on `refreshing_credential`. Delegates to
|
|
162
|
+
* the `onRefreshCredential` callback (which mints a fresh `ek_`/`rk_` from the
|
|
163
|
+
* still-valid login and pushes it into the credential source) and maps its
|
|
164
|
+
* tri-state outcome onto the FSM:
|
|
165
|
+
* - `refreshed` → `CREDENTIAL_REFRESHED` → re-probe & reconnect.
|
|
166
|
+
* - `session_error` → `BOOTSTRAP_FAILED_SESSION` → sign out (login is gone).
|
|
167
|
+
* - `network_error` → `RECONNECT_FAILED` → back off & retry (never sign out).
|
|
168
|
+
*
|
|
169
|
+
* A bounded attempt counter guards against a hot loop where the server keeps
|
|
170
|
+
* reporting the key stale even after a "successful" re-mint (e.g. a clock skew
|
|
171
|
+
* or a mint that returns an already-rejected key): after
|
|
172
|
+
* `MAX_CREDENTIAL_REFRESH_ATTEMPTS` we fall through to `auth_blocked` (stop,
|
|
173
|
+
* no sign-out) rather than spin. The counter resets once we reach `connected`.
|
|
174
|
+
*
|
|
175
|
+
* When no refresher is wired (e.g. a static `apiKey` deployment), we re-probe
|
|
176
|
+
* directly — the credential source's own scheduler owns refresh there.
|
|
177
|
+
*/
|
|
178
|
+
private runRefreshCredential;
|
|
125
179
|
private runReconnect;
|
|
126
180
|
private scheduleBackoff;
|
|
127
181
|
private setupBrowserListeners;
|
|
@@ -46,6 +46,10 @@ const DEFAULT_BACKOFF = {
|
|
|
46
46
|
const ONLINE_DEBOUNCE_MS = 500;
|
|
47
47
|
const WATCHDOG_INTERVAL_MS = 30_000;
|
|
48
48
|
const MAX_STUCK_CYCLES_BEFORE_RELOAD = 6;
|
|
49
|
+
/** Cap on consecutive access-key re-mints before giving up to `auth_blocked`.
|
|
50
|
+
* Stops a hot loop if the server keeps reporting the key stale even after a
|
|
51
|
+
* "successful" re-mint (clock skew, a mint returning an already-rejected key). */
|
|
52
|
+
const MAX_CREDENTIAL_REFRESH_ATTEMPTS = 3;
|
|
49
53
|
// ─── ConnectionManager ────────────────────────────────────────────────────
|
|
50
54
|
export class ConnectionManager {
|
|
51
55
|
// Observable state
|
|
@@ -59,14 +63,19 @@ export class ConnectionManager {
|
|
|
59
63
|
debounceTimer = null;
|
|
60
64
|
watchdogTimer = null;
|
|
61
65
|
stuckCycles = 0;
|
|
66
|
+
/** Consecutive access-key re-mints in the current recovery cycle; reset on
|
|
67
|
+
* reaching `connected`. See {@link MAX_CREDENTIAL_REFRESH_ATTEMPTS}. */
|
|
68
|
+
credentialRefreshAttempts = 0;
|
|
62
69
|
disposed = false;
|
|
63
70
|
baseUrl;
|
|
71
|
+
getAuthToken;
|
|
64
72
|
backoff;
|
|
65
73
|
handleBrowserOnline = null;
|
|
66
74
|
handleBrowserOffline = null;
|
|
67
75
|
handleVisibilityChange = null;
|
|
68
76
|
constructor(options = {}) {
|
|
69
77
|
this.baseUrl = options.baseUrl;
|
|
78
|
+
this.getAuthToken = options.getAuthToken;
|
|
70
79
|
this.backoff = { ...DEFAULT_BACKOFF, ...(options.backoff ?? {}) };
|
|
71
80
|
makeAutoObservable(this, {}, { autoBind: true });
|
|
72
81
|
}
|
|
@@ -151,6 +160,7 @@ export class ConnectionManager {
|
|
|
151
160
|
case 'MANUAL_RETRY':
|
|
152
161
|
case 'TAB_VISIBLE':
|
|
153
162
|
case 'WS_HANDSHAKE_FAILED':
|
|
163
|
+
case 'CREDENTIAL_REFRESHED':
|
|
154
164
|
return 'probing_network';
|
|
155
165
|
case 'WS_SESSION_ERROR':
|
|
156
166
|
case 'BOOTSTRAP_FAILED_SESSION':
|
|
@@ -162,6 +172,9 @@ export class ConnectionManager {
|
|
|
162
172
|
switch (event.type) {
|
|
163
173
|
case 'PROBE_SUCCESS':
|
|
164
174
|
return event.sessionValid ? 'reconnecting' : 'session_expired';
|
|
175
|
+
case 'PROBE_CREDENTIAL_STALE':
|
|
176
|
+
// Access key expired but the login is fine — re-mint, don't sign out.
|
|
177
|
+
return 'refreshing_credential';
|
|
165
178
|
case 'PROBE_AUTH_BLOCKED':
|
|
166
179
|
return 'auth_blocked';
|
|
167
180
|
case 'PROBE_FAILED':
|
|
@@ -177,6 +190,7 @@ export class ConnectionManager {
|
|
|
177
190
|
case 'TAB_VISIBLE':
|
|
178
191
|
case 'MANUAL_RETRY':
|
|
179
192
|
case 'BACKOFF_ELAPSED':
|
|
193
|
+
case 'CREDENTIAL_REFRESHED':
|
|
180
194
|
return 'probing_network';
|
|
181
195
|
case 'NETWORK_LOST':
|
|
182
196
|
return 'offline';
|
|
@@ -187,6 +201,8 @@ export class ConnectionManager {
|
|
|
187
201
|
switch (event.type) {
|
|
188
202
|
case 'PROBE_SUCCESS':
|
|
189
203
|
return event.sessionValid ? 'reconnecting' : 'session_expired';
|
|
204
|
+
case 'PROBE_CREDENTIAL_STALE':
|
|
205
|
+
return 'refreshing_credential';
|
|
190
206
|
case 'PROBE_AUTH_BLOCKED':
|
|
191
207
|
return 'auth_blocked';
|
|
192
208
|
case 'NETWORK_LOST':
|
|
@@ -194,6 +210,31 @@ export class ConnectionManager {
|
|
|
194
210
|
default:
|
|
195
211
|
return null;
|
|
196
212
|
}
|
|
213
|
+
case 'refreshing_credential':
|
|
214
|
+
// Re-minting the short-lived access key (the Stripe-style `ek_`/`rk_`).
|
|
215
|
+
// The login is presumed valid; this is NOT a sign-out state.
|
|
216
|
+
switch (event.type) {
|
|
217
|
+
case 'CREDENTIAL_REFRESHED':
|
|
218
|
+
// Fresh key in hand — re-probe so we reconnect with it.
|
|
219
|
+
return 'probing_network';
|
|
220
|
+
case 'BOOTSTRAP_FAILED_SESSION':
|
|
221
|
+
// The re-mint hit a genuine 401/403: the long-lived login itself is
|
|
222
|
+
// gone. THIS is the only path from here to sign-out.
|
|
223
|
+
return 'session_expired';
|
|
224
|
+
case 'RECONNECT_FAILED':
|
|
225
|
+
// Couldn't reach the mint endpoint (offline/5xx/throw) — transient.
|
|
226
|
+
// Back off and retry; never sign out for a network failure.
|
|
227
|
+
return 'backoff';
|
|
228
|
+
case 'PROBE_AUTH_BLOCKED':
|
|
229
|
+
// Bounded-attempt fallback: the key keeps coming back stale even
|
|
230
|
+
// after re-mint (see runRefreshCredential's attempt guard). Stop
|
|
231
|
+
// looping without signing out.
|
|
232
|
+
return 'auth_blocked';
|
|
233
|
+
case 'NETWORK_LOST':
|
|
234
|
+
return 'offline';
|
|
235
|
+
default:
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
197
238
|
case 'reconnecting':
|
|
198
239
|
switch (event.type) {
|
|
199
240
|
case 'RECONNECT_SUCCESS':
|
|
@@ -218,10 +259,11 @@ export class ConnectionManager {
|
|
|
218
259
|
return 'probing_network';
|
|
219
260
|
case 'NETWORK_ONLINE':
|
|
220
261
|
case 'TAB_VISIBLE':
|
|
221
|
-
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
262
|
+
case 'CREDENTIAL_REFRESHED':
|
|
263
|
+
// Network came back (or a fresh credential arrived) while we were
|
|
264
|
+
// waiting out a backoff delay — jump straight to probing instead of
|
|
265
|
+
// waiting the full exponential interval. Fixes the "doesn't
|
|
266
|
+
// retrigger when internet comes back" bug.
|
|
225
267
|
return 'probing_network';
|
|
226
268
|
case 'NETWORK_LOST':
|
|
227
269
|
return 'offline';
|
|
@@ -235,12 +277,14 @@ export class ConnectionManager {
|
|
|
235
277
|
// Reachable, but the data-plane rejected the credential (non-retryable,
|
|
236
278
|
// non-expiry — e.g. api_key_required, jwt_issuer_untrusted). Don't
|
|
237
279
|
// auto-reconnect and don't sign out. Allow a manual retry or a
|
|
238
|
-
// tab-focus / network-return re-probe (e.g. after a
|
|
239
|
-
//
|
|
280
|
+
// tab-focus / network-return / fresh-credential re-probe (e.g. after a
|
|
281
|
+
// server deploy or an out-of-band re-mint); a network drop parks
|
|
282
|
+
// offline; a genuine session error still expires.
|
|
240
283
|
switch (event.type) {
|
|
241
284
|
case 'MANUAL_RETRY':
|
|
242
285
|
case 'TAB_VISIBLE':
|
|
243
286
|
case 'NETWORK_ONLINE':
|
|
287
|
+
case 'CREDENTIAL_REFRESHED':
|
|
244
288
|
return 'probing_network';
|
|
245
289
|
case 'NETWORK_LOST':
|
|
246
290
|
return 'offline';
|
|
@@ -261,6 +305,9 @@ export class ConnectionManager {
|
|
|
261
305
|
switch (state) {
|
|
262
306
|
case 'connected':
|
|
263
307
|
this.clearBackoffTimer();
|
|
308
|
+
runInAction(() => {
|
|
309
|
+
this.credentialRefreshAttempts = 0;
|
|
310
|
+
});
|
|
264
311
|
break;
|
|
265
312
|
case 'offline':
|
|
266
313
|
this.clearBackoffTimer();
|
|
@@ -275,6 +322,9 @@ export class ConnectionManager {
|
|
|
275
322
|
case 'reconnecting':
|
|
276
323
|
this.runReconnect();
|
|
277
324
|
break;
|
|
325
|
+
case 'refreshing_credential':
|
|
326
|
+
this.runRefreshCredential();
|
|
327
|
+
break;
|
|
278
328
|
case 'backoff':
|
|
279
329
|
this.scheduleBackoff();
|
|
280
330
|
break;
|
|
@@ -299,24 +349,108 @@ export class ConnectionManager {
|
|
|
299
349
|
// ── Async operations ─────────────────────────────────────────────────
|
|
300
350
|
async runProbe() {
|
|
301
351
|
try {
|
|
302
|
-
const result = await probeNetwork(
|
|
352
|
+
const result = await probeNetwork({
|
|
353
|
+
baseUrl: this.baseUrl,
|
|
354
|
+
getAuthToken: this.getAuthToken,
|
|
355
|
+
});
|
|
303
356
|
runInAction(() => {
|
|
304
357
|
this.lastProbeResult = result;
|
|
305
358
|
});
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
359
|
+
// One probe outcome → one event. Exhaustive over ProbeOutcome so a new
|
|
360
|
+
// outcome can't be silently dropped.
|
|
361
|
+
switch (result.outcome) {
|
|
362
|
+
case 'reachable':
|
|
363
|
+
this.send({ type: 'PROBE_SUCCESS', sessionValid: true });
|
|
364
|
+
break;
|
|
365
|
+
case 'session_expired':
|
|
366
|
+
// Genuine login expiry — terminal. (PROBE_SUCCESS with
|
|
367
|
+
// sessionValid:false routes to session_expired in the FSM.)
|
|
368
|
+
this.send({ type: 'PROBE_SUCCESS', sessionValid: false });
|
|
369
|
+
break;
|
|
370
|
+
case 'credential_stale':
|
|
371
|
+
// Access key expired but the login is fine — re-mint, don't sign out.
|
|
372
|
+
this.send({ type: 'PROBE_CREDENTIAL_STALE' });
|
|
373
|
+
break;
|
|
374
|
+
case 'auth_blocked':
|
|
375
|
+
this.send({ type: 'PROBE_AUTH_BLOCKED' });
|
|
376
|
+
break;
|
|
377
|
+
case 'unreachable':
|
|
378
|
+
this.send({ type: 'PROBE_FAILED' });
|
|
379
|
+
break;
|
|
380
|
+
default: {
|
|
381
|
+
const _exhaustive = result.outcome;
|
|
382
|
+
void _exhaustive;
|
|
383
|
+
this.send({ type: 'PROBE_FAILED' });
|
|
384
|
+
}
|
|
314
385
|
}
|
|
315
386
|
}
|
|
316
387
|
catch {
|
|
317
388
|
this.send({ type: 'PROBE_FAILED' });
|
|
318
389
|
}
|
|
319
390
|
}
|
|
391
|
+
/**
|
|
392
|
+
* Re-mint the short-lived access key on `refreshing_credential`. Delegates to
|
|
393
|
+
* the `onRefreshCredential` callback (which mints a fresh `ek_`/`rk_` from the
|
|
394
|
+
* still-valid login and pushes it into the credential source) and maps its
|
|
395
|
+
* tri-state outcome onto the FSM:
|
|
396
|
+
* - `refreshed` → `CREDENTIAL_REFRESHED` → re-probe & reconnect.
|
|
397
|
+
* - `session_error` → `BOOTSTRAP_FAILED_SESSION` → sign out (login is gone).
|
|
398
|
+
* - `network_error` → `RECONNECT_FAILED` → back off & retry (never sign out).
|
|
399
|
+
*
|
|
400
|
+
* A bounded attempt counter guards against a hot loop where the server keeps
|
|
401
|
+
* reporting the key stale even after a "successful" re-mint (e.g. a clock skew
|
|
402
|
+
* or a mint that returns an already-rejected key): after
|
|
403
|
+
* `MAX_CREDENTIAL_REFRESH_ATTEMPTS` we fall through to `auth_blocked` (stop,
|
|
404
|
+
* no sign-out) rather than spin. The counter resets once we reach `connected`.
|
|
405
|
+
*
|
|
406
|
+
* When no refresher is wired (e.g. a static `apiKey` deployment), we re-probe
|
|
407
|
+
* directly — the credential source's own scheduler owns refresh there.
|
|
408
|
+
*/
|
|
409
|
+
async runRefreshCredential() {
|
|
410
|
+
if (this.credentialRefreshAttempts >= MAX_CREDENTIAL_REFRESH_ATTEMPTS) {
|
|
411
|
+
getContext().logger.warn('[ConnectionManager] Access key still stale after repeated re-mints — stopping', { attempts: this.credentialRefreshAttempts });
|
|
412
|
+
runInAction(() => {
|
|
413
|
+
this.credentialRefreshAttempts = 0;
|
|
414
|
+
});
|
|
415
|
+
this.send({ type: 'PROBE_AUTH_BLOCKED' });
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
runInAction(() => {
|
|
419
|
+
this.credentialRefreshAttempts += 1;
|
|
420
|
+
});
|
|
421
|
+
const refresher = this.callbacks?.onRefreshCredential;
|
|
422
|
+
if (!refresher) {
|
|
423
|
+
// No re-mint path wired — re-probe with whatever the credential source
|
|
424
|
+
// holds (a static-key deployment refreshes out-of-band, if at all).
|
|
425
|
+
this.send({ type: 'CREDENTIAL_REFRESHED' });
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
try {
|
|
429
|
+
const result = await refresher();
|
|
430
|
+
switch (result) {
|
|
431
|
+
case 'refreshed':
|
|
432
|
+
this.send({ type: 'CREDENTIAL_REFRESHED' });
|
|
433
|
+
break;
|
|
434
|
+
case 'session_error':
|
|
435
|
+
this.send({ type: 'BOOTSTRAP_FAILED_SESSION' });
|
|
436
|
+
break;
|
|
437
|
+
case 'network_error':
|
|
438
|
+
this.send({ type: 'RECONNECT_FAILED' });
|
|
439
|
+
break;
|
|
440
|
+
default: {
|
|
441
|
+
const _exhaustive = result;
|
|
442
|
+
void _exhaustive;
|
|
443
|
+
this.send({ type: 'RECONNECT_FAILED' });
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
catch (error) {
|
|
448
|
+
// A thrown refresher is transient by contract (offline / mint endpoint
|
|
449
|
+
// unreachable) — back off and retry, never sign out.
|
|
450
|
+
getContext().logger.warn('[ConnectionManager] Credential re-mint threw (transient)', { error });
|
|
451
|
+
this.send({ type: 'RECONNECT_FAILED' });
|
|
452
|
+
}
|
|
453
|
+
}
|
|
320
454
|
async runReconnect() {
|
|
321
455
|
if (!this.callbacks)
|
|
322
456
|
return;
|
|
@@ -431,6 +565,7 @@ export class ConnectionManager {
|
|
|
431
565
|
const isStuck = this.state !== 'connected' &&
|
|
432
566
|
this.state !== 'session_expired' &&
|
|
433
567
|
this.state !== 'probing_network' &&
|
|
568
|
+
this.state !== 'refreshing_credential' &&
|
|
434
569
|
this.state !== 'reconnecting';
|
|
435
570
|
if (isStuck) {
|
|
436
571
|
this.stuckCycles++;
|
|
@@ -467,7 +602,11 @@ export class ConnectionManager {
|
|
|
467
602
|
get isConnected() { return this.state === 'connected'; }
|
|
468
603
|
get isOffline() { return this.state === 'offline' || this.state === 'waiting_for_network'; }
|
|
469
604
|
get isReconnecting() {
|
|
470
|
-
return this.state === 'probing_network' ||
|
|
605
|
+
return (this.state === 'probing_network' ||
|
|
606
|
+
this.state === 'validating_session' ||
|
|
607
|
+
this.state === 'refreshing_credential' ||
|
|
608
|
+
this.state === 'reconnecting' ||
|
|
609
|
+
this.state === 'backoff');
|
|
471
610
|
}
|
|
472
611
|
get isSessionExpired() { return this.state === 'session_expired'; }
|
|
473
612
|
get offlineDuration() {
|
|
@@ -33,12 +33,11 @@ export interface HydrationCoordinatorOptions {
|
|
|
33
33
|
/** Bootstrap base URL (without trailing slash), e.g. `https://api.example.com/api`. */
|
|
34
34
|
readonly baseUrl: string;
|
|
35
35
|
/**
|
|
36
|
-
* Lazy getter for the active
|
|
37
|
-
*
|
|
38
|
-
* Optional: browser consumers ride session cookies and can omit this;
|
|
39
|
-
* Node consumers (agent-worker) must wire it through or HTTP queries
|
|
40
|
-
* fail with 401 because cookies aren't available.
|
|
36
|
+
* Lazy getter for the active bearer token. Resolved per request so refreshes
|
|
37
|
+
* propagate without re-instantiating the coordinator.
|
|
41
38
|
*/
|
|
39
|
+
readonly getAuthToken?: () => string | null;
|
|
40
|
+
/** @deprecated Use `getAuthToken`. */
|
|
42
41
|
readonly getCapabilityToken?: () => string | null;
|
|
43
42
|
}
|
|
44
43
|
export interface FetchOptions<T> {
|
|
@@ -55,10 +54,14 @@ export interface FetchOptions<T> {
|
|
|
55
54
|
};
|
|
56
55
|
readonly limit?: number;
|
|
57
56
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* `
|
|
61
|
-
*
|
|
57
|
+
* Freshness mode. When omitted, the default is derived from the model's
|
|
58
|
+
* load strategy: `lazy` models default to `'unknown'` (local-first), while
|
|
59
|
+
* `instant`/`partial` models default to `'complete'`.
|
|
60
|
+
*
|
|
61
|
+
* `'complete'`: wait for the network round-trip even if local data exists,
|
|
62
|
+
* so the caller observes server-confirmed state (read-after-write).
|
|
63
|
+
* `'unknown'`: return whatever's in the pool/IDB immediately and fire the
|
|
64
|
+
* network refresh in the background (stale-while-revalidate).
|
|
62
65
|
*/
|
|
63
66
|
readonly type?: 'complete' | 'unknown';
|
|
64
67
|
/**
|
|
@@ -71,18 +74,35 @@ export interface FetchOptions<T> {
|
|
|
71
74
|
export declare class HydrationCoordinator {
|
|
72
75
|
private readonly opts;
|
|
73
76
|
private readonly inFlight;
|
|
74
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Query keys with a background confirm currently in flight. Distinct from
|
|
79
|
+
* {@link inFlight} (which dedupes *blocking* callers awaiting the same
|
|
80
|
+
* fetch): this set dedupes the fire-and-forget network confirm kicked off
|
|
81
|
+
* after a local-first read returns cached data, so a burst of mounts that
|
|
82
|
+
* all hit the warm pool/IDB don't each spawn their own redundant fetch.
|
|
83
|
+
*/
|
|
84
|
+
private readonly revalidating;
|
|
85
|
+
/**
|
|
86
|
+
* Query keys that have been satisfied from the server at least once this
|
|
87
|
+
* session. Once a key is here, repeat reads serve purely from the pool with
|
|
88
|
+
* NO network: the WebSocket delta stream keeps those pool rows fresh, so
|
|
89
|
+
* re-running the HTTP query would be redundant polling. This is the ledger
|
|
90
|
+
* that stops an already-open deck from re-querying on every navigation.
|
|
91
|
+
*
|
|
92
|
+
* Cleared on reconnect (see {@link invalidate}) so that, after a connection
|
|
93
|
+
* drop where deltas may have been missed, the next read re-confirms once.
|
|
94
|
+
*/
|
|
95
|
+
private readonly hydratedKeys;
|
|
96
|
+
private authTokenProvider;
|
|
75
97
|
constructor(opts: HydrationCoordinatorOptions);
|
|
76
98
|
/**
|
|
77
|
-
* Late-bind the
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* construction). Browser consumers ride session cookies and don't
|
|
81
|
-
* need this; Node consumers (agent-worker) MUST call it or HTTP
|
|
82
|
-
* queries fail with 401 because cookies aren't available.
|
|
99
|
+
* Late-bind the auth token getter. Browser cookie consumers can omit this;
|
|
100
|
+
* bearer consumers need it so lazy HTTP queries use the same credential as
|
|
101
|
+
* bootstrap and the WebSocket.
|
|
83
102
|
*/
|
|
103
|
+
setAuthTokenProvider(provider: () => string | null): void;
|
|
104
|
+
/** @deprecated Use `setAuthTokenProvider`. */
|
|
84
105
|
setCapabilityTokenProvider(provider: () => string | null): void;
|
|
85
|
-
private resolveToken;
|
|
86
106
|
/**
|
|
87
107
|
* Fetch matching rows for a model, hydrating the pool from IDB or
|
|
88
108
|
* network if not already present. Idempotent and single-flight
|
|
@@ -90,6 +110,62 @@ export declare class HydrationCoordinator {
|
|
|
90
110
|
*/
|
|
91
111
|
fetch<T>(modelName: string, options?: FetchOptions<T>): Promise<Model[]>;
|
|
92
112
|
private runFetch;
|
|
113
|
+
/**
|
|
114
|
+
* Read a query's rows from local storage only — pool first, then IndexedDB
|
|
115
|
+
* on a pool miss (cold start after reload, or LRU eviction), hydrating the
|
|
116
|
+
* pool from IDB as a side effect. Resolves requested `expand` relations from
|
|
117
|
+
* their own local stores too. Never touches the network.
|
|
118
|
+
*/
|
|
119
|
+
private readLocal;
|
|
120
|
+
/**
|
|
121
|
+
* Drop the hydration ledger so the next read of each query re-confirms with
|
|
122
|
+
* the server. Called on reconnect — after a connection drop, deltas may have
|
|
123
|
+
* been missed, so the "WS keeps the pool fresh" assumption no longer holds
|
|
124
|
+
* until a fresh fetch (or the engine's delta catch-up) reconciles.
|
|
125
|
+
*/
|
|
126
|
+
invalidate(): void;
|
|
127
|
+
/**
|
|
128
|
+
* Run the network leg of a fetch: query the server, hydrate primary rows
|
|
129
|
+
* (and any expanded relations) into the pool, and persist them to IDB.
|
|
130
|
+
* Shared by the blocking path (`runFetch` step 3) and the background
|
|
131
|
+
* revalidation kicked off after an `'unknown'` local hit.
|
|
132
|
+
*/
|
|
133
|
+
private fetchFromNetwork;
|
|
134
|
+
/**
|
|
135
|
+
* Fire-and-forget the ONE server confirm for a query that was just served
|
|
136
|
+
* from local cache but isn't hydrated yet. On success the key is marked
|
|
137
|
+
* hydrated, so every later read serves pure-local with no network until a
|
|
138
|
+
* reconnect invalidates the ledger. Deduped per query key so a render burst
|
|
139
|
+
* doesn't stampede. Errors are swallowed — the caller already has a usable
|
|
140
|
+
* local snapshot, and a failed confirm leaves the key un-hydrated so the
|
|
141
|
+
* next read simply tries again.
|
|
142
|
+
*/
|
|
143
|
+
private scheduleHydratingFetch;
|
|
144
|
+
/**
|
|
145
|
+
* Hydrate a parent's `hasMany`/`hasOne` relations from their OWN local
|
|
146
|
+
* stores (pool first, then IndexedDB by the FK secondary index) into the
|
|
147
|
+
* pool. The mirror of {@link hydrateExpanded} for the local read path:
|
|
148
|
+
* `hydrateExpanded` walks server-JOINed nested rows, this walks the child
|
|
149
|
+
* model's own store keyed by the relation's foreign key.
|
|
150
|
+
*
|
|
151
|
+
* Fully schema-driven via the relation's `target` + `foreignKey` — no
|
|
152
|
+
* per-model special-casing. `belongsTo` relations are skipped: those point
|
|
153
|
+
* at a single parent (the inverse direction), already covered by the
|
|
154
|
+
* primary scan when that parent is itself the fetched model.
|
|
155
|
+
*/
|
|
156
|
+
private hydrateExpandedFromLocal;
|
|
157
|
+
/**
|
|
158
|
+
* Read a child model's rows from local storage by foreign key.
|
|
159
|
+
*
|
|
160
|
+
* Uses the FK secondary index (O(matches) per parent) only when the schema
|
|
161
|
+
* declares one — `getAllFromIndex` resolves `[]` for a missing index rather
|
|
162
|
+
* than throwing, so the decision is made up front from the registry, not by
|
|
163
|
+
* catching. Unindexed FKs — and in-memory stores, which carry no secondary
|
|
164
|
+
* indexes at all — fall back to a single full-store scan filtered in JS.
|
|
165
|
+
*/
|
|
166
|
+
private readChildrenLocal;
|
|
167
|
+
/** Typed accessor for a model's schema definition (typename + relations). */
|
|
168
|
+
private getModelDef;
|
|
93
169
|
private hydrateOne;
|
|
94
170
|
/**
|
|
95
171
|
* Stamp `__typename` onto a row when it's known (from the schema's
|