@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/dist/local/BaseSyncedStore.d.ts +2 -0
- package/dist/local/Database.d.ts +1 -1
- package/dist/local/Database.js +34 -30
- package/dist/local/client/storeLifecycle.js +1 -0
- package/dist/local/stores/DatabaseManager.d.ts +2 -0
- package/dist/local/stores/DatabaseManager.js +5 -0
- package/dist/local/stores/ObjectStore.js +4 -0
- package/dist/local/stores/persistenceIdentity.d.ts +6 -3
- package/dist/local/stores/persistenceIdentity.js +9 -2
- package/dist/local/sync/BootstrapFetcher.d.ts +2 -1
- package/dist/local/sync/BootstrapFetcher.js +40 -13
- package/dist/local/sync/OnDemandLoader.d.ts +2 -3
- package/dist/local/sync/OnDemandLoader.js +30 -28
- package/dist/local/sync/initialize.js +6 -2
- package/package.json +2 -2
- package/src/local/BaseSyncedStore.ts +2 -0
- package/src/local/Database.ts +35 -41
- package/src/local/client/storeLifecycle.ts +1 -0
- package/src/local/stores/DatabaseManager.ts +7 -0
- package/src/local/stores/ObjectStore.ts +4 -0
- package/src/local/stores/persistenceIdentity.ts +15 -4
- package/src/local/sync/BootstrapFetcher.ts +44 -11
- package/src/local/sync/OnDemandLoader.ts +33 -35
- package/src/local/sync/initialize.ts +7 -2
|
@@ -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.
|
package/dist/local/Database.d.ts
CHANGED
|
@@ -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
|
|
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';
|
package/dist/local/Database.js
CHANGED
|
@@ -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
|
-
|
|
501
|
-
//
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
478
|
-
|
|
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
|
-
|
|
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.
|
|
85
|
-
* load strategy
|
|
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
|
-
//
|
|
121
|
-
const
|
|
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(
|
|
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(
|
|
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
|
-
|
|
179
|
-
|
|
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
|
-
//
|
|
245
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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()
|