@abloatai/ablo 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +72 -1
- package/README.md +80 -66
- package/dist/BaseSyncedStore.d.ts +73 -0
- package/dist/BaseSyncedStore.js +179 -5
- package/dist/Model.d.ts +42 -0
- package/dist/Model.js +103 -44
- package/dist/SyncEngineContext.d.ts +2 -1
- package/dist/SyncEngineContext.js +5 -3
- package/dist/agent/session.js +6 -5
- 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 +26 -36
- package/dist/auth/schemas.d.ts +35 -0
- package/dist/auth/schemas.js +53 -0
- package/dist/client/Ablo.d.ts +259 -33
- package/dist/client/Ablo.js +276 -73
- package/dist/client/ApiClient.d.ts +52 -4
- package/dist/client/ApiClient.js +236 -66
- package/dist/client/auth.d.ts +21 -2
- package/dist/client/auth.js +77 -5
- package/dist/client/createInternalComponents.d.ts +2 -0
- package/dist/client/createInternalComponents.js +8 -1
- package/dist/client/createModelProxy.d.ts +187 -79
- package/dist/client/createModelProxy.js +203 -68
- 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 +63 -11
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/client/registerDataSource.d.ts +19 -0
- package/dist/client/registerDataSource.js +59 -0
- package/dist/client/validateAbloOptions.d.ts +2 -1
- package/dist/client/validateAbloOptions.js +8 -7
- 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 +92 -1
- package/dist/errorCodes.js +139 -7
- package/dist/errors.d.ts +54 -3
- package/dist/errors.js +192 -44
- package/dist/index.d.ts +23 -10
- package/dist/index.js +21 -8
- package/dist/keys/index.d.ts +76 -0
- package/dist/keys/index.js +171 -0
- 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 +22 -14
- package/dist/react/AbloProvider.d.ts +23 -101
- package/dist/react/AbloProvider.js +61 -103
- package/dist/react/ClientSideSuspense.d.ts +1 -1
- package/dist/react/DefaultFallback.d.ts +1 -1
- package/dist/react/SyncGroupProvider.d.ts +1 -1
- package/dist/react/index.d.ts +3 -2
- package/dist/react/index.js +3 -2
- package/dist/react/useAblo.d.ts +4 -4
- package/dist/react/useAblo.js +10 -5
- package/dist/react/useCurrentUserId.d.ts +1 -1
- package/dist/react/useCurrentUserId.js +1 -1
- package/dist/react/useMutators.js +19 -12
- package/dist/react/useReactive.js +16 -3
- 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 +7 -3
- package/dist/schema/serialize.js +6 -2
- 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 +56 -42
- package/dist/sync/ConnectionManager.d.ts +57 -1
- package/dist/sync/ConnectionManager.js +186 -11
- package/dist/sync/HydrationCoordinator.d.ts +93 -17
- package/dist/sync/HydrationCoordinator.js +241 -41
- package/dist/sync/NetworkProbe.d.ts +60 -18
- package/dist/sync/NetworkProbe.js +121 -23
- package/dist/sync/SyncWebSocket.d.ts +45 -70
- package/dist/sync/SyncWebSocket.js +113 -89
- package/dist/sync/createIntentStream.js +10 -1
- package/dist/sync/participants.js +5 -2
- package/dist/transactions/TransactionQueue.js +13 -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-keys.md +5 -5
- package/docs/api.md +125 -65
- package/docs/audit.md +16 -9
- package/docs/cli.md +57 -47
- package/docs/client-behavior.md +54 -40
- package/docs/coordination.md +66 -80
- package/docs/data-sources.md +56 -34
- package/docs/examples/agent-human.md +74 -28
- package/docs/examples/ai-sdk-tool.md +29 -22
- package/docs/examples/existing-python-backend.md +41 -26
- package/docs/examples/nextjs.md +32 -17
- package/docs/examples/scoped-agent.md +43 -28
- package/docs/examples/server-agent.md +40 -15
- package/docs/guarantees.md +38 -27
- package/docs/identity.md +65 -59
- package/docs/index.md +30 -19
- package/docs/integration-guide.md +78 -78
- package/docs/interaction-model.md +43 -35
- package/docs/mcp/claude-code.md +11 -19
- package/docs/mcp/cursor.md +7 -25
- package/docs/mcp/windsurf.md +7 -20
- package/docs/mcp.md +103 -26
- package/docs/quickstart.md +63 -61
- package/docs/react.md +24 -16
- package/docs/roadmap.md +13 -13
- package/docs/schema-contract.md +111 -0
- package/docs/the-loop.md +21 -0
- package/examples/README.md +8 -4
- package/examples/data-source/README.md +10 -7
- package/examples/data-source/customer-server.ts +27 -25
- package/examples/data-source/run.ts +4 -3
- package/examples/quickstart.ts +1 -1
- package/llms.txt +55 -21
- package/package.json +48 -3
|
@@ -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
|
|
@@ -20,28 +20,46 @@
|
|
|
20
20
|
* models accessed by id/where after the engine is ready.
|
|
21
21
|
*/
|
|
22
22
|
import { ModelScope } from '../ObjectPool.js';
|
|
23
|
+
import { AbloValidationError } from '../errors.js';
|
|
23
24
|
import { postQuery } from '../query/client.js';
|
|
24
25
|
export class HydrationCoordinator {
|
|
25
26
|
opts;
|
|
26
27
|
inFlight = new Map();
|
|
27
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Query keys with a background confirm currently in flight. Distinct from
|
|
30
|
+
* {@link inFlight} (which dedupes *blocking* callers awaiting the same
|
|
31
|
+
* fetch): this set dedupes the fire-and-forget network confirm kicked off
|
|
32
|
+
* after a local-first read returns cached data, so a burst of mounts that
|
|
33
|
+
* all hit the warm pool/IDB don't each spawn their own redundant fetch.
|
|
34
|
+
*/
|
|
35
|
+
revalidating = new Set();
|
|
36
|
+
/**
|
|
37
|
+
* Query keys that have been satisfied from the server at least once this
|
|
38
|
+
* session. Once a key is here, repeat reads serve purely from the pool with
|
|
39
|
+
* NO network: the WebSocket delta stream keeps those pool rows fresh, so
|
|
40
|
+
* re-running the HTTP query would be redundant polling. This is the ledger
|
|
41
|
+
* that stops an already-open deck from re-querying on every navigation.
|
|
42
|
+
*
|
|
43
|
+
* Cleared on reconnect (see {@link invalidate}) so that, after a connection
|
|
44
|
+
* drop where deltas may have been missed, the next read re-confirms once.
|
|
45
|
+
*/
|
|
46
|
+
hydratedKeys = new Set();
|
|
47
|
+
authTokenProvider = null;
|
|
28
48
|
constructor(opts) {
|
|
29
49
|
this.opts = opts;
|
|
30
|
-
this.
|
|
50
|
+
this.authTokenProvider = opts.getAuthToken ?? opts.getCapabilityToken ?? null;
|
|
31
51
|
}
|
|
32
52
|
/**
|
|
33
|
-
* Late-bind the
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* construction). Browser consumers ride session cookies and don't
|
|
37
|
-
* need this; Node consumers (agent-worker) MUST call it or HTTP
|
|
38
|
-
* queries fail with 401 because cookies aren't available.
|
|
53
|
+
* Late-bind the auth token getter. Browser cookie consumers can omit this;
|
|
54
|
+
* bearer consumers need it so lazy HTTP queries use the same credential as
|
|
55
|
+
* bootstrap and the WebSocket.
|
|
39
56
|
*/
|
|
40
|
-
|
|
41
|
-
this.
|
|
57
|
+
setAuthTokenProvider(provider) {
|
|
58
|
+
this.authTokenProvider = provider;
|
|
42
59
|
}
|
|
43
|
-
|
|
44
|
-
|
|
60
|
+
/** @deprecated Use `setAuthTokenProvider`. */
|
|
61
|
+
setCapabilityTokenProvider(provider) {
|
|
62
|
+
this.setAuthTokenProvider(provider);
|
|
45
63
|
}
|
|
46
64
|
/**
|
|
47
65
|
* Fetch matching rows for a model, hydrating the pool from IDB or
|
|
@@ -53,52 +71,222 @@ export class HydrationCoordinator {
|
|
|
53
71
|
const ModelClass = this.opts.registry.getModelByName(typename)
|
|
54
72
|
?? this.opts.registry.getModelByName(modelName);
|
|
55
73
|
if (!ModelClass) {
|
|
56
|
-
throw new
|
|
57
|
-
`not registered in the schema
|
|
74
|
+
throw new AbloValidationError(`HydrationCoordinator.fetch: unknown model "${modelName}" — ` +
|
|
75
|
+
`not registered in the schema.`, { code: 'model_not_registered' });
|
|
58
76
|
}
|
|
59
77
|
const clauses = normalizeWhere(options?.where);
|
|
60
|
-
const queryKey = stableKey(modelName, clauses, options?.orderBy, options?.limit);
|
|
78
|
+
const queryKey = stableKey(modelName, clauses, options?.orderBy, options?.limit, options?.expand);
|
|
61
79
|
// Single-flight: an identical hydration is already in flight.
|
|
62
80
|
const inFlight = this.inFlight.get(queryKey);
|
|
63
81
|
if (inFlight)
|
|
64
82
|
return inFlight;
|
|
65
|
-
const work = this.runFetch(modelName, typename, ModelClass, clauses, options);
|
|
83
|
+
const work = this.runFetch(modelName, typename, ModelClass, clauses, options, queryKey);
|
|
66
84
|
this.inFlight.set(queryKey, work);
|
|
67
85
|
work.finally(() => {
|
|
68
86
|
this.inFlight.delete(queryKey);
|
|
69
87
|
});
|
|
70
88
|
return work;
|
|
71
89
|
}
|
|
72
|
-
async runFetch(modelName, typename, ModelClass, clauses, options) {
|
|
73
|
-
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
90
|
+
async runFetch(modelName, typename, ModelClass, clauses, options, queryKey) {
|
|
91
|
+
// `{ type: 'complete' }` is the only way to force a server round-trip:
|
|
92
|
+
// read-after-write certainty. Every other read is local-first.
|
|
93
|
+
const explicitComplete = options?.type === 'complete';
|
|
94
|
+
const expand = options?.expand;
|
|
95
|
+
const hasExpand = !!(expand && expand.length > 0);
|
|
96
|
+
// Fast path — this exact query was already satisfied from the server this
|
|
97
|
+
// session. The WebSocket delta stream has kept the pool fresh since, so a
|
|
98
|
+
// repeat read needs ZERO network: serve straight from local. This is what
|
|
99
|
+
// stops an already-open deck from re-querying on every navigation when no
|
|
100
|
+
// new deltas have arrived.
|
|
101
|
+
if (!explicitComplete && this.hydratedKeys.has(queryKey)) {
|
|
102
|
+
return applyLimit(await this.readLocal(modelName, typename, ModelClass, clauses, hasExpand, expand), options?.limit);
|
|
80
103
|
}
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
104
|
+
// Not yet hydrated (or an explicit complete read). For a non-complete read
|
|
105
|
+
// WITHOUT expand, if there's anything local to show (warm pool, or IDB
|
|
106
|
+
// after a reload), hand it back immediately and confirm with the server
|
|
107
|
+
// ONCE in the background — then mark the key hydrated so subsequent reads
|
|
108
|
+
// are pure-local. First paint never blocks on the network.
|
|
109
|
+
//
|
|
110
|
+
// Expand queries are deliberately excluded here: a present primary says
|
|
111
|
+
// nothing about whether its relations are loaded. Returning the parent now
|
|
112
|
+
// would surface it with empty children and let `layersReady` flip before
|
|
113
|
+
// the layers exist (the "pop-in" the deck gate guards against). So an
|
|
114
|
+
// un-hydrated expand query falls through to the blocking fetch that brings
|
|
115
|
+
// parent + children together; the SECOND open is served by the fast path.
|
|
116
|
+
if (!explicitComplete && !hasExpand) {
|
|
117
|
+
const local = await this.readLocal(modelName, typename, ModelClass, clauses, hasExpand, expand);
|
|
118
|
+
if (local.length > 0) {
|
|
119
|
+
this.scheduleHydratingFetch(queryKey, modelName, typename, clauses, options);
|
|
120
|
+
return applyLimit(local, options?.limit);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// Cold cache, or caller demanded server-confirmed state: block on the
|
|
124
|
+
// network, then mark this query hydrated so future reads serve local.
|
|
125
|
+
const networkModels = await this.fetchFromNetwork(modelName, typename, clauses, options);
|
|
126
|
+
this.hydratedKeys.add(queryKey);
|
|
127
|
+
if (networkModels.length > 0)
|
|
128
|
+
return applyLimit(networkModels, options?.limit);
|
|
129
|
+
// Network returned nothing — fall back to whatever's local (e.g. a
|
|
130
|
+
// complete read whose server result was empty but IDB still holds rows).
|
|
131
|
+
return applyLimit(await this.readLocal(modelName, typename, ModelClass, clauses, hasExpand, expand), options?.limit);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Read a query's rows from local storage only — pool first, then IndexedDB
|
|
135
|
+
* on a pool miss (cold start after reload, or LRU eviction), hydrating the
|
|
136
|
+
* pool from IDB as a side effect. Resolves requested `expand` relations from
|
|
137
|
+
* their own local stores too. Never touches the network.
|
|
138
|
+
*/
|
|
139
|
+
async readLocal(modelName, typename, ModelClass, clauses, hasExpand, expand) {
|
|
140
|
+
let local = scanPool(this.opts.objectPool, ModelClass, clauses);
|
|
141
|
+
if (local.length === 0) {
|
|
142
|
+
const fromIdb = await scanIdb(this.opts.database, typename, clauses);
|
|
143
|
+
const idbModels = fromIdb
|
|
144
|
+
.map((raw) => this.hydrateOne(raw, typename))
|
|
145
|
+
.filter((m) => m !== null);
|
|
146
|
+
if (idbModels.length > 0) {
|
|
147
|
+
this.opts.objectPool.addBatch(idbModels, ModelScope.live);
|
|
148
|
+
local = idbModels;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (hasExpand && expand && local.length > 0) {
|
|
152
|
+
await this.hydrateExpandedFromLocal(modelName, local.map((m) => m.id), expand);
|
|
90
153
|
}
|
|
91
|
-
|
|
154
|
+
return local;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Drop the hydration ledger so the next read of each query re-confirms with
|
|
158
|
+
* the server. Called on reconnect — after a connection drop, deltas may have
|
|
159
|
+
* been missed, so the "WS keeps the pool fresh" assumption no longer holds
|
|
160
|
+
* until a fresh fetch (or the engine's delta catch-up) reconciles.
|
|
161
|
+
*/
|
|
162
|
+
invalidate() {
|
|
163
|
+
this.hydratedKeys.clear();
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Run the network leg of a fetch: query the server, hydrate primary rows
|
|
167
|
+
* (and any expanded relations) into the pool, and persist them to IDB.
|
|
168
|
+
* Shared by the blocking path (`runFetch` step 3) and the background
|
|
169
|
+
* revalidation kicked off after an `'unknown'` local hit.
|
|
170
|
+
*/
|
|
171
|
+
async fetchFromNetwork(modelName, typename, clauses, options) {
|
|
92
172
|
const networkRows = await this.queryNetwork(modelName, clauses, options);
|
|
93
173
|
const networkModels = networkRows
|
|
94
174
|
.map((raw) => this.hydrateOne(raw, typename))
|
|
95
175
|
.filter((m) => m !== null);
|
|
96
176
|
if (networkModels.length > 0) {
|
|
97
177
|
this.opts.objectPool.addBatch(networkModels, ModelScope.live);
|
|
98
|
-
// Background IDB write — don't block the caller.
|
|
178
|
+
// Background IDB write — don't block the caller. Expanded children are
|
|
179
|
+
// persisted to their own stores inside `queryNetwork`/`hydrateExpanded`.
|
|
99
180
|
void this.persistToIdb(modelName, networkRows);
|
|
100
181
|
}
|
|
101
|
-
return
|
|
182
|
+
return networkModels;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Fire-and-forget the ONE server confirm for a query that was just served
|
|
186
|
+
* from local cache but isn't hydrated yet. On success the key is marked
|
|
187
|
+
* hydrated, so every later read serves pure-local with no network until a
|
|
188
|
+
* reconnect invalidates the ledger. Deduped per query key so a render burst
|
|
189
|
+
* doesn't stampede. Errors are swallowed — the caller already has a usable
|
|
190
|
+
* local snapshot, and a failed confirm leaves the key un-hydrated so the
|
|
191
|
+
* next read simply tries again.
|
|
192
|
+
*/
|
|
193
|
+
scheduleHydratingFetch(queryKey, modelName, typename, clauses, options) {
|
|
194
|
+
if (this.revalidating.has(queryKey))
|
|
195
|
+
return;
|
|
196
|
+
this.revalidating.add(queryKey);
|
|
197
|
+
void this.fetchFromNetwork(modelName, typename, clauses, options)
|
|
198
|
+
.then(() => {
|
|
199
|
+
this.hydratedKeys.add(queryKey);
|
|
200
|
+
})
|
|
201
|
+
.catch(() => undefined)
|
|
202
|
+
.finally(() => {
|
|
203
|
+
this.revalidating.delete(queryKey);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Hydrate a parent's `hasMany`/`hasOne` relations from their OWN local
|
|
208
|
+
* stores (pool first, then IndexedDB by the FK secondary index) into the
|
|
209
|
+
* pool. The mirror of {@link hydrateExpanded} for the local read path:
|
|
210
|
+
* `hydrateExpanded` walks server-JOINed nested rows, this walks the child
|
|
211
|
+
* model's own store keyed by the relation's foreign key.
|
|
212
|
+
*
|
|
213
|
+
* Fully schema-driven via the relation's `target` + `foreignKey` — no
|
|
214
|
+
* per-model special-casing. `belongsTo` relations are skipped: those point
|
|
215
|
+
* at a single parent (the inverse direction), already covered by the
|
|
216
|
+
* primary scan when that parent is itself the fetched model.
|
|
217
|
+
*/
|
|
218
|
+
async hydrateExpandedFromLocal(parentModelName, parentIds, relationNames) {
|
|
219
|
+
if (parentIds.length === 0)
|
|
220
|
+
return;
|
|
221
|
+
const parentDef = this.getModelDef(parentModelName);
|
|
222
|
+
if (!parentDef?.relations)
|
|
223
|
+
return;
|
|
224
|
+
for (const rel of relationNames) {
|
|
225
|
+
const relDef = parentDef.relations[rel];
|
|
226
|
+
if (!relDef)
|
|
227
|
+
continue;
|
|
228
|
+
if (relDef.type !== 'hasMany' && relDef.type !== 'hasOne')
|
|
229
|
+
continue;
|
|
230
|
+
const targetKey = relDef.target;
|
|
231
|
+
const foreignKey = relDef.foreignKey;
|
|
232
|
+
if (!targetKey || !foreignKey)
|
|
233
|
+
continue;
|
|
234
|
+
const targetTypename = this.resolveTypename(targetKey);
|
|
235
|
+
// Skip parents whose children are already pool-resident (O(1) when the
|
|
236
|
+
// FK is indexed). Falls through to a local read for the rest.
|
|
237
|
+
const missing = parentIds.filter((pid) => this.opts.objectPool.getByForeignKey(targetTypename, foreignKey, pid).length === 0);
|
|
238
|
+
if (missing.length === 0)
|
|
239
|
+
continue;
|
|
240
|
+
const rows = await this.readChildrenLocal(targetTypename, foreignKey, missing);
|
|
241
|
+
const models = rows
|
|
242
|
+
.map((raw) => this.hydrateOne(this.stampTypename(raw, targetTypename), targetTypename))
|
|
243
|
+
.filter((m) => m !== null);
|
|
244
|
+
if (models.length > 0) {
|
|
245
|
+
this.opts.objectPool.addBatch(models, ModelScope.live);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Read a child model's rows from local storage by foreign key.
|
|
251
|
+
*
|
|
252
|
+
* Uses the FK secondary index (O(matches) per parent) only when the schema
|
|
253
|
+
* declares one — `getAllFromIndex` resolves `[]` for a missing index rather
|
|
254
|
+
* than throwing, so the decision is made up front from the registry, not by
|
|
255
|
+
* catching. Unindexed FKs — and in-memory stores, which carry no secondary
|
|
256
|
+
* indexes at all — fall back to a single full-store scan filtered in JS.
|
|
257
|
+
*/
|
|
258
|
+
async readChildrenLocal(childTypename, foreignKey, parentIds) {
|
|
259
|
+
const store = this.opts.database.getStore(childTypename);
|
|
260
|
+
if (!store)
|
|
261
|
+
return [];
|
|
262
|
+
const isIndexed = this.opts.registry.getIndexedProperties(childTypename).includes(foreignKey);
|
|
263
|
+
if (isIndexed) {
|
|
264
|
+
const collected = [];
|
|
265
|
+
for (const pid of parentIds) {
|
|
266
|
+
const rows = await store.getAllFromIndex(foreignKey, pid);
|
|
267
|
+
if (Array.isArray(rows))
|
|
268
|
+
collected.push(...rows);
|
|
269
|
+
}
|
|
270
|
+
// A non-empty result means the index is live (browser IDB). Empty can
|
|
271
|
+
// mean "no children" OR "no physical index" (in-memory) — fall through
|
|
272
|
+
// to the scan so the in-memory/SSR path stays correct.
|
|
273
|
+
if (collected.length > 0)
|
|
274
|
+
return collected;
|
|
275
|
+
}
|
|
276
|
+
try {
|
|
277
|
+
const all = await store.getAll();
|
|
278
|
+
if (!Array.isArray(all))
|
|
279
|
+
return [];
|
|
280
|
+
const idSet = new Set(parentIds);
|
|
281
|
+
return all.filter((r) => idSet.has(r[foreignKey]));
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return [];
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/** Typed accessor for a model's schema definition (typename + relations). */
|
|
288
|
+
getModelDef(modelName) {
|
|
289
|
+
return this.opts.schema.models?.[modelName];
|
|
102
290
|
}
|
|
103
291
|
hydrateOne(raw, typename) {
|
|
104
292
|
if (!raw || typeof raw !== 'object')
|
|
@@ -175,7 +363,7 @@ export class HydrationCoordinator {
|
|
|
175
363
|
};
|
|
176
364
|
const result = await postQuery({
|
|
177
365
|
baseUrl: this.opts.baseUrl,
|
|
178
|
-
|
|
366
|
+
getAuthToken: this.authTokenProvider ?? undefined,
|
|
179
367
|
}, { queries: [query] });
|
|
180
368
|
const rows = Array.isArray(result.results[0]) ? result.results[0] : [];
|
|
181
369
|
// Normalize: wire rows lack `__typename` when the server elides it.
|
|
@@ -206,8 +394,7 @@ export class HydrationCoordinator {
|
|
|
206
394
|
* → `_Typename`), so the SDK can't trust whatever string lands.
|
|
207
395
|
*/
|
|
208
396
|
hydrateExpanded(parentModelName, rows, relationNames) {
|
|
209
|
-
const
|
|
210
|
-
const parentDef = schemaModels?.[parentModelName];
|
|
397
|
+
const parentDef = this.getModelDef(parentModelName);
|
|
211
398
|
for (const row of rows) {
|
|
212
399
|
if (!row || typeof row !== 'object')
|
|
213
400
|
continue;
|
|
@@ -222,8 +409,10 @@ export class HydrationCoordinator {
|
|
|
222
409
|
const targetTypename = targetKey ? this.resolveTypename(targetKey) : undefined;
|
|
223
410
|
const items = Array.isArray(nested) ? nested : [nested];
|
|
224
411
|
const models = [];
|
|
412
|
+
const stampedItems = [];
|
|
225
413
|
for (const item of items) {
|
|
226
414
|
const stamped = this.stampTypename(item, targetTypename);
|
|
415
|
+
stampedItems.push(stamped);
|
|
227
416
|
const m = this.hydrateOne(stamped);
|
|
228
417
|
if (m)
|
|
229
418
|
models.push(m);
|
|
@@ -231,6 +420,13 @@ export class HydrationCoordinator {
|
|
|
231
420
|
if (models.length > 0) {
|
|
232
421
|
this.opts.objectPool.addBatch(models, ModelScope.live);
|
|
233
422
|
}
|
|
423
|
+
// Persist expanded children to their OWN typed store so they survive
|
|
424
|
+
// reload and can be re-served by `hydrateExpandedFromLocal` — without
|
|
425
|
+
// this, expand-fetched relations live only inside the parent's row
|
|
426
|
+
// and are lost to a lazy child query after a cold start.
|
|
427
|
+
if (stampedItems.length > 0 && targetKey) {
|
|
428
|
+
void this.persistToIdb(targetKey, stampedItems);
|
|
429
|
+
}
|
|
234
430
|
}
|
|
235
431
|
}
|
|
236
432
|
}
|
|
@@ -279,7 +475,7 @@ export class HydrationCoordinator {
|
|
|
279
475
|
}
|
|
280
476
|
}
|
|
281
477
|
// ── Helpers ────────────────────────────────────────────────────────────
|
|
282
|
-
function stableKey(modelName, clauses, orderBy, limit) {
|
|
478
|
+
function stableKey(modelName, clauses, orderBy, limit, expand) {
|
|
283
479
|
// Sort clauses by their stringified form so caller order doesn't
|
|
284
480
|
// produce different dedup keys for semantically identical queries.
|
|
285
481
|
const sorted = [...clauses].map((c) => [...c]).sort((a, b) => {
|
|
@@ -287,7 +483,11 @@ function stableKey(modelName, clauses, orderBy, limit) {
|
|
|
287
483
|
const kb = JSON.stringify(b);
|
|
288
484
|
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
|
289
485
|
});
|
|
290
|
-
|
|
486
|
+
// Expand is part of the query identity: `slides where deck=d1` and the same
|
|
487
|
+
// with `expand:['layers']` hydrate different data, so they must not share a
|
|
488
|
+
// ledger/dedup key. Sorted so relation order doesn't fork the key.
|
|
489
|
+
const expandKey = expand && expand.length > 0 ? [...expand].sort() : undefined;
|
|
490
|
+
return JSON.stringify({ modelName, where: sorted, orderBy, limit, expand: expandKey });
|
|
291
491
|
}
|
|
292
492
|
function applyLimit(arr, limit) {
|
|
293
493
|
return typeof limit === 'number' ? arr.slice(0, limit) : arr;
|
|
@@ -6,12 +6,15 @@
|
|
|
6
6
|
* After laptop sleep/wake, it may report true before WiFi/DNS are functional.
|
|
7
7
|
*
|
|
8
8
|
* This module provides an authenticated probe against the sync server to verify
|
|
9
|
-
* real connectivity +
|
|
10
|
-
* `/api/auth/check`, which runs the SAME auth middleware as the WebSocket
|
|
11
|
-
* upgrade path
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* real connectivity + credential validity in a single round-trip. The probe
|
|
10
|
+
* hits `/api/auth/check`, which runs the SAME auth middleware as the WebSocket
|
|
11
|
+
* upgrade path, and classifies the response into a single {@link ProbeOutcome}
|
|
12
|
+
* via the closed recovery taxonomy ({@link classifyRecovery}):
|
|
13
|
+
* 204 No Content → `reachable` (credential valid)
|
|
14
|
+
* 401 `apikey_expired` (ephemeral key) → `credential_stale` (re-mint & retry, NO sign-out)
|
|
15
|
+
* 401 `session_expired` / bare 401 → `session_expired` (sign out)
|
|
16
|
+
* 401/403 credential-type/config/perm → `auth_blocked` (stop, no loop, no sign-out)
|
|
17
|
+
* network fail / offline → `unreachable`
|
|
15
18
|
*
|
|
16
19
|
* This closes a real gap: the browser's WebSocket API hides HTTP status from
|
|
17
20
|
* the handshake, so a 401 on the WS upgrade surfaces only as `close code
|
|
@@ -21,14 +24,53 @@
|
|
|
21
24
|
*
|
|
22
25
|
* @see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/onLine
|
|
23
26
|
*/
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
import { z } from 'zod';
|
|
28
|
+
import { type AuthTokenGetter } from '../auth/credentialSource.js';
|
|
29
|
+
/**
|
|
30
|
+
* The closed set of probe outcomes — one value carrying both reachability and
|
|
31
|
+
* credential disposition, so the {@link ConnectionManager} branches on a single
|
|
32
|
+
* exhaustive discriminant instead of reconstructing intent from a trio of
|
|
33
|
+
* booleans. Mirrors the {@link RecoveryClass} taxonomy at the connectivity tier.
|
|
34
|
+
*/
|
|
35
|
+
export declare const PROBE_OUTCOMES: readonly ["reachable", "unreachable", "session_expired", "credential_stale", "auth_blocked"];
|
|
36
|
+
/** Zod enum derived from {@link PROBE_OUTCOMES}. */
|
|
37
|
+
export declare const probeOutcomeSchema: z.ZodEnum<{
|
|
38
|
+
auth_blocked: "auth_blocked";
|
|
39
|
+
session_expired: "session_expired";
|
|
40
|
+
reachable: "reachable";
|
|
41
|
+
unreachable: "unreachable";
|
|
42
|
+
credential_stale: "credential_stale";
|
|
43
|
+
}>;
|
|
44
|
+
/** A single probe outcome. See {@link PROBE_OUTCOMES}. */
|
|
45
|
+
export type ProbeOutcome = z.infer<typeof probeOutcomeSchema>;
|
|
46
|
+
/** Result of a network probe: a single {@link ProbeOutcome} plus round-trip
|
|
47
|
+
* latency (null when the probe never completed). */
|
|
48
|
+
export declare const probeResultSchema: z.ZodObject<{
|
|
49
|
+
outcome: z.ZodEnum<{
|
|
50
|
+
auth_blocked: "auth_blocked";
|
|
51
|
+
session_expired: "session_expired";
|
|
52
|
+
reachable: "reachable";
|
|
53
|
+
unreachable: "unreachable";
|
|
54
|
+
credential_stale: "credential_stale";
|
|
55
|
+
}>;
|
|
56
|
+
latencyMs: z.ZodNullable<z.ZodNumber>;
|
|
57
|
+
}, z.core.$strip>;
|
|
58
|
+
/** @see {@link probeResultSchema} */
|
|
59
|
+
export type ProbeResult = z.infer<typeof probeResultSchema>;
|
|
60
|
+
export interface NetworkProbeOptions {
|
|
61
|
+
/**
|
|
62
|
+
* Sync-server base URL (HTTP or WS scheme accepted). If omitted, falls
|
|
63
|
+
* back to the legacy `NEXT_PUBLIC_GO_SERVER_URL` default.
|
|
64
|
+
*/
|
|
65
|
+
baseUrl?: string;
|
|
66
|
+
/**
|
|
67
|
+
* Optional bearer credential. Browser cookie deployments can omit this;
|
|
68
|
+
* bearer-first deployments must pass the same `ek_`/`rk_` token used by
|
|
69
|
+
* bootstrap and the WebSocket upgrade.
|
|
70
|
+
*/
|
|
71
|
+
getAuthToken?: AuthTokenGetter;
|
|
72
|
+
/** Compatibility fallback for callers with a copied token string. */
|
|
73
|
+
authToken?: string | null;
|
|
32
74
|
}
|
|
33
75
|
/**
|
|
34
76
|
* Probe the sync engine server with a lightweight HEAD request.
|
|
@@ -36,8 +78,8 @@ export interface ProbeResult {
|
|
|
36
78
|
* Returns reachability AND session status in a single call, so the
|
|
37
79
|
* ConnectionStore can make the right state transition without guessing.
|
|
38
80
|
*
|
|
39
|
-
* @param
|
|
40
|
-
*
|
|
41
|
-
*
|
|
81
|
+
* @param input The sync-server base URL (HTTP or WS scheme accepted), or an
|
|
82
|
+
* options bag with `authToken`. A bare string is still accepted
|
|
83
|
+
* for backwards compatibility.
|
|
42
84
|
*/
|
|
43
|
-
export declare function probeNetwork(
|
|
85
|
+
export declare function probeNetwork(input?: string | NetworkProbeOptions): Promise<ProbeResult>;
|