@abloatai/ablo 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/CHANGELOG.md +46 -1
  2. package/README.md +33 -28
  3. package/dist/BaseSyncedStore.d.ts +83 -0
  4. package/dist/BaseSyncedStore.js +194 -2
  5. package/dist/Model.d.ts +42 -0
  6. package/dist/Model.js +103 -44
  7. package/dist/agent/session.js +3 -3
  8. package/dist/ai-sdk/coordination-context.js +4 -0
  9. package/dist/ai-sdk/index.d.ts +56 -47
  10. package/dist/ai-sdk/index.js +56 -47
  11. package/dist/ai-sdk/intent-broadcast.d.ts +5 -0
  12. package/dist/ai-sdk/intent-broadcast.js +11 -4
  13. package/dist/ai-sdk/wrap.d.ts +14 -11
  14. package/dist/ai-sdk/wrap.js +11 -13
  15. package/dist/auth/credentialSource.d.ts +34 -0
  16. package/dist/auth/credentialSource.js +63 -0
  17. package/dist/auth/index.d.ts +2 -22
  18. package/dist/auth/index.js +4 -42
  19. package/dist/auth/schemas.d.ts +35 -0
  20. package/dist/auth/schemas.js +53 -0
  21. package/dist/client/Ablo.d.ts +160 -42
  22. package/dist/client/Ablo.js +145 -75
  23. package/dist/client/ApiClient.d.ts +20 -4
  24. package/dist/client/ApiClient.js +166 -28
  25. package/dist/client/auth.d.ts +14 -5
  26. package/dist/client/auth.js +60 -7
  27. package/dist/client/createInternalComponents.d.ts +2 -0
  28. package/dist/client/createInternalComponents.js +8 -1
  29. package/dist/client/createModelProxy.d.ts +130 -66
  30. package/dist/client/createModelProxy.js +152 -49
  31. package/dist/client/httpClient.d.ts +71 -0
  32. package/dist/client/httpClient.js +69 -0
  33. package/dist/client/identity.d.ts +2 -6
  34. package/dist/client/identity.js +49 -11
  35. package/dist/client/index.d.ts +1 -0
  36. package/dist/client/index.js +1 -0
  37. package/dist/client/registerDataSource.d.ts +3 -3
  38. package/dist/client/registerDataSource.js +11 -9
  39. package/dist/client/validateAbloOptions.js +1 -1
  40. package/dist/core/DatabaseManager.js +30 -2
  41. package/dist/core/openIDBWithTimeout.d.ts +36 -0
  42. package/dist/core/openIDBWithTimeout.js +88 -1
  43. package/dist/errorCodes.d.ts +70 -1
  44. package/dist/errorCodes.js +108 -9
  45. package/dist/errors.d.ts +2 -2
  46. package/dist/errors.js +72 -22
  47. package/dist/index.d.ts +17 -8
  48. package/dist/index.js +15 -6
  49. package/dist/keys/index.d.ts +16 -1
  50. package/dist/keys/index.js +26 -6
  51. package/dist/mutators/UndoManager.d.ts +158 -50
  52. package/dist/mutators/UndoManager.js +345 -22
  53. package/dist/mutators/inverseOp.d.ts +129 -0
  54. package/dist/mutators/inverseOp.js +74 -0
  55. package/dist/mutators/readerActions.d.ts +1 -1
  56. package/dist/mutators/undoApply.d.ts +42 -0
  57. package/dist/mutators/undoApply.js +143 -0
  58. package/dist/query/client.d.ts +10 -9
  59. package/dist/query/client.js +3 -6
  60. package/dist/react/AbloProvider.d.ts +23 -126
  61. package/dist/react/AbloProvider.js +62 -199
  62. package/dist/react/context.d.ts +31 -0
  63. package/dist/react/useAblo.d.ts +2 -2
  64. package/dist/react/useCurrentUserId.d.ts +1 -1
  65. package/dist/react/useCurrentUserId.js +1 -1
  66. package/dist/react/useMutators.js +19 -12
  67. package/dist/schema/ddl.d.ts +34 -3
  68. package/dist/schema/ddl.js +162 -4
  69. package/dist/schema/index.d.ts +5 -1
  70. package/dist/schema/index.js +13 -1
  71. package/dist/schema/model.d.ts +11 -0
  72. package/dist/schema/model.js +2 -0
  73. package/dist/schema/openapi.d.ts +28 -0
  74. package/dist/schema/openapi.js +118 -0
  75. package/dist/schema/plane.d.ts +23 -0
  76. package/dist/schema/plane.js +19 -0
  77. package/dist/schema/relation.d.ts +20 -0
  78. package/dist/schema/serialize.d.ts +4 -0
  79. package/dist/schema/serialize.js +4 -0
  80. package/dist/schema/sync-delta-row.d.ts +157 -0
  81. package/dist/schema/sync-delta-row.js +102 -0
  82. package/dist/schema/sync-delta-wire.d.ts +180 -0
  83. package/dist/schema/sync-delta-wire.js +102 -0
  84. package/dist/server/adapter.d.ts +156 -0
  85. package/dist/server/adapter.js +19 -0
  86. package/dist/server/commit.d.ts +82 -0
  87. package/dist/server/commit.js +1 -0
  88. package/dist/server/index.d.ts +14 -0
  89. package/dist/server/index.js +1 -0
  90. package/dist/server/next.d.ts +51 -0
  91. package/dist/server/next.js +47 -0
  92. package/dist/server/read-config.d.ts +60 -0
  93. package/dist/server/read-config.js +8 -0
  94. package/dist/server/storage-mode.d.ts +17 -0
  95. package/dist/server/storage-mode.js +12 -0
  96. package/dist/source/adapter.d.ts +65 -0
  97. package/dist/source/adapter.js +20 -0
  98. package/dist/source/adapters/drizzle.d.ts +43 -0
  99. package/dist/source/adapters/drizzle.js +185 -0
  100. package/dist/source/adapters/memory.d.ts +12 -0
  101. package/dist/source/adapters/memory.js +114 -0
  102. package/dist/source/adapters/prisma.d.ts +57 -0
  103. package/dist/source/adapters/prisma.js +176 -0
  104. package/dist/source/conformance.d.ts +32 -0
  105. package/dist/source/conformance.js +134 -0
  106. package/dist/source/contract.d.ts +144 -0
  107. package/dist/source/contract.js +99 -0
  108. package/dist/source/index.d.ts +62 -10
  109. package/dist/source/index.js +99 -0
  110. package/dist/source/migrations.d.ts +14 -0
  111. package/dist/source/migrations.js +39 -0
  112. package/dist/source/next.d.ts +33 -0
  113. package/dist/source/next.js +26 -0
  114. package/dist/sync/BootstrapHelper.d.ts +10 -0
  115. package/dist/sync/BootstrapHelper.js +10 -15
  116. package/dist/sync/ConnectionManager.d.ts +55 -1
  117. package/dist/sync/ConnectionManager.js +155 -16
  118. package/dist/sync/HydrationCoordinator.d.ts +93 -17
  119. package/dist/sync/HydrationCoordinator.js +238 -39
  120. package/dist/sync/NetworkProbe.d.ts +58 -24
  121. package/dist/sync/NetworkProbe.js +118 -42
  122. package/dist/sync/SyncWebSocket.d.ts +45 -70
  123. package/dist/sync/SyncWebSocket.js +70 -36
  124. package/dist/sync/createIntentStream.js +10 -1
  125. package/dist/types/streams.d.ts +9 -0
  126. package/dist/utils/mobx-setup.js +1 -0
  127. package/dist/webhooks/events.d.ts +38 -0
  128. package/dist/webhooks/events.js +40 -0
  129. package/dist/webhooks/index.d.ts +10 -0
  130. package/dist/webhooks/index.js +10 -0
  131. package/dist/wire/errorEnvelope.d.ts +34 -0
  132. package/dist/wire/errorEnvelope.js +86 -0
  133. package/dist/wire/frames.d.ts +119 -0
  134. package/dist/wire/frames.js +1 -0
  135. package/dist/wire/index.d.ts +24 -0
  136. package/dist/wire/index.js +21 -0
  137. package/dist/wire/listEnvelope.d.ts +45 -0
  138. package/dist/wire/listEnvelope.js +17 -0
  139. package/docs/api.md +47 -44
  140. package/docs/cli.md +44 -44
  141. package/docs/client-behavior.md +30 -30
  142. package/docs/coordination.md +33 -36
  143. package/docs/data-sources.md +35 -15
  144. package/docs/examples/agent-human.md +45 -43
  145. package/docs/examples/ai-sdk-tool.md +20 -16
  146. package/docs/examples/existing-python-backend.md +16 -12
  147. package/docs/examples/nextjs.md +14 -12
  148. package/docs/examples/scoped-agent.md +1 -1
  149. package/docs/examples/server-agent.md +24 -21
  150. package/docs/guarantees.md +15 -13
  151. package/docs/index.md +2 -2
  152. package/docs/integration-guide.md +30 -30
  153. package/docs/interaction-model.md +19 -23
  154. package/docs/mcp/claude-code.md +3 -3
  155. package/docs/mcp/cursor.md +1 -1
  156. package/docs/mcp/windsurf.md +2 -2
  157. package/docs/mcp.md +6 -6
  158. package/docs/quickstart.md +41 -31
  159. package/docs/react.md +13 -9
  160. package/docs/schema-contract.md +12 -10
  161. package/docs/the-loop.md +21 -0
  162. package/examples/data-source/README.md +4 -5
  163. package/examples/data-source/customer-server.ts +27 -25
  164. package/llms.txt +28 -5
  165. package/package.json +43 -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 capability token. Resolved per-request
