@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.
@@ -109,6 +109,8 @@ export interface UserContext {
109
109
  * structure comes from schema-declared scopes and server-issued
110
110
  * authorization. */
111
111
  syncGroups?: readonly string[];
112
+ /** Server-confirmed operation grants; also partition persisted replicas. */
113
+ operations?: readonly string[];
112
114
  /**
113
115
  * How aggressively this participant should pull baseline state at
114
116
  * startup.
@@ -6,7 +6,7 @@
6
6
  * in-memory mirror of what this class persists.
7
7
  */
8
8
  import { type DatabaseInfo, type WorkspaceMetadata } from './stores/DatabaseManager.js';
9
- import type { PersistenceIdentity } from './stores/persistenceIdentity.js';
9
+ import { type PersistenceIdentity } from './stores/persistenceIdentity.js';
10
10
  import { ModelRegistry } from './ModelRegistry.js';
11
11
  import { LoadStrategy } from '@abloatai/transaction/types';
12
12
  import type { RuntimeContext } from './RuntimeContext.js';
@@ -6,6 +6,7 @@
6
6
  * in-memory mirror of what this class persists.
7
7
  */
8
8
  import { DatabaseManager } from './stores/DatabaseManager.js';
9
+ import { persistenceIdentityMatches } from './stores/persistenceIdentity.js';
9
10
  import { StoreManager } from './stores/StoreManager.js';
10
11
  import { ModelRegistry } from './ModelRegistry.js';
11
12
  import { LoadStrategy } from '@abloatai/transaction/types';
@@ -142,12 +143,16 @@ export class Database {
142
143
  async open(identity, version = 1) {
143
144
  this.isClosing = false;
144
145
  if (this.workspaceDb && this.currentDbInfo) {
146
+ if (!persistenceIdentityMatches(this.currentDbInfo, identity)) {
147
+ throw new AbloConnectionError('Dispose the client before changing persistence authority.', { code: 'db_identity_mismatch' });
148
+ }
145
149
  return;
146
150
  }
147
151
  // ── In-memory mode: skip IndexedDB entirely ──────────────────
148
152
  // Creates InMemoryObjectStore instances for all registered models.
149
153
  // Bootstrap via HTTP still works; only local persistence is skipped.
150
154
  if (this.inMemory) {
155
+ this.bootstrapHelper.setCacheScope(null);
151
156
  this.runtime.logger.debug('Opening in-memory database (headless mode)');
152
157
  const allModels = this.modelRegistry.getRegisteredModelNames();
153
158
  for (const modelName of allModels) {
@@ -164,6 +169,8 @@ export class Database {
164
169
  // Initialize meta database
165
170
  await this.databaseManager.initializeMetaDatabase();
166
171
  this.currentDbInfo = await this.databaseManager.calculateDatabaseInfo(identity, version);
172
+ // Both persistence layers must share the complete authenticated authority.
173
+ this.bootstrapHelper.setCacheScope(this.currentDbInfo.name);
167
174
  // Register database
168
175
  await this.databaseManager.registerDatabase(this.currentDbInfo);
169
176
  // Open workspace database
@@ -172,6 +179,20 @@ export class Database {
172
179
  });
173
180
  // Initialize stores
174
181
  await this.storeManager.initializeStores(this.workspaceDb);
182
+ // A live group addition can have widened a previous client's replica after
183
+ // it opened this namespace. Check persisted coverage before restoring rows
184
+ // or pending writes, not after the first WebSocket reconnect.
185
+ const metadata = await this.getWorkspaceMetadata();
186
+ const allowed = new Set(identity.syncGroups ?? []);
187
+ if (metadata?.subscribedSyncGroups.some(group => !allowed.has(group))) {
188
+ // Preserve pending writes for their original authority; never replay them
189
+ // into a narrower replica or silently discard them.
190
+ await this.close();
191
+ throw new AbloConnectionError('Persisted sync groups exceed the current authority.', {
192
+ code: 'db_identity_mismatch',
193
+ });
194
+ }
195
+ await this.updateWorkspaceMetadata({ subscribedSyncGroups: [...allowed] });
175
196
  const readiness = await this.storeManager.checkReadinessOfStores();
176
197
  this.runtime.logger.info(`Database opened: ${this.currentDbInfo.name} (${readiness.readyStores.length}/${readiness.totalStores} stores ready)`);
177
198
  }
@@ -202,6 +202,7 @@ export function startStoreLifecycle(deps) {
202
202
  capabilityToken,
203
203
  syncGroups,
204
204
  deliveryPartition,
205
+ operations: resolved.authority.operations,
205
206
  bootstrapMode: resolvedBootstrapMode,
206
207
  });
207
208
  let current = gen.next();
@@ -17,6 +17,8 @@ export interface DatabaseInfo {
17
17
  projectId: string | null;
18
18
  branchId: string;
19
19
  branchRoot: boolean;
20
+ syncGroups?: readonly string[];
21
+ operations?: readonly string[];
20
22
  schemaHash: string;
21
23
  schemaVersion: number;
22
24
  userVersion?: number;
@@ -116,6 +116,8 @@ export class DatabaseManager {
116
116
  projectId: identity.projectId,
117
117
  branchId: identity.branchId,
118
118
  branchRoot: identity.branchRoot,
119
+ syncGroups: [...new Set(identity.syncGroups ?? [])].sort(),
120
+ operations: [...new Set(identity.operations ?? [])].sort(),
119
121
  schemaHash,
120
122
  schemaVersion,
121
123
  userVersion,
@@ -1,6 +1,5 @@
1
1
  /**
2
- * The complete authenticated branch that owns one local replica. A branch id
3
- * is authoritative.
2
+ * The authenticated branch, groups and permissions that own one local replica.
4
3
  */
5
4
  export interface PersistenceIdentity {
6
5
  readonly participantId: string;
@@ -9,6 +8,8 @@ export interface PersistenceIdentity {
9
8
  readonly projectId: string | null;
10
9
  readonly branchId: string;
11
10
  readonly branchRoot: boolean;
11
+ readonly syncGroups?: readonly string[];
12
+ readonly operations?: readonly string[];
12
13
  }
13
14
  export interface PersistedIdentityMetadata {
14
15
  readonly namespaceVersion?: number;
@@ -18,8 +19,10 @@ export interface PersistedIdentityMetadata {
18
19
  readonly projectId?: string | null;
19
20
  readonly branchId?: string;
20
21
  readonly branchRoot?: boolean;
22
+ readonly syncGroups?: readonly string[];
23
+ readonly operations?: readonly string[];
21
24
  }
22
- export declare const PERSISTENCE_NAMESPACE_VERSION = 4;
25
+ export declare const PERSISTENCE_NAMESPACE_VERSION = 5;
23
26
  /** Collision-resistant IndexedDB name for one authenticated data plane. */
24
27
  export declare function persistenceDatabaseName(identity: PersistenceIdentity, userVersion?: number): Promise<string>;
25
28
  /** Defense-in-depth check after namespace lookup and before persisted reads. */
@@ -1,5 +1,8 @@
1
1
  import { AbloConnectionError } from '@abloatai/transaction/errors';
2
- export const PERSISTENCE_NAMESPACE_VERSION = 4;
2
+ export const PERSISTENCE_NAMESPACE_VERSION = 5;
3
+ function scopeKey(values = []) {
4
+ return JSON.stringify([...new Set(values)].sort());
5
+ }
3
6
  function canonicalIdentity(identity, userVersion) {
4
7
  return JSON.stringify([
5
8
  PERSISTENCE_NAMESPACE_VERSION,
@@ -8,6 +11,8 @@ function canonicalIdentity(identity, userVersion) {
8
11
  identity.organizationId,
9
12
  identity.participantKind,
10
13
  identity.participantId,
14
+ scopeKey(identity.syncGroups),
15
+ scopeKey(identity.operations),
11
16
  userVersion,
12
17
  ]);
13
18
  }
@@ -33,5 +38,7 @@ export function persistenceIdentityMatches(info, identity) {
33
38
  info.participantKind === identity.participantKind &&
34
39
  (info.projectId ?? null) === identity.projectId &&
35
40
  info.branchId === identity.branchId &&
36
- (info.branchRoot ?? false) === identity.branchRoot);
41
+ (info.branchRoot ?? false) === identity.branchRoot &&
42
+ scopeKey(info.syncGroups) === scopeKey(identity.syncGroups) &&
43
+ scopeKey(info.operations) === scopeKey(identity.operations));
37
44
  }
@@ -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",
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.3",
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.
@@ -6,7 +6,7 @@
6
6
  * in-memory mirror of what this class persists.
7
7
  */
8
8
  import { DatabaseManager, type DatabaseInfo, type WorkspaceMetadata } from './stores/DatabaseManager.js';
9
- import type { PersistenceIdentity } from './stores/persistenceIdentity.js';
9
+ import { persistenceIdentityMatches, type PersistenceIdentity } from './stores/persistenceIdentity.js';
10
10
  import { StoreManager } from './stores/StoreManager.js';
11
11
  import { ModelRegistry } from './ModelRegistry.js';
12
12
  import { LoadStrategy } from '@abloatai/transaction/types';
@@ -226,6 +226,9 @@ export class Database {
226
226
  this.isClosing = false;
227
227
 
228
228
  if (this.workspaceDb && this.currentDbInfo) {
229
+ if (!persistenceIdentityMatches(this.currentDbInfo, identity)) {
230
+ throw new AbloConnectionError('Dispose the client before changing persistence authority.', { code: 'db_identity_mismatch' });
231
+ }
229
232
  return;
230
233
  }
231
234
 
@@ -233,6 +236,7 @@ export class Database {
233
236
  // Creates InMemoryObjectStore instances for all registered models.
234
237
  // Bootstrap via HTTP still works; only local persistence is skipped.
235
238
  if (this.inMemory) {
239
+ this.bootstrapHelper.setCacheScope(null);
236
240
  this.runtime.logger.debug('Opening in-memory database (headless mode)');
237
241
  const allModels = this.modelRegistry.getRegisteredModelNames();
238
242
  for (const modelName of allModels) {
@@ -264,6 +268,9 @@ export class Database {
264
268
  version
265
269
  );
266
270
 
271
+ // Both persistence layers must share the complete authenticated authority.
272
+ this.bootstrapHelper.setCacheScope(this.currentDbInfo.name);
273
+
267
274
  // Register database
268
275
  await this.databaseManager.registerDatabase(this.currentDbInfo);
269
276
 
@@ -278,6 +285,21 @@ export class Database {
278
285
  // Initialize stores
279
286
  await this.storeManager.initializeStores(this.workspaceDb);
280
287
 
288
+ // A live group addition can have widened a previous client's replica after
289
+ // it opened this namespace. Check persisted coverage before restoring rows
290
+ // or pending writes, not after the first WebSocket reconnect.
291
+ const metadata = await this.getWorkspaceMetadata();
292
+ const allowed = new Set(identity.syncGroups ?? []);
293
+ if (metadata?.subscribedSyncGroups.some(group => !allowed.has(group))) {
294
+ // Preserve pending writes for their original authority; never replay them
295
+ // into a narrower replica or silently discard them.
296
+ await this.close();
297
+ throw new AbloConnectionError('Persisted sync groups exceed the current authority.', {
298
+ code: 'db_identity_mismatch',
299
+ });
300
+ }
301
+ await this.updateWorkspaceMetadata({ subscribedSyncGroups: [...allowed] });
302
+
281
303
  const readiness = await this.storeManager.checkReadinessOfStores();
282
304
  this.runtime.logger.info(
283
305
  `Database opened: ${this.currentDbInfo.name} (${readiness.readyStores.length}/${readiness.totalStores} stores ready)`
@@ -325,6 +325,7 @@ export function startStoreLifecycle<S extends SchemaRecord>(
325
325
  capabilityToken,
326
326
  syncGroups,
327
327
  deliveryPartition,
328
+ operations: resolved.authority.operations,
328
329
  bootstrapMode: resolvedBootstrapMode,
329
330
  });
330
331
  let current = gen.next();
@@ -33,6 +33,8 @@ export interface DatabaseInfo {
33
33
  projectId: string | null;
34
34
  branchId: string;
35
35
  branchRoot: boolean;
36
+ syncGroups?: readonly string[];
37
+ operations?: readonly string[];
36
38
  schemaHash: string;
37
39
  schemaVersion: number;
38
40
  userVersion?: number;
@@ -175,6 +177,8 @@ export class DatabaseManager {
175
177
  projectId: identity.projectId,
176
178
  branchId: identity.branchId,
177
179
  branchRoot: identity.branchRoot,
180
+ syncGroups: [...new Set(identity.syncGroups ?? [])].sort(),
181
+ operations: [...new Set(identity.operations ?? [])].sort(),
178
182
  schemaHash,
179
183
  schemaVersion,
180
184
  userVersion,
@@ -1,8 +1,7 @@
1
1
  import { AbloConnectionError } from '@abloatai/transaction/errors';
2
2
 
3
3
  /**
4
- * The complete authenticated branch that owns one local replica. A branch id
5
- * is authoritative.
4
+ * The authenticated branch, groups and permissions that own one local replica.
6
5
  */
7
6
  export interface PersistenceIdentity {
8
7
  readonly participantId: string;
@@ -11,6 +10,8 @@ export interface PersistenceIdentity {
11
10
  readonly projectId: string | null;
12
11
  readonly branchId: string;
13
12
  readonly branchRoot: boolean;
13
+ readonly syncGroups?: readonly string[];
14
+ readonly operations?: readonly string[];
14
15
  }
15
16
 
16
17
  export interface PersistedIdentityMetadata {
@@ -21,9 +22,15 @@ export interface PersistedIdentityMetadata {
21
22
  readonly projectId?: string | null;
22
23
  readonly branchId?: string;
23
24
  readonly branchRoot?: boolean;
25
+ readonly syncGroups?: readonly string[];
26
+ readonly operations?: readonly string[];
24
27
  }
25
28
 
26
- export const PERSISTENCE_NAMESPACE_VERSION = 4;
29
+ export const PERSISTENCE_NAMESPACE_VERSION = 5;
30
+
31
+ function scopeKey(values: readonly string[] = []): string {
32
+ return JSON.stringify([...new Set(values)].sort());
33
+ }
27
34
 
28
35
  function canonicalIdentity(
29
36
  identity: PersistenceIdentity,
@@ -36,6 +43,8 @@ function canonicalIdentity(
36
43
  identity.organizationId,
37
44
  identity.participantKind,
38
45
  identity.participantId,
46
+ scopeKey(identity.syncGroups),
47
+ scopeKey(identity.operations),
39
48
  userVersion,
40
49
  ]);
41
50
  }
@@ -76,6 +85,8 @@ export function persistenceIdentityMatches(
76
85
  info.participantKind === identity.participantKind &&
77
86
  (info.projectId ?? null) === identity.projectId &&
78
87
  info.branchId === identity.branchId &&
79
- (info.branchRoot ?? false) === identity.branchRoot
88
+ (info.branchRoot ?? false) === identity.branchRoot &&
89
+ scopeKey(info.syncGroups) === scopeKey(identity.syncGroups) &&
90
+ scopeKey(info.operations) === scopeKey(identity.operations)
80
91
  );
81
92
  }
@@ -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(