@abloatai/ablo 0.29.3 → 0.30.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.
- package/CHANGELOG.md +21 -0
- package/README.md +3 -2
- package/dist/BaseSyncedStore.js +3 -1
- package/dist/Database.js +29 -2
- package/dist/SyncClient.d.ts +5 -3
- package/dist/SyncClient.js +8 -4
- package/dist/ai-sdk/coordinatedTool.d.ts +0 -1
- package/dist/ai-sdk/coordinatedTool.js +0 -1
- package/dist/ai-sdk/coordinationContext.js +1 -5
- package/dist/cli.cjs +377 -84
- package/dist/client/Ablo.d.ts +1 -1
- package/dist/client/Ablo.js +24 -36
- package/dist/client/auth.d.ts +8 -18
- package/dist/client/auth.js +16 -96
- package/dist/client/createModelProxy.d.ts +16 -11
- package/dist/client/createModelProxy.js +162 -20
- package/dist/client/httpClient.d.ts +1 -1
- package/dist/client/httpTransport.js +263 -89
- package/dist/client/options.d.ts +0 -15
- package/dist/client/resourceTypes.d.ts +13 -26
- package/dist/client/schemaConfig.js +5 -0
- package/dist/client/writeOptionsSchema.d.ts +1 -0
- package/dist/client/writeOptionsSchema.js +11 -2
- package/dist/client/wsMutationExecutor.d.ts +3 -5
- package/dist/coordination/schema.d.ts +17 -13
- package/dist/coordination/schema.js +36 -10
- package/dist/errorCodes.d.ts +9 -4
- package/dist/errorCodes.js +10 -5
- package/dist/errors.js +4 -9
- package/dist/index.d.ts +4 -0
- package/dist/interfaces/index.d.ts +31 -19
- package/dist/schema/index.d.ts +1 -1
- package/dist/schema/index.js +4 -1
- package/dist/schema/schema.d.ts +13 -0
- package/dist/schema/select.js +7 -0
- package/dist/schema/syncDeltaRow.d.ts +31 -13
- package/dist/schema/syncDeltaRow.js +33 -15
- package/dist/schema/tenancy.d.ts +52 -16
- package/dist/schema/tenancy.js +86 -25
- package/dist/server/commit.d.ts +8 -24
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +1 -0
- package/dist/server/readConfig.d.ts +10 -0
- package/dist/source/adapter.d.ts +26 -15
- package/dist/source/adapter.js +5 -3
- package/dist/source/adapters/drizzle.js +35 -9
- package/dist/source/adapters/kysely.d.ts +33 -61
- package/dist/source/adapters/kysely.js +170 -177
- package/dist/source/adapters/kyselyMutationCore.d.ts +76 -0
- package/dist/source/adapters/kyselyMutationCore.js +125 -0
- package/dist/source/adapters/memory.d.ts +1 -1
- package/dist/source/adapters/memory.js +24 -10
- package/dist/source/adapters/prisma.js +35 -8
- package/dist/source/conformance.d.ts +26 -28
- package/dist/source/conformance.js +142 -69
- package/dist/source/contract.d.ts +46 -4
- package/dist/source/contract.js +66 -5
- package/dist/source/factory.d.ts +3 -3
- package/dist/source/factory.js +24 -3
- package/dist/source/idempotency.d.ts +46 -0
- package/dist/source/idempotency.js +129 -0
- package/dist/source/index.d.ts +6 -4
- package/dist/source/index.js +5 -3
- package/dist/source/migrations.d.ts +9 -5
- package/dist/source/migrations.js +39 -6
- package/dist/source/types.d.ts +88 -14
- package/dist/source/types.js +16 -0
- package/dist/surface.d.ts +1 -1
- package/dist/surface.js +0 -1
- package/dist/sync/BootstrapFetcher.js +7 -0
- package/dist/sync/SyncWebSocket.d.ts +5 -27
- package/dist/sync/awaitClaimGrant.d.ts +6 -0
- package/dist/sync/awaitClaimGrant.js +12 -2
- package/dist/sync/commitFrames.d.ts +2 -4
- package/dist/sync/commitFrames.js +1 -0
- package/dist/sync/createClaimStream.d.ts +1 -1
- package/dist/sync/createClaimStream.js +13 -15
- package/dist/sync/deltaPipeline.d.ts +1 -1
- package/dist/sync/deltaPipeline.js +1 -1
- package/dist/sync/participants.d.ts +2 -3
- package/dist/sync/participants.js +2 -2
- package/dist/sync/wsFrameHandlers.js +35 -15
- package/dist/testing/mocks/MockMutationExecutor.d.ts +21 -0
- package/dist/testing/mocks/MockMutationExecutor.js +53 -1
- package/dist/transactions/TransactionQueue.d.ts +61 -9
- package/dist/transactions/TransactionQueue.js +502 -114
- package/dist/transactions/commitEnvelope.d.ts +4 -0
- package/dist/transactions/commitEnvelope.js +25 -0
- package/dist/transactions/commitPayload.d.ts +23 -5
- package/dist/transactions/commitPayload.js +3 -0
- package/dist/transactions/deltaConfirmation.d.ts +2 -1
- package/dist/transactions/deltaConfirmation.js +22 -4
- package/dist/transactions/durableWriteStore.d.ts +8 -1
- package/dist/transactions/httpCommitEnvelope.d.ts +3 -1
- package/dist/transactions/httpCommitEnvelope.js +30 -0
- package/dist/transactions/replayValidation.d.ts +4 -0
- package/dist/transactions/replayValidation.js +6 -0
- package/dist/types/streams.d.ts +29 -17
- package/dist/wire/commit.d.ts +671 -0
- package/dist/wire/commit.js +300 -0
- package/dist/wire/delta.d.ts +4 -0
- package/dist/wire/delta.js +9 -0
- package/dist/wire/frames.d.ts +17 -45
- package/dist/wire/index.d.ts +2 -0
- package/dist/wire/index.js +4 -0
- package/docs/agent-messaging.md +4 -5
- package/docs/agents.md +1 -2
- package/docs/api-keys.md +1 -2
- package/docs/client-behavior.md +4 -6
- package/docs/coordination.md +64 -9
- package/docs/data-sources.md +4 -7
- package/docs/integration-guide.md +6 -8
- package/docs/migration.md +0 -3
- package/docs/quickstart.md +7 -8
- package/docs/schema-contract.md +7 -8
- package/package.json +4 -4
- package/dist/client/registerDataSource.d.ts +0 -19
- package/dist/client/registerDataSource.js +0 -66
package/dist/source/types.d.ts
CHANGED
|
@@ -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
|
|
110
|
-
*
|
|
111
|
-
* event
|
|
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
|
-
*
|
|
132
|
-
* `
|
|
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
|
-
*
|
|
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 `
|
|
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
|
|
277
|
-
* `
|
|
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 {
|
package/dist/source/types.js
CHANGED
|
@@ -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", "
|
|
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
|
@@ -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 {
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Wire frames:
|
|
15
15
|
* • Outbound `claim_begin` — announce a claim: `{ claimId, entityType,
|
|
16
|
-
* entityId,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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:
|
|
411
|
-
|
|
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
|
-
/**
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
80
|
-
...(
|
|
81
|
-
? {
|
|
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
|