37
- * so cap refreshes propagate without re-instantiating the coordinator.
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
- * `'complete'` (default): wait for network round-trip even if local
59
- * data exists, so the caller observes server-confirmed state.
60
- * `'unknown'`: return whatever's in the pool/IDB immediately and
61
- * fire the network in the background.
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
- private capabilityTokenProvider;
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 capability token getter. Used by `Ablo.ts` to wire
78
- * the token closure after the coordinator is constructed (the token
79
- * isn't known until auth resolves, which happens after component
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
@@ -25,24 +25,41 @@ import { postQuery } from '../query/client.js';
25
25
  export class HydrationCoordinator {
26
26
  opts;
27
27
  inFlight = new Map();
28
- capabilityTokenProvider = null;
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.capabilityTokenProvider = opts.getCapabilityToken ?? null;
50
+ this.authTokenProvider = opts.getAuthToken ?? opts.getCapabilityToken ?? null;
32
51
  }
33
52
  /**
34
- * Late-bind the capability token getter. Used by `Ablo.ts` to wire
35
- * the token closure after the coordinator is constructed (the token
36
- * isn't known until auth resolves, which happens after component
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
- setCapabilityTokenProvider(provider) {
42
- this.capabilityTokenProvider = provider;
57
+ setAuthTokenProvider(provider) {
58
+ this.authTokenProvider = provider;
43
59
  }
44
- resolveToken() {
45
- return this.capabilityTokenProvider?.() ?? undefined;
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
- const wantsComplete = (options?.type ?? 'complete') === 'complete';
75
- // Step 1 — pool hit. Skip when caller asked for complete:
76
- // they want server-confirmed state, not a stale pool snapshot.
77
- if (!wantsComplete) {
78
- const fromPool = scanPool(this.opts.objectPool, ModelClass, clauses);
79
- if (fromPool.length > 0)
80
- return applyLimit(fromPool, options?.limit);
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
- // Step 2 IndexedDB. Survives reload + offline.
83
- const fromIdb = await scanIdb(this.opts.database, typename, clauses);
84
- const idbModels = fromIdb
85
- .map((raw) => this.hydrateOne(raw, typename))
86
- .filter((m) => m !== null);
87
- if (idbModels.length > 0) {
88
- this.opts.objectPool.addBatch(idbModels, ModelScope.live);
89
- if (!wantsComplete)
90
- return applyLimit(idbModels, options?.limit);
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
- // Step 3 — network. Last resort. Always runs when wantsComplete.
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 applyLimit(networkModels.length > 0 ? networkModels : idbModels, options?.limit);
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
- capabilityToken: this.resolveToken(),
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 schemaModels = this.opts.schema.models;
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
- return JSON.stringify({ modelName, where: sorted, orderBy, limit });
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 + session validity in a single round-trip. The probe hits
10
- * `/api/auth/check`, which runs the SAME auth middleware as the WebSocket
11
- * upgrade path:
12
- * 204 No Content reachable, session cookie valid
13
- * 401/403 → reachable, session expired or invalid
14
- * network failunreachable
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
- /** Result of a network probe */
25
- export interface ProbeResult {
26
- /** Whether the server was reachable */
27
- reachable: boolean;
28
- /** Whether the session cookie is still valid (null if server unreachable) */
29
- sessionValid: boolean | null;
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
- * Reachable, but a NON-retryable auth/config failure that is NOT a session
32
- * expiry (e.g. `api_key_required`, `jwt_issuer_untrusted`). The session is
33
- * fine the data-plane rejected the credential TYPE — so neither
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
- authBlocked?: boolean;
38
- /** Round-trip time in ms (null if failed) */
39
- latencyMs: number | null;
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 baseUrl The sync-server base URL (HTTP or WS scheme accepted).
48
- * If omitted, falls back to `NEXT_PUBLIC_GO_SERVER_URL`
49
- * `http://localhost:8080` for backwards compatibility.
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(baseUrl?: string): Promise<ProbeResult>;
85
+ export declare function probeNetwork(input?: string | NetworkProbeOptions): Promise<ProbeResult>;