@abloatai/humans 0.64.3 → 0.64.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/humans",
3
- "version": "0.64.3",
3
+ "version": "0.64.5",
4
4
  "description": "The optional human-facing local-state package for Ablo: presence, live queries, and React bindings.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -85,7 +85,7 @@
85
85
  "directory": "packages/humans"
86
86
  },
87
87
  "dependencies": {
88
- "@abloatai/transaction": "0.64.3",
88
+ "@abloatai/transaction": "0.64.5",
89
89
  "events": "^3.3.0",
90
90
  "mobx": "^6.13.7",
91
91
  "uuid": "^11.1.0",
@@ -168,6 +168,8 @@ export interface UserContext {
168
168
  * structure comes from schema-declared scopes and server-issued
169
169
  * authorization. */
170
170
  syncGroups?: readonly string[];
171
+ /** Server-confirmed operation grants; also partition persisted replicas. */
172
+ operations?: readonly string[];
171
173
  /**
172
174
  * How aggressively this participant should pull baseline state at
173
175
  * startup.
@@ -6,7 +6,7 @@
6
6
  * in-memory mirror of what this class persists.
7
7
  */
8
8
  import { DatabaseManager, type DatabaseInfo, type WorkspaceMetadata } from './stores/DatabaseManager.js';
9
- import type { PersistenceIdentity } from './stores/persistenceIdentity.js';
9
+ import { persistenceIdentityMatches, type PersistenceIdentity } from './stores/persistenceIdentity.js';
10
10
  import { StoreManager } from './stores/StoreManager.js';
11
11
  import { ModelRegistry } from './ModelRegistry.js';
12
12
  import { LoadStrategy } from '@abloatai/transaction/types';
@@ -226,6 +226,9 @@ export class Database {
226
226
  this.isClosing = false;
227
227
 
228
228
  if (this.workspaceDb && this.currentDbInfo) {
229
+ if (!persistenceIdentityMatches(this.currentDbInfo, identity)) {
230
+ throw new AbloConnectionError('Dispose the client before changing persistence authority.', { code: 'db_identity_mismatch' });
231
+ }
229
232
  return;
230
233
  }
231
234
 
@@ -233,6 +236,7 @@ export class Database {
233
236
  // Creates InMemoryObjectStore instances for all registered models.
234
237
  // Bootstrap via HTTP still works; only local persistence is skipped.
235
238
  if (this.inMemory) {
239
+ this.bootstrapHelper.setCacheScope(null);
236
240
  this.runtime.logger.debug('Opening in-memory database (headless mode)');
237
241
  const allModels = this.modelRegistry.getRegisteredModelNames();
238
242
  for (const modelName of allModels) {
@@ -264,6 +268,9 @@ export class Database {
264
268
  version
265
269
  );
266
270
 
271
+ // Both persistence layers must share the complete authenticated authority.
272
+ this.bootstrapHelper.setCacheScope(this.currentDbInfo.name);
273
+
267
274
  // Register database
268
275
  await this.databaseManager.registerDatabase(this.currentDbInfo);
269
276
 
@@ -278,6 +285,21 @@ export class Database {
278
285
  // Initialize stores
279
286
  await this.storeManager.initializeStores(this.workspaceDb);
280
287
 
288
+ // A live group addition can have widened a previous client's replica after
289
+ // it opened this namespace. Check persisted coverage before restoring rows
290
+ // or pending writes, not after the first WebSocket reconnect.
291
+ const metadata = await this.getWorkspaceMetadata();
292
+ const allowed = new Set(identity.syncGroups ?? []);
293
+ if (metadata?.subscribedSyncGroups.some(group => !allowed.has(group))) {
294
+ // Preserve pending writes for their original authority; never replay them
295
+ // into a narrower replica or silently discard them.
296
+ await this.close();
297
+ throw new AbloConnectionError('Persisted sync groups exceed the current authority.', {
298
+ code: 'db_identity_mismatch',
299
+ });
300
+ }
301
+ await this.updateWorkspaceMetadata({ subscribedSyncGroups: [...allowed] });
302
+
281
303
  const readiness = await this.storeManager.checkReadinessOfStores();
282
304
  this.runtime.logger.info(
283
305
  `Database opened: ${this.currentDbInfo.name} (${readiness.readyStores.length}/${readiness.totalStores} stores ready)`
@@ -599,6 +621,9 @@ export class Database {
599
621
 
600
622
  // Use batch processing for better performance
601
623
  const batch = await this.processDeltaBatch(formattedDeltas);
624
+ if (batch.persistedSyncId < Math.max(...formattedDeltas.map(delta => delta.syncId))) {
625
+ throw new Error('Could not persist all bootstrap changes; local storage must recover before retrying.');
626
+ }
602
627
  deltaResults = batch.results;
603
628
  deltasApplied = formattedDeltas.length;
604
629
  onProgress?.(deltasApplied);
@@ -665,48 +690,16 @@ export class Database {
665
690
  );
666
691
  continue;
667
692
  }
668
- let writeErrors = 0;
669
- // Store all items to IndexedDB (compacted)
693
+ // Fail before marking the model or snapshot persisted. A dropped row
694
+ // must be retried, not hidden behind an advanced bootstrap cursor.
670
695
  for (const item of modelData) {
671
- try {
672
- const compacted = this.compactRecord(modelName, item as ModelData);
673
- await store.put(compacted);
674
- modelsStored++;
675
- modelsLoaded++;
676
-
677
- // Report progress every 10 items
678
- if (modelsLoaded % 10 === 0) {
679
- onProgress?.(modelsLoaded);
680
- }
681
- } catch (error) {
682
- writeErrors++;
683
- this.runtime.observability.breadcrumb(
684
- `Failed to store ${modelName} item`,
685
- 'sync.database',
686
- 'error',
687
- {
688
- error: error instanceof Error ? error.message : String(error),
689
- }
690
- );
691
- }
692
- }
693
-
694
- // The model is marked persisted below whether or not every item landed,
695
- // because a partial store is still what the next sync reconciles
696
- // against. Counted and surfaced here so a partial does not read as a
697
- // clean bootstrap.
698
- if (writeErrors > 0) {
699
- this.runtime.observability.breadcrumb(
700
- `Stored ${modelName} with ${writeErrors} of ${modelData.length} items dropped`,
701
- 'sync.database',
702
- 'warning',
703
- );
696
+ const compacted = this.compactRecord(modelName, item as ModelData);
697
+ await store.put(compacted);
698
+ modelsStored++;
699
+ modelsLoaded++;
700
+ if (modelsLoaded % 10 === 0) onProgress?.(modelsLoaded);
704
701
  }
705
-
706
- // Mark model as persisted after successful write
707
- try {
708
- await this.setModelPersisted(modelName, true);
709
- } catch {}
702
+ await this.setModelPersisted(modelName, true);
710
703
  }
711
704
 
712
705
  // Update workspace metadata with bootstrap snapshot's lastSyncId
@@ -1468,6 +1461,7 @@ export class Database {
1468
1461
  // Wait for transaction to complete
1469
1462
  await new Promise<void>((resolve, reject) => {
1470
1463
  tx.oncomplete = () => { resolve(); };
1464
+ tx.onabort = () => { reject(tx.error ?? new DOMException('IndexedDB transaction aborted', 'AbortError')); };
1471
1465
  tx.onerror = () => { reject(tx.error); };
1472
1466
  });
1473
1467
  // Only commit staged results to the global results if the transaction
@@ -325,6 +325,7 @@ export function startStoreLifecycle<S extends SchemaRecord>(
325
325
  capabilityToken,
326
326
  syncGroups,
327
327
  deliveryPartition,
328
+ operations: resolved.authority.operations,
328
329
  bootstrapMode: resolvedBootstrapMode,
329
330
  });
330
331
  let current = gen.next();
@@ -33,6 +33,8 @@ export interface DatabaseInfo {
33
33
  projectId: string | null;
34
34
  branchId: string;
35
35
  branchRoot: boolean;
36
+ syncGroups?: readonly string[];
37
+ operations?: readonly string[];
36
38
  schemaHash: string;
37
39
  schemaVersion: number;
38
40
  userVersion?: number;
@@ -175,6 +177,8 @@ export class DatabaseManager {
175
177
  projectId: identity.projectId,
176
178
  branchId: identity.branchId,
177
179
  branchRoot: identity.branchRoot,
180
+ syncGroups: [...new Set(identity.syncGroups ?? [])].sort(),
181
+ operations: [...new Set(identity.operations ?? [])].sort(),
178
182
  schemaHash,
179
183
  schemaVersion,
180
184
  userVersion,
@@ -204,6 +208,7 @@ export class DatabaseManager {
204
208
  resolve();
205
209
  };
206
210
 
211
+ tx.onabort = () => { reject(indexedDbError(tx.error, 'registry transaction')); };
207
212
  tx.onerror = () => { reject(indexedDbError(tx.error, 'registry transaction')); };
208
213
  request.onerror = () => { reject(indexedDbError(request.error, 'registry write')); };
209
214
  });
@@ -343,6 +348,7 @@ export class DatabaseManager {
343
348
  const request = store.put(metadata, 'metadata');
344
349
 
345
350
  tx.oncomplete = () => { resolve(); };
351
+ tx.onabort = () => { reject(indexedDbError(tx.error, 'workspace metadata transaction')); };
346
352
  tx.onerror = () => { reject(indexedDbError(tx.error, 'workspace metadata transaction')); };
347
353
  request.onerror = () => { reject(indexedDbError(request.error, 'workspace metadata write')); };
348
354
  });
@@ -384,6 +390,7 @@ export class DatabaseManager {
384
390
  const request = store.put(persistenceData, modelName);
385
391
 
386
392
  tx.oncomplete = () => { resolve(); };
393
+ tx.onabort = () => { reject(indexedDbError(tx.error, 'model persistence transaction')); };
387
394
  tx.onerror = () => { reject(indexedDbError(tx.error, 'model persistence transaction')); };
388
395
  request.onerror = () => { reject(indexedDbError(request.error, 'model persistence write')); };
389
396
  });
@@ -54,6 +54,7 @@ export class ObjectStore implements ObjectStoreContract {
54
54
  const request = store.add(data);
55
55
 
56
56
  tx.oncomplete = () => { resolve(); };
57
+ tx.onabort = () => { reject(tx.error ?? new DOMException('IndexedDB transaction aborted', 'AbortError')); };
57
58
  tx.onerror = () => { reject(tx.error ?? new Error('IndexedDB transaction error')); };
58
59
  request.onerror = () => { reject(request.error ?? new Error('IndexedDB request error')); };
59
60
  } catch (error) {
@@ -110,6 +111,7 @@ export class ObjectStore implements ObjectStoreContract {
110
111
  const request = store.put(data);
111
112
 
112
113
  tx.oncomplete = () => { resolve(); };
114
+ tx.onabort = () => { reject(tx.error ?? new DOMException('IndexedDB transaction aborted', 'AbortError')); };
113
115
  tx.onerror = () => { reject(tx.error ?? new Error('IndexedDB transaction error')); };
114
116
  request.onerror = () => { reject(request.error ?? new Error('IndexedDB request error')); };
115
117
  } catch (error) {
@@ -223,6 +225,7 @@ export class ObjectStore implements ObjectStoreContract {
223
225
  const request = store.delete(id);
224
226
 
225
227
  tx.oncomplete = () => { resolve(); };
228
+ tx.onabort = () => { reject(tx.error ?? new DOMException('IndexedDB transaction aborted', 'AbortError')); };
226
229
  tx.onerror = () => { reject(tx.error ?? new Error('IndexedDB transaction error')); };
227
230
  request.onerror = () => { reject(request.error ?? new Error('IndexedDB request error')); };
228
231
  } catch (error) {
@@ -261,6 +264,7 @@ export class ObjectStore implements ObjectStoreContract {
261
264
  const request = store.clear();
262
265
 
263
266
  tx.oncomplete = () => { resolve(); };
267
+ tx.onabort = () => { reject(tx.error ?? new DOMException('IndexedDB transaction aborted', 'AbortError')); };
264
268
  tx.onerror = () => { reject(tx.error ?? new Error('IndexedDB transaction error')); };
265
269
  request.onerror = () => { reject(request.error ?? new Error('IndexedDB request error')); };
266
270
  } catch (error) {
@@ -1,8 +1,7 @@
1
1
  import { AbloConnectionError } from '@abloatai/transaction/errors';
2
2
 
3
3
  /**
4
- * The complete authenticated branch that owns one local replica. A branch id
5
- * is authoritative.
4
+ * The authenticated branch, groups and permissions that own one local replica.
6
5
  */
7
6
  export interface PersistenceIdentity {
8
7
  readonly participantId: string;
@@ -11,6 +10,8 @@ export interface PersistenceIdentity {
11
10
  readonly projectId: string | null;
12
11
  readonly branchId: string;
13
12
  readonly branchRoot: boolean;
13
+ readonly syncGroups?: readonly string[];
14
+ readonly operations?: readonly string[];
14
15
  }
15
16
 
16
17
  export interface PersistedIdentityMetadata {
@@ -21,9 +22,15 @@ export interface PersistedIdentityMetadata {
21
22
  readonly projectId?: string | null;
22
23
  readonly branchId?: string;
23
24
  readonly branchRoot?: boolean;
25
+ readonly syncGroups?: readonly string[];
26
+ readonly operations?: readonly string[];
24
27
  }
25
28
 
26
- export const PERSISTENCE_NAMESPACE_VERSION = 4;
29
+ export const PERSISTENCE_NAMESPACE_VERSION = 5;
30
+
31
+ function scopeKey(values: readonly string[] = []): string {
32
+ return JSON.stringify([...new Set(values)].sort());
33
+ }
27
34
 
28
35
  function canonicalIdentity(
29
36
  identity: PersistenceIdentity,
@@ -36,6 +43,8 @@ function canonicalIdentity(
36
43
  identity.organizationId,
37
44
  identity.participantKind,
38
45
  identity.participantId,
46
+ scopeKey(identity.syncGroups),
47
+ scopeKey(identity.operations),
39
48
  userVersion,
40
49
  ]);
41
50
  }
@@ -76,6 +85,8 @@ export function persistenceIdentityMatches(
76
85
  info.participantKind === identity.participantKind &&
77
86
  (info.projectId ?? null) === identity.projectId &&
78
87
  info.branchId === identity.branchId &&
79
- (info.branchRoot ?? false) === identity.branchRoot
88
+ (info.branchRoot ?? false) === identity.branchRoot &&
89
+ scopeKey(info.syncGroups) === scopeKey(identity.syncGroups) &&
90
+ scopeKey(info.operations) === scopeKey(identity.operations)
80
91
  );
81
92
  }
@@ -107,6 +107,7 @@ import { globalRuntime } from '../context.js';
107
107
  import type { RuntimeContext } from '../RuntimeContext.js';
108
108
  import { AbloError, AbloSessionError, AbloConnectionError, translateHttpError, toAbloError, isRetryableCode } from '@abloatai/transaction/errors';
109
109
  import { withAuthHeaders, type AuthTokenGetter } from '@abloatai/transaction/auth/credentialSource';
110
+ import { retryAfterSecondsFromHeader } from '@abloatai/transaction/wire/rateLimit';
110
111
  import {
111
112
  classifySchemaDrift,
112
113
  describeSchemaDrift,
@@ -244,12 +245,16 @@ export class BootstrapFetcher {
244
245
  * through its models {@link CHUNK_CONCURRENCY} at a time; each request may
245
246
  * spend `fetchTimeout` waiting for response headers and `stallTimeout`
246
247
  * waiting for the next body chunk, and may be retried `maxRetries` times.
248
+ * Capacity recovery has a separate `fetchTimeout` window.
247
249
  */
248
250
  get budgetMs(): number {
249
251
  const models = Math.max(this.options.instantModels?.length ?? 1, 1);
250
252
  const waves = Math.ceil(models / CHUNK_CONCURRENCY);
251
253
  return (
252
- waves * (this.options.fetchTimeout + this.options.stallTimeout) * this.options.maxRetries
254
+ waves * (
255
+ (this.options.fetchTimeout + this.options.stallTimeout) * this.options.maxRetries +
256
+ this.options.fetchTimeout
257
+ )
253
258
  );
254
259
  }
255
260
 
@@ -406,8 +411,8 @@ export class BootstrapFetcher {
406
411
  * Update the offline-cache namespace once auth has resolved the server-side
407
412
  * account scope. This is intentionally not a public organizationId input.
408
413
  */
409
- setCacheScope(cacheScope: string): void {
410
- if (cacheScope.trim().length === 0) return;
414
+ setCacheScope(cacheScope: string | null): void {
415
+ if (cacheScope !== null && cacheScope.trim().length === 0) return;
411
416
  this.options.cacheScope = cacheScope;
412
417
  }
413
418
 
@@ -587,7 +592,7 @@ export class BootstrapFetcher {
587
592
  });
588
593
 
589
594
  // Persist for offline fallback
590
- if (this.options.cacheScope) {
595
+ if (!syncGroupsOverride && this.options.cacheScope) {
591
596
  this.saveCachedBootstrap(this.options.cacheScope, data);
592
597
  }
593
598
  return data;
@@ -604,7 +609,7 @@ export class BootstrapFetcher {
604
609
  }
605
610
 
606
611
  // Transient failure after exhausting retries → cached fallback.
607
- const cached = this.options.cacheScope
612
+ const cached = !syncGroupsOverride && this.options.cacheScope
608
613
  ? this.loadCachedBootstrap(this.options.cacheScope)
609
614
  : null;
610
615
  if (cached) {
@@ -625,7 +630,8 @@ export class BootstrapFetcher {
625
630
  */
626
631
  private async fetchWithRetries(url: string, lane: CancelLane): Promise<BootstrapData> {
627
632
  let lastError: Error | null = null;
628
- for (let attempt = 0; attempt < this.options.maxRetries; attempt++) {
633
+ const capacityDeadline = Date.now() + this.options.fetchTimeout;
634
+ for (let attempt = 0; attempt < this.options.maxRetries;) {
629
635
  try {
630
636
  return await this.fetchOnce(url, lane);
631
637
  } catch (error) {
@@ -664,8 +670,19 @@ export class BootstrapFetcher {
664
670
  attempt: attempt + 1,
665
671
  });
666
672
 
667
- if (attempt < this.options.maxRetries - 1) {
668
- await this.delay(this.options.retryDelay * Math.pow(2, attempt));
673
+ const delayMs = Math.max(
674
+ this.options.retryDelay * Math.pow(2, attempt),
675
+ (ablo.retryAfterSeconds ?? 0) * 1_000,
676
+ );
677
+ const capacity = ablo.code === 'instance_at_capacity' && ablo.retryAfterSeconds !== undefined;
678
+ // Admission has not run the request yet. Allow its recovery window
679
+ // without spending the attempts reserved for actual fetch failures.
680
+ if (ablo.retryAfterSeconds !== undefined && Date.now() + delayMs >= capacityDeadline) {
681
+ throw ablo;
682
+ }
683
+ if (!capacity) attempt++;
684
+ if (capacity || attempt < this.options.maxRetries) {
685
+ await this.delay(delayMs, lane);
669
686
  }
670
687
  }
671
688
  }
@@ -827,6 +844,7 @@ export class BootstrapFetcher {
827
844
  res.status,
828
845
  parsed ?? `Bootstrap fetch failed: ${res.status} ${res.statusText}`,
829
846
  res.headers.get('x-request-id') ?? undefined,
847
+ { retryAfterSeconds: retryAfterSecondsFromHeader(res.headers.get('retry-after')) },
830
848
  );
831
849
  // Only a genuine session or JWT expiry — or a bare auth failure carrying
832
850
  // no structured code — should drive the sign-in redirect. A specific auth
@@ -1019,6 +1037,7 @@ export class BootstrapFetcher {
1019
1037
  response.status,
1020
1038
  parsed ?? `Bootstrap fetch failed: ${response.status} ${response.statusText}`,
1021
1039
  response.headers.get('x-request-id') ?? undefined,
1040
+ { retryAfterSeconds: retryAfterSecondsFromHeader(response.headers.get('retry-after')) },
1022
1041
  );
1023
1042
  if (
1024
1043
  translated.code === 'session_expired' ||
@@ -1095,6 +1114,7 @@ export class BootstrapFetcher {
1095
1114
  response.status,
1096
1115
  parsed ?? `Entity fetch failed: ${response.status} ${response.statusText}`,
1097
1116
  response.headers.get('x-request-id') ?? undefined,
1117
+ { retryAfterSeconds: retryAfterSecondsFromHeader(response.headers.get('retry-after')) },
1098
1118
  );
1099
1119
  }
1100
1120
 
@@ -1129,7 +1149,7 @@ export class BootstrapFetcher {
1129
1149
 
1130
1150
  // Cache helpers for offline bootstrap
1131
1151
  private getBootstrapCacheKey(orgId: string): string {
1132
- return `ablo:bootstrap:${orgId}`;
1152
+ return `ablo:bootstrap:v5:${JSON.stringify([orgId, this.options.baseUrl, [...new Set(this.options.syncGroups)].sort()])}`;
1133
1153
  }
1134
1154
  private saveCachedBootstrap(orgId: string, data: BootstrapData): void {
1135
1155
  if (typeof window === 'undefined') return;
@@ -1168,8 +1188,21 @@ export class BootstrapFetcher {
1168
1188
  /**
1169
1189
  * Helper to delay execution
1170
1190
  */
1171
- private delay(ms: number): Promise<void> {
1172
- return new Promise((resolve) => setTimeout(resolve, ms));
1191
+ private async delay(ms: number, lane: CancelLane): Promise<void> {
1192
+ const controller = new AbortController();
1193
+ this.activeControllers.set(controller, lane);
1194
+ let timer: ReturnType<typeof setTimeout> | undefined;
1195
+ try {
1196
+ await new Promise<void>((resolve, reject) => {
1197
+ timer = setTimeout(resolve, ms);
1198
+ controller.signal.addEventListener('abort', () => {
1199
+ reject(classifyRequestFailure(undefined, controller, 'Bootstrap retry aborted'));
1200
+ }, { once: true });
1201
+ });
1202
+ } finally {
1203
+ clearTimeout(timer);
1204
+ this.activeControllers.delete(controller);
1205
+ }
1173
1206
  }
1174
1207
 
1175
1208
  /**
@@ -85,9 +85,8 @@ export interface FetchOptions<T> {
85
85
  readonly orderBy?: { [K in keyof T]?: 'asc' | 'desc' };
86
86
  readonly limit?: number;
87
87
  /**
88
- * Freshness mode. When omitted, the default is derived from the model's
89
- * load strategy: `lazy` models default to `'unknown'` (local-first), while
90
- * `instant`/`partial` models default to `'complete'`.
88
+ * Freshness mode. Omitted and `'unknown'` use local-first reads for every
89
+ * load strategy. A previously hydrated query relies on the live stream.
91
90
  *
92
91
  * `'complete'`: wait for the network round-trip even if local data exists,
93
92
  * so the caller observes server-confirmed state (read-after-write).
@@ -240,19 +239,20 @@ export class OnDemandLoader {
240
239
  const clauses = normalizeWhere(options?.where);
241
240
  const queryKey = stableKey(modelName, clauses, options?.orderBy, options?.limit, options?.expand);
242
241
 
243
- // Single-flight: an identical hydration is already in flight.
244
- const inFlight = this.inFlight.get(queryKey);
242
+ // A local-first read cannot satisfy an authoritative caller.
243
+ const flightKey = `${options?.type === 'complete' ? 'complete' : 'unknown'}:${queryKey}`;
244
+ const inFlight = this.inFlight.get(flightKey);
245
245
  if (inFlight) return inFlight;
246
246
 
247
247
  const work = this.runFetch(modelName, typename, ModelClass, clauses, options, queryKey);
248
- this.inFlight.set(queryKey, work);
248
+ this.inFlight.set(flightKey, work);
249
249
  // The rejection (if any) reaches callers via the returned `work`; this
250
250
  // side-chain only clears the single-flight slot. Without the trailing
251
251
  // catch, `.finally()` mirrors the rejection into a second, unhandled
252
252
  // promise even when every caller handles theirs.
253
253
  void work
254
254
  .finally(() => {
255
- this.inFlight.delete(queryKey);
255
+ this.inFlight.delete(flightKey);
256
256
  })
257
257
  .catch(() => undefined);
258
258
  return work;
@@ -313,14 +313,8 @@ export class OnDemandLoader {
313
313
  // network, then mark this query hydrated so future reads serve local.
314
314
  const networkModels = await this.fetchFromNetwork(modelName, typename, clauses, options);
315
315
  this.hydratedKeys.add(queryKey);
316
- if (networkModels.length > 0) return applyLimit(networkModels, options?.limit);
317
-
318
- // Network returned nothing — fall back to whatever's local (e.g. a
319
- // complete read whose server result was empty but IDB still holds rows).
320
- return applyLimit(
321
- await this.readLocal(modelName, typename, ModelClass, clauses, hasExpand, expand),
322
- options?.limit,
323
- );
316
+ // An empty authoritative answer is absence, not permission to serve stale rows.
317
+ return applyLimit(networkModels, options?.limit);
324
318
  }
325
319
 
326
320
  /**
@@ -379,6 +373,7 @@ export class OnDemandLoader {
379
373
  const network = await this.queryNetwork(modelName, clauses, options);
380
374
  const networkRows = network.rows;
381
375
  const evidenceById = new Map(network.evidence.map((entry) => [entry.id, entry.stamp]));
376
+ const acceptedRows: unknown[] = [];
382
377
  const networkModels = networkRows
383
378
  // Strict: a row the server returned whose type name this client never
384
379
  // registered is a genuine schema collision (the pushed schema differs
@@ -389,7 +384,7 @@ export class OnDemandLoader {
389
384
  raw,
390
385
  { kind: 'network', position: snapshotPosition(raw, evidenceById, network.position) },
391
386
  typename,
392
- { strict: true },
387
+ { strict: true, acceptedRows },
393
388
  ),
394
389
  )
395
390
  .filter((m): m is Model => m !== null);
@@ -405,9 +400,8 @@ export class OnDemandLoader {
405
400
 
406
401
  if (networkModels.length > 0) {
407
402
  this.opts.objectPool.addBatch(networkModels, ModelScope.live);
408
- // Background IDB write — don't block the caller. Expanded children are
409
- // persisted to their own stores inside `queryNetwork`/`hydrateExpanded`.
410
- void this.persistToIdb(modelName, networkRows);
403
+ // Persist only accepted snapshots: a stale response must not roll disk back.
404
+ await this.persistToIdb(modelName, acceptedRows);
411
405
  }
412
406
 
413
407
  return networkModels;
@@ -538,7 +532,7 @@ export class OnDemandLoader {
538
532
  raw: unknown,
539
533
  origin: HydrationOrigin,
540
534
  typename?: string,
541
- opts?: { strict?: boolean },
535
+ opts?: { strict?: boolean; acceptedRows?: unknown[] },
542
536
  ): Model | null {
543
537
  if (!raw || typeof raw !== 'object') return null;
544
538
  const obj = raw as Record<string, unknown>;
@@ -559,6 +553,7 @@ export class OnDemandLoader {
559
553
  if (this.opts.objectPool.watermarks.isAheadOf(existing, origin.position)) return existing;
560
554
 
561
555
  const stamped = this.stampTypename(obj, typename) as Record<string, unknown>;
556
+ opts?.acceptedRows?.push(stamped);
562
557
  // Retain pending local fields while accepting the server's others —
563
558
  // the same local-first merge contract SyncClient's delta resolver uses.
564
559
  const localChanges = existing.getChanges();
@@ -581,7 +576,12 @@ export class OnDemandLoader {
581
576
  // re-populate it). The typename comes from the schema relation
582
577
  // (`'Block'`, `'Section'`, etc.) so no guessing involved.
583
578
  const stamped = this.stampTypename(obj, typename) as Record<string, unknown>;
584
- return this.opts.objectPool.createFromData(stamped, undefined, opts);
579
+ const model = this.opts.objectPool.createFromData(stamped, undefined, opts);
580
+ if (model && origin.kind === 'network') {
581
+ this.opts.objectPool.watermarks.advance(model, origin.position);
582
+ opts?.acceptedRows?.push(stamped);
583
+ }
584
+ return model;
585
585
  }
586
586
 
587
587
  /**
@@ -672,7 +672,7 @@ export class OnDemandLoader {
672
672
  // own typed pool, then leave the nested arrays in place on the
673
673
  // primary row.
674
674
  if (options?.expand && options.expand.length > 0) {
675
- this.hydrateExpanded(modelName, normalized, options.expand, position);
675
+ await this.hydrateExpanded(modelName, normalized, options.expand, position);
676
676
  }
677
677
  return { rows: normalized, evidence, position };
678
678
  }
@@ -684,12 +684,13 @@ export class OnDemandLoader {
684
684
  * `__typename` field gets mangled by `postgres.camel` (`__typename`
685
685
  * → `_Typename`), so the SDK can't trust whatever string lands.
686
686
  */
687
- private hydrateExpanded(
687
+ private async hydrateExpanded(
688
688
  parentModelName: string,
689
689
  rows: unknown[],
690
690
  relationNames: readonly string[],
691
691
  position: number,
692
- ): void {
692
+ ): Promise<void> {
693
+ const writes: Promise<void>[] = [];
693
694
  const parentDef = this.getModelDef(parentModelName);
694
695
  // Nested rows carry no evidence of their own; the read floor at issue
695
696
  // time is what they provably reflect. A floor of zero says nothing.
@@ -710,8 +711,7 @@ export class OnDemandLoader {
710
711
  const stampedItems: unknown[] = [];
711
712
  for (const item of items) {
712
713
  const stamped = this.stampTypename(item, targetTypename);
713
- stampedItems.push(stamped);
714
- const m = this.hydrateOne(stamped, origin);
714
+ const m = this.hydrateOne(stamped, origin, targetTypename, { acceptedRows: stampedItems });
715
715
  if (m) models.push(m);
716
716
  }
717
717
  if (models.length > 0) {
@@ -722,23 +722,21 @@ export class OnDemandLoader {
722
722
  // this, expand-fetched relations live only inside the parent's row
723
723
  // and are lost to a lazy child query after a cold start.
724
724
  if (stampedItems.length > 0 && targetKey) {
725
- void this.persistToIdb(targetKey, stampedItems);
725
+ writes.push(this.persistToIdb(targetKey, stampedItems));
726
726
  }
727
727
  }
728
728
  }
729
+ await Promise.all(writes);
729
730
  }
730
731
 
731
732
  private async persistToIdb(modelName: string, rows: unknown[]): Promise<void> {
733
+ if (rows.length === 0) return;
732
734
  const store = this.opts.database.getStore(this.resolveTypename(modelName));
735
+ // Before ready(), callers may have a graph but no initialized storage.
733
736
  if (!store) return;
734
- for (const row of rows) {
735
- try {
736
- await store.put(row as Record<string, unknown>);
737
- } catch {
738
- // IDB writes are best-effort — a transient quota/transaction
739
- // failure shouldn't break the hydration's primary purpose.
740
- }
741
- }
737
+ // Enqueue together, before yielding: a later delta must not be followed by
738
+ // an older row from the tail of this query's sequential write loop.
739
+ await Promise.all(rows.map((row) => store.put(row as Record<string, unknown>)));
742
740
  }
743
741
 
744
742
  private resolveTypename(modelName: string): string {
@@ -42,6 +42,7 @@ export function* initialize<TCollaboration extends EventMap<TCollaboration>>(
42
42
  if (host.initialized) return { success: true };
43
43
 
44
44
  host.userContext = context;
45
+ let persistenceReady = false;
45
46
 
46
47
  try {
47
48
  host.updateSyncStatus({ state: 'syncing', progress: 0 });
@@ -56,8 +57,12 @@ export function* initialize<TCollaboration extends EventMap<TCollaboration>>(
56
57
  projectId: context.projectId ?? context.organizationId,
57
58
  branchId: context.branchId,
58
59
  branchRoot: context.branchRoot ?? false,
60
+ syncGroups: host.resolveSyncGroups(context),
61
+ operations: context.operations,
59
62
  });
60
63
 
64
+ persistenceReady = true;
65
+
61
66
  // Propagate identity only after storage is ready, then restore sealed
62
67
  // requests before accepting fresh mutations.
63
68
  yield host.syncClient.initialize(
@@ -182,13 +187,13 @@ export function* initialize<TCollaboration extends EventMap<TCollaboration>>(
182
187
  }
183
188
 
184
189
  // Fallback: show local data if available
185
- if (host.objectPool.size === 0) {
190
+ if (persistenceReady && host.objectPool.size === 0) {
186
191
  try {
187
192
  yield host.syncClient.hydrateFromDatabase();
188
193
  } catch {}
189
194
  }
190
195
 
191
- if (host.objectPool.size > 0) {
196
+ if (persistenceReady && host.objectPool.size > 0) {
192
197
  host.dataReady = true;
193
198
  host.initialized = true;
194
199
  host.updateSyncStatus(