@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abloatai/humans",
|
|
3
|
-
"version": "0.64.
|
|
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.
|
|
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.
|
package/src/local/Database.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
669
|
-
//
|
|
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
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
|
@@ -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
|
|
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 =
|
|
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 * (
|
|
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
|
-
|
|
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
|
-
|
|
668
|
-
|
|
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
|
-
|
|
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.
|
|
89
|
-
* load strategy
|
|
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
|
-
//
|
|
244
|
-
const
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
//
|
|
409
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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(
|