@abloatai/ablo 0.27.0 → 0.28.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 (73) hide show
  1. package/CHANGELOG.md +29 -3
  2. package/README.md +1 -1
  3. package/dist/BaseSyncedStore.js +8 -9
  4. package/dist/Database.d.ts +14 -1
  5. package/dist/Database.js +225 -28
  6. package/dist/Model.d.ts +17 -0
  7. package/dist/Model.js +32 -1
  8. package/dist/SyncClient.d.ts +10 -6
  9. package/dist/SyncClient.js +379 -76
  10. package/dist/adapters/inMemoryStorage.d.ts +1 -0
  11. package/dist/adapters/inMemoryStorage.js +12 -0
  12. package/dist/cli.cjs +6 -2
  13. package/dist/client/Ablo.d.ts +24 -1
  14. package/dist/client/Ablo.js +4 -3
  15. package/dist/client/ApiClient.js +309 -43
  16. package/dist/client/createInternalComponents.d.ts +2 -0
  17. package/dist/client/createInternalComponents.js +1 -1
  18. package/dist/client/httpClient.d.ts +2 -0
  19. package/dist/client/modelRegistration.js +11 -0
  20. package/dist/client/options.d.ts +23 -0
  21. package/dist/client/wsMutationExecutor.d.ts +3 -2
  22. package/dist/client/wsMutationExecutor.js +3 -2
  23. package/dist/commit/contract.d.ts +493 -0
  24. package/dist/commit/contract.js +187 -0
  25. package/dist/commit/index.d.ts +6 -0
  26. package/dist/commit/index.js +5 -0
  27. package/dist/core/StoreManager.d.ts +2 -0
  28. package/dist/core/StoreManager.js +12 -0
  29. package/dist/errorCodes.js +6 -2
  30. package/dist/index.d.ts +6 -0
  31. package/dist/index.js +2 -0
  32. package/dist/interfaces/index.d.ts +2 -2
  33. package/dist/mutators/UndoManager.d.ts +2 -0
  34. package/dist/mutators/UndoManager.js +32 -0
  35. package/dist/react/useAblo.d.ts +6 -4
  36. package/dist/react/useAblo.js +25 -3
  37. package/dist/schema/index.d.ts +1 -1
  38. package/dist/schema/schema.d.ts +31 -1
  39. package/dist/stores/ObjectStore.d.ts +14 -1
  40. package/dist/stores/ObjectStore.js +27 -4
  41. package/dist/stores/ObjectStoreContract.d.ts +2 -0
  42. package/dist/surface.d.ts +1 -1
  43. package/dist/surface.js +2 -0
  44. package/dist/sync/SyncWebSocket.d.ts +2 -1
  45. package/dist/sync/persistedPrefix.d.ts +12 -0
  46. package/dist/sync/persistedPrefix.js +22 -0
  47. package/dist/testing/index.d.ts +2 -0
  48. package/dist/testing/index.js +1 -0
  49. package/dist/testing/mocks/FakeDatabase.d.ts +18 -0
  50. package/dist/testing/mocks/FakeDatabase.js +10 -0
  51. package/dist/testing/mocks/MockWebSocket.d.ts +2 -1
  52. package/dist/transactions/TransactionQueue.d.ts +66 -8
  53. package/dist/transactions/TransactionQueue.js +607 -89
  54. package/dist/transactions/commitEnvelope.d.ts +132 -0
  55. package/dist/transactions/commitEnvelope.js +139 -0
  56. package/dist/transactions/commitOutboxStore.d.ts +32 -0
  57. package/dist/transactions/commitOutboxStore.js +26 -0
  58. package/dist/transactions/commitPayload.d.ts +15 -0
  59. package/dist/transactions/commitPayload.js +6 -0
  60. package/dist/transactions/httpCommitEnvelope.d.ts +43 -0
  61. package/dist/transactions/httpCommitEnvelope.js +179 -0
  62. package/dist/transactions/replayValidation.d.ts +83 -0
  63. package/dist/transactions/replayValidation.js +46 -1
  64. package/dist/wire/bootstrapReason.d.ts +9 -0
  65. package/dist/wire/bootstrapReason.js +8 -0
  66. package/dist/wire/frames.d.ts +236 -0
  67. package/dist/wire/frames.js +21 -0
  68. package/dist/wire/index.d.ts +4 -2
  69. package/dist/wire/index.js +2 -1
  70. package/docs/api.md +10 -10
  71. package/docs/coordination.md +2 -2
  72. package/docs/mcp.md +1 -1
  73. package/package.json +7 -2
