@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.
@@ -109,6 +109,8 @@ export interface UserContext {
109
109
  * structure comes from schema-declared scopes and server-issued
110
110
  * authorization. */
111
111
  syncGroups?: readonly string[];
112
+ /** Server-confirmed operation grants; also partition persisted replicas. */
113
+ operations?: readonly string[];
112
114
  /**
113
115
  * How aggressively this participant should pull baseline state at
114
116
  * startup.
@@ -6,7 +6,7 @@
6
6
  * in-memory mirror of what this class persists.
7
7
  */
8
8
  import { type DatabaseInfo, type WorkspaceMetadata } from './stores/DatabaseManager.js';
9
- import type { PersistenceIdentity } from './stores/persistenceIdentity.js';
9
+ import { type PersistenceIdentity } from './stores/persistenceIdentity.js';
10
10
  import { ModelRegistry } from './ModelRegistry.js';
11
11
  import { LoadStrategy } from '@abloatai/transaction/types';
12
12
  import type { RuntimeContext } from './RuntimeContext.js';
@@ -6,6 +6,7 @@
6
6
  * in-memory mirror of what this class persists.
7
7
  */
8
8
  import { DatabaseManager } from './stores/DatabaseManager.js';
9
+ import { persistenceIdentityMatches } from './stores/persistenceIdentity.js';
9
10
  import { StoreManager } from './stores/StoreManager.js';
10
11
  import { ModelRegistry } from './ModelRegistry.js';
11
12
  import { LoadStrategy } from '@abloatai/transaction/types';
@@ -142,12 +143,16 @@ export class Database {
142
143
  async open(identity, version = 1) {
143
144
  this.isClosing = false;
144
145
  if (this.workspaceDb && this.currentDbInfo) {
146
+ if (!persistenceIdentityMatches(this.currentDbInfo, identity)) {
147
+ throw new AbloConnectionError('Dispose the client before changing persistence authority.', { code: 'db_identity_mismatch' });
148
+ }
145
149
  return;
146
150
  }
147
151
  // ── In-memory mode: skip IndexedDB entirely ──────────────────
148
152
  // Creates InMemoryObjectStore instances for all registered models.
149
153
  // Bootstrap via HTTP still works; only local persistence is skipped.
150
154
  if (this.inMemory) {
155
+ this.bootstrapHelper.setCacheScope(null);
151
156
  this.runtime.logger.debug('Opening in-memory database (headless mode)');
152
157
  const allModels = this.modelRegistry.getRegisteredModelNames();
153
158
  for (const modelName of allModels) {
@@ -164,6 +169,8 @@ export class Database {
164
169
  // Initialize meta database
165
170
  await this.databaseManager.initializeMetaDatabase();
166
171
  this.currentDbInfo = await this.databaseManager.calculateDatabaseInfo(identity, version);
172
+ // Both persistence layers must share the complete authenticated authority.
173
+ this.bootstrapHelper.setCacheScope(this.currentDbInfo.name);
167
174
  // Register database
168
175
  await this.databaseManager.registerDatabase(this.currentDbInfo);
169
176
  // Open workspace database
@@ -172,6 +179,20 @@ export class Database {
172
179
  });
173
180
  // Initialize stores
174
181
  await this.storeManager.initializeStores(this.workspaceDb);
182
+ // A live group addition can have widened a previous client's replica after
183
+ // it opened this namespace. Check persisted coverage before restoring rows
184
+ // or pending writes, not after the first WebSocket reconnect.
185
+ const metadata = await this.getWorkspaceMetadata();
186
+ const allowed = new Set(identity.syncGroups ?? []);
187
+ if (metadata?.subscribedSyncGroups.some(group => !allowed.has(group))) {
188
+ // Preserve pending writes for their original authority; never replay them
189
+ // into a narrower replica or silently discard them.
190
+ await this.close();
191
+ throw new AbloConnectionError('Persisted sync groups exceed the current authority.', {
192
+ code: 'db_identity_mismatch',
193
+ });
194
+ }
195
+ await this.updateWorkspaceMetadata({ subscribedSyncGroups: [...allowed] });
175
196
  const readiness = await this.storeManager.checkReadinessOfStores();
176
197
  this.runtime.logger.info(`Database opened: ${this.currentDbInfo.name} (${readiness.readyStores.length}/${readiness.totalStores} stores ready)`);
177
198
  }
@@ -451,6 +472,9 @@ export class Database {
451
472
  }));
452
473
  // Use batch processing for better performance
453
474
  const batch = await this.processDeltaBatch(formattedDeltas);
475
+ if (batch.persistedSyncId < Math.max(...formattedDeltas.map(delta => delta.syncId))) {
476
+ throw new Error('Could not persist all bootstrap changes; local storage must recover before retrying.');
477
+ }
454
478
  deltaResults = batch.results;
455
479
  deltasApplied = formattedDeltas.length;
456
480
  onProgress?.(deltasApplied);
@@ -497,38 +521,17 @@ export class Database {
497
521
  this.runtime.logger.debug(`[Bootstrap] NO IDB STORE for ${modelName} — ${modelData.length} items DROPPED`);
498
522
  continue;
499
523
  }
500
- let writeErrors = 0;
501
- // Store all items to IndexedDB (compacted)
524
+ // Fail before marking the model or snapshot persisted. A dropped row
525
+ // must be retried, not hidden behind an advanced bootstrap cursor.
502
526
  for (const item of modelData) {
503
- try {
504
- const compacted = this.compactRecord(modelName, item);
505
- await store.put(compacted);
506
- modelsStored++;
507
- modelsLoaded++;
508
- // Report progress every 10 items
509
- if (modelsLoaded % 10 === 0) {
510
- onProgress?.(modelsLoaded);
511
- }
512
- }
513
- catch (error) {
514
- writeErrors++;
515
- this.runtime.observability.breadcrumb(`Failed to store ${modelName} item`, 'sync.database', 'error', {
516
- error: error instanceof Error ? error.message : String(error),
517
- });
518
- }
519
- }
520
- // The model is marked persisted below whether or not every item landed,
521
- // because a partial store is still what the next sync reconciles
522
- // against. Counted and surfaced here so a partial does not read as a
523
- // clean bootstrap.
524
- if (writeErrors > 0) {
525
- this.runtime.observability.breadcrumb(`Stored ${modelName} with ${writeErrors} of ${modelData.length} items dropped`, 'sync.database', 'warning');
526
- }
527
- // Mark model as persisted after successful write
528
- try {
529
- await this.setModelPersisted(modelName, true);
527
+ const compacted = this.compactRecord(modelName, item);
528
+ await store.put(compacted);
529
+ modelsStored++;
530
+ modelsLoaded++;
531
+ if (modelsLoaded % 10 === 0)
532
+ onProgress?.(modelsLoaded);
530
533
  }
531
- catch { }
534
+ await this.setModelPersisted(modelName, true);
532
535
  }
533
536
  // Update workspace metadata with bootstrap snapshot's lastSyncId
534
537
  // Note: This method is only called for 'full' bootstrap (not 'local')
@@ -1121,6 +1124,7 @@ export class Database {
1121
1124
  // Wait for transaction to complete
1122
1125
  await new Promise((resolve, reject) => {
1123
1126
  tx.oncomplete = () => { resolve(); };
1127
+ tx.onabort = () => { reject(tx.error ?? new DOMException('IndexedDB transaction aborted', 'AbortError')); };
1124
1128
  tx.onerror = () => { reject(tx.error); };
1125
1129
  });
1126
1130
  // Only commit staged results to the global results if the transaction
@@ -202,6 +202,7 @@ export function startStoreLifecycle(deps) {
202
202
  capabilityToken,
203
203
  syncGroups,
204
204
  deliveryPartition,
205
+ operations: resolved.authority.operations,
205
206
  bootstrapMode: resolvedBootstrapMode,
206
207
  });
207
208
  let current = gen.next();
@@ -17,6 +17,8 @@ export interface DatabaseInfo {
17
17
  projectId: string | null;
18
18
  branchId: string;
19
19
  branchRoot: boolean;
20
+ syncGroups?: readonly string[];
21
+ operations?: readonly string[];
20
22
  schemaHash: string;
21
23
  schemaVersion: number;
22
24
  userVersion?: number;
@@ -116,6 +116,8 @@ export class DatabaseManager {
116
116
  projectId: identity.projectId,
117
117
  branchId: identity.branchId,
118
118
  branchRoot: identity.branchRoot,
119
+ syncGroups: [...new Set(identity.syncGroups ?? [])].sort(),
120
+ operations: [...new Set(identity.operations ?? [])].sort(),
119
121
  schemaHash,
120
122
  schemaVersion,
121
123
  userVersion,
@@ -140,6 +142,7 @@ export class DatabaseManager {
140
142
  tx.oncomplete = () => {
141
143
  resolve();
142
144
  };
145
+ tx.onabort = () => { reject(indexedDbError(tx.error, 'registry transaction')); };
143
146
  tx.onerror = () => { reject(indexedDbError(tx.error, 'registry transaction')); };
144
147
  request.onerror = () => { reject(indexedDbError(request.error, 'registry write')); };
145
148
  });
@@ -268,6 +271,7 @@ export class DatabaseManager {
268
271
  const store = tx.objectStore('__meta');
269
272
  const request = store.put(metadata, 'metadata');
270
273
  tx.oncomplete = () => { resolve(); };
274
+ tx.onabort = () => { reject(indexedDbError(tx.error, 'workspace metadata transaction')); };
271
275
  tx.onerror = () => { reject(indexedDbError(tx.error, 'workspace metadata transaction')); };
272
276
  request.onerror = () => { reject(indexedDbError(request.error, 'workspace metadata write')); };
273
277
  });
@@ -302,6 +306,7 @@ export class DatabaseManager {
302
306
  };
303
307
  const request = store.put(persistenceData, modelName);
304
308
  tx.oncomplete = () => { resolve(); };
309
+ tx.onabort = () => { reject(indexedDbError(tx.error, 'model persistence transaction')); };
305
310
  tx.onerror = () => { reject(indexedDbError(tx.error, 'model persistence transaction')); };
306
311
  request.onerror = () => { reject(indexedDbError(request.error, 'model persistence write')); };
307
312
  });
