@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
|
@@ -25,24 +25,41 @@ import { postQuery } from '../query/client.js';
|
|
|
25
25
|
export class HydrationCoordinator {
|
|
26
26
|
opts;
|
|
27
27
|
inFlight = new Map();
|
|
28
|
-
|
|
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;
|
|
29
48
|
constructor(opts) {
|
|
30
49
|
this.opts = opts;
|
|
31
|
-
this.
|
|
50
|
+
this.authTokenProvider = opts.getAuthToken ?? opts.getCapabilityToken ?? null;
|
|
32
51
|
}
|
|
33
52
|
/**
|
|
34
|
-
* Late-bind the
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* construction). Browser consumers ride session cookies and don't
|
|
38
|
-
* need this; Node consumers (agent-worker) MUST call it or HTTP
|
|
39
|
-
* 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.
|
|
40
56
|
*/
|
|
41
|
-
|
|
42
|
-
this.
|
|
57
|
+
setAuthTokenProvider(provider) {
|
|
58
|
+
this.authTokenProvider = provider;
|
|
43
59
|
}
|
|
44
|
-
|
|
45
|
-
|
|
60
|
+
/** @deprecated Use `setAuthTokenProvider`. */
|
|
61
|
+
setCapabilityTokenProvider(provider) {
|
|
62
|
+
this.setAuthTokenProvider(provider);
|
|
46
63
|
}
|
|
47
64
|
/**
|
|
48
65
|
* Fetch matching rows for a model, hydrating the pool from IDB or
|
|
@@ -58,48 +75,218 @@ export class HydrationCoordinator {
|
|
|
58
75
|
`not registered in the schema.`, { code: 'model_not_registered' });
|
|
59
76
|
}
|
|
60
77
|
const clauses = normalizeWhere(options?.where);
|
|
61
|
-
const queryKey = stableKey(modelName, clauses, options?.orderBy, options?.limit);
|
|
78
|
+
const queryKey = stableKey(modelName, clauses, options?.orderBy, options?.limit, options?.expand);
|
|
62
79
|
// Single-flight: an identical hydration is already in flight.
|
|
63
80
|
const inFlight = this.inFlight.get(queryKey);
|
|
64
81
|
if (inFlight)
|
|
65
82
|
return inFlight;
|
|
66
|
-
const work = this.runFetch(modelName, typename, ModelClass, clauses, options);
|
|
83
|
+
const work = this.runFetch(modelName, typename, ModelClass, clauses, options, queryKey);
|
|
67
84
|
this.inFlight.set(queryKey, work);
|
|
68
85
|
work.finally(() => {
|
|
69
86
|
this.inFlight.delete(queryKey);
|
|
70
87
|
});
|
|
71
88
|
return work;
|
|
72
89
|
}
|
|
73
|
-
async runFetch(modelName, typename, ModelClass, clauses, options) {
|
|
74
|
-
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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);
|
|
81
103
|
}
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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);
|
|
91
153
|
}
|
|
92
|
-
|
|
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) {
|
|
93
172
|
const networkRows = await this.queryNetwork(modelName, clauses, options);
|
|
94
173
|
const networkModels = networkRows
|
|
95
174
|
.map((raw) => this.hydrateOne(raw, typename))
|
|
96
175
|
.filter((m) => m !== null);
|
|
97
176
|
if (networkModels.length > 0) {
|
|
98
177
|
this.opts.objectPool.addBatch(networkModels, ModelScope.live);
|
|
99
|
-
// 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`.
|
|
100
180
|
void this.persistToIdb(modelName, networkRows);
|
|
101
181
|
}
|
|
102
|
-
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];
|
|
103
290
|
}
|
|
104
291
|
hydrateOne(raw, typename) {
|
|
105
292
|
if (!raw || typeof raw !== 'object')
|
|
@@ -176,7 +363,7 @@ export class HydrationCoordinator {
|
|
|
176
363
|
};
|
|
177
364
|
const result = await postQuery({
|
|
178
365
|
baseUrl: this.opts.baseUrl,
|
|
179
|
-
|
|
366
|
+
getAuthToken: this.authTokenProvider ?? undefined,
|
|
180
367
|
}, { queries: [query] });
|
|
181
368
|
const rows = Array.isArray(result.results[0]) ? result.results[0] : [];
|
|
182
369
|
// Normalize: wire rows lack `__typename` when the server elides it.
|
|
@@ -207,8 +394,7 @@ export class HydrationCoordinator {
|
|
|
207
394
|
* → `_Typename`), so the SDK can't trust whatever string lands.
|
|
208
395
|
*/
|
|
209
396
|
hydrateExpanded(parentModelName, rows, relationNames) {
|
|
210
|
-
const
|
|
211
|
-
const parentDef = schemaModels?.[parentModelName];
|
|
397
|
+
const parentDef = this.getModelDef(parentModelName);
|
|
212
398
|
for (const row of rows) {
|
|
213
399
|
if (!row || typeof row !== 'object')
|
|
214
400
|
continue;
|
|
@@ -223,8 +409,10 @@ export class HydrationCoordinator {
|
|
|
223
409
|
const targetTypename = targetKey ? this.resolveTypename(targetKey) : undefined;
|
|
224
410
|
const items = Array.isArray(nested) ? nested : [nested];
|
|
225
411
|
const models = [];
|
|
412
|
+
const stampedItems = [];
|
|
226
413
|
for (const item of items) {
|
|
227
414
|
const stamped = this.stampTypename(item, targetTypename);
|
|
415
|
+
stampedItems.push(stamped);
|
|
228
416
|
const m = this.hydrateOne(stamped);
|
|
229
417
|
if (m)
|
|
230
418
|
models.push(m);
|
|
@@ -232,6 +420,13 @@ export class HydrationCoordinator {
|
|
|
232
420
|
if (models.length > 0) {
|
|
233
421
|
this.opts.objectPool.addBatch(models, ModelScope.live);
|
|
234
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
|
+
}
|
|
235
430
|
}
|
|
236
431
|
}
|
|
237
432
|
}
|
|
@@ -280,7 +475,7 @@ export class HydrationCoordinator {
|
|
|
280
475
|
}
|
|
281
476
|
}
|
|
282
477
|
// ── Helpers ────────────────────────────────────────────────────────────
|
|
283
|
-
function stableKey(modelName, clauses, orderBy, limit) {
|
|
478
|
+
function stableKey(modelName, clauses, orderBy, limit, expand) {
|
|
284
479
|
// Sort clauses by their stringified form so caller order doesn't
|
|
285
480
|
// produce different dedup keys for semantically identical queries.
|
|
286
481
|
const sorted = [...clauses].map((c) => [...c]).sort((a, b) => {
|
|
@@ -288,7 +483,11 @@ function stableKey(modelName, clauses, orderBy, limit) {
|
|
|
288
483
|
const kb = JSON.stringify(b);
|
|
289
484
|
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
|
290
485
|
});
|
|
291
|
-
|
|
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 });
|
|
292
491
|
}
|
|
293
492
|
function applyLimit(arr, limit) {
|
|
294
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,22 +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
|
-
|
|
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;
|
|
30
66
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* reconnecting nor re-authenticating will help. The manager stops instead of
|
|
35
|
-
* looping. Distinct from `sessionValid: false` (genuine expiry → sign in).
|
|
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.
|
|
36
70
|
*/
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
|
|
71
|
+
getAuthToken?: AuthTokenGetter;
|
|
72
|
+
/** Compatibility fallback for callers with a copied token string. */
|
|
73
|
+
authToken?: string | null;
|
|
40
74
|
}
|
|
41
75
|
/**
|
|
42
76
|
* Probe the sync engine server with a lightweight HEAD request.
|
|
@@ -44,8 +78,8 @@ export interface ProbeResult {
|
|
|
44
78
|
* Returns reachability AND session status in a single call, so the
|
|
45
79
|
* ConnectionStore can make the right state transition without guessing.
|
|
46
80
|
*
|
|
47
|
-
* @param
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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.
|
|
50
84
|
*/
|
|
51
|
-
export declare function probeNetwork(
|
|
85
|
+
export declare function probeNetwork(input?: string | NetworkProbeOptions): Promise<ProbeResult>;
|