@abloatai/ablo 0.29.3 → 0.30.1

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 (118) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +3 -2
  3. package/dist/BaseSyncedStore.js +3 -1
  4. package/dist/Database.js +29 -2
  5. package/dist/SyncClient.d.ts +5 -3
  6. package/dist/SyncClient.js +8 -4
  7. package/dist/ai-sdk/coordinatedTool.d.ts +0 -1
  8. package/dist/ai-sdk/coordinatedTool.js +0 -1
  9. package/dist/ai-sdk/coordinationContext.js +1 -5
  10. package/dist/cli.cjs +377 -84
  11. package/dist/client/Ablo.d.ts +1 -1
  12. package/dist/client/Ablo.js +24 -36
  13. package/dist/client/auth.d.ts +8 -18
  14. package/dist/client/auth.js +16 -96
  15. package/dist/client/createModelProxy.d.ts +16 -11
  16. package/dist/client/createModelProxy.js +162 -20
  17. package/dist/client/httpClient.d.ts +1 -1
  18. package/dist/client/httpTransport.js +263 -89
  19. package/dist/client/options.d.ts +0 -15
  20. package/dist/client/resourceTypes.d.ts +13 -26
  21. package/dist/client/schemaConfig.js +5 -0
  22. package/dist/client/writeOptionsSchema.d.ts +1 -0
  23. package/dist/client/writeOptionsSchema.js +11 -2
  24. package/dist/client/wsMutationExecutor.d.ts +3 -5
  25. package/dist/coordination/schema.d.ts +17 -13
  26. package/dist/coordination/schema.js +36 -10
  27. package/dist/errorCodes.d.ts +9 -4
  28. package/dist/errorCodes.js +10 -5
  29. package/dist/errors.js +4 -9
  30. package/dist/index.d.ts +4 -0
  31. package/dist/interfaces/index.d.ts +31 -19
  32. package/dist/schema/index.d.ts +1 -1
  33. package/dist/schema/index.js +4 -1
  34. package/dist/schema/schema.d.ts +13 -0
  35. package/dist/schema/select.js +7 -0
  36. package/dist/schema/syncDeltaRow.d.ts +31 -13
  37. package/dist/schema/syncDeltaRow.js +33 -15
  38. package/dist/schema/tenancy.d.ts +52 -16
  39. package/dist/schema/tenancy.js +86 -25
  40. package/dist/server/commit.d.ts +8 -24
  41. package/dist/server/index.d.ts +1 -0
  42. package/dist/server/index.js +1 -0
  43. package/dist/server/readConfig.d.ts +10 -0
  44. package/dist/source/adapter.d.ts +26 -15
  45. package/dist/source/adapter.js +5 -3
  46. package/dist/source/adapters/drizzle.js +35 -9
  47. package/dist/source/adapters/kysely.d.ts +33 -61
  48. package/dist/source/adapters/kysely.js +170 -177
  49. package/dist/source/adapters/kyselyMutationCore.d.ts +76 -0
  50. package/dist/source/adapters/kyselyMutationCore.js +125 -0
  51. package/dist/source/adapters/memory.d.ts +1 -1
  52. package/dist/source/adapters/memory.js +24 -10
  53. package/dist/source/adapters/prisma.js +35 -8
  54. package/dist/source/conformance.d.ts +26 -28
  55. package/dist/source/conformance.js +142 -69
  56. package/dist/source/contract.d.ts +46 -4
  57. package/dist/source/contract.js +66 -5
  58. package/dist/source/factory.d.ts +3 -3
  59. package/dist/source/factory.js +24 -3
  60. package/dist/source/idempotency.d.ts +46 -0
  61. package/dist/source/idempotency.js +129 -0
  62. package/dist/source/index.d.ts +6 -4
  63. package/dist/source/index.js +5 -3
  64. package/dist/source/migrations.d.ts +9 -5
  65. package/dist/source/migrations.js +39 -6
  66. package/dist/source/types.d.ts +88 -14
  67. package/dist/source/types.js +16 -0
  68. package/dist/surface.d.ts +1 -1
  69. package/dist/surface.js +0 -1
  70. package/dist/sync/BootstrapFetcher.js +7 -0
  71. package/dist/sync/SyncWebSocket.d.ts +5 -27
  72. package/dist/sync/awaitClaimGrant.d.ts +6 -0
  73. package/dist/sync/awaitClaimGrant.js +12 -2
  74. package/dist/sync/commitFrames.d.ts +2 -4
  75. package/dist/sync/commitFrames.js +1 -0
  76. package/dist/sync/createClaimStream.d.ts +1 -1
  77. package/dist/sync/createClaimStream.js +13 -15
  78. package/dist/sync/deltaPipeline.d.ts +1 -1
  79. package/dist/sync/deltaPipeline.js +1 -1
  80. package/dist/sync/participants.d.ts +2 -3
  81. package/dist/sync/participants.js +2 -2
  82. package/dist/sync/wsFrameHandlers.js +35 -15
  83. package/dist/testing/mocks/MockMutationExecutor.d.ts +21 -0
  84. package/dist/testing/mocks/MockMutationExecutor.js +53 -1
  85. package/dist/transactions/TransactionQueue.d.ts +61 -9
  86. package/dist/transactions/TransactionQueue.js +502 -114
  87. package/dist/transactions/commitEnvelope.d.ts +4 -0
  88. package/dist/transactions/commitEnvelope.js +25 -0
  89. package/dist/transactions/commitPayload.d.ts +23 -5
  90. package/dist/transactions/commitPayload.js +3 -0
  91. package/dist/transactions/deltaConfirmation.d.ts +2 -1
  92. package/dist/transactions/deltaConfirmation.js +22 -4
  93. package/dist/transactions/durableWriteStore.d.ts +8 -1
  94. package/dist/transactions/httpCommitEnvelope.d.ts +3 -1
  95. package/dist/transactions/httpCommitEnvelope.js +30 -0
  96. package/dist/transactions/replayValidation.d.ts +4 -0
  97. package/dist/transactions/replayValidation.js +6 -0
  98. package/dist/types/streams.d.ts +29 -17
  99. package/dist/wire/commit.d.ts +671 -0
  100. package/dist/wire/commit.js +300 -0
  101. package/dist/wire/delta.d.ts +4 -0
  102. package/dist/wire/delta.js +9 -0
  103. package/dist/wire/frames.d.ts +17 -45
  104. package/dist/wire/index.d.ts +2 -0
  105. package/dist/wire/index.js +4 -0
  106. package/docs/agent-messaging.md +4 -5
  107. package/docs/agents.md +1 -2
  108. package/docs/api-keys.md +1 -2
  109. package/docs/client-behavior.md +4 -6
  110. package/docs/coordination.md +64 -9
  111. package/docs/data-sources.md +4 -7
  112. package/docs/integration-guide.md +6 -8
  113. package/docs/migration.md +0 -3
  114. package/docs/quickstart.md +7 -8
  115. package/docs/schema-contract.md +7 -8
  116. package/package.json +4 -4
  117. package/dist/client/registerDataSource.d.ts +0 -19
  118. package/dist/client/registerDataSource.js +0 -66
