@abloatai/humans 0.45.0 → 0.47.0

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.
Files changed (34) hide show
  1. package/dist/local/BaseSyncedStore.d.ts +3 -0
  2. package/dist/local/BaseSyncedStore.js +1 -0
  3. package/dist/local/client/clientPrelude.js +5 -1
  4. package/dist/local/client/createModelProxy.d.ts +1 -3
  5. package/dist/local/client/createModelProxy.js +28 -23
  6. package/dist/local/client/options.d.ts +17 -32
  7. package/dist/local/client/reactiveEngine.js +1 -3
  8. package/dist/local/client/resourceTypes.d.ts +1 -1
  9. package/dist/local/client/storeLifecycle.d.ts +4 -0
  10. package/dist/local/client/storeLifecycle.js +30 -1
  11. package/dist/local/sync/createClaimStream.js +33 -24
  12. package/dist/local/transactions/mutations/MutationQueue.d.ts +20 -1
  13. package/dist/local/transactions/mutations/MutationQueue.js +20 -2
  14. package/dist/local/transactions/mutations/commitLane.d.ts +7 -0
  15. package/dist/local/transactions/mutations/commitLane.js +8 -5
  16. package/dist/local/transactions/mutations/commitPayload.d.ts +2 -0
  17. package/dist/local/transactions/mutations/failureHandling.d.ts +2 -1
  18. package/dist/local/transactions/mutations/failureHandling.js +18 -13
  19. package/dist/surface.d.ts +1 -1
  20. package/dist/surface.js +2 -1
  21. package/package.json +3 -2
  22. package/src/local/BaseSyncedStore.ts +4 -0
  23. package/src/local/client/clientPrelude.ts +5 -1
  24. package/src/local/client/createModelProxy.ts +30 -24
  25. package/src/local/client/options.ts +20 -34
  26. package/src/local/client/reactiveEngine.ts +0 -2
  27. package/src/local/client/resourceTypes.ts +1 -0
  28. package/src/local/client/storeLifecycle.ts +43 -0
  29. package/src/local/sync/createClaimStream.ts +55 -31
  30. package/src/local/transactions/mutations/MutationQueue.ts +30 -4
  31. package/src/local/transactions/mutations/commitLane.ts +21 -5
  32. package/src/local/transactions/mutations/commitPayload.ts +2 -0
  33. package/src/local/transactions/mutations/failureHandling.ts +27 -12
  34. package/src/surface.ts +2 -1
@@ -1,5 +1,17 @@
1
1
  import { AbloError } from '@abloatai/transaction/errors';
2
2
  import { extractStatusCode } from './commitPayload.js';
3
+ export function transientRetryDelayMs(error, attempt, retryBackoff) {
4
+ const { baseMs, capMs } = retryBackoff;
5
+ let base = baseMs;
6
+ try {
7
+ const status = extractStatusCode(error);
8
+ if (status === 429 || status === 503)
9
+ base = Math.max(baseMs, 1_000);
10
+ }
11
+ catch { }
12
+ const ceiling = Math.min(capMs, base * Math.pow(2, Math.max(0, attempt - 1)));
13
+ return Math.floor(Math.random() * ceiling);
14
+ }
3
15
  export async function handleFailure(ctx, transaction, error) {
4
16
  transaction.attempts++;
5
17
  // Check whether this is a permanent error that should not be retried.
@@ -89,28 +101,21 @@ export async function handleFailure(ctx, transaction, error) {
89
101
  await ctx.rollbackOptimistic(transaction, 'permanent_error', error);
90
102
  }
91
103
  ctx.emit('transaction:failed', { transaction, error, permanent: true });
92
- // The id-suffixed event is what `waitForConfirmation` (the
93
- // `wait:'confirmed'` path) listens on — without it a permanently
104
+ // The id-suffixed event is what the awaited model-write promise listens
105
+ // on through `waitForConfirmation` — without it a permanently
94
106
  // rejected write left the caller's promise hanging forever.
95
107
  ctx.emit(`transaction:failed:${transaction.id}`, { error });
96
108
  return;
97
109
  }