package/dist/cli.cjs CHANGED
@@ -276796,7 +276796,7 @@ var ERROR_CODES = {
276796
276796
  jwt_invalid: wire("auth", 401, false, "The bearer JWT failed validation for a reason the server could not classify further. Check the token's issuer, signature, audience, and expiry."),
276797
276797
  jwt_malformed: wire("auth", 401, false, "The bearer token is not a well-formed JWT and could not be decoded. Check that the full, unmodified token was sent."),
276798
276798
  jwt_missing_issuer: wire("auth", 401, false, "The bearer JWT has no `iss` (issuer) claim, so it cannot be routed to a trusted issuer."),
276799
- jwt_issuer_untrusted: wire("auth", 401, false, "The bearer JWT's `iss` is not a registered trusted issuer. Register it via POST /v1/trusted-issuers, or check the token's issuer claim."),
276799
+ jwt_issuer_untrusted: wire("auth", 401, false, "The bearer JWT's `iss` is not a registered trusted issuer. Check the token's issuer claim, or register the issuer with your deployment before retrying."),
276800
276800
  jwt_signature_invalid: wire("auth", 401, false, "The bearer JWT's signature could not be verified against the issuer's JWKS (wrong key, rotated key, or forged token)."),
276801
276801
  jwt_audience_mismatch: wire("auth", 401, false, "The bearer JWT's `aud` (audience) claim does not match the audience this issuer is registered with."),
276802
276802
  jwt_missing_subject: wire("auth", 401, false, "The bearer JWT has no `sub` (subject) claim to identify the user."),
@@ -276845,7 +276845,11 @@ var ERROR_CODES = {
276845
276845
  model_claim_not_configured: client("claim", "Claiming requires the collaboration runtime, which the standard Ablo({ schema, apiKey }) client wires up for every model automatically \u2014 there is no per-model claim configuration to add. This appears only when a model proxy is constructed directly without that runtime (an internal/advanced path)."),
276846
276846
  model_watch_not_configured: client("claim", "watch() opens a presence/claim subscription and needs a live WebSocket, so it is unavailable on the HTTP transport and on model proxies built without a socket. Use the standard Ablo({ schema, apiKey }) client (default WebSocket transport)."),
276847
276847
  // ── stale context / idempotency (409) ──────────────────────────────
276848
- stale_context: wire("conflict", 409, true, "The row changed after you read it \u2014 the write's `readAt` watermark is older than the current row version. Re-read the row and retry."),
276848
+ // Not retryable at the transport: the rejected request carries its frozen
276849
+ // `readAt`, so resending the identical payload can never succeed. Recovery
276850
+ // is a caller-level re-read that produces a NEW request with a fresh
276851
+ // watermark — the same shape as `claim_conflict`.
276852
+ stale_context: wire("conflict", 409, false, "The row changed after you read it \u2014 the write's `readAt` watermark is older than the current row version. Re-read the row and retry."),
276849
276853
  // Raised by the functional `update(id, current => next)` form once its
276850
276854
  // internal reconcile budget is exhausted, because the row stayed continuously
276851
276855
  // contended. The SDK has already retried; the caller decides whether to back
@@ -18,7 +18,7 @@
18
18
  * });
19
19
  * await sync.reports.delete({ id: reportId });
20
20
  */
21
- import type { Schema, SchemaRecord, InferModel, InferCreate } from '../schema/schema.js';
21
+ import type { Schema, SchemaRecord, InferModel, InferCreate, InferRow } from '../schema/schema.js';
22
22
  import type { ModelTarget, ModelClaim } from '../coordination/schema.js';
23
23
  export type { ModelTarget, ModelClaim };
24
24
  import { InstanceCache } from '../InstanceCache.js';
@@ -34,6 +34,18 @@ export type { ApiKeySetter, AbloOptions, InternalAbloOptions } from './options.j
34
34
  export type { LocalCountOptions, LocalReadOptions, ModelListScope, ServerReadOptions, ModelRetrieveParams, ModelCreateParams, ModelUpdateParams, ModelDeleteParams, ClaimOptions, ClaimParams, ClaimLookupParams, ClaimReorderParams, Claim, ClaimHeartbeat, ClaimHeartbeatOptions, HeldClaim, ModelOperations, ModelOperationAction, CommitWait, ModelRead, IfClaimedPolicy, ClaimedOptions, ClaimWaitOptions, ModelReadOptions, ClaimCreateOptions, CommitOperationInput, CommitCreateOptions, CommitReceipt, CommitResource, ClaimResource, ModelMutationOptions, HttpClaimApi, ModelClient, SessionOperation, CreateUserSessionParams, CreateAgentSessionParams, CreateSessionParams, CreateAgentClientParams, AbloSession, } from './resourceTypes.js';
35
35
  export { computeFKDepthPriority } from './schemaConfig.js';
36
36
  import type { ModelOperations } from './createModelProxy.js';
37
+ /**
38
+ * The reactive-read client a `useAblo` selector receives. The same surface as
39
+ * {@link Ablo}, except model reads are typed as reactive rows
40
+ * ({@link InferRow}: data fields + computeds, no relation accessors, no model
41
+ * methods) — the shape a reactive read actually delivers after
42
+ * `toReactiveSnapshot()`. Reading `row.layers` off a reactive read is a
43
+ * compile error here instead of a silent runtime `undefined`; compose
44
+ * relations through selectors or hooks that resolve the pool's instance.
45
+ */
46
+ export type AbloReads<S extends SchemaRecord> = Omit<Ablo<S>, keyof S & string> & {
47
+ readonly [K in keyof S & string]: ModelOperations<InferRow<Schema<S>, K>, InferCreate<Schema<S>, K>>;
48
+ };
37
49
  /** The typed sync engine client — one property per model in the schema */