@@ -60,6 +60,10 @@ export interface SourceRequestContext {
60
60
  readonly participantId?: string;
61
61
  readonly participantKind?: 'user' | 'agent' | 'system';
62
62
  readonly organizationId?: string;
63
+ /** Trusted project plane selected by the authenticating credential. */
64
+ readonly projectId?: string;
65
+ /** Trusted sandbox plane selected by the authenticating credential. */
66
+ readonly sandboxId?: string;
63
67
  readonly requiredSyncGroups?: readonly string[];
64
68
  /**
65
69
  * Whether this request runs in production or sandbox mode. Branch your
@@ -106,9 +110,9 @@ export interface SourceDelta {
106
110
  * through the SDK.
107
111
  *
108
112
  * Your handler can return the whole outbox unfiltered. Ablo deduplicates on
109
- * the stable `id` and uses `clientTxId` to drop echoes of changes the SDK
110
- * already committed. If that earlier commit never landed, the same outbox
111
- * event repairs the gap on the next poll or push.
113
+ * the stable `id`. A mediated endpoint write carries `correlationId` plus the
114
+ * operation's `transactionId`; those fields promote its queued receipt after
115
+ * the event is durably appended. External writes omit them.
112
116
  */
113
117
  export interface SourceEvent {
114
118
  /**
@@ -128,13 +132,14 @@ export interface SourceEvent {
128
132
  */
129
133
  readonly organizationId?: string;
130
134
  /**
131
- * The originating SDK commit id, when you know it. If your outbox records the
132
- * `clientTxId` that Ablo passed into the matching `commit` handler, echo it
133
- * back here and Ablo will skip events whose commit already produced a change.
134
- * Leave it unset for changes made outside the SDK, such as cron jobs, batch
135
- * imports, or manual edits.
135
+ * @deprecated Legacy echo identity. It is not trusted for queued settlement;
136
+ * use `correlationId` and `transactionId` for mediated endpoint writes.
136
137
  */
137
138
  readonly clientTxId?: string;
139
+ /** Scoped server-authored identity of the queued mediated commit. */
140
+ readonly correlationId?: string;
141
+ /** Stable per-operation identity within the correlated commit. */
142
+ readonly transactionId?: string;
138
143
  /**
139
144
  * When the change occurred in your database. Optional and used only as an
140
145
  * ordering hint; Ablo trusts the order of your handler's response over this
@@ -162,11 +167,12 @@ export interface SourceEventForOperationOptions {
162
167
  */
163
168
  readonly data?: Record<string, unknown> | null;
164
169
  /**
165
- * The commit request's idempotency key. Echoing it lets Ablo drop echoes of
166
- * a change the SDK already committed, while still letting the outbox event
167
- * repair that change if it never landed.
170
+ * @deprecated Legacy echo identity. Prefer the explicit correlation fields.
168
171
  */
169
172
  readonly clientTxId?: string;
173
+ readonly correlationId?: string;
174
+ /** Defaults to `operation.transactionId` when present. */
175
+ readonly transactionId?: string;
170
176
  readonly organizationId?: string;
171
177
  readonly occurredAt?: number | Date;
172
178
  }
@@ -192,10 +198,64 @@ export interface SourceCommitResult<Row = Record<string, unknown>> {
192
198
  */
193
199
  readonly deltas?: readonly SourceDelta[];
194
200
  }
201
+ /**
202
+ * Requests a commit-correlated marker in the customer's Postgres WAL.
203
+ *
204
+ * This is opt-in because ordinary Data Source endpoints can be backed by
205
+ * stores other than Postgres. Connected Postgres planes request it for
206
+ * `wait: 'confirmed'`: the row changes and the logical message must be written
207
+ * by the same customer-side transaction, so a rollback publishes neither.
208
+ */
209
+ export interface SourceCommitEcho {
210
+ readonly kind: 'postgres-wal';
211
+ /**
212
+ * Opaque, versioned marker payload authored by Ablo. A source handler must
213
+ * pass these exact bytes to `pg_logical_emit_message` inside the write
214
+ * transaction; it must not reconstruct or normalize the JSON.
215
+ */
216
+ readonly payload: string;
217
+ }
218
+ /** Prefix reserved for Ablo commit-correlation messages in pgoutput. */
219
+ export declare const ABLO_POSTGRES_COMMIT_ECHO_PREFIX = "ablo";
220
+ /**
221
+ * Maximum source commit-correlation key length. This matches the server's
222
+ * idempotency-key contract: a WAL marker longer than this can never name an
223
+ * accepted Ablo commit and must not be appended as an apparently correlatable
224
+ * echo.
225
+ */
226
+ export declare const ABLO_SOURCE_CLIENT_TX_ID_MAX_LENGTH = 255;
227
+ /** Connected-source batches are deliberately smaller than the generic wire cap. */
228
+ export declare const ABLO_SOURCE_ECHO_MAX_OPERATIONS = 500;
229
+ /** Maximum UTF-8 bytes accepted for one transactional WAL marker payload. */
230
+ export declare const ABLO_SOURCE_ECHO_MAX_PAYLOAD_BYTES: number;
231
+ export interface SourceCommitEchoOperation {
232
+ readonly model: string;
233
+ readonly id: string;
234
+ readonly action: 'I' | 'U' | 'D';
235
+ readonly transactionId: string;
236
+ }
237
+ /** Strict payload encoded into {@link SourceCommitEcho.payload}. */
238
+ export interface SourceCommitEchoMarker {
239
+ readonly version: 1;
240
+ readonly correlationId: string;
241
+ readonly operations: readonly SourceCommitEchoOperation[];
242
+ }
195
243
  /** The arguments passed to a top-level {@link SourceCommitHandler}. */
196
244
  export interface SourceCommitParams<TAuth = unknown> {
197
245
  readonly operations: readonly SourceOperation[];
246
+ /** Scoped, server-authored customer-ledger identity. */
247
+ readonly correlationId?: string;
248
+ /** @deprecated Legacy wire name for `correlationId`. */
198
249
  readonly clientTxId?: string;
250
+ /**
251
+ * Canonical hash of the complete caller intent (operations plus guarded
252
+ * context), authored and signed by Ablo. Persist it beside `correlationId` and
253
+ * reject a replay whose hash differs; this protects an ambiguous retry even
254
+ * if Ablo's local execution row rolled back after your commit succeeded.
255
+ */
256
+ readonly intentHash?: string;
257
+ /** Present when the caller requires a transaction-correlated WAL echo. */
258
+ readonly echo?: SourceCommitEcho;
199
259
  readonly context: SourceHandlerContext<TAuth>;
200
260
  }
201
261
  /**
@@ -245,7 +305,7 @@ export interface SourceHandlerContext<TAuth = unknown> {
245
305
  * The `webhook-id` from the signed request, globally unique per the
246
306
  * Standard Webhooks specification. Dedupe by this id to defend against
247
307
  * replay: Ablo does not deduplicate at the source-handler boundary.
248
- * Commit idempotency keys on `clientTxId`, and event replay protection
308
+ * Commit idempotency keys on the scoped `correlationId`, and event replay protection
249
309
  * keys on the outbox event `id`.
250
310
  */
251
311
  readonly messageId?: string;
@@ -273,12 +333,19 @@ export interface SourceModelHandlers<Row, CreateInput, TAuth = unknown> {
273
333
  }): Promise<SourceListResult<Row>> | SourceListResult<Row>;
