@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.
- package/CHANGELOG.md +29 -3
- package/README.md +1 -1
- package/dist/BaseSyncedStore.js +8 -9
- package/dist/Database.d.ts +14 -1
- package/dist/Database.js +225 -28
- package/dist/Model.d.ts +17 -0
- package/dist/Model.js +32 -1
- package/dist/SyncClient.d.ts +10 -6
- package/dist/SyncClient.js +379 -76
- package/dist/adapters/inMemoryStorage.d.ts +1 -0
- package/dist/adapters/inMemoryStorage.js +12 -0
- package/dist/cli.cjs +6 -2
- package/dist/client/Ablo.d.ts +24 -1
- package/dist/client/Ablo.js +4 -3
- package/dist/client/ApiClient.js +309 -43
- package/dist/client/createInternalComponents.d.ts +2 -0
- package/dist/client/createInternalComponents.js +1 -1
- package/dist/client/httpClient.d.ts +2 -0
- package/dist/client/modelRegistration.js +11 -0
- package/dist/client/options.d.ts +23 -0
- package/dist/client/wsMutationExecutor.d.ts +3 -2
- package/dist/client/wsMutationExecutor.js +3 -2
- package/dist/commit/contract.d.ts +493 -0
- package/dist/commit/contract.js +187 -0
- package/dist/commit/index.d.ts +6 -0
- package/dist/commit/index.js +5 -0
- package/dist/core/StoreManager.d.ts +2 -0
- package/dist/core/StoreManager.js +12 -0
- package/dist/errorCodes.js +6 -2
- package/dist/index.d.ts +6 -0
- package/dist/index.js +2 -0
- package/dist/interfaces/index.d.ts +2 -2
- package/dist/mutators/UndoManager.d.ts +2 -0
- package/dist/mutators/UndoManager.js +32 -0
- package/dist/react/useAblo.d.ts +6 -4
- package/dist/react/useAblo.js +25 -3
- package/dist/schema/index.d.ts +1 -1
- package/dist/schema/schema.d.ts +31 -1
- package/dist/stores/ObjectStore.d.ts +14 -1
- package/dist/stores/ObjectStore.js +27 -4
- package/dist/stores/ObjectStoreContract.d.ts +2 -0
- package/dist/surface.d.ts +1 -1
- package/dist/surface.js +2 -0
- package/dist/sync/SyncWebSocket.d.ts +2 -1
- package/dist/sync/persistedPrefix.d.ts +12 -0
- package/dist/sync/persistedPrefix.js +22 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/mocks/FakeDatabase.d.ts +18 -0
- package/dist/testing/mocks/FakeDatabase.js +10 -0
- package/dist/testing/mocks/MockWebSocket.d.ts +2 -1
- package/dist/transactions/TransactionQueue.d.ts +66 -8
- package/dist/transactions/TransactionQueue.js +607 -89
- package/dist/transactions/commitEnvelope.d.ts +132 -0
- package/dist/transactions/commitEnvelope.js +139 -0
- package/dist/transactions/commitOutboxStore.d.ts +32 -0
- package/dist/transactions/commitOutboxStore.js +26 -0
- package/dist/transactions/commitPayload.d.ts +15 -0
- package/dist/transactions/commitPayload.js +6 -0
- package/dist/transactions/httpCommitEnvelope.d.ts +43 -0
- package/dist/transactions/httpCommitEnvelope.js +179 -0
- package/dist/transactions/replayValidation.d.ts +83 -0
- package/dist/transactions/replayValidation.js +46 -1
- package/dist/wire/bootstrapReason.d.ts +9 -0
- package/dist/wire/bootstrapReason.js +8 -0
- package/dist/wire/frames.d.ts +236 -0
- package/dist/wire/frames.js +21 -0
- package/dist/wire/index.d.ts +4 -2
- package/dist/wire/index.js +2 -1
- package/docs/api.md +10 -10
- package/docs/coordination.md +2 -2
- package/docs/mcp.md +1 -1
- 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.
|
|
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
|
-
|
|
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
|
package/dist/client/Ablo.d.ts
CHANGED
|
@@ -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;
|
package/dist/client/Ablo.js
CHANGED
|
@@ -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') {
|
package/dist/client/ApiClient.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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 (
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
|
614
|
+
body = await dispatchHttpCommit({
|
|
615
|
+
path: '/v1/commits',
|
|
364
616
|
method: 'POST',
|
|
365
617
|
idempotencyKey: clientTxId,
|
|
366
|
-
body:
|
|
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
|
|
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
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
|
920
|
+
body = await dispatchHttpCommit({
|
|
921
|
+
path,
|
|
667
922
|
method,
|
|
668
923
|
idempotencyKey: clientTxId,
|
|
669
|
-
body:
|
|
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
|
|
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
|
-
|
|
901
|
-
|
|
902
|
-
|
|
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
|
|
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
|
|
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
|