98
- if (transaction.attempts < ctx.config.maxRetries) {
110
+ transaction.firstTransientFailureAt ??= Date.now();
111
+ const insideAvailabilityWindow = Date.now() - transaction.firstTransientFailureAt < ctx.config.availabilityRetryWindowMs;
112
+ if (transaction.attempts < ctx.config.maxRetries || insideAvailabilityWindow) {
99
113
  // Exponential backoff with full jitter on every transient retry:
100
114
  // `sleep = random(0, min(cap, base * 2^attempt))`. Throttling responses
101
115
  // (429/503) use a longer base than other transient errors. The re-enqueue
102
116
  // is scheduled rather than awaited, so one backing-off transaction cannot
103
117
  // stall unrelated commits.
104
- const { baseMs, capMs } = ctx.config.retryBackoff;
105
- let base = baseMs;
106
- try {
107
- const status = extractStatusCode(error);
108
- if (status === 429 || status === 503)
109
- base = Math.max(baseMs, 1_000);
110
- }
111
- catch { }
112
- const ceiling = Math.min(capMs, base * Math.pow(2, transaction.attempts - 1));
113
- const delay = Math.floor(Math.random() * ceiling);
118
+ const delay = transientRetryDelayMs(error, transaction.attempts, ctx.config.retryBackoff);
114
119
  ctx.store.updateStatus(transaction.id, 'pending');
115
120
  setTimeout(() => {
116
121
  // The queue may have shut down or the tx may have been settled
package/dist/surface.d.ts CHANGED
@@ -30,7 +30,7 @@ export declare const PUBLIC_LIST_OPTION_KEYS: readonly ["where", "filter", "orde
30
30
  * The keys of the client constructor options, {@link AbloOptions}. Only
31
31
  * `schema` is required; every other key is optional.
32
32
  */
33
- export declare const PUBLIC_ABLO_OPTION_KEYS: readonly ["schema", "apiKey", "authEndpoint", "authTimeoutMs", "allowCrossOriginAuthEndpoint", "persistence", "durableWrites", "commitOutbox", "commitOutboxScope", "debug", "logLevel", "logger", "authToken", "baseURL", "fetch", "defaultHeaders", "defaultQuery", "dangerouslyAllowBrowser", "collaborationEvents", "plugins", "wait"];
33
+ export declare const PUBLIC_ABLO_OPTION_KEYS: readonly ["schema", "apiKey", "projectId", "branchId", "authEndpoint", "authTimeoutMs", "allowCrossOriginAuthEndpoint", "persistence", "durableWrites", "commitOutbox", "commitOutboxScope", "debug", "logLevel", "logger", "authToken", "baseURL", "fetch", "defaultHeaders", "defaultQuery", "dangerouslyAllowBrowser", "collaborationEvents", "plugins"];
34
34
  export type ModelVerb = (typeof PUBLIC_MODEL_VERBS)[number];
35
35
  export type ListOptionKey = (typeof PUBLIC_LIST_OPTION_KEYS)[number];
36
36
  export type AbloOptionKey = (typeof PUBLIC_ABLO_OPTION_KEYS)[number];
package/dist/surface.js CHANGED
@@ -55,6 +55,8 @@ export const PUBLIC_LIST_OPTION_KEYS = [
55
55
  export const PUBLIC_ABLO_OPTION_KEYS = [
56
56
  'schema',
57
57
  'apiKey',
58
+ 'projectId',
59
+ 'branchId',
58
60
  'authEndpoint',
59
61
  'authTimeoutMs',
60
62
  'allowCrossOriginAuthEndpoint',
@@ -73,5 +75,4 @@ export const PUBLIC_ABLO_OPTION_KEYS = [
73
75
  'dangerouslyAllowBrowser',
74
76
  'collaborationEvents',
75
77
  'plugins',
76
- 'wait',
77
78
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/humans",
3
- "version": "0.45.0",
3
+ "version": "0.47.0",
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",
@@ -84,7 +84,7 @@
84
84
  "directory": "packages/humans"
85
85
  },
86
86
  "dependencies": {
87
- "@abloatai/transaction": "^0.45.0",
87
+ "@abloatai/transaction": "^0.47.0",
88
88
  "mobx": "^6.13.7",
89
89
  "uuid": "^11.1.0",
90
90
  "zod": "^4.4.3"
@@ -99,6 +99,7 @@
99
99
  },
100
100
  "devDependencies": {
101
101
  "@jest/globals": "^30.2.0",
102
+ "@testing-library/dom": "^10.0.0",
102
103
  "@testing-library/react": "^16.0.0",
103
104
  "@types/jest": "^30.0.0",
104
105
  "@types/react": "^19.0.0",
@@ -73,6 +73,7 @@ import type { PoolContext, RehydrationStats } from './sync/bootstrapApply.js';
73
73
  import * as deltaPipeline from './sync/deltaPipeline.js';
74
74
  import type { DeltaPipelineContext } from './sync/deltaPipeline.js';
75
75
  import type { ParticipantKind } from '@abloatai/transaction/types/participant';
76
+ import type { DeliveryPartitionRoute } from '@abloatai/transaction/auth/deliveryPartition';
76
77
  import { queryByClass as runQueryByClass, countModels } from './store/queryApi.js';
77
78
  import type { QueuedMutation } from './transactions/mutations/MutationQueue.js';
78
79
  import type { CommitLatencySample } from './transactions/mutations/commitLatency.js';
@@ -150,6 +151,8 @@ export interface UserContext {
150
151
  branchId: string;
151
152
  /** True only when branchId is the project's production root. */
152
153
  branchRoot?: boolean;
154
+ /** Server-resolved WebSocket gateway route; never an authorization claim. */
155
+ deliveryPartition?: DeliveryPartitionRoute | null;
153
156
  role?: string;
154
157
  teamIds?: string[];
155
158
  /** Participant kind on the wire. Default 'user' for browser
@@ -1457,6 +1460,7 @@ export class BaseSyncedStore<
1457
1460
  }
1458
1461
  const syncGroups = this.resolveSyncGroups(context);
1459
1462
  this.syncWebSocket.setSyncGroups(syncGroups);
1463
+ this.syncWebSocket.setDeliveryPartition(context.deliveryPartition ?? null);
1460
1464
  this.syncWebSocket.setLastSyncId(lastSyncId || 0);
1461
1465
  // The permanent base scopes for read interest — same set the connection
1462
1466
  // subscribes to at upgrade, so the two can never disagree.
@@ -69,8 +69,12 @@ export interface ClientPrelude<S extends SchemaRecord> {
69
69
  export function resolveClientPrelude<S extends SchemaRecord>(
70
70
  options: AbloOptions<S>,
71
71
  ): ClientPrelude<S> {
72
- const internalOptions = options as InternalAbloOptions<S>;
73
72
  const env = readProcessEnv();
73
+ const internalOptions = {
74
+ ...options,
75
+ projectId: options.projectId ?? env.ABLO_PROJECT_ID,
76
+ branchId: options.branchId ?? env.ABLO_BRANCH_ID,
77
+ } as InternalAbloOptions<S>;
74
78
  const authInput = { options, env };
75
79
  const configuredApiKey = resolveApiKey(authInput);
76
80
  const configuredAuthToken = resolveAuthToken(authInput);
@@ -405,8 +405,6 @@ export function createModelProxy<T, C>(
405
405
  */
406
406
  hydration: Pick<OnDemandLoader, 'fetch'>,
407
407
  collaboration?: ModelCollaboration,
408
- /** The client-wide `wait` default; a per-call `wait` still wins over it. */
409
- defaultWait?: 'queued' | 'confirmed',
410
408
  ): ModelOperations<T, C> {
411
409
  /**
412
410
  * Resolve a row **this** resource owns.
@@ -471,20 +469,34 @@ export function createModelProxy<T, C>(
471
469
  };
472
470
  };
473
471
 
472
+ const guardWrite = <A extends unknown[], R>(
473
+ fn: (...args: A) => Promise<R>,
474
+ ): ((...args: A) => Promise<R>) => {
475
+ const guarded = guard(fn);
476
+ return (...args: A): Promise<R> => {
477
+ const confirmation = guarded(...args);
478
+ // Optimistic writes are intentionally useful without awaiting them. The
479
+ // transaction pipeline already reports a later refusal through
480
+ // `onMutationFailure`; attach an observer here as well so choosing not to
481
+ // await does not create an unhandled-rejection process error. Returning
482
+ // the original promise preserves normal rejection for callers that do
483
+ // await or attach their own catch handler.
484
+ void confirmation.catch(() => undefined);
485
+ return confirmation;
486
+ };
487
+ };
488
+
474
489
  const load = async (options?: ServerReadOptions<T>): Promise<T[]> => {
475
490
  const rows = await hydration.fetch<T>(schemaKey, options);
476
491
  return rows.map((row) => modelAsRow<T>(row));
477
492
  };
478
493
 
479
- const waitForMutation = async (
480
- model: Model,
481
- options?: MutationOptions,
482
- ): Promise<void> => {
483
- // A per-call `wait` wins; otherwise the client-wide default decides. This
484
- // is the single point that turns "confirmed" into actually waiting, so a
485
- // client configured that way rejects on a refused write everywhere rather
486
- // than in the one place a caller remembered to ask.
487
- if ((options?.wait ?? defaultWait) !== 'confirmed') return;
494
+ const waitForMutation = async (model: Model): Promise<void> => {
495
+ // Model writes are optimistic locally, but their promise has one stable
496
+ // meaning: authoritative confirmation. Callers that do not need the
497
+ // barrier can keep using the row immediately and leave the promise to the
498
+ // global mutation-failure handler; awaiting the promise never means merely
499
+ // "placed in the local queue".
488
500
  // Let sibling writes from the same synchronous burst enter the mutation
489
501
  // queue before forcing a drain. Without this yield, every confirmed
490
502
  // create/update calls syncNow() alone, defeating the queue's microtask
@@ -540,7 +552,6 @@ export function createModelProxy<T, C>(
540
552
  ? { idempotencyKey: params.idempotencyKey }
541
553
  : {}),
542
554
  ...(params.label !== undefined ? { label: params.label } : {}),
543
- ...(params.wait !== undefined ? { wait: params.wait } : {}),
544
555
  ...(params.readAt !== undefined ? { readAt: params.readAt } : {}),
545
556
  ...(params.onStale !== undefined ? { onStale: params.onStale } : {}),
546
557
  ...(params.fenceToken !== undefined ? { fenceToken: params.fenceToken } : {}),
@@ -1086,7 +1097,7 @@ export function createModelProxy<T, C>(
1086
1097
  // unbounded set of rows' entity groups.
1087
1098
  list: guard(load),
1088
1099
 
1089
- create: guard(async (params: ModelCreateParams<T, C>): Promise<T> => {
1100
+ create: guardWrite(async (params: ModelCreateParams<T, C>): Promise<T> => {
1090
1101
  const id = params.id ?? Model.generateId();
1091
1102
  const opts = mutationOptions(params);
1092
1103
  const claim = params.claim;
@@ -1142,7 +1153,7 @@ export function createModelProxy<T, C>(
1142
1153
  };
1143
1154
  try {
1144
1155
  syncClient.add(model, effective);
1145
- await waitForMutation(model, effective);
1156
+ await waitForMutation(model);
1146
1157
  return modelAsRow<T>(model);
1147
1158
  } finally {
1148
1159
  await autoLease?.release?.().catch(() => {});
@@ -1154,7 +1165,7 @@ export function createModelProxy<T, C>(
1154
1165
  // wrapping while exposing the two public signatures (a plain `guard(...)`
1155
1166
  // would collapse them to one).
1156
1167
  update: ((): ModelOperations<T, C>['update'] => {
1157
- const updateImpl = guard(
1168
+ const updateImpl = guardWrite(
1158
1169
  async (
1159
1170
  arg: ModelUpdateParams<T, C> | string,
1160
1171
  updater?: ModelUpdater<T>,
@@ -1206,13 +1217,12 @@ export function createModelProxy<T, C>(
1206
1217
  );
1207
1218
  }
1208
1219
  const effective: MutationOptions = {
1209
- wait: 'confirmed',
1210
1220
  readAt,
1211
1221
  onStale: 'reject',
1212
1222
  };
1213
1223
  model.applyChanges(patch);
1214
1224
  syncClient.update(model, effective);
1215
- await waitForMutation(model, effective);
1225
+ await waitForMutation(model);
1216
1226
  return modelAsRow<T>(model);
1217
1227
  },
1218
1228
  });
@@ -1250,7 +1260,6 @@ export function createModelProxy<T, C>(
1250
1260
  const handle = isClaimHandle(params.claim) ? params.claim : undefined;
1251
1261
  const effective: MutationOptions | undefined = claimed
1252
1262
  ? {
1253
- wait: 'confirmed',
1254
1263
  readAt: claimed.lease.readAt ?? claimed.snapshot.stamp,
1255
1264
  onStale: 'reject',
1256
1265
  claimRef: { id: claimed.lease.id },
@@ -1262,7 +1271,6 @@ export function createModelProxy<T, C>(
1262
1271
  // works across clients (HTTP-minted handles included).
1263
1272
  ...(handle?.readAt !== undefined
1264
1273
  ? {
1265
- wait: 'confirmed' as const,
1266
1274
  readAt: handle.readAt,
1267
1275
  onStale: 'reject' as const,
1268
1276
  ...(handle.fenceToken !== undefined
@@ -1279,7 +1287,7 @@ export function createModelProxy<T, C>(
1279
1287
  // the tracking, producing an empty `input: {}` no-op mutation.)
1280
1288
  model.applyChanges(params.data);
1281
1289
  syncClient.update(model, effective);
1282
- await waitForMutation(model, effective);
1290
+ await waitForMutation(model);
1283
1291
  return modelAsRow<T>(model);
1284
1292
  },
1285
1293
  );
@@ -1299,7 +1307,7 @@ export function createModelProxy<T, C>(
1299
1307
  return update;
1300
1308
  })(),
1301
1309
 
1302
- delete: guard(async (params: ModelDeleteParams<T, C>): Promise<void> => {
1310
+ delete: guardWrite(async (params: ModelDeleteParams<T, C>): Promise<void> => {
1303
1311
  const autoClaim =
1304
1312
  params.claim && !isClaimHandle(params.claim) ? params.claim : null;
1305
1313
  if (autoClaim) {
@@ -1334,7 +1342,6 @@ export function createModelProxy<T, C>(
1334
1342
  const handle = isClaimHandle(params.claim) ? params.claim : undefined;
1335
1343
  const effective: MutationOptions | undefined = claimed
1336
1344
  ? {
1337
- wait: 'confirmed',
1338
1345
  readAt: claimed.lease.readAt ?? claimed.snapshot.stamp,
1339
1346
  onStale: 'reject',
1340
1347
  claimRef: { id: claimed.lease.id },
@@ -1346,7 +1353,6 @@ export function createModelProxy<T, C>(
1346
1353
  : {
1347
1354
  ...(handle?.readAt !== undefined
1348
1355
  ? {
1349
- wait: 'confirmed' as const,
1350
1356
  readAt: handle.readAt,
1351
1357
  onStale: 'reject' as const,
1352
1358
  }
@@ -1355,7 +1361,7 @@ export function createModelProxy<T, C>(
1355
1361
  ...(handle ? { claim: { id: handle.id } } : {}),
1356
1362
  };
1357
1363
  syncClient.delete(model, effective);
1358
- await waitForMutation(model, effective);
1364
+ await waitForMutation(model);
1359
1365
  }),
1360
1366
 
1361
1367
  // `claim` is a callable namespace (take a claim) carrying the coordination
@@ -87,6 +87,21 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
87
87
  */
88
88
  apiKey?: string | CredentialProvider | null | undefined;
89
89
 
90
+ /**
91
+ * Pins this client to one Ablo project. During `ready()` the server resolves
92
+ * the API key's actual project and the client refuses to start when it differs.
93
+ * Defaults to `ABLO_PROJECT_ID`; `ablo dev` writes that value beside the key.
94
+ * This is an assertion, never a routing selector — the key remains authoritative.
95
+ */
96
+ projectId?: string | null | undefined;
97
+
98
+ /**
99
+ * Pins this client to one immutable Ablo branch. Defaults to
100
+ * `ABLO_BRANCH_ID`; `ablo dev` writes it beside the branch key. Like
101
+ * `projectId`, this is a startup assertion and never selects a branch.
102
+ */
103
+ branchId?: string | null | undefined;
104
+
90
105
  /**
91
106
  * The session-mint endpoint — the browser-side auth field, and the named
92
107
  * endpoint for the route that mints the signed-in user's short-lived token:
@@ -230,34 +245,6 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
230
245
  * session token (`ek_`/`rk_`) or you route through a controlled server proxy.
231
246
  */
232
247
  dangerouslyAllowBrowser?: boolean | undefined;
233
-
234
- /**
235
- * How far a write goes before its promise settles, for every model write on
236
- * this client. The same word each write already takes per call
237
- * (`create({ …, wait: 'confirmed' })`); setting it here makes it the default
238
- * instead of repeating it.
239
- *
240
- * A write resolves as soon as it is applied locally and queued. That is what
241
- * makes the UI immediate, and it is right for most writes — but it means a
242
- * write the server later REFUSES has no caller left to tell. The rejection
243
- * reverts the local row and reaches `ablo.onMutationFailure(…)`, and an
244
- * application that subscribes to neither shows the change, then loses it,
245
- * with nothing thrown anywhere.
246
- *
247
- * ```ts
248
- * const ablo = new Ablo({ schema, apiKey, wait: 'confirmed' });
249
- * try {
250
- * await ablo.documents.update({ id, data }); // throws if refused
251
- * } catch (err) {
252
- * if (err instanceof AbloError) show(err.message);
253
- * }
254
- * ```
255
- *
256
- * The cost is real: each write now waits for the server's answer, so it is a
257
- * choice between immediacy and certainty rather than a strict improvement.
258
- * A per-call `wait` still wins over this.
259
- */
260
- wait?: 'queued' | 'confirmed' | undefined;
261
248
  }
262
249
 
263
250
  export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
@@ -274,6 +261,9 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
274
261
  */
275
262
  apiKey?: string | CredentialProvider | null | undefined;
276
263
 
264
+ /** Expected project assertion; see {@link AbloOptions.projectId}. */
265
+ projectId?: string | null | undefined;
266
+
277
267
  /**
278
268
  * Session-mint endpoint (string or async resolver) — see
279
269
  * {@link AbloOptions.authEndpoint}. Mutually exclusive with `apiKey`.
@@ -521,15 +511,11 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
521
511
  organizationId?: string;
522
512
 
523
513
  /**
524
- * Immutable branch selected by a self-hosted credential. Hosted clients
525
- * receive this from the credential exchange.
514
+ * Expected immutable branch. Hosted clients compare it with the credential
515
+ * exchange; self-hosted clients use it as their locally selected branch.
526
516
  */
527
517
  branchId?: string;
528
518
 
529
519
  /** Whether the selected self-hosted branch is the project's root branch. */
530
520
  branchRoot?: boolean;
531
-
532
- /** The client-wide write default — see {@link AbloOptions.wait}. Projected
533
- * from the public option rather than restated, so the two cannot diverge. */
534
- wait?: AbloOptions['wait'];
535
521
  }
@@ -607,8 +607,6 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
607
607
  ...(options?.ttl !== undefined ? { ttl: options.ttl } : {}),
608
608
  }),
609
609
  },
610
- // The client-wide `wait` default; a per-call `wait` still wins.
611
- internalOptions.wait,
612
610
  );
613
611
  }
614
612
 
@@ -18,6 +18,7 @@ export type {
18
18
  ModelListScope,
19
19
  ServerReadOptions,
20
20
  ModelRetrieveParams,
21
+ ModelWriteOptions,
21
22
  ModelCreateParams,
22
23
  ModelUpdateParams,
23
24
  ModelDeleteParams,
@@ -63,6 +63,44 @@ export interface StoreLifecycleDeps<S extends SchemaRecord> {
63
63
  readonly onIdentityResolved: (seed: IdentitySeed) => void;
64
64
  }
65
65
 
66
+ /** Refuse a credential for another project before the store opens its socket. */
67
+ export function assertExpectedProject(
68
+ expectedProjectId: string | null | undefined,
69
+ actualProjectId: string | null
70
+ ): void {
71
+ const expected = expectedProjectId?.trim();
72
+ if (!expected || actualProjectId === expected) return;
73
+ throw new AbloAuthenticationError(
74
+ `ABLO_API_KEY belongs to project ${actualProjectId ?? '(none)'}, but this app is pinned to ${expected} by projectId/ABLO_PROJECT_ID.`,
75
+ {
76
+ code: 'project_scope_denied',
77
+ details: {
78
+ expectedProjectId: expected,
79
+ actualProjectId,
80
+ },
81
+ }
82
+ );
83
+ }
84
+
85
+ /** Refuse a credential for another branch before the store opens its socket. */
86
+ export function assertExpectedBranch(
87
+ expectedBranchId: string | null | undefined,
88
+ actualBranchId: string | null
89
+ ): void {
90
+ const expected = expectedBranchId?.trim();
91
+ if (!expected || actualBranchId === expected) return;
92
+ throw new AbloAuthenticationError(
93
+ `ABLO_API_KEY belongs to branch ${actualBranchId ?? '(none)'}, but this app is pinned to ${expected} by branchId/ABLO_BRANCH_ID.`,
94
+ {
95
+ code: 'branch_scope_denied',
96
+ details: {
97
+ expectedBranchId: expected,
98
+ actualBranchId,
99
+ },
100
+ }
101
+ );
102
+ }
103
+
66
104
  /**
67
105
  * Wires the credential machinery onto the cluster and returns the `ready`
68
106
  * the client exposes. Wiring happens now — the refresh lifecycle, the
@@ -200,8 +238,12 @@ export function startStoreLifecycle<S extends SchemaRecord>(
200
238
  capabilityToken,
201
239
  syncGroups,
202
240
  participantKind,
241
+ deliveryPartition,
203
242
  } = resolved;
204
243
 
244
+ assertExpectedProject(internalOptions.projectId, projectId);
245
+ assertExpectedBranch(internalOptions.branchId, branchId);
246
+
205
247
  // Fail-loud guard: detect the degenerate "no real sync groups
206
248
  // resolved" state before opening the socket. It is the same class of bug as
207
249
  // a sensible-looking default that's functionally broken: the
@@ -274,6 +316,7 @@ export function startStoreLifecycle<S extends SchemaRecord>(
274
316
  kind: participantKind,
275
317
  capabilityToken,
276
318
  syncGroups,
319
+ deliveryPartition,
277
320
  bootstrapMode: resolvedBootstrapMode,
278
321
  });
279
322
  let current = gen.next();
@@ -44,6 +44,7 @@ import {
44
44
  claimDescription,
45
45
  descriptionFromMeta,
46
46
  participantKindFromWire,
47
+ type WireClaimSummary,
47
48
  } from '@abloatai/transaction/coordination/schema';
48
49
  import {
49
50
  isTargetTuple,
@@ -198,6 +199,38 @@ export function createClaimStream(
198
199
  }
199
200
  };
200
201
 
202
+ const observeForeignClaim = (
203
+ heldBy: string,
204
+ claim: WireClaimSummary,
205
+ participantKind?: 'user' | 'agent' | 'system',
206
+ isAgent?: boolean,
207
+ ): void => {
208
+ const description =
209
+ claim.description ??
210
+ descriptionFromMeta(claim.meta) ??
211
+ 'editing';
212
+ const { meta, ...details } = subTarget(claim);
213
+ activeByClaimId.set(claim.claimId, {
214
+ object: 'claim',
215
+ id: claim.claimId,
216
+ status: 'active',
217
+ heldBy,
218
+ participantKind: participantKindFromWire(participantKind, isAgent),
219
+ target: {
220
+ ...streamTarget(claim),
221
+ ...details,
222
+ ...(meta !== undefined ? { meta: declaredMeta(meta) } : {}),
223
+ },
224
+ description,
225
+ ttlSeconds: Math.max(
226
+ 0,
227
+ Math.floor((claim.expiresAt - Date.now()) / 1000),
228
+ ),
229
+ createdAt: claim.declaredAt,
230
+ expiresAt: claim.expiresAt,
231
+ });
232
+ };
233
+
201
234
  // ── Wire wiring ──────────────────────────────────────────────────
202
235
  let attached: ClaimTransport | null = null;
203
236
  const unsubs: (() => void)[] = [];
@@ -241,37 +274,12 @@ export function createClaimStream(
241
274
  // drops it from `others`, which is what resolves a contender's
242
275
  // `settled()`. Absent status means active (wire back-compat).
243
276
  if (claim.status && claim.status !== 'active') continue;
244
- // Resolve the always-present public field, tolerating a frame that
245
- // carries the value in `meta` rather than as an explicit description.
246
- const description =
247
- claim.description ??
248
- descriptionFromMeta(claim.meta) ??
249
- 'editing';
250
- // The frame is parsed permissively, on purpose; `declaredMeta` is where
251
- // that wire value becomes the shape the program declared.
252
- const { meta, ...details } = subTarget(claim);
253
- activeByClaimId.set(claim.claimId, {
254
- object: 'claim',
255
- id: claim.claimId,
256
- status: 'active',
257
- heldBy: event.userId,
258
- participantKind: participantKindFromWire(
259
- event.participantKind,
260
- event.isAgent,
261
- ),
262
- target: {
263
- ...streamTarget(claim),
264
- ...details,
265
- ...(meta !== undefined ? { meta: declaredMeta(meta) } : {}),
266
- },
267
- description,
268
- ttlSeconds: Math.max(
269
- 0,
270
- Math.floor((claim.expiresAt - Date.now()) / 1000),
271
- ),
272
- createdAt: claim.declaredAt,
273
- expiresAt: claim.expiresAt,
274
- });
277
+ observeForeignClaim(
278
+ event.userId,
279
+ claim,
280
+ event.participantKind,
281
+ event.isAgent,
282
+ );
275
283
  mutated = true;
276
284
  }
277
285
  if (mutated) notifyListeners();
@@ -295,6 +303,22 @@ export function createClaimStream(
295
303
  // a claim the server already rejected (would just spam both
296
304
  // sides with conflicts).
297
305
  ownClaims.delete(rejection.claimId);
306
+ // A holder on another server may have claimed before this client joined
307
+ // the row group, so its one-shot presence frame was missed. A conflict
308
+ // reply carries the authoritative holder summary; seed the same local
309
+ // state immediately instead of continuing to report the row as free.
310
+ if (
311
+ rejection.reason === 'conflict' &&
312
+ rejection.heldBy &&
313
+ rejection.heldByClaim
314
+ ) {
315
+ observeForeignClaim(
316
+ rejection.heldBy,
317
+ rejection.heldByClaim,
318
+ rejection.heldByKind,
319
+ );
320
+ notifyListeners();
321
+ }
298
322
  for (const l of rejectionListeners) {
299
323
  try {
300
324
  l(rejection);