274
334
  /**
275
335
  * Apply one or more operations for this model within your own database
276
- * transaction. Your handler must be idempotent on the operation and its
277
- * `clientTxId`, so that a retried commit does not apply the change twice.
336
+ * transaction. Your handler must be idempotent on the scoped server
337
+ * `correlationId`, so that a retried commit does not apply the change twice.
278
338
  */
279
339
  commit?(params: {
280
340
  readonly operations: readonly SourceOperation[];
341
+ /** Scoped, server-authored customer-ledger identity. */
342
+ readonly correlationId?: string;
343
+ /** @deprecated Legacy wire name for `correlationId`. */
281
344
  readonly clientTxId?: string;
345
+ /** Persist and compare this hash as part of the idempotency ledger. */
346
+ readonly intentHash?: string;
347
+ /** Emit this marker inside the same transaction as the operations. */
348
+ readonly echo?: SourceCommitEcho;
282
349
  readonly context: SourceHandlerContext<TAuth>;
283
350
  }): Promise<SourceCommitResult<Row>> | SourceCommitResult<Row>;
284
351
  }
@@ -304,7 +371,14 @@ export interface SourceCommitRequest {
304
371
  */
305
372
  readonly model?: string;
306
373
  readonly operations: readonly SourceOperation[];
374
+ /** Scoped, server-authored customer-ledger identity. */
375
+ readonly correlationId?: string;
376
+ /** @deprecated Legacy wire name for `correlationId`. */
307
377
  readonly clientTxId?: string;
378
+ /** Signed canonical hash of the complete server-side commit intent. */
379
+ readonly intentHash?: string;
380
+ /** Commit-confirmation marker requested by a connected Postgres plane. */
381
+ readonly echo?: SourceCommitEcho;
308
382
  readonly scope?: SourceRequestContext;
309
383
  }
