@abloatai/humans 0.64.3 → 0.64.4
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 +21 -0
- package/dist/local/client/storeLifecycle.js +1 -0
- package/dist/local/stores/DatabaseManager.d.ts +2 -0
- package/dist/local/stores/DatabaseManager.js +2 -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 +1 -1
- package/dist/local/sync/BootstrapFetcher.js +4 -4
- 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 +23 -1
- package/src/local/client/storeLifecycle.ts +1 -0
- package/src/local/stores/DatabaseManager.ts +4 -0
- package/src/local/stores/persistenceIdentity.ts +15 -4
- package/src/local/sync/BootstrapFetcher.ts +5 -5
- 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
|
}
|
|
@@ -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,
|
|
@@ -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
|
}
|
|
@@ -171,7 +171,7 @@ export declare class BootstrapFetcher {
|
|
|
171
171
|
* Update the offline-cache namespace once auth has resolved the server-side
|
|
172
172
|
* account scope. This is intentionally not a public organizationId input.
|
|
173
173
|
*/
|
|
174
|
-
setCacheScope(cacheScope: string): void;
|
|
174
|
+
setCacheScope(cacheScope: string | null): void;
|
|
175
175
|
setSyncGroups(syncGroups: readonly string[] | undefined): void;
|
|
176
176
|
/**
|
|
177
177
|
* Sets a fixed credential for callers that construct the helper directly.
|
|
@@ -256,7 +256,7 @@ export class BootstrapFetcher {
|
|
|
256
256
|
* account scope. This is intentionally not a public organizationId input.
|
|
257
257
|
*/
|
|
258
258
|
setCacheScope(cacheScope) {
|
|
259
|
-
if (cacheScope.trim().length === 0)
|
|
259
|
+
if (cacheScope !== null && cacheScope.trim().length === 0)
|
|
260
260
|
return;
|
|
261
261
|
this.options.cacheScope = cacheScope;
|
|
262
262
|
}
|
|
@@ -410,7 +410,7 @@ export class BootstrapFetcher {
|
|
|
410
410
|
: 0,
|
|
411
411
|
});
|
|
412
412
|
// Persist for offline fallback
|
|
413
|
-
if (this.options.cacheScope) {
|
|
413
|
+
if (!syncGroupsOverride && this.options.cacheScope) {
|
|
414
414
|
this.saveCachedBootstrap(this.options.cacheScope, data);
|
|
415
415
|
}
|
|
416
416
|
return data;
|
|
@@ -427,7 +427,7 @@ export class BootstrapFetcher {
|
|
|
427
427
|
throw ablo;
|
|
428
428
|
}
|
|
429
429
|
// Transient failure after exhausting retries → cached fallback.
|
|
430
|
-
const cached = this.options.cacheScope
|
|
430
|
+
const cached = !syncGroupsOverride && this.options.cacheScope
|
|
431
431
|
? this.loadCachedBootstrap(this.options.cacheScope)
|
|
432
432
|
: null;
|
|
433
433
|
if (cached) {
|
|
@@ -859,7 +859,7 @@ export class BootstrapFetcher {
|
|
|
859
859
|
}
|
|
860
860
|
// Cache helpers for offline bootstrap
|
|
861
861
|
getBootstrapCacheKey(orgId) {
|
|
862
|
-
return `ablo:bootstrap:${orgId}`;
|
|
862
|
+
return `ablo:bootstrap:v5:${JSON.stringify([orgId, this.options.baseUrl, [...new Set(this.options.syncGroups)].sort()])}`;
|
|
863
863
|
}
|
|
864
864
|
saveCachedBootstrap(orgId, data) {
|
|
865
865
|
if (typeof window === 'undefined')
|
|
@@ -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()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abloatai/humans",
|
|
3
|
-
"version": "0.64.
|
|
3
|
+
"version": "0.64.4",
|
|
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.4",
|
|
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)`
|
|
@@ -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,
|
|
@@ -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
|
}
|
|
@@ -406,8 +406,8 @@ export class BootstrapFetcher {
|
|
|
406
406
|
* Update the offline-cache namespace once auth has resolved the server-side
|
|
407
407
|
* account scope. This is intentionally not a public organizationId input.
|
|
408
408
|
*/
|
|
409
|
-
setCacheScope(cacheScope: string): void {
|
|
410
|
-
if (cacheScope.trim().length === 0) return;
|
|
409
|
+
setCacheScope(cacheScope: string | null): void {
|
|
410
|
+
if (cacheScope !== null && cacheScope.trim().length === 0) return;
|
|
411
411
|
this.options.cacheScope = cacheScope;
|
|
412
412
|
}
|
|
413
413
|
|
|
@@ -587,7 +587,7 @@ export class BootstrapFetcher {
|
|
|
587
587
|
});
|
|
588
588
|
|
|
589
589
|
// Persist for offline fallback
|
|
590
|
-
if (this.options.cacheScope) {
|
|
590
|
+
if (!syncGroupsOverride && this.options.cacheScope) {
|
|
591
591
|
this.saveCachedBootstrap(this.options.cacheScope, data);
|
|
592
592
|
}
|
|
593
593
|
return data;
|
|
@@ -604,7 +604,7 @@ export class BootstrapFetcher {
|
|
|
604
604
|
}
|
|
605
605
|
|
|
606
606
|
// Transient failure after exhausting retries → cached fallback.
|
|
607
|
-
const cached = this.options.cacheScope
|
|
607
|
+
const cached = !syncGroupsOverride && this.options.cacheScope
|
|
608
608
|
? this.loadCachedBootstrap(this.options.cacheScope)
|
|
609
609
|
: null;
|
|
610
610
|
if (cached) {
|
|
@@ -1129,7 +1129,7 @@ export class BootstrapFetcher {
|
|
|
1129
1129
|
|
|
1130
1130
|
// Cache helpers for offline bootstrap
|
|
1131
1131
|
private getBootstrapCacheKey(orgId: string): string {
|
|
1132
|
-
return `ablo:bootstrap:${orgId}`;
|
|
1132
|
+
return `ablo:bootstrap:v5:${JSON.stringify([orgId, this.options.baseUrl, [...new Set(this.options.syncGroups)].sort()])}`;
|
|
1133
1133
|
}
|
|
1134
1134
|
private saveCachedBootstrap(orgId: string, data: BootstrapData): void {
|
|
1135
1135
|
if (typeof window === 'undefined') return;
|
|
@@ -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(
|