@@ -41,6 +41,7 @@ export class ObjectStore {
41
41
  const store = tx.objectStore(this.storeName);
42
42
  const request = store.add(data);
43
43
  tx.oncomplete = () => { resolve(); };
44
+ tx.onabort = () => { reject(tx.error ?? new DOMException('IndexedDB transaction aborted', 'AbortError')); };
44
45
  tx.onerror = () => { reject(tx.error ?? new Error('IndexedDB transaction error')); };
45
46
  request.onerror = () => { reject(request.error ?? new Error('IndexedDB request error')); };
46
47
  }
@@ -93,6 +94,7 @@ export class ObjectStore {
93
94
  const store = tx.objectStore(this.storeName);
94
95
  const request = store.put(data);
95
96
  tx.oncomplete = () => { resolve(); };
97
+ tx.onabort = () => { reject(tx.error ?? new DOMException('IndexedDB transaction aborted', 'AbortError')); };
96
98
  tx.onerror = () => { reject(tx.error ?? new Error('IndexedDB transaction error')); };
97
99
  request.onerror = () => { reject(request.error ?? new Error('IndexedDB request error')); };
98
100
  }
@@ -197,6 +199,7 @@ export class ObjectStore {
197
199
  const store = tx.objectStore(this.storeName);
198
200
  const request = store.delete(id);
199
201
  tx.oncomplete = () => { resolve(); };
202
+ tx.onabort = () => { reject(tx.error ?? new DOMException('IndexedDB transaction aborted', 'AbortError')); };
200
203
  tx.onerror = () => { reject(tx.error ?? new Error('IndexedDB transaction error')); };
201
204
  request.onerror = () => { reject(request.error ?? new Error('IndexedDB request error')); };
202
205
  }