310
384
  export interface SourceEventsRequest {
@@ -24,6 +24,7 @@ export function sourceEventForOperation(options) {
24
24
  throw new AbloValidationError('sourceEventForOperation requires operation.id or an explicit entityId', { code: 'source_event_invalid' });
25
25
  }
26
26
  const occurredAt = normalizeEventOccurredAt(options.occurredAt);
27
+ const transactionId = options.transactionId ?? options.operation.transactionId ?? undefined;
27
28
  return {
28
29
  id: options.eventId,
29
30
  model: options.operation.model,
@@ -32,6 +33,8 @@ export function sourceEventForOperation(options) {
32
33
  ...(options.data !== undefined ? { data: options.data } : {}),
33
34
  ...(options.organizationId ? { organizationId: options.organizationId } : {}),
34
35
  ...(options.clientTxId ? { clientTxId: options.clientTxId } : {}),
36
+ ...(options.correlationId ? { correlationId: options.correlationId } : {}),
37
+ ...(transactionId ? { transactionId } : {}),
35
38
  ...(occurredAt !== undefined ? { occurredAt } : {}),
36
39
  };
37
40
  }
@@ -41,3 +44,16 @@ function normalizeEventOccurredAt(value) {
41
44
  const timestamp = value instanceof Date ? value.getTime() : value;
42
45
  return Number.isFinite(timestamp) ? timestamp : undefined;
43
46
  }
47
+ /** Prefix reserved for Ablo commit-correlation messages in pgoutput. */
48
+ export const ABLO_POSTGRES_COMMIT_ECHO_PREFIX = 'ablo';
49
+ /**
50
+ * Maximum source commit-correlation key length. This matches the server's
51
+ * idempotency-key contract: a WAL marker longer than this can never name an
52
+ * accepted Ablo commit and must not be appended as an apparently correlatable
53
+ * echo.
54
+ */
55
+ export const ABLO_SOURCE_CLIENT_TX_ID_MAX_LENGTH = 255;
56
+ /** Connected-source batches are deliberately smaller than the generic wire cap. */
57
+ export const ABLO_SOURCE_ECHO_MAX_OPERATIONS = 500;
58
+ /** Maximum UTF-8 bytes accepted for one transactional WAL marker payload. */
59
+ export const ABLO_SOURCE_ECHO_MAX_PAYLOAD_BYTES = 256 * 1024;
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", "databaseUrl", "persistence", "durableWrites", "commitOutbox", "commitOutboxScope", "transport", "debug", "logLevel", "authToken", "baseURL", "fetch", "defaultHeaders", "defaultQuery", "dangerouslyAllowBrowser"];
33
+ export declare const PUBLIC_ABLO_OPTION_KEYS: readonly ["schema", "apiKey", "authEndpoint", "persistence", "durableWrites", "commitOutbox", "commitOutboxScope", "transport", "debug", "logLevel", "authToken", "baseURL", "fetch", "defaultHeaders", "defaultQuery", "dangerouslyAllowBrowser"];
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
@@ -56,7 +56,6 @@ export const PUBLIC_ABLO_OPTION_KEYS = [
56
56
  'schema',
57
57
  'apiKey',
58
58
  'authEndpoint',
59
- 'databaseUrl',
60
59
  'persistence',
61
60
  'durableWrites',
62
61
  'commitOutbox',
@@ -42,6 +42,13 @@ export class BootstrapFetcher {
42
42
  const clientHash = getContext().config.expectedSchemaHash;
43
43
  if (!clientHash || clientHash === serverHash)
44
44
  return;
45
+ // A projection (`selectModels`/`omitModels`) hashes its subset, which never
46
+ // equals the full schema a server runs — so it also carries the source
47
+ // schema's hash. Matching that means the client is a faithful subset of the
48
+ // deployed schema: current, not drifted. Only warn when neither matches.
49
+ const sourceHash = getContext().config.expectedSourceSchemaHash;
50
+ if (sourceHash && sourceHash === serverHash)
51
+ return;
45
52
  this.schemaDriftWarned = true;
46
53
  const org = this.options.organizationId;
47
54
  const where = org ? `${this.baseUrl} (org ${org})` : this.baseUrl;
@@ -10,7 +10,7 @@ import { EventEmitter } from 'events';
10
10
  import type { MutationOperation } from '../interfaces/index.js';
11
11
  import { type ClientSyncDelta } from '../wire/delta.js';
12
12
  import type { BootstrapReason } from '../wire/bootstrapReason.js';
13
- import type { ClaimError, ClaimRejection, StaleNotification, ReadDependency } from '../coordination/schema.js';
13
+ import type { ClaimRejection, StaleNotification, ReadDependency, WireClaim } from '../coordination/schema.js';
14
14
  import { type CommitAck } from './commitFrames.js';
15
15
  export type { CommitAck } from './commitFrames.js';
16
16
  import { type AuthTokenGetter } from '../auth/credentialSource.js';
@@ -153,32 +153,10 @@ export interface PresenceUpdateEvent {
153
153
  participantKind?: string;
154
154
  timestamp?: number;
155
155
  /** Every presence frame carries this participant's open claims, stamped by
156
- * the server, so peers see them without a separate channel. */
157
- activeClaims?: {
158
- claimId: string;
159
- entityType: string;
160
- entityId: string;
161
- path?: string;
162
- range?: {
163
- startLine: number;
164
- endLine: number;
165
- startColumn?: number;
166
- endColumn?: number;
167
- };
168
- reason: string;
169
- field?: string;
170
- meta?: Record<string, unknown>;
171
- declaredAt: number;
172
- expiresAt: number;
173
- /**
174
- * The claim's lifecycle state. When absent, the reader treats it as
175
- * `'active'`. A terminal state (`committed`, `expired`, or `canceled`) rides
176
- * one final frame as the claim ends, so peers learn how it resolved before
177
- * it drops from the active set.
178
- */
179
- status?: 'active' | 'committed' | 'expired' | 'canceled';
180
- error?: ClaimError;
181
- }[];
156
+ * the server, so peers see them without a separate channel. The claim shape
157
+ * is the canonical {@link WireClaim} — one declaration in
158
+ * `coordination/schema.ts`, not a hand-kept copy. */
159
+ activeClaims?: WireClaim[];
182
160
  localTime?: string;
183
161
  type?: string;
184
162
  timezone?: string;
@@ -28,6 +28,12 @@ export interface ClaimGrantInfo {
28
28
  * The grant frame itself is the authoritative signal.
29
29
  */
30
30
  readonly waited: boolean;
31
+ /**
32
+ * The fencing token the server minted for this grant (Option B), read off the
33
+ * grant frame — the authoritative source, since the token is server-stamped.
34
+ * `undefined` when the server does not fence (no minter wired).
35
+ */
36
+ readonly fenceToken?: number;
31
37
  }
32
38
  export declare function awaitClaimGrant(transport: GrantTransport, claimId: string, options?: {
33
39
  timeoutMs?: number;
@@ -14,6 +14,10 @@
14
14
  */
15
15
  import { AbloClaimedError, formatClaimedErrorMessage, claimTargetLabel, } from '../errors.js';
16
16
  import { getContext } from '../context.js';
17
+ /** Read the server-stamped fencing token off a grant frame, if present. */
18
+ function readFenceToken(p) {
19
+ return typeof p.fenceToken === 'number' ? p.fenceToken : undefined;
20
+ }
17
21
  export function awaitClaimGrant(transport, claimId, options) {
18
22
  return new Promise((resolve, reject) => {
19
23
  const unsubs = [];
@@ -31,7 +35,10 @@ export function awaitClaimGrant(transport, claimId, options) {
31
35
  unsubs.push(transport.subscribe('claim_acquired', (p) => {
32
36
  if (p?.claimId === claimId) {
33
37
  getContext().logger.debug(`claim: acquired ${claimId} (target was free)`);
34
- settle(() => { resolve({ waited: false }); });
38
+ const fenceToken = readFenceToken(p);
39
+ settle(() => {
40
+ resolve({ waited: false, ...(fenceToken !== undefined ? { fenceToken } : {}) });
41
+ });
35
42
  }
36
43
  }));
37
44
  unsubs.push(transport.subscribe('claim_granted', (p) => {
@@ -39,7 +46,10 @@ export function awaitClaimGrant(transport, claimId, options) {
39
46
  // Promoted to the head of the line — the creator's "it's the agent's
40
47
  // turn now" moment after waiting behind a holder.
41
48
  getContext().logger.info(`claim: granted ${claimId} — your turn (waited in queue)`);
42
- settle(() => { resolve({ waited: true }); });
49
+ const fenceToken = readFenceToken(p);
50
+ settle(() => {
51
+ resolve({ waited: true, ...(fenceToken !== undefined ? { fenceToken } : {}) });
52
+ });
43
53
  }
44
54
  }));
45
55
  if (options?.maxQueueDepth !== undefined) {
@@ -5,6 +5,7 @@
5
5
  * dispatch can share them.
6
6
  */
7
7
  import type { CommitMessage } from '../wire/index.js';
8
+ import type { CommitAck as CanonicalCommitAck } from '../wire/commit.js';
8
9
  import type { MutationOperation, ClaimEvent } from '../interfaces/index.js';
9
10
  import type { StaleNotification, ReadDependency } from '../coordination/schema.js';
10
11
  /**
@@ -13,10 +14,7 @@ import type { StaleNotification, ReadDependency } from '../coordination/schema.j
13
14
  * carries the advisory signal that lets the writer self-heal, and the same
14
15
  * signal also arrives on the `conflict:notified` event.
15
16
  */
16
- export interface CommitAck {
17
- lastSyncId: number;
18
- notifications?: StaleNotification[];
19
- }
17
+ export type CommitAck = CanonicalCommitAck;
20
18
  /**
21
19
  * Converts the client's list of {@link MutationOperation} values into the wire
22
20
  * {@link CommitMessage} the server accepts. This is the one place the loosely
@@ -26,6 +26,7 @@ export function buildCommitFrame(operations, clientTxId, causedByTaskId, reads)
26
26
  transactionId: op.transactionId,
27
27
  readAt: op.readAt,
28
28
  onStale: op.onStale,
29
+ fenceToken: op.fenceToken,
29
30
  })),
30
31
  clientTxId,
31
32
  };
@@ -13,7 +13,7 @@
13
13
  *
14
14
  * Wire frames:
15
15
  * • Outbound `claim_begin` — announce a claim: `{ claimId, entityType,
16
- * entityId, reason, field?, estimatedMs? }`.
16
+ * entityId, description, field?, estimatedMs? }`.
17
17
  * • Outbound `claim_abandon` — release it: `{ claimId, entityType?,
18
18
  * entityId? }`.
19
19
  * • Inbound, via presence — `event.activeClaims`, each stamped with
@@ -13,7 +13,7 @@
13
13
  *
14
14
  * Wire frames:
15
15
  * • Outbound `claim_begin` — announce a claim: `{ claimId, entityType,
16
- * entityId, reason, field?, estimatedMs? }`.
16
+ * entityId, description, field?, estimatedMs? }`.
17
17
  * • Outbound `claim_abandon` — release it: `{ claimId, entityType?,
18
18
  * entityId? }`.
19
19
  * • Inbound, via presence — `event.activeClaims`, each stamped with
@@ -120,7 +120,11 @@ export function createClaimStream(config, transport = null) {
120
120
  // `settled()`. Absent status means active (wire back-compat).
121
121
  if (claim.status && claim.status !== 'active')
122
122
  continue;
123
- const description = descriptionFromMeta(claim.meta);
123
+ // Resolve the always-present public field, tolerating a frame that
124
+ // carries the value in `meta` rather than as an explicit description.
125
+ const description = claim.description ??
126
+ descriptionFromMeta(claim.meta) ??
127
+ 'editing';
124
128
  activeByClaimId.set(claim.claimId, {
125
129
  object: 'claim',
126
130
  id: claim.claimId,
@@ -135,8 +139,7 @@ export function createClaimStream(config, transport = null) {
135
139
  field: claim.field,
136
140
  meta: claim.meta,
137
141
  },
138
- reason: claim.reason,
139
- ...(description ? { description } : {}),
142
+ description,
140
143
  ttlSeconds: Math.max(0, Math.floor((claim.expiresAt - Date.now()) / 1000)),
141
144
  createdAt: claim.declaredAt,
142
145
  expiresAt: claim.expiresAt,
@@ -267,7 +270,7 @@ export function createClaimStream(config, transport = null) {
267
270
  entityId: claim.entityId,
268
271
  path: claim.path,
269
272
  range: claim.range,
270
- reason: claim.reason,
273
+ description: claim.description,
271
274
  field: claim.field,
272
275
  meta: claim.meta,
273
276
  estimatedMs: claim.estimatedMs,
@@ -337,11 +340,6 @@ export function createClaimStream(config, transport = null) {
337
340
  },
338
341
  });
339
342
  }
340
- function withDescription(meta, description) {
341
- if (!description)
342
- return meta;
343
- return { ...(meta ?? {}), description };
344
- }
345
343
  function mintHandle(args) {
346
344
  const claimId = crypto.randomUUID();
347
345
  const estimatedMs = args.ttl !== undefined ? toMs(args.ttl) : undefined;
@@ -352,14 +350,14 @@ export function createClaimStream(config, transport = null) {
352
350
  range: args.range,
353
351
  field: args.field,
354
352
  meta: args.meta,
355
- reason: args.reason,
353
+ description: args.description,
356
354
  estimatedMs,
357
355
  queue: args.queue,
358
356
  };
359
357
  ownClaims.set(claimId, claim);
360
358
  sendBegin(claimId, claim);
361
359
  // Coordination trace (info): the creator can see their human/agent claims.
362
- getContext().logger.info(`claim: requesting ${claimLabel(claim.entityType, claim.entityId, claim.field)} for "${claim.reason}"` +
360
+ getContext().logger.info(`claim: requesting ${claimLabel(claim.entityType, claim.entityId, claim.field)} for "${claim.description}"` +
363
361
  (claim.queue ? ' (will queue if contended)' : ''), { claimId });
364
362
  let revoked = false;
365
363
  const revoke = () => {
@@ -374,7 +372,7 @@ export function createClaimStream(config, transport = null) {
374
372
  object: 'claim',
375
373
  id: claimId,
376
374
  status: 'active',
377
- reason: args.reason,
375
+ description: args.description,
378
376
  target: {
379
377
  type: args.entityType,
380
378
  id: args.entityId,
@@ -407,8 +405,8 @@ export function createClaimStream(config, transport = null) {
407
405
  path: resolved.path,
408
406
  range: resolved.range,
409
407
  field: resolved.field,
410
- meta: withDescription(resolved.meta, opts?.description),
411
- reason: opts?.reason ?? 'editing',
408
+ meta: resolved.meta,
409
+ description: opts?.description ?? 'editing',
412
410
  ttl: opts?.ttl,
413
411
  queue: opts?.queue,
414
412
  });
@@ -41,7 +41,7 @@ export interface DeltaPipelineContext {
41
41
  readonly highestProcessedSyncId: number;
42
42
  /** Resume/ack cursor (`syncClient.position.persisted`). */
43
43
  readonly lastAckedId: number;
44
- onDeltaReceived(syncId: number): void;
44
+ onDeltaReceived(syncId: number, transactionId?: string, correlationId?: string): void;
45
45
  advanceApplied(syncId: number): void;
46
46
  advancePersisted(syncId: number): void;
47
47
  processDeltaBatch(deltas: {
@@ -125,7 +125,7 @@ export function enqueueDelta(ctx, delta, options = {}) {
125
125
  return false;
126
126
  }
127
127
  // Confirm awaiting transactions via sync ID threshold (before batching)
128
- ctx.onDeltaReceived(delta.id);
128
+ ctx.onDeltaReceived(delta.id, delta.transactionId, delta.correlationId);
129
129
  // Queue during active bootstrap
130
130
  if (ctx.bootstrapDeltaQueue !== null) {
131
131
  ctx.bootstrapDeltaQueue.push(delta);
@@ -61,9 +61,8 @@ export interface ScopedPresence {
61
61
  export interface ScopedClaimOptions {
62
62
  /** Override the participant's focus target for this one claim. */
63
63
  readonly target?: PresenceTarget;
64
- /** Free-form reason. Defaults to `'editing'`. Common: `'editing'`,
65
- * `'writing'`, `'reviewing'`, app-specific phases. */
66
- readonly reason?: string;
64
+ /** Peer-visible description of the work. Defaults to `'editing'`. */
65
+ readonly description?: string;
67
66
  /** How long the claim lives; the server expires it automatically after this. */
68
67
  readonly ttl?: import('../types/streams.js').Duration;
69
68
  }
@@ -221,7 +221,7 @@ function createJoinedParticipant(args) {
221
221
  return {
222
222
  object: 'claim',
223
223
  id: handle.id,
224
- reason: handle.reason,
224
+ description: handle.description,
225
225
  target: handle.target,
226
226
  async release() {
227
227
  ownHandles.delete(handle);
@@ -246,7 +246,7 @@ function createJoinedParticipant(args) {
246
246
  },
247
247
  claim(opts) {
248
248
  return track(args.claims.claim(requireTarget(opts?.target), {
249
- reason: opts?.reason,
249
+ description: opts?.description,
250
250
  ttl: opts?.ttl,
251
251
  }));
252
252
  },
@@ -7,7 +7,8 @@
7
7
  * point; every parsed frame then passes through {@link dispatchWsFrame}.
8
8
  */
9
9
  import { getContext } from '../context.js';
10
- import { CapabilityError, errorFromWire, } from '../errors.js';
10
+ import { AbloConnectionError, CapabilityError, errorFromWire, } from '../errors.js';
11
+ import { commitAckSchema, legacyCompatibleCommitReceiptSchema, } from '../wire/commit.js';
11
12
  import { subscriptionAckPayloadSchema } from '../coordination/schema.js';
12
13
  import { formatConflict } from '../coordination/trace.js';
13
14
  import { parseNotifications, recordClaim } from './commitFrames.js';
@@ -35,10 +36,15 @@ export function isWsInboundFrame(value) {
35
36
  */
36
37
  const handleMutationResult = (session, message) => {
37
38
  const p = (message.payload ?? message);
38
- const { clientTxId, success, lastSyncId, error } = p ?? {};
39
+ const { clientTxId, success, error } = p;
39
40
  // Defensive: validate notifications against the canonical schema —
40
41
  // untrusted wire data from a possibly-older/newer server.
41
42
  const notifications = parseNotifications(p?.notifications);
43
+ const missingIds = Array.isArray(p.missingIds)
44
+ ? [
45
+ ...new Set(p.missingIds.filter((id) => typeof id === 'string' && id.length > 0)),
46
+ ]
47
+ : undefined;
42
48
  const pending = typeof clientTxId === 'string'
43
49
  ? session.pendingMutations.get(clientTxId)
44
50
  : undefined;
@@ -48,21 +54,28 @@ const handleMutationResult = (session, message) => {
48
54
  // `pending` exists ⇒ clientTxId was a string key (the guard above).
49
55
  session.pendingMutations.delete(clientTxId);
50
56
  if (success) {
51
- // Coerce defensively — bigint columns serialize as strings
52
- // from older servers (see normalizeWireDelta).
53
- const ackedSyncId = Number(lastSyncId);
57
+ const parsedReceipt = legacyCompatibleCommitReceiptSchema.safeParse({
58
+ ...p,
59
+ notifications,
60
+ missingIds,
61
+ });
62
+ if (!parsedReceipt.success) {
63
+ pending.reject(new AbloConnectionError('The sync server returned an invalid commit receipt; its outcome remains pending and is safe to retry.', { code: 'commit_no_result', cause: parsedReceipt.error }));
64
+ return;
65
+ }
66
+ const receipt = parsedReceipt.data;
54
67
  // The write succeeded, but a guarded premise shifted underneath it.
55
68
  // Emit the advisory signal so a caller can react, and still resolve
56
69
  // the receipt, since the commit itself went through.
57
- if (notifications && notifications.length > 0) {
58
- const txId = typeof clientTxId === 'string' ? clientTxId : '';
70
+ if (receipt.notifications && receipt.notifications.length > 0) {
71
+ const txId = receipt.clientTxId;
59
72
  const event = {
60
73
  clientTxId: txId,
61
- rows: notifications.map((n) => ({
74
+ rows: receipt.notifications.map((n) => ({
62
75
  model: n.model,
63
76
  id: n.id,
64
77
  fields: n.conflictingFields,
65
- writtenBy: n.writtenBy?.kind,
78
+ writtenBy: n.writtenBy.kind,
66
79
  })),
67
80
  };
68
81
  const message = formatConflict(event);
@@ -72,15 +85,22 @@ const handleMutationResult = (session, message) => {
72
85
  ctx.observability.captureConflict(event);
73
86
  session.emit('conflict:notified', {
74
87
  clientTxId: txId,
75
- notifications,
88
+ notifications: receipt.notifications,
76
89
  });
77
90
  }
78
- pending.resolve({
79
- lastSyncId: Number.isFinite(ackedSyncId) ? ackedSyncId : 0,
80
- ...(notifications && notifications.length > 0
81
- ? { notifications }
91
+ pending.resolve(commitAckSchema.parse({
92
+ status: receipt.status,
93
+ ...(receipt.correlationId
94
+ ? { correlationId: receipt.correlationId }
82
95
  : {}),
83
- });
96
+ lastSyncId: receipt.lastSyncId,
97
+ ...(receipt.notifications && receipt.notifications.length > 0
98
+ ? { notifications: receipt.notifications }
99
+ : {}),
100
+ ...(receipt.missingIds && receipt.missingIds.length > 0
101
+ ? { missingIds: receipt.missingIds }
102
+ : {}),
103
+ }));
84
104
  }
85
105
  else {
86
106
  // Capture the full server error so the caller can see what actually