38
50
  export type Ablo<S extends SchemaRecord> = {
39
51
  readonly [K in keyof S & string]: ModelOperations<InferModel<Schema<S>, K>, InferCreate<Schema<S>, K>>;
@@ -351,6 +363,11 @@ import type * as _SchemaTypes from '../schema/schema.js';
351
363
  */
352
364
  export declare namespace Ablo {
353
365
  type Options<S extends SchemaRecord = SchemaRecord> = AbloOptions<S>;
366
+ /**
367
+ * The read view of the client that `useAblo` selectors receive: model reads
368
+ * typed as reactive rows (data fields + computeds, no relation accessors).
369
+ */
370
+ type Reads<S extends SchemaRecord = SchemaRecord> = AbloReads<S>;
354
371
  type Api = AbloApi;
355
372
  type ApiClaims = AbloApiClaims;
356
373
  type Capability = import('./ApiClient.js').Capability;
@@ -390,6 +407,12 @@ export declare namespace Ablo {
390
407
  namespace Schema {
391
408
  type InferModel<S extends _SchemaTypes.Schema, K extends keyof S['models']> = _SchemaTypes.InferModel<S, K>;
392
409
  type InferCreate<S extends _SchemaTypes.Schema, K extends keyof S['models']> = _SchemaTypes.InferCreate<S, K>;
410
+ /**
411
+ * The reactive-row companion to {@link InferModel}: data fields + computed
412
+ * getters, no relation accessors, no model methods — the shape `useAblo`
413
+ * reads return.
414
+ */
415
+ type InferRow<S extends _SchemaTypes.Schema, K extends keyof S['models']> = _SchemaTypes.InferRow<S, K>;
393
416
  type InferModelNames<S extends _SchemaTypes.Schema> = _SchemaTypes.InferModelNames<S>;
394
417
  }
395
418
  type Conflict = _Policy.Conflict;
@@ -18,6 +18,7 @@
18
18
  * });
19
19
  * await sync.reports.delete({ id: reportId });
20
20
  */
21
+ import { durableCommitOperationSchema, } from '../transactions/commitEnvelope.js';
21
22
  import { AbloAuthenticationError, AbloConnectionError, AbloValidationError, AbloNotFoundError, translateHttpError, toAbloError, claimedError } from '../errors.js';
22
23
  import { descriptionFromMeta } from '../coordination/schema.js';
23
24
  import { initSyncEngine } from '../context.js';
@@ -523,7 +524,7 @@ export function Ablo(options) {
523
524
  }
524
525
  const type = op.action.toUpperCase();
525
526
  const id = op.id ?? op.target?.id ?? '';
526
- return {
527
+ return durableCommitOperationSchema.parse({
527
528
  type,
528
529
  model: model.toLowerCase(),
529
530
  id,
@@ -531,7 +532,7 @@ export function Ablo(options) {
531
532
  transactionId: op.transactionId ?? undefined,
532
533
  readAt: op.readAt ?? defaults.readAt ?? undefined,
533
534
  onStale: op.onStale ?? defaults.onStale ?? undefined,
534
- };
535
+ });
535
536
  }
536
537
  function normalizeCommitOperations(commitOptions) {
537
538
  if (commitOptions.operation && commitOptions.operations) {
@@ -817,7 +818,7 @@ export function Ablo(options) {
817
818
  // SyncClient we already hold from createInternalComponents —
818
819
  // no need to leak an accessor through BaseSyncedStore.
819
820
  const queue = syncClient.getTransactionQueue();
820
- queue.enqueueCommit(clientTxId, operations, {
821
+ await queue.enqueueCommit(clientTxId, operations, {
821
822
  ...(commitOptions.reads ? { reads: [...commitOptions.reads] } : {}),
822
823
  });
823
824
  if (wait === 'queued') {
@@ -4,7 +4,9 @@
4
4
  * and Commit nouns directly to HTTP routes on the server. This is the transport
5
5
  * used for server-side agents, workers, and serverless code.
6
6
  */
7
- import { AbloClaimedError, AbloAuthenticationError, AbloConnectionError, AbloValidationError, AbloNotFoundError, claimedError, translateHttpError, } from '../errors.js';
7
+ import { AbloClaimedError, AbloAuthenticationError, AbloConnectionError, AbloIdempotencyError, AbloValidationError, AbloNotFoundError, claimedError, translateHttpError, } from '../errors.js';
8
+ import { z } from 'zod';
9
+ import { v5 as uuidv5 } from 'uuid';
8
10
  import { reconcileFunctionalUpdate, } from './functionalUpdate.js';
9
11
  import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, resolveDatabaseUrl, warnIfCliKeyMismatch, warnIfDatabaseUrlEnvIgnored, warnIfDatabaseUrlDeprecated, } from './auth.js';
10
12
  import { registerDataSource } from './registerDataSource.js';
@@ -12,6 +14,7 @@ import { PROTOCOL_VERSION, PROTOCOL_VERSION_HEADER } from '../wire/protocolVersi
12
14
  import { toMs, toSeconds } from '../utils/duration.js';
13
15
  import { heartbeatCadenceMs, resolveHeartbeatOptions, startClaimHeartbeatLoop, } from './claimHeartbeatLoop.js';
14
16
  import { mintSession } from './sessionMint.js';
17
+ import { parseIdentityResolveResponse } from '../auth/schemas.js';
15
18
  /**
16
19
  * Interpret a heartbeat reply for a lease this handle HOLDS: anything other
17
20
  * than `held` means the lease is no longer ours (a holder cannot be `queued`;
@@ -29,8 +32,35 @@ function heldHeartbeatReply(reply, label) {
29
32
  throw new AbloClaimedError(`The lease behind ${label} is no longer held — it expired or was granted onward. Re-acquire the claim and retry; a write attempted under the old lease is rejected by its \`readAt\` guard.`, { code: 'claim_lost' });
30
33
  }
31
34
  import { assertWriteOptions } from './writeOptionsSchema.js';
35
+ import { createDurableHttpCommitEnvelope, canonicalHttpCommitBody, durableHttpCommitEnvelopeSchema, httpCommitEnvelopeRecordId, isHttpCommitReplayExpired, } from '../transactions/httpCommitEnvelope.js';
32
36
  /** Default per-request deadline for the stateless HTTP transport. */
33
37
  export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
38
+ const successfulCommitResponseSchema = z
39
+ .object({
40
+ object: z.literal('commit_receipt'),
41
+ id: z.string().min(1).optional(),
42
+ clientTxId: z.string().min(1),
43
+ serverTxId: z.string().min(1),
44
+ status: z.enum(['queued', 'confirmed']),
45
+ success: z.literal(true),
46
+ lastSyncId: z.number().int().nonnegative().optional(),
47
+ ops: z.number().int().positive(),
48
+ /** Ids of UPDATE/DELETE targets that matched zero rows (loud 0-row writes). */
49
+ missingIds: z.array(z.string().min(1)).optional(),
50
+ })
51
+ .loose();
52
+ function parseSuccessfulCommitResponse(value, idempotencyKey) {
53
+ const parsed = successfulCommitResponseSchema.safeParse(value);
54
+ if (!parsed.success || parsed.data.clientTxId !== idempotencyKey) {
55
+ throw new AbloConnectionError('The commit endpoint returned an invalid success receipt; its outcome remains pending and is safe to retry.', {
56
+ code: 'commit_no_result',
57
+ cause: parsed.success
58
+ ? new Error('Commit receipt clientTxId did not match its idempotency key')
59
+ : parsed.error,
60
+ });
61
+ }
62
+ return parsed.data;
63
+ }
34
64
  const DEFAULT_AGENT_LEASE = '10m';
35
65
  export function createProtocolClient(options) {
36
66
  const env = readProcessEnv();
@@ -64,14 +94,17 @@ export function createProtocolClient(options) {
64
94
  const recordCoordinationConflict = (error, clientTxId, fallbackRows) => {
65
95
  if (!observability)
66
96
  return;
67
- const code = error?.code;
97
+ const errorRecord = typeof error === 'object' && error !== null
98
+ ? error
99
+ : undefined;
100
+ const code = errorRecord?.code;
68
101
  const isConflict = code === 'stale_context' ||
69
102
  code === 'claim_conflict' ||
70
103
  code === 'entity_claimed' ||
71
104
  (typeof code === 'string' && code.startsWith('policy:'));
72
105
  if (!isConflict)
73
106
  return;
74
- const rawConflicts = error?.conflicts;
107
+ const rawConflicts = errorRecord?.conflicts;
75
108
  const rows = Array.isArray(rawConflicts) && rawConflicts.length > 0
76
109
  ? rawConflicts.map((r) => ({
77
110
  model: typeof r.model === 'string' ? r.model : 'unknown',
@@ -90,19 +123,54 @@ export function createProtocolClient(options) {
90
123
  url,
91
124
  bootstrapBaseUrl: options.bootstrapBaseUrl,
92
125
  }).replace(/\/+$/, '');
126
+ const commitOutbox = options.commitOutbox;
127
+ const httpOutboxPlaneNamespace = canonicalHttpCommitBody({
128
+ apiBaseUrl,
129
+ defaultQuery: Object.entries(options.defaultQuery ?? {}).sort(([a], [b]) => a.localeCompare(b)),
130
+ });
131
+ let httpOutboxScopeNamespace = null;
93
132
  let readyPromise = null;
133
+ let httpCommitLane = Promise.resolve();
134
+ function runInHttpCommitLane(work) {
135
+ const result = httpCommitLane.then(work);
136
+ httpCommitLane = result.then(() => undefined, () => undefined);
137
+ return result;
138
+ }
139
+ async function resolveHttpOutboxScope() {
140
+ if (!commitOutbox)
141
+ return null;
142
+ if (httpOutboxScopeNamespace)
143
+ return httpOutboxScopeNamespace;
144
+ let scope = options.commitOutboxScope;
145
+ if (!scope) {
146
+ const rawIdentity = await requestJson('/auth/identity', { method: 'GET' }, true);
147
+ const identity = parseIdentityResolveResponse(rawIdentity);
148
+ scope = {
149
+ organizationId: identity.accountScope,
150
+ participantId: identity.participantId,
151
+ namespace: 'http',
152
+ };
153
+ }
154
+ httpOutboxScopeNamespace = canonicalHttpCommitBody({
155
+ ...scope,
156
+ plane: httpOutboxPlaneNamespace,
157
+ });
158
+ return httpOutboxScopeNamespace;
159
+ }
94
160
  async function ready() {
95
161
  if (readyPromise)
96
162
  return readyPromise;
97
163
  readyPromise = (async () => {
98
- if (!configuredDatabaseUrl)
99
- return;
100
- await registerDataSource({
101
- baseUrl: apiBaseUrl,
102
- apiKey: await resolveApiKeyValue(configuredApiKey),
103
- databaseUrl: configuredDatabaseUrl,
104
- ...(options.fetch ? { fetchImpl: options.fetch } : {}),
105
- });
164
+ if (configuredDatabaseUrl) {
165
+ await registerDataSource({
166
+ baseUrl: apiBaseUrl,
167
+ apiKey: await resolveApiKeyValue(configuredApiKey),
168
+ databaseUrl: configuredDatabaseUrl,
169
+ ...(options.fetch ? { fetchImpl: options.fetch } : {}),
170
+ });
171
+ }
172
+ await resolveHttpOutboxScope();
173
+ await replayHttpCommitOutbox();
106
174
  })();
107
175
  try {
108
176
  await readyPromise;
@@ -144,8 +212,9 @@ export function createProtocolClient(options) {
144
212
  return target.toString();
145
213
  }
146
214
  const requestTimeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
147
- async function requestJson(path, init) {
148
- await ready();
215
+ async function requestJson(path, init, skipReady = false) {
216
+ if (!skipReady)
217
+ await ready();
149
218
  const { idempotencyKey, ...requestInit } = init;
150
219
  const headers = await authHeaders();
151
220
  if (idempotencyKey)
@@ -189,6 +258,7 @@ export function createProtocolClient(options) {
189
258
  bodyText = await res.text();
190
259
  }
191
260
  catch (error) {
261
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- assigned asynchronously by the deadline callback
192
262
  if (timedOut) {
193
263
  // Retryable by contract: `wait_for_timeout` is a registered transient
194
264
  // transport code, so `isRetryableCode` steers callers to retry.
@@ -209,6 +279,169 @@ export function createProtocolClient(options) {
209
279
  }
210
280
  return body;
211
281
  }
282
+ function isDefinitiveHttpRejection(error) {
283
+ if (typeof error !== 'object' || error === null)
284
+ return false;
285
+ const candidate = error;
286
+ const status = typeof candidate.httpStatus === 'number'
287
+ ? candidate.httpStatus
288
+ : typeof candidate.status === 'number'
289
+ ? candidate.status
290
+ : undefined;
291
+ return (status !== undefined &&
292
+ status >= 400 &&
293
+ status < 500 &&
294
+ status !== 408 &&
295
+ status !== 425 &&
296
+ status !== 429);
297
+ }
298
+ async function settleHttpEnvelope(recordId) {
299
+ if (!commitOutbox)
300
+ return;
301
+ try {
302
+ await commitOutbox.remove(recordId);
303
+ }
304
+ catch (cause) {
305
+ // Do not report the remote outcome until local settlement is durable.
306
+ // The retained record can still be replayed inside the safe window.
307
+ throw new AbloConnectionError('The server settled the commit, but its local outbox record could not be cleared.', { code: 'db_not_opened', cause });
308
+ }
309
+ }
310
+ async function replayHttpCommitOutbox() {
311
+ const replayed = new Map();
312
+ if (!commitOutbox)
313
+ return replayed;
314
+ const scopeNamespace = await resolveHttpOutboxScope();
315
+ if (!scopeNamespace)
316
+ return replayed;
317
+ const rows = await commitOutbox.list();
318
+ const envelopes = [];
319
+ for (const row of rows) {
320
+ if (typeof row !== 'object' ||
321
+ row === null ||
322
+ row.type !== 'http_commit_envelope')
323
+ continue;
324
+ const parsed = durableHttpCommitEnvelopeSchema.safeParse(row);
325
+ if (!parsed.success) {
326
+ throw new AbloValidationError('A saved HTTP write is unreadable; replay stopped before any newer write was sent.', { code: 'write_options_invalid', cause: parsed.error });
327
+ }
328
+ if (parsed.data.scopeNamespace !== scopeNamespace)
329
+ continue;
330
+ if (isHttpCommitReplayExpired(parsed.data)) {
331
+ throw new AbloIdempotencyError('A saved HTTP write is older than the server idempotency window and cannot be replayed safely.', { code: 'idempotency_conflict' });
332
+ }
333
+ envelopes.push(parsed.data);
334
+ }
335
+ envelopes.sort((a, b) => (a.sequence ?? a.sealedAt * 1_000) -
336
+ (b.sequence ?? b.sealedAt * 1_000) ||
337
+ a.id.localeCompare(b.id));
338
+ for (const envelope of envelopes) {
339
+ try {
340
+ const raw = await requestJson(envelope.request.path, {
341
+ method: envelope.request.method,
342
+ idempotencyKey: envelope.idempotencyKey,
343
+ body: envelope.request.body,
344
+ }, true);
345
+ const response = parseSuccessfulCommitResponse(raw, envelope.idempotencyKey);
346
+ await settleHttpEnvelope(envelope.id);
347
+ replayed.set(envelope.idempotencyKey, { envelope, response });
348
+ }
349
+ catch (error) {
350
+ if (isDefinitiveHttpRejection(error)) {
351
+ await settleHttpEnvelope(envelope.id);
352
+ }
353
+ throw error;
354
+ }
355
+ }
356
+ return replayed;
357
+ }
358
+ let lastHttpCommitSequence = 0;
359
+ function nextHttpCommitSequence() {
360
+ const wallSequence = Date.now() * 1_000;
361
+ lastHttpCommitSequence = Math.max(wallSequence, lastHttpCommitSequence + 1);
362
+ return lastHttpCommitSequence;
363
+ }
364
+ async function sealHttpCommit(input) {
365
+ if (!commitOutbox)
366
+ return null;
367
+ const scopeNamespace = await resolveHttpOutboxScope();
368
+ if (!scopeNamespace) {
369
+ throw new AbloValidationError('HTTP commit outbox scope was not resolved', {
370
+ code: 'write_options_invalid',
371
+ });
372
+ }
373
+ const recordId = httpCommitEnvelopeRecordId(input.idempotencyKey, scopeNamespace);
374
+ const legacyRecordId = httpCommitEnvelopeRecordId(input.idempotencyKey);
375
+ const existingRows = await commitOutbox.list();
376
+ const existingRaw = existingRows.find((row) => typeof row === 'object' &&
377
+ row !== null &&
378
+ (row.id === recordId ||
379
+ row.id === legacyRecordId));
380
+ const serializedBody = canonicalHttpCommitBody(input.body);
381
+ if (existingRaw !== undefined) {
382
+ const existing = durableHttpCommitEnvelopeSchema.parse(existingRaw);
383
+ if (isHttpCommitReplayExpired(existing)) {
384
+ throw new AbloIdempotencyError('This saved HTTP write is older than the server idempotency window and cannot be retried safely.', { code: 'idempotency_conflict' });
385
+ }
386
+ if (existing.scopeNamespace !== scopeNamespace ||
387
+ existing.request.method !== input.method ||
388
+ existing.request.path !== input.path ||
389
+ existing.request.body !== serializedBody) {
390
+ throw new AbloIdempotencyError('Idempotency key reused with a different HTTP commit request', { code: 'idempotency_conflict' });
391
+ }
392
+ return existing;
393
+ }
394
+ const envelope = createDurableHttpCommitEnvelope({
395
+ idempotencyKey: input.idempotencyKey,
396
+ request: { method: input.method, path: input.path, body: input.body },
397
+ scopeNamespace,
398
+ sequence: nextHttpCommitSequence(),
399
+ });
400
+ await commitOutbox.seal(envelope, []);
401
+ return envelope;
402
+ }
403
+ async function dispatchHttpCommit(input, beforeSettlement) {
404
+ return runInHttpCommitLane(async () => {
405
+ await ready();
406
+ // `ready()` covers startup. Re-draining here makes every later write wait
407
+ // behind an ambiguous predecessor from this same process.
408
+ const replayed = await replayHttpCommitOutbox();
409
+ const prior = replayed.get(input.idempotencyKey);
410
+ if (prior) {
411
+ const serializedBody = canonicalHttpCommitBody(input.body);
412
+ if (prior.envelope.request.method !== input.method ||
413
+ prior.envelope.request.path !== input.path ||
414
+ prior.envelope.request.body !== serializedBody) {
415
+ throw new AbloIdempotencyError('Idempotency key reused with a different HTTP commit request', { code: 'idempotency_conflict' });
416
+ }
417
+ await beforeSettlement?.(prior.response);
418
+ return prior.response;
419
+ }
420
+ const durableEnvelope = await sealHttpCommit(input);
421
+ const requestBody = durableEnvelope?.request.body ?? canonicalHttpCommitBody(input.body);
422
+ let response;
423
+ try {
424
+ const raw = await requestJson(input.path, {
425
+ method: input.method,
426
+ idempotencyKey: input.idempotencyKey,
427
+ body: requestBody,
428
+ }, true);
429
+ response = parseSuccessfulCommitResponse(raw, input.idempotencyKey);
430
+ }
431
+ catch (error) {
432
+ if (durableEnvelope && isDefinitiveHttpRejection(error)) {
433
+ await settleHttpEnvelope(durableEnvelope.id);
434
+ }
435
+ throw error;
436
+ }
437
+ // A model-create readback can participate in settlement: if it fails,
438
+ // retain the exact write so a same-key retry recovers the generated id.
439
+ await beforeSettlement?.(response);
440
+ if (durableEnvelope)
441
+ await settleHttpEnvelope(durableEnvelope.id);
442
+ return response;
443
+ });
444
+ }
212
445
  function createClientTxId(idempotencyKey) {
213
446
  if (idempotencyKey && idempotencyKey.length > 0)
214
447
  return idempotencyKey;
@@ -221,17 +454,28 @@ export function createProtocolClient(options) {
221
454
  ? `int_${crypto.randomUUID()}`
222
455
  : `int_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
223
456
  }
224
- function createModelId() {
457
+ function createModelId(modelName, idempotencyKey) {
458
+ if (idempotencyKey) {
459
+ return uuidv5(`${modelName}:${idempotencyKey}`, 'aa4ba6d4-bf0b-5b38-9c45-116f79a6e548');
460
+ }
225
461
  return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
226
462
  ? crypto.randomUUID()
227
463
  : `id_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
228
464
  }
229
- function childClient(authToken) {
465
+ function childClient(authToken, scope) {
230
466
  return createProtocolClient({
231
467
  ...options,
232
468
  apiKey: null,
233
469
  authToken,
234
470
  schema: null,
471
+ ...(scope
472
+ ? {
473
+ commitOutboxScope: {
474
+ ...scope,
475
+ namespace: options.commitOutboxScope?.namespace ?? 'http',
476
+ },
477
+ }
478
+ : { commitOutboxScope: undefined }),
235
479
  });
236
480
  }
237
481
  function normalizeCommitOperation(op, defaults) {
@@ -358,17 +602,20 @@ export function createProtocolClient(options) {
358
602
  readAt: commitOptions.readAt ?? claim?.readAt ?? null,
359
603
  onStale: commitOptions.onStale ?? (claim?.readAt !== undefined ? 'reject' : null),
360
604
  });
605
+ const requestBody = {
606
+ clientTxId,
607
+ idempotencyKey: clientTxId,
608
+ claim: normalizeClaimId(commitOptions.claimRef) ?? claim?.id,
609
+ operations,
610
+ reads: commitOptions.reads,
611
+ };
361
612
  let body;
362
613
  try {
363
- body = await requestJson('/v1/commits', {
614
+ body = await dispatchHttpCommit({
615
+ path: '/v1/commits',
364
616
  method: 'POST',
365
617
  idempotencyKey: clientTxId,
366
- body: JSON.stringify({
367
- clientTxId,
368
- idempotencyKey: clientTxId,
369
- claim: normalizeClaimId(commitOptions.claimRef) ?? claim?.id,
370
- operations,
371
- }),
618
+ body: requestBody,
372
619
  });
373
620
  }
374
621
  catch (error) {
@@ -387,7 +634,7 @@ export function createProtocolClient(options) {
387
634
  // the rejection body (already thrown).
388
635
  const status = body.status === 'queued' ? 'queued' : 'confirmed';
389
636
  return {
390
- id: body.id ?? body.clientTxId ?? clientTxId,
637
+ id: body.id ?? body.clientTxId,
391
638
  status,
392
639
  lastSyncId: body.lastSyncId,
393
640
  ...(body.missingIds && body.missingIds.length > 0
@@ -398,9 +645,9 @@ export function createProtocolClient(options) {
398
645
  };
399
646
  const capabilities = {
400
647
  async create(capabilityOptions) {
401
- const ttlSeconds = capabilityOptions.ttlSeconds ??
648
+ const ttlSeconds = capabilityOptions.ttlSeconds ?? // eslint-disable-line @typescript-eslint/no-deprecated -- compatibility alias
402
649
  capabilityOptions.leaseSeconds ??
403
- toSeconds(capabilityOptions.ttl ?? capabilityOptions.lease ?? DEFAULT_AGENT_LEASE);
650
+ toSeconds(capabilityOptions.ttl ?? capabilityOptions.lease ?? DEFAULT_AGENT_LEASE); // eslint-disable-line @typescript-eslint/no-deprecated -- compatibility alias
404
651
  const body = await requestJson('/v1/capabilities', {
405
652
  method: 'POST',
406
653
  body: JSON.stringify({
@@ -425,7 +672,10 @@ export function createProtocolClient(options) {
425
672
  organizationId: body.organizationId,
426
673
  scope: body.scope,
427
674
  userMeta: body.userMeta,
428
- client: () => childClient(body.token),
675
+ client: () => childClient(body.token, {
676
+ organizationId: body.organizationId,
677
+ participantId: body.scope.participantId,
678
+ }),
429
679
  };
430
680
  },
431
681
  async retrieve(id) {
@@ -479,7 +729,10 @@ export function createProtocolClient(options) {
479
729
  id: body.rotatedFrom.capabilityId ?? body.rotatedFrom.id ?? id,
480
730
  expiresAt: body.rotatedFrom.expiresAt,
481
731
  },
482
- client: () => childClient(body.token),
732
+ client: () => childClient(body.token, {
733
+ organizationId: body.organizationId,
734
+ participantId: body.scope.participantId,
735
+ }),
483
736
  };
484
737
  },
485
738
  mint(options) {
@@ -628,7 +881,7 @@ export function createProtocolClient(options) {
628
881
  * multi-operation envelopes; this helper handles the one-operation,
629
882
  * one-record case.
630
883
  */
631
- async function mutateModel(action, modelName, id, data, options) {
884
+ async function mutateModel(action, modelName, id, data, options, beforeSettlement) {
632
885
  assertWriteOptions(options && {
633
886
  idempotencyKey: options.idempotencyKey,
634
887
  readAt: options.readAt,
@@ -644,11 +897,12 @@ export function createProtocolClient(options) {
644
897
  const method = action === 'create' ? 'POST' : action === 'update' ? 'PATCH' : 'DELETE';
645
898
  // A carried claim handle supplies the stale-guard defaults — one claim
646
899
  // vocabulary across the WS proxy, `commits.create`, and these routes.
647
- const claimHandle = typeof options?.claim === 'object' &&
648
- options?.claim !== null &&
649
- options.claim.object === 'claim' &&
650
- typeof options.claim.id === 'string'
651
- ? options.claim
900
+ const rawClaim = options?.claim;
901
+ const claimHandle = typeof rawClaim === 'object' &&
902
+ rawClaim !== null &&
903
+ rawClaim.object === 'claim' &&
904
+ typeof rawClaim.id === 'string'
905
+ ? rawClaim
652
906
  : undefined;
653
907
  const readAt = options?.readAt ?? claimHandle?.readAt;
654
908
  const requestBody = {
@@ -663,11 +917,12 @@ export function createProtocolClient(options) {
663
917
  requestBody.data = data;
664
918
  let body;
665
919
  try {
666
- body = await requestJson(path, {
920
+ body = await dispatchHttpCommit({
921
+ path,
667
922
  method,
668
923
  idempotencyKey: clientTxId,
669
- body: JSON.stringify(requestBody),
670
- });
924
+ body: requestBody,
925
+ }, beforeSettlement);
671
926
  }
672
927
  catch (error) {
673
928
  // The per-model write door (`ablo.<model>.update/create/delete`). Capture
@@ -680,7 +935,7 @@ export function createProtocolClient(options) {
680
935
  // subset; `'rejected'` only appears on a thrown rejection body.
681
936
  const status = body.status === 'queued' ? 'queued' : 'confirmed';
682
937
  return {
683
- id: body.serverTxId ?? body.id ?? body.clientTxId ?? clientTxId,
938
+ id: body.serverTxId,
684
939
  status,
685
940
  lastSyncId: body.lastSyncId,
686
941
  };
@@ -885,7 +1140,7 @@ export function createProtocolClient(options) {
885
1140
  return listModel(name, options);
886
1141
  },
887
1142
  async create(params) {
888
- const id = params.id ?? createModelId();
1143
+ const id = params.id ?? createModelId(name, params.idempotencyKey);
889
1144
  return withMutationClaim(id, params, async (options) => {
890
1145
  await applyClaimedPolicy({ model: name, id }, options);
891
1146
  // Confirm the write, then return the row — the obvious expectation of
@@ -893,15 +1148,23 @@ export function createProtocolClient(options) {
893
1148
  // back is the authoritative server row, so it carries the framework
894
1149
  // defaults (createdAt, createdBy, …) and, for an idempotent re-create of
895
1150
  // an existing id, the existing row rather than the caller's input.
1151
+ let created;
896
1152
  await mutateModel('create', name, id, params.data, {
897
1153
  ...options,
898
1154
  wait: options?.wait ?? 'confirmed',
1155
+ }, async () => {
1156
+ const read = await retrieveModel(name, { id });
1157
+ if (read.data === undefined) {
1158
+ throw new AbloNotFoundError(`create ${name}/${id} did not yield a readable row (the write did not confirm).`, [id]);
1159
+ }
1160
+ created = read.data;
899
1161
  });
900
- const { data } = await retrieveModel(name, { id });
901
- if (data === undefined) {
902
- throw new AbloNotFoundError(`create ${name}/${id} did not yield a readable row (the write did not confirm).`, [id]);
1162
+ if (created === undefined) {
1163
+ throw new AbloConnectionError('Create settlement did not return its row.', {
1164
+ code: 'commit_no_result',
1165
+ });
903
1166
  }
904
- return data;
1167
+ return created;
905
1168
  });
906
1169
  },
907
1170
  update: updateModel,
@@ -915,7 +1178,10 @@ export function createProtocolClient(options) {
915
1178
  }
916
1179
  return {
917
1180
  ready,
918
- async waitForFlush() { },
1181
+ waitForFlush: () => runInHttpCommitLane(async () => {
1182
+ await ready();
1183
+ await replayHttpCommitOutbox();
1184
+ }),
919
1185
  async dispose() { },
920
1186
  async purge() { },
921
1187
  capabilities,
@@ -16,6 +16,7 @@ import { BootstrapFetcher } from '../sync/BootstrapFetcher.js';
16
16
  import type { AuthCredentialSource } from '../auth/credentialSource.js';
17
17
  import type { Schema, SchemaRecord } from '../schema/schema.js';
18
18
  import { type AbloPersistence } from './persistence.js';
19
+ import type { CommitOutboxStore } from '../transactions/commitOutboxStore.js';
19
20
  export interface InternalComponentsInput<S extends SchemaRecord> {
20
21
  readonly schema: Schema<S>;
21
22
  /** The WebSocket URL. Used to derive the bootstrap HTTP base URL when the
@@ -28,6 +29,7 @@ export interface InternalComponentsInput<S extends SchemaRecord> {
28
29
  readonly persistence?: AbloPersistence;
29
30
  readonly offline?: boolean;
30
31
  readonly inMemory?: boolean;
32
+ readonly commitOutbox?: CommitOutboxStore;
31
33
  };
32
34
  readonly auth?: AuthCredentialSource;
33
35
  }
@@ -41,7 +41,7 @@ export function createInternalComponents(input) {
41
41
  // IndexedDB is unavailable there.
42
42
  inMemory: shouldUseInMemoryPersistence(options),
43
43
  });
44
- const syncClient = new SyncClient(objectPool, database);
44
+ const syncClient = new SyncClient(objectPool, database, options.commitOutbox, url);
45
45
  // Lazy-load lane: hydrates the object pool and IndexedDB on demand for
46
46
  // entities not in scope at bootstrap (`load: 'lazy'` models, or an entity
47
47
  // reached by deep link before the pool warmed up). Single-flight, with