@@ -233,6 +236,7 @@ export class ObjectStore {
233
236
  const store = tx.objectStore(this.storeName);
234
237
  const request = store.clear();
235
238
  tx.oncomplete = () => { resolve(); };
239
+ tx.onabort = () => { reject(tx.error ?? new DOMException('IndexedDB transaction aborted', 'AbortError')); };
236
240
  tx.onerror = () => { reject(tx.error ?? new Error('IndexedDB transaction error')); };
237
241
  request.onerror = () => { reject(request.error ?? new Error('IndexedDB request error')); };
238
242
  }
@@ -1,6 +1,5 @@
1
1
  /**
2
- * The complete authenticated branch that owns one local replica. A branch id
3
- * is authoritative.
2
+ * The authenticated branch, groups and permissions that own one local replica.
4
3
  */
5
4
  export interface PersistenceIdentity {
6
5
  readonly participantId: string;
@@ -9,6 +8,8 @@ export interface PersistenceIdentity {
9
8
  readonly projectId: string | null;
10
9
  readonly branchId: string;
11
10
  readonly branchRoot: boolean;
11
+ readonly syncGroups?: readonly string[];
12
+ readonly operations?: readonly string[];
12
13
  }
13
14
  export interface PersistedIdentityMetadata {
14
15
  readonly namespaceVersion?: number;
@@ -18,8 +19,10 @@ export interface PersistedIdentityMetadata {
18
19
  readonly projectId?: string | null;
19
20
  readonly branchId?: string;
20
21
  readonly branchRoot?: boolean;
22
+ readonly syncGroups?: readonly string[];
23
+ readonly operations?: readonly string[];
21
24
  }
22
- export declare const PERSISTENCE_NAMESPACE_VERSION = 4;
25
+ export declare const PERSISTENCE_NAMESPACE_VERSION = 5;
23
26
  /** Collision-resistant IndexedDB name for one authenticated data plane. */
24
27
  export declare function persistenceDatabaseName(identity: PersistenceIdentity, userVersion?: number): Promise<string>;
25
28
  /** Defense-in-depth check after namespace lookup and before persisted reads. */
@@ -1,5 +1,8 @@
1
1
  import { AbloConnectionError } from '@abloatai/transaction/errors';
2
- export const PERSISTENCE_NAMESPACE_VERSION = 4;
2
+ export const PERSISTENCE_NAMESPACE_VERSION = 5;
3
+ function scopeKey(values = []) {
4
+ return JSON.stringify([...new Set(values)].sort());
5
+ }
3
6
  function canonicalIdentity(identity, userVersion) {
4
7
  return JSON.stringify([
5
8
  PERSISTENCE_NAMESPACE_VERSION,
@@ -8,6 +11,8 @@ function canonicalIdentity(identity, userVersion) {
8
11
  identity.organizationId,
9
12
  identity.participantKind,
10
13
  identity.participantId,
14
+ scopeKey(identity.syncGroups),
15
+ scopeKey(identity.operations),
11
16
  userVersion,
12
17
  ]);
13
18
  }
@@ -33,5 +38,7 @@ export function persistenceIdentityMatches(info, identity) {
33
38
  info.participantKind === identity.participantKind &&
34
39
  (info.projectId ?? null) === identity.projectId &&
35
40
  info.branchId === identity.branchId &&
36
- (info.branchRoot ?? false) === identity.branchRoot);
41
+ (info.branchRoot ?? false) === identity.branchRoot &&
42
+ scopeKey(info.syncGroups) === scopeKey(identity.syncGroups) &&
43
+ scopeKey(info.operations) === scopeKey(identity.operations));
37
44
  }
@@ -141,6 +141,7 @@ export declare class BootstrapFetcher {
141
141
  * through its models {@link CHUNK_CONCURRENCY} at a time; each request may
142
142
  * spend `fetchTimeout` waiting for response headers and `stallTimeout`
143
143
  * waiting for the next body chunk, and may be retried `maxRetries` times.
144
+ * Capacity recovery has a separate `fetchTimeout` window.
144
145
  */
145
146
  get budgetMs(): number;
146
147
  get baseUrl(): string;
@@ -171,7 +172,7 @@ export declare class BootstrapFetcher {
171
172
  * Update the offline-cache namespace once auth has resolved the server-side
172
173
  * account scope. This is intentionally not a public organizationId input.
173
174
  */
174
- setCacheScope(cacheScope: string): void;
175
+ setCacheScope(cacheScope: string | null): void;
175
176
  setSyncGroups(syncGroups: readonly string[] | undefined): void;
176
177
  /**
177
178
  * Sets a fixed credential for callers that construct the helper directly.
@@ -9,6 +9,7 @@
9
9
  import { globalRuntime } from '../context.js';
10
10
  import { AbloError, AbloSessionError, AbloConnectionError, translateHttpError, toAbloError, isRetryableCode } from '@abloatai/transaction/errors';
11
11
  import { withAuthHeaders } from '@abloatai/transaction/auth/credentialSource';
12
+ import { retryAfterSecondsFromHeader } from '@abloatai/transaction/wire/rateLimit';
12
13
  import { classifySchemaDrift, describeSchemaDrift, } from './schemaDrift.js';
13
14
  // SyncObservability replaced by this.runtime.observability
14
15
  import { parseBootstrapResponse } from './schemas.js';
@@ -111,11 +112,13 @@ export class BootstrapFetcher {
111
112
  * through its models {@link CHUNK_CONCURRENCY} at a time; each request may
112
113
  * spend `fetchTimeout` waiting for response headers and `stallTimeout`
113
114
  * waiting for the next body chunk, and may be retried `maxRetries` times.
115
+ * Capacity recovery has a separate `fetchTimeout` window.
114
116
  */
115
117
  get budgetMs() {
116
118
  const models = Math.max(this.options.instantModels?.length ?? 1, 1);
117
119
  const waves = Math.ceil(models / CHUNK_CONCURRENCY);
118
- return (waves * (this.options.fetchTimeout + this.options.stallTimeout) * this.options.maxRetries);
120
+ return (waves * ((this.options.fetchTimeout + this.options.stallTimeout) * this.options.maxRetries +
121
+ this.options.fetchTimeout));
119
122
  }
120
123
  get baseUrl() {
121
124
  return this.options.baseUrl;
@@ -256,7 +259,7 @@ export class BootstrapFetcher {
256
259
  * account scope. This is intentionally not a public organizationId input.
257
260
  */
258
261
  setCacheScope(cacheScope) {
259
- if (cacheScope.trim().length === 0)
262
+ if (cacheScope !== null && cacheScope.trim().length === 0)
260
263
  return;
261
264
  this.options.cacheScope = cacheScope;
262
265
  }
@@ -410,7 +413,7 @@ export class BootstrapFetcher {
410
413
  : 0,
411
414
  });
412
415
  // Persist for offline fallback
413
- if (this.options.cacheScope) {
416
+ if (!syncGroupsOverride && this.options.cacheScope) {
414
417
  this.saveCachedBootstrap(this.options.cacheScope, data);
415
418
  }
416
419
  return data;
@@ -427,7 +430,7 @@ export class BootstrapFetcher {
427
430
  throw ablo;
428
431
  }
429
432
  // Transient failure after exhausting retries → cached fallback.
430
- const cached = this.options.cacheScope
433
+ const cached = !syncGroupsOverride && this.options.cacheScope
431
434
  ? this.loadCachedBootstrap(this.options.cacheScope)
432
435
  : null;
433
436
  if (cached) {
@@ -447,7 +450,8 @@ export class BootstrapFetcher {
447
450
  */
448
451
  async fetchWithRetries(url, lane) {
449
452
  let lastError = null;
450
- for (let attempt = 0; attempt < this.options.maxRetries; attempt++) {
453
+ const capacityDeadline = Date.now() + this.options.fetchTimeout;
454
+ for (let attempt = 0; attempt < this.options.maxRetries;) {
451
455
  try {
452
456
  return await this.fetchOnce(url, lane);
453
457
  }
@@ -474,8 +478,17 @@ export class BootstrapFetcher {
474
478
  this.runtime.observability.breadcrumb('Bootstrap fetch failed', 'sync.bootstrap', 'warning', {
475
479
  attempt: attempt + 1,
476
480
  });
477
- if (attempt < this.options.maxRetries - 1) {
478
- await this.delay(this.options.retryDelay * Math.pow(2, attempt));
481
+ const delayMs = Math.max(this.options.retryDelay * Math.pow(2, attempt), (ablo.retryAfterSeconds ?? 0) * 1_000);
482
+ const capacity = ablo.code === 'instance_at_capacity' && ablo.retryAfterSeconds !== undefined;
483
+ // Admission has not run the request yet. Allow its recovery window
484
+ // without spending the attempts reserved for actual fetch failures.
485
+ if (ablo.retryAfterSeconds !== undefined && Date.now() + delayMs >= capacityDeadline) {
486
+ throw ablo;
487
+ }
488
+ if (!capacity)
489
+ attempt++;
490
+ if (capacity || attempt < this.options.maxRetries) {
491
+ await this.delay(delayMs, lane);
479
492
  }
480
493
  }
481
494
  }
@@ -609,7 +622,7 @@ export class BootstrapFetcher {
609
622
  // Translate the canonical envelope first so the server's specific code
610
623
  // and message survive (for example `api_key_required` or
611
624
  // `jwt_issuer_untrusted`).
612
- const translated = translateHttpError(res.status, parsed ?? `Bootstrap fetch failed: ${res.status} ${res.statusText}`, res.headers.get('x-request-id') ?? undefined);
625
+ const translated = translateHttpError(res.status, parsed ?? `Bootstrap fetch failed: ${res.status} ${res.statusText}`, res.headers.get('x-request-id') ?? undefined, { retryAfterSeconds: retryAfterSecondsFromHeader(res.headers.get('retry-after')) });
613
626
  // Only a genuine session or JWT expiry — or a bare auth failure carrying
614
627
  // no structured code — should drive the sign-in redirect. A specific auth
615
628
  // code like `api_key_required` is not an expired session: signing in again
@@ -764,7 +777,7 @@ export class BootstrapFetcher {
764
777
  // Same code-aware handling as the primary bootstrap fetch: preserve the
765
778
  // server's specific code/message; only a genuine expiry (or a bare,
766
779
  // code-less auth failure) drives the sign-in redirect.
767
- const translated = translateHttpError(response.status, parsed ?? `Bootstrap fetch failed: ${response.status} ${response.statusText}`, response.headers.get('x-request-id') ?? undefined);
780
+ const translated = translateHttpError(response.status, parsed ?? `Bootstrap fetch failed: ${response.status} ${response.statusText}`, response.headers.get('x-request-id') ?? undefined, { retryAfterSeconds: retryAfterSecondsFromHeader(response.headers.get('retry-after')) });
768
781
  if (translated.code === 'session_expired' ||
769
782
  translated.code === 'jwt_expired' ||
770
783
  ((response.status === 401 || response.status === 403) &&
@@ -828,7 +841,7 @@ export class BootstrapFetcher {
828
841
  // Keep as string.
829
842
  }
830
843
  }
831
- throw translateHttpError(response.status, parsed ?? `Entity fetch failed: ${response.status} ${response.statusText}`, response.headers.get('x-request-id') ?? undefined);
844
+ throw translateHttpError(response.status, parsed ?? `Entity fetch failed: ${response.status} ${response.statusText}`, response.headers.get('x-request-id') ?? undefined, { retryAfterSeconds: retryAfterSecondsFromHeader(response.headers.get('retry-after')) });
832
845
  }
833
846
  return (await response.json());
834
847
  }
@@ -859,7 +872,7 @@ export class BootstrapFetcher {
859
872
  }
860
873
  // Cache helpers for offline bootstrap
861
874
  getBootstrapCacheKey(orgId) {
862
- return `ablo:bootstrap:${orgId}`;
875
+ return `ablo:bootstrap:v5:${JSON.stringify([orgId, this.options.baseUrl, [...new Set(this.options.syncGroups)].sort()])}`;
863
876
  }
864
877
  saveCachedBootstrap(orgId, data) {
865
878
  if (typeof window === 'undefined')
@@ -901,8 +914,22 @@ export class BootstrapFetcher {
901
914
  /**
902
915
  * Helper to delay execution
903
916
  */
904
- delay(ms) {
905
- return new Promise((resolve) => setTimeout(resolve, ms));
917
+ async delay(ms, lane) {
918
+ const controller = new AbortController();
919
+ this.activeControllers.set(controller, lane);
920
+ let timer;
921
+ try {
922
+ await new Promise((resolve, reject) => {
923
+ timer = setTimeout(resolve, ms);
924
+ controller.signal.addEventListener('abort', () => {
925
+ reject(classifyRequestFailure(undefined, controller, 'Bootstrap retry aborted'));
926
+ }, { once: true });
927
+ });
928
+ }
929
+ finally {
930
+ clearTimeout(timer);
931
+ this.activeControllers.delete(controller);
932
+ }
906
933
  }
907
934
  /**
908
935
  * Get health status of sync engine
@@ -81,9 +81,8 @@ export interface FetchOptions<T> {
81
81
  };
82
82
  readonly limit?: number;
83
83
  /**
84
- * Freshness mode. When omitted, the default is derived from the model's
85
- * load strategy: `lazy` models default to `'unknown'` (local-first), while
86
- * `instant`/`partial` models default to `'complete'`.
84
+ * Freshness mode. Omitted and `'unknown'` use local-first reads for every
85
+ * load strategy. A previously hydrated query relies on the live stream.
87
86
  *
88
87
  * `'complete'`: wait for the network round-trip even if local data exists,
89
88
  * so the caller observes server-confirmed state (read-after-write).
@@ -117,19 +117,20 @@ export class OnDemandLoader {
117
117
  }
118
118
  const clauses = normalizeWhere(options?.where);
119
119
  const queryKey = stableKey(modelName, clauses, options?.orderBy, options?.limit, options?.expand);
120
- // Single-flight: an identical hydration is already in flight.
121
- const inFlight = this.inFlight.get(queryKey);
120
+ // A local-first read cannot satisfy an authoritative caller.
121
+ const flightKey = `${options?.type === 'complete' ? 'complete' : 'unknown'}:${queryKey}`;
122
+ const inFlight = this.inFlight.get(flightKey);
122
123
  if (inFlight)
123
124
  return inFlight;
124
125
  const work = this.runFetch(modelName, typename, ModelClass, clauses, options, queryKey);
125
- this.inFlight.set(queryKey, work);
126
+ this.inFlight.set(flightKey, work);
126
127
  // The rejection (if any) reaches callers via the returned `work`; this
127
128
  // side-chain only clears the single-flight slot. Without the trailing
128
129
  // catch, `.finally()` mirrors the rejection into a second, unhandled
129
130
  // promise even when every caller handles theirs.
130
131
  void work
131
132
  .finally(() => {
132
- this.inFlight.delete(queryKey);
133
+ this.inFlight.delete(flightKey);
133
134
  })
134
135
  .catch(() => undefined);
135
136
  return work;
@@ -175,11 +176,8 @@ export class OnDemandLoader {
175
176
  // network, then mark this query hydrated so future reads serve local.
176
177
  const networkModels = await this.fetchFromNetwork(modelName, typename, clauses, options);
177
178
  this.hydratedKeys.add(queryKey);
178
- if (networkModels.length > 0)
179
- return applyLimit(networkModels, options?.limit);
180
- // Network returned nothing — fall back to whatever's local (e.g. a
181
- // complete read whose server result was empty but IDB still holds rows).
182
- return applyLimit(await this.readLocal(modelName, typename, ModelClass, clauses, hasExpand, expand), options?.limit);
179
+ // An empty authoritative answer is absence, not permission to serve stale rows.
180
+ return applyLimit(networkModels, options?.limit);
183
181
  }
184
182
  /**
185
183
  * Read a query's rows from local storage only — pool first, then IndexedDB
@@ -223,12 +221,13 @@ export class OnDemandLoader {
223
221
  const network = await this.queryNetwork(modelName, clauses, options);
224
222
  const networkRows = network.rows;
225
223
  const evidenceById = new Map(network.evidence.map((entry) => [entry.id, entry.stamp]));
224
+ const acceptedRows = [];
226
225
  const networkModels = networkRows
227
226
  // Strict: a row the server returned whose type name this client never
228
227
  // registered is a genuine schema collision (the pushed schema differs
229
228
  // from the local one). Throw here, naming the cause, rather than silently
230
229
  // dropping the row and failing downstream as `entity_not_found`.
231
- .map((raw) => this.hydrateOne(raw, { kind: 'network', position: snapshotPosition(raw, evidenceById, network.position) }, typename, { strict: true }))
230
+ .map((raw) => this.hydrateOne(raw, { kind: 'network', position: snapshotPosition(raw, evidenceById, network.position) }, typename, { strict: true, acceptedRows }))
232
231
  .filter((m) => m !== null);
233
232
  for (const model of networkModels) {
234
233
  const stamp = evidenceById.get(model.id);
@@ -241,9 +240,8 @@ export class OnDemandLoader {
241
240
  }
242
241
  if (networkModels.length > 0) {
243
242
  this.opts.objectPool.addBatch(networkModels, ModelScope.live);
244
- // Background IDB write — don't block the caller. Expanded children are
245
- // persisted to their own stores inside `queryNetwork`/`hydrateExpanded`.
246
- void this.persistToIdb(modelName, networkRows);
243
+ // Persist only accepted snapshots: a stale response must not roll disk back.
244
+ await this.persistToIdb(modelName, acceptedRows);
247
245
  }
248
246
  return networkModels;
249
247
  }
@@ -377,6 +375,7 @@ export class OnDemandLoader {
377
375
  if (this.opts.objectPool.watermarks.isAheadOf(existing, origin.position))
378
376
  return existing;
379
377
  const stamped = this.stampTypename(obj, typename);
378
+ opts?.acceptedRows?.push(stamped);
380
379
  // Retain pending local fields while accepting the server's others —
381
380
  // the same local-first merge contract SyncClient's delta resolver uses.
382
381
  const localChanges = existing.getChanges();
@@ -397,7 +396,12 @@ export class OnDemandLoader {
397
396
  // re-populate it). The typename comes from the schema relation
398
397
  // (`'Block'`, `'Section'`, etc.) so no guessing involved.
399
398
  const stamped = this.stampTypename(obj, typename);
400
- return this.opts.objectPool.createFromData(stamped, undefined, opts);
399
+ const model = this.opts.objectPool.createFromData(stamped, undefined, opts);
400
+ if (model && origin.kind === 'network') {
401
+ this.opts.objectPool.watermarks.advance(model, origin.position);
402
+ opts?.acceptedRows?.push(stamped);
403
+ }
404
+ return model;
401
405
  }
402
406
  /**
403
407
  * Stamp `__typename` onto a row when it's known (from the schema's
@@ -472,7 +476,7 @@ export class OnDemandLoader {
472
476
  // own typed pool, then leave the nested arrays in place on the
473
477
  // primary row.
474
478
  if (options?.expand && options.expand.length > 0) {
475
- this.hydrateExpanded(modelName, normalized, options.expand, position);
479
+ await this.hydrateExpanded(modelName, normalized, options.expand, position);
476
480
  }
477
481
  return { rows: normalized, evidence, position };
478
482
  }
@@ -483,7 +487,8 @@ export class OnDemandLoader {
483
487
  * `__typename` field gets mangled by `postgres.camel` (`__typename`
484
488
  * → `_Typename`), so the SDK can't trust whatever string lands.
485
489
  */
486
- hydrateExpanded(parentModelName, rows, relationNames, position) {
490
+ async hydrateExpanded(parentModelName, rows, relationNames, position) {
491
+ const writes = [];
487
492
  const parentDef = this.getModelDef(parentModelName);
488
493
  // Nested rows carry no evidence of their own; the read floor at issue
489
494
  // time is what they provably reflect. A floor of zero says nothing.
@@ -505,8 +510,7 @@ export class OnDemandLoader {
505
510
  const stampedItems = [];
506
511
  for (const item of items) {
507
512
  const stamped = this.stampTypename(item, targetTypename);
508
- stampedItems.push(stamped);
509
- const m = this.hydrateOne(stamped, origin);
513
+ const m = this.hydrateOne(stamped, origin, targetTypename, { acceptedRows: stampedItems });
510
514
  if (m)
511
515
  models.push(m);
512
516
  }
@@ -518,24 +522,22 @@ export class OnDemandLoader {
518
522
  // this, expand-fetched relations live only inside the parent's row
519
523
  // and are lost to a lazy child query after a cold start.
520
524
  if (stampedItems.length > 0 && targetKey) {
521
- void this.persistToIdb(targetKey, stampedItems);
525
+ writes.push(this.persistToIdb(targetKey, stampedItems));
522
526
  }
523
527
  }
524
528
  }
529
+ await Promise.all(writes);
525
530
  }
526
531
  async persistToIdb(modelName, rows) {
532
+ if (rows.length === 0)
533
+ return;
527
534
  const store = this.opts.database.getStore(this.resolveTypename(modelName));
535
+ // Before ready(), callers may have a graph but no initialized storage.
528
536
  if (!store)
529
537
  return;
530
- for (const row of rows) {
531
- try {
532
- await store.put(row);
533
- }
534
- catch {
535
- // IDB writes are best-effort — a transient quota/transaction
536
- // failure shouldn't break the hydration's primary purpose.
537
- }
538
- }
538
+ // Enqueue together, before yielding: a later delta must not be followed by
539
+ // an older row from the tail of this query's sequential write loop.
540
+ await Promise.all(rows.map((row) => store.put(row)));
539
541
  }
540
542
  resolveTypename(modelName) {
541
543
  // Schema is the source of truth for wire typenames. The model proxy
@@ -3,6 +3,7 @@ export function* initialize(host, context, signal) {
3
3
  if (host.initialized)
4
4
  return { success: true };
5
5
  host.userContext = context;
6
+ let persistenceReady = false;
6
7
  try {
7
8
  host.updateSyncStatus({ state: 'syncing', progress: 0 });
8
9
  // The commit outbox and offline mutation journal live in IndexedDB.
@@ -15,7 +16,10 @@ export function* initialize(host, context, signal) {
15
16
  projectId: context.projectId ?? context.organizationId,
16
17
  branchId: context.branchId,
17
18
  branchRoot: context.branchRoot ?? false,
19
+ syncGroups: host.resolveSyncGroups(context),
20
+ operations: context.operations,
18
21
  });
22
+ persistenceReady = true;
19
23
  // Propagate identity only after storage is ready, then restore sealed
20
24
  // requests before accepting fresh mutations.
21
25
  yield host.syncClient.initialize(context.userId, context.organizationId);
@@ -117,13 +121,13 @@ export function* initialize(host, context, signal) {
117
121
  return { success: false, error: error };
118
122
  }
119
123
  // Fallback: show local data if available
120
- if (host.objectPool.size === 0) {
124
+ if (persistenceReady && host.objectPool.size === 0) {
121
125
  try {
122
126
  yield host.syncClient.hydrateFromDatabase();
123
127
  }
124
128
  catch { }
125
129
  }
126
- if (host.objectPool.size > 0) {
130
+ if (persistenceReady && host.objectPool.size > 0) {
127
131
  host.dataReady = true;
128
132
  host.initialized = true;
129
133
  host.updateSyncStatus(host.syncWebSocket.isConnected()