@abloatai/ablo 0.27.0 → 0.29.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 +59 -3
- package/README.md +1 -1
- package/dist/BaseSyncedStore.js +8 -9
- package/dist/Database.d.ts +14 -1
- package/dist/Database.js +228 -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 +390 -75
- package/dist/adapters/inMemoryStorage.d.ts +1 -0
- package/dist/adapters/inMemoryStorage.js +29 -13
- package/dist/ai-sdk/coordinatedTool.d.ts +15 -2
- package/dist/auth/schemas.d.ts +6 -0
- package/dist/auth/schemas.js +7 -0
- package/dist/cli.cjs +821 -402
- package/dist/client/Ablo.d.ts +53 -24
- package/dist/client/Ablo.js +12 -199
- package/dist/client/claimHeartbeatLoop.d.ts +1 -1
- package/dist/client/claimHeartbeatLoop.js +1 -1
- package/dist/client/createInternalComponents.d.ts +4 -0
- package/dist/client/createInternalComponents.js +3 -1
- package/dist/client/durableWrites.d.ts +21 -0
- package/dist/client/durableWrites.js +46 -0
- package/dist/client/httpClient.d.ts +21 -45
- package/dist/client/httpClient.js +104 -26
- package/dist/client/httpTransport.d.ts +8 -0
- package/dist/client/{ApiClient.js → httpTransport.js} +361 -308
- package/dist/client/modelRegistration.js +11 -0
- package/dist/client/options.d.ts +54 -7
- package/dist/client/options.js +3 -2
- package/dist/client/resourceTypes.d.ts +9 -85
- package/dist/client/resourceTypes.js +1 -1
- package/dist/client/sessionMint.d.ts +5 -6
- package/dist/client/sessionMint.js +4 -5
- package/dist/client/validateAbloOptions.js +3 -3
- package/dist/client/wsMutationExecutor.d.ts +3 -2
- package/dist/client/wsMutationExecutor.js +3 -2
- package/dist/coordination/schema.d.ts +30 -0
- package/dist/coordination/schema.js +14 -0
- package/dist/core/StoreManager.d.ts +2 -0
- package/dist/core/StoreManager.js +12 -0
- package/dist/core/index.d.ts +1 -1
- package/dist/errorCodes.js +6 -2
- package/dist/errors.d.ts +4 -40
- package/dist/errors.js +5 -5
- package/dist/index.d.ts +20 -8
- package/dist/index.js +9 -5
- package/dist/interfaces/index.d.ts +5 -2
- package/dist/mutators/UndoManager.d.ts +2 -0
- package/dist/mutators/UndoManager.js +32 -0
- package/dist/mutators/defineMutators.d.ts +18 -0
- package/dist/mutators/defineMutators.js +4 -8
- package/dist/react/index.d.ts +1 -1
- package/dist/react/useAblo.d.ts +6 -4
- package/dist/react/useAblo.js +25 -3
- package/dist/schema/index.d.ts +2 -2
- package/dist/schema/index.js +1 -1
- package/dist/schema/schema.d.ts +31 -1
- package/dist/schema/select.d.ts +15 -0
- package/dist/schema/select.js +27 -3
- package/dist/source/connector.d.ts +3 -3
- package/dist/source/factory.d.ts +4 -6
- package/dist/source/factory.js +4 -7
- package/dist/source/index.d.ts +4 -4
- package/dist/source/index.js +2 -2
- package/dist/source/signing.d.ts +0 -3
- package/dist/source/types.d.ts +0 -26
- package/dist/stores/ObjectStore.d.ts +14 -1
- package/dist/stores/ObjectStore.js +33 -10
- package/dist/stores/ObjectStoreContract.d.ts +2 -0
- package/dist/surface.d.ts +1 -1
- package/dist/surface.js +3 -0
- package/dist/sync/BootstrapFetcher.d.ts +10 -0
- package/dist/sync/BootstrapFetcher.js +27 -4
- package/dist/sync/SyncWebSocket.d.ts +2 -1
- package/dist/sync/createClaimStream.d.ts +11 -2
- package/dist/sync/createClaimStream.js +4 -3
- 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/durableWriteStore.d.ts +123 -0
- package/dist/transactions/durableWriteStore.js +30 -0
- package/dist/transactions/httpCommitEnvelope.d.ts +44 -0
- package/dist/transactions/httpCommitEnvelope.js +181 -0
- package/dist/transactions/idempotencyKey.d.ts +10 -0
- package/dist/transactions/idempotencyKey.js +9 -0
- package/dist/transactions/replayValidation.d.ts +83 -0
- package/dist/transactions/replayValidation.js +46 -1
- package/dist/types/global.d.ts +10 -29
- package/dist/types/global.js +4 -7
- package/dist/types/streams.d.ts +2 -28
- package/dist/wire/bootstrapReason.d.ts +9 -0
- package/dist/wire/bootstrapReason.js +8 -0
- package/dist/wire/frames.d.ts +6 -21
- package/dist/wire/frames.js +4 -4
- package/dist/wire/index.d.ts +6 -3
- package/dist/wire/index.js +3 -2
- package/dist/wire/protocolVersion.d.ts +30 -17
- package/dist/wire/protocolVersion.js +34 -18
- package/docs/agents.md +8 -3
- package/docs/api.md +16 -14
- package/docs/client-behavior.md +6 -3
- package/docs/coordination.md +4 -5
- package/docs/data-sources.md +5 -8
- package/docs/guarantees.md +21 -0
- package/docs/integration-guide.md +1 -0
- package/docs/mcp.md +1 -1
- package/docs/migration.md +21 -1
- package/docs/quickstart.md +11 -0
- package/docs/react.md +0 -46
- package/examples/README.md +6 -5
- package/llms.txt +15 -15
- package/package.json +3 -3
- package/dist/client/ApiClient.d.ts +0 -177
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* object pool, local database, or WebSocket
|
|
4
|
-
* and Commit
|
|
5
|
-
*
|
|
2
|
+
* Private HTTP protocol client behind `Ablo({ schema, transport: 'http' })`.
|
|
3
|
+
* It carries no object pool, local database, or WebSocket and maps Model,
|
|
4
|
+
* Claim, and Commit protocol shapes directly to server routes. The typed
|
|
5
|
+
* facade in `httpClient.ts` is the application boundary; this module owns
|
|
6
|
+
* transport envelopes, watermarks, replay, and route details.
|
|
6
7
|
*/
|
|
7
|
-
import { AbloClaimedError, AbloAuthenticationError, AbloConnectionError, AbloValidationError, AbloNotFoundError, claimedError, translateHttpError, } from '../errors.js';
|
|
8
|
+
import { AbloClaimedError, AbloAuthenticationError, AbloConnectionError, AbloIdempotencyError, AbloValidationError, AbloNotFoundError, claimedError, translateHttpError, } from '../errors.js';
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
import { v5 as uuidv5 } from 'uuid';
|
|
8
11
|
import { reconcileFunctionalUpdate, } from './functionalUpdate.js';
|
|
9
12
|
import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, resolveDatabaseUrl, warnIfCliKeyMismatch, warnIfDatabaseUrlEnvIgnored, warnIfDatabaseUrlDeprecated, } from './auth.js';
|
|
10
13
|
import { registerDataSource } from './registerDataSource.js';
|
|
11
14
|
import { PROTOCOL_VERSION, PROTOCOL_VERSION_HEADER } from '../wire/protocolVersion.js';
|
|
12
|
-
import { toMs
|
|
15
|
+
import { toMs } from '../utils/duration.js';
|
|
13
16
|
import { heartbeatCadenceMs, resolveHeartbeatOptions, startClaimHeartbeatLoop, } from './claimHeartbeatLoop.js';
|
|
14
17
|
import { mintSession } from './sessionMint.js';
|
|
18
|
+
import { parseIdentityResolveResponse } from '../auth/schemas.js';
|
|
19
|
+
import { staleNotificationSchema } from '../coordination/schema.js';
|
|
15
20
|
/**
|
|
16
21
|
* Interpret a heartbeat reply for a lease this handle HOLDS: anything other
|
|
17
22
|
* than `held` means the lease is no longer ours (a holder cannot be `queued`;
|
|
@@ -29,10 +34,61 @@ function heldHeartbeatReply(reply, label) {
|
|
|
29
34
|
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
35
|
}
|
|
31
36
|
import { assertWriteOptions } from './writeOptionsSchema.js';
|
|
32
|
-
|
|
37
|
+
import { createDurableHttpCommitEnvelope, canonicalHttpCommitBody, durableHttpCommitEnvelopeSchema, httpCommitEnvelopeRecordId, isHttpCommitReplayExpired, } from '../transactions/httpCommitEnvelope.js';
|
|
38
|
+
import { resolveDurableWrites } from './durableWrites.js';
|
|
39
|
+
/** @internal Default per-request deadline for the private HTTP transport. */
|
|
33
40
|
export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
34
|
-
const
|
|
35
|
-
|
|
41
|
+
const successfulCommitResponseSchema = z
|
|
42
|
+
.object({
|
|
43
|
+
object: z.literal('commit_receipt'),
|
|
44
|
+
id: z.string().min(1).optional(),
|
|
45
|
+
clientTxId: z.string().min(1),
|
|
46
|
+
serverTxId: z.string().min(1),
|
|
47
|
+
status: z.enum(['queued', 'confirmed']),
|
|
48
|
+
success: z.literal(true),
|
|
49
|
+
lastSyncId: z.number().int().nonnegative().optional(),
|
|
50
|
+
ops: z.number().int().positive(),
|
|
51
|
+
notifications: z.array(staleNotificationSchema).optional(),
|
|
52
|
+
/** Ids of UPDATE/DELETE targets that matched zero rows (loud 0-row writes). */
|
|
53
|
+
missingIds: z.array(z.string().min(1)).optional(),
|
|
54
|
+
})
|
|
55
|
+
.loose();
|
|
56
|
+
function parseSuccessfulCommitResponse(value, idempotencyKey) {
|
|
57
|
+
const parsed = successfulCommitResponseSchema.safeParse(value);
|
|
58
|
+
if (!parsed.success || parsed.data.clientTxId !== idempotencyKey) {
|
|
59
|
+
throw new AbloConnectionError('The commit endpoint returned an invalid success receipt; its outcome remains pending and is safe to retry.', {
|
|
60
|
+
code: 'commit_no_result',
|
|
61
|
+
cause: parsed.success
|
|
62
|
+
? new Error('Commit receipt clientTxId did not match its idempotency key')
|
|
63
|
+
: parsed.error,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return parsed.data;
|
|
67
|
+
}
|
|
68
|
+
/** Decode the HTTP claim DTO into the one public Claim shape. */
|
|
69
|
+
function claimFromModelClaim(claim) {
|
|
70
|
+
return {
|
|
71
|
+
object: 'claim',
|
|
72
|
+
id: claim.id,
|
|
73
|
+
...(claim.status ? { status: claim.status } : {}),
|
|
74
|
+
reason: claim.reason,
|
|
75
|
+
...(claim.description ? { description: claim.description } : {}),
|
|
76
|
+
heldBy: claim.actor,
|
|
77
|
+
participantKind: claim.participantKind,
|
|
78
|
+
expiresAt: claim.expiresAt,
|
|
79
|
+
...(claim.position !== undefined ? { position: claim.position } : {}),
|
|
80
|
+
target: {
|
|
81
|
+
type: claim.target.model,
|
|
82
|
+
id: claim.target.id,
|
|
83
|
+
...(claim.target.path ? { path: claim.target.path } : {}),
|
|
84
|
+
...(claim.target.range ? { range: claim.target.range } : {}),
|
|
85
|
+
...(claim.target.field ? { field: claim.target.field } : {}),
|
|
86
|
+
...(claim.target.meta ? { meta: claim.target.meta } : {}),
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/** @internal Constructed only by the typed HTTP facade. */
|
|
91
|
+
export function createHttpTransport(options) {
|
|
36
92
|
const env = readProcessEnv();
|
|
37
93
|
const authInput = { options, env };
|
|
38
94
|
const configuredApiKey = resolveApiKey(authInput);
|
|
@@ -64,14 +120,17 @@ export function createProtocolClient(options) {
|
|
|
64
120
|
const recordCoordinationConflict = (error, clientTxId, fallbackRows) => {
|
|
65
121
|
if (!observability)
|
|
66
122
|
return;
|
|
67
|
-
const
|
|
123
|
+
const errorRecord = typeof error === 'object' && error !== null
|
|
124
|
+
? error
|
|
125
|
+
: undefined;
|
|
126
|
+
const code = errorRecord?.code;
|
|
68
127
|
const isConflict = code === 'stale_context' ||
|
|
69
128
|
code === 'claim_conflict' ||
|
|
70
129
|
code === 'entity_claimed' ||
|
|
71
130
|
(typeof code === 'string' && code.startsWith('policy:'));
|
|
72
131
|
if (!isConflict)
|
|
73
132
|
return;
|
|
74
|
-
const rawConflicts =
|
|
133
|
+
const rawConflicts = errorRecord?.conflicts;
|
|
75
134
|
const rows = Array.isArray(rawConflicts) && rawConflicts.length > 0
|
|
76
135
|
? rawConflicts.map((r) => ({
|
|
77
136
|
model: typeof r.model === 'string' ? r.model : 'unknown',
|
|
@@ -90,19 +149,64 @@ export function createProtocolClient(options) {
|
|
|
90
149
|
url,
|
|
91
150
|
bootstrapBaseUrl: options.bootstrapBaseUrl,
|
|
92
151
|
}).replace(/\/+$/, '');
|
|
152
|
+
const durableWrites = resolveDurableWrites(options);
|
|
153
|
+
// Internal replay code retains transactional-outbox terminology. The public
|
|
154
|
+
// constructor exposes the behavior as `durableWrites`.
|
|
155
|
+
const commitOutbox = durableWrites.store;
|
|
156
|
+
const durableWriteNamespace = durableWrites.namespace ?? 'http';
|
|
157
|
+
const legacyCommitOutboxScope = options.commitOutboxScope;
|
|
158
|
+
const httpOutboxPlaneNamespace = canonicalHttpCommitBody({
|
|
159
|
+
apiBaseUrl,
|
|
160
|
+
defaultQuery: Object.entries(options.defaultQuery ?? {}).sort(([a], [b]) => a.localeCompare(b)),
|
|
161
|
+
});
|
|
162
|
+
let httpOutboxScopeNamespace = null;
|
|
93
163
|
let readyPromise = null;
|
|
164
|
+
let httpCommitLane = Promise.resolve();
|
|
165
|
+
function runInHttpCommitLane(work) {
|
|
166
|
+
const result = httpCommitLane.then(work);
|
|
167
|
+
httpCommitLane = result.then(() => undefined, () => undefined);
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
async function resolveHttpOutboxScope() {
|
|
171
|
+
if (!commitOutbox)
|
|
172
|
+
return null;
|
|
173
|
+
if (httpOutboxScopeNamespace)
|
|
174
|
+
return httpOutboxScopeNamespace;
|
|
175
|
+
let scope = legacyCommitOutboxScope
|
|
176
|
+
? {
|
|
177
|
+
...legacyCommitOutboxScope,
|
|
178
|
+
namespace: durableWriteNamespace,
|
|
179
|
+
}
|
|
180
|
+
: undefined;
|
|
181
|
+
if (!scope) {
|
|
182
|
+
const rawIdentity = await requestJson('/auth/identity', { method: 'GET' }, true);
|
|
183
|
+
const identity = parseIdentityResolveResponse(rawIdentity);
|
|
184
|
+
scope = {
|
|
185
|
+
organizationId: identity.accountScope,
|
|
186
|
+
participantId: identity.participantId,
|
|
187
|
+
namespace: durableWriteNamespace,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
httpOutboxScopeNamespace = canonicalHttpCommitBody({
|
|
191
|
+
...scope,
|
|
192
|
+
plane: httpOutboxPlaneNamespace,
|
|
193
|
+
});
|
|
194
|
+
return httpOutboxScopeNamespace;
|
|
195
|
+
}
|
|
94
196
|
async function ready() {
|
|
95
197
|
if (readyPromise)
|
|
96
198
|
return readyPromise;
|
|
97
199
|
readyPromise = (async () => {
|
|
98
|
-
if (
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
200
|
+
if (configuredDatabaseUrl) {
|
|
201
|
+
await registerDataSource({
|
|
202
|
+
baseUrl: apiBaseUrl,
|
|
203
|
+
apiKey: await resolveApiKeyValue(configuredApiKey),
|
|
204
|
+
databaseUrl: configuredDatabaseUrl,
|
|
205
|
+
...(options.fetch ? { fetchImpl: options.fetch } : {}),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
await resolveHttpOutboxScope();
|
|
209
|
+
await replayHttpCommitOutbox();
|
|
106
210
|
})();
|
|
107
211
|
try {
|
|
108
212
|
await readyPromise;
|
|
@@ -112,11 +216,11 @@ export function createProtocolClient(options) {
|
|
|
112
216
|
throw error;
|
|
113
217
|
}
|
|
114
218
|
}
|
|
115
|
-
async function authHeaders() {
|
|
219
|
+
async function authHeaders(sealedProtocolVersion) {
|
|
116
220
|
const apiKey = await resolveApiKeyValue(configuredApiKey);
|
|
117
221
|
const token = apiKey ?? configuredAuthToken;
|
|
118
222
|
if (!token) {
|
|
119
|
-
throw new AbloAuthenticationError('
|
|
223
|
+
throw new AbloAuthenticationError('The HTTP client requires an API key. Pass `apiKey` or set ABLO_API_KEY.', { code: 'api_key_required' });
|
|
120
224
|
}
|
|
121
225
|
const headers = {
|
|
122
226
|
'Content-Type': 'application/json',
|
|
@@ -133,6 +237,12 @@ export function createProtocolClient(options) {
|
|
|
133
237
|
headers[key] = value;
|
|
134
238
|
}
|
|
135
239
|
}
|
|
240
|
+
// A durable write owns its wire version. Force the sealed value after
|
|
241
|
+
// caller defaults so a restarted (or rolled-back) SDK cannot rewrite the
|
|
242
|
+
// protocol identity of a request that may already have reached the server.
|
|
243
|
+
if (sealedProtocolVersion !== undefined) {
|
|
244
|
+
headers[PROTOCOL_VERSION_HEADER] = String(sealedProtocolVersion);
|
|
245
|
+
}
|
|
136
246
|
return headers;
|
|
137
247
|
}
|
|
138
248
|
function endpoint(path) {
|
|
@@ -144,10 +254,11 @@ export function createProtocolClient(options) {
|
|
|
144
254
|
return target.toString();
|
|
145
255
|
}
|
|
146
256
|
const requestTimeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
147
|
-
async function requestJson(path, init) {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const
|
|
257
|
+
async function requestJson(path, init, skipReady = false) {
|
|
258
|
+
if (!skipReady)
|
|
259
|
+
await ready();
|
|
260
|
+
const { idempotencyKey, sealedProtocolVersion, ...requestInit } = init;
|
|
261
|
+
const headers = await authHeaders(sealedProtocolVersion);
|
|
151
262
|
if (idempotencyKey)
|
|
152
263
|
headers['Idempotency-Key'] = idempotencyKey;
|
|
153
264
|
// Deadline: abort the request after `timeoutMs` so a black-holed server
|
|
@@ -189,6 +300,7 @@ export function createProtocolClient(options) {
|
|
|
189
300
|
bodyText = await res.text();
|
|
190
301
|
}
|
|
191
302
|
catch (error) {
|
|
303
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- assigned asynchronously by the deadline callback
|
|
192
304
|
if (timedOut) {
|
|
193
305
|
// Retryable by contract: `wait_for_timeout` is a registered transient
|
|
194
306
|
// transport code, so `isRetryableCode` steers callers to retry.
|
|
@@ -209,6 +321,173 @@ export function createProtocolClient(options) {
|
|
|
209
321
|
}
|
|
210
322
|
return body;
|
|
211
323
|
}
|
|
324
|
+
function isDefinitiveHttpRejection(error) {
|
|
325
|
+
if (typeof error !== 'object' || error === null)
|
|
326
|
+
return false;
|
|
327
|
+
const candidate = error;
|
|
328
|
+
const status = typeof candidate.httpStatus === 'number'
|
|
329
|
+
? candidate.httpStatus
|
|
330
|
+
: typeof candidate.status === 'number'
|
|
331
|
+
? candidate.status
|
|
332
|
+
: undefined;
|
|
333
|
+
return (status !== undefined &&
|
|
334
|
+
status >= 400 &&
|
|
335
|
+
status < 500 &&
|
|
336
|
+
status !== 408 &&
|
|
337
|
+
status !== 425 &&
|
|
338
|
+
status !== 429);
|
|
339
|
+
}
|
|
340
|
+
async function settleHttpEnvelope(recordId) {
|
|
341
|
+
if (!commitOutbox)
|
|
342
|
+
return;
|
|
343
|
+
try {
|
|
344
|
+
await commitOutbox.remove(recordId);
|
|
345
|
+
}
|
|
346
|
+
catch (cause) {
|
|
347
|
+
// Do not report the remote outcome until local settlement is durable.
|
|
348
|
+
// The retained record can still be replayed inside the safe window.
|
|
349
|
+
throw new AbloConnectionError('The server settled the commit, but its local outbox record could not be cleared.', { code: 'db_not_opened', cause });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
async function replayHttpCommitOutbox() {
|
|
353
|
+
const replayed = new Map();
|
|
354
|
+
if (!commitOutbox)
|
|
355
|
+
return replayed;
|
|
356
|
+
const scopeNamespace = await resolveHttpOutboxScope();
|
|
357
|
+
if (!scopeNamespace)
|
|
358
|
+
return replayed;
|
|
359
|
+
const rows = await commitOutbox.list();
|
|
360
|
+
const envelopes = [];
|
|
361
|
+
for (const row of rows) {
|
|
362
|
+
if (typeof row !== 'object' ||
|
|
363
|
+
row === null ||
|
|
364
|
+
row.type !== 'http_commit_envelope')
|
|
365
|
+
continue;
|
|
366
|
+
const parsed = durableHttpCommitEnvelopeSchema.safeParse(row);
|
|
367
|
+
if (!parsed.success) {
|
|
368
|
+
throw new AbloValidationError('A saved HTTP write is unreadable; replay stopped before any newer write was sent.', { code: 'write_options_invalid', cause: parsed.error });
|
|
369
|
+
}
|
|
370
|
+
if (parsed.data.scopeNamespace !== scopeNamespace)
|
|
371
|
+
continue;
|
|
372
|
+
if (isHttpCommitReplayExpired(parsed.data)) {
|
|
373
|
+
throw new AbloIdempotencyError('A saved HTTP write is older than the server idempotency window and cannot be replayed safely.', { code: 'idempotency_conflict' });
|
|
374
|
+
}
|
|
375
|
+
envelopes.push(parsed.data);
|
|
376
|
+
}
|
|
377
|
+
envelopes.sort((a, b) => (a.sequence ?? a.sealedAt * 1_000) -
|
|
378
|
+
(b.sequence ?? b.sealedAt * 1_000) ||
|
|
379
|
+
a.id.localeCompare(b.id));
|
|
380
|
+
for (const envelope of envelopes) {
|
|
381
|
+
try {
|
|
382
|
+
const raw = await requestJson(envelope.request.path, {
|
|
383
|
+
method: envelope.request.method,
|
|
384
|
+
idempotencyKey: envelope.idempotencyKey,
|
|
385
|
+
sealedProtocolVersion: envelope.protocolVersion,
|
|
386
|
+
body: envelope.request.body,
|
|
387
|
+
}, true);
|
|
388
|
+
const response = parseSuccessfulCommitResponse(raw, envelope.idempotencyKey);
|
|
389
|
+
await settleHttpEnvelope(envelope.id);
|
|
390
|
+
replayed.set(envelope.idempotencyKey, { envelope, response });
|
|
391
|
+
}
|
|
392
|
+
catch (error) {
|
|
393
|
+
if (isDefinitiveHttpRejection(error)) {
|
|
394
|
+
await settleHttpEnvelope(envelope.id);
|
|
395
|
+
}
|
|
396
|
+
throw error;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return replayed;
|
|
400
|
+
}
|
|
401
|
+
let lastHttpCommitSequence = 0;
|
|
402
|
+
function nextHttpCommitSequence() {
|
|
403
|
+
const wallSequence = Date.now() * 1_000;
|
|
404
|
+
lastHttpCommitSequence = Math.max(wallSequence, lastHttpCommitSequence + 1);
|
|
405
|
+
return lastHttpCommitSequence;
|
|
406
|
+
}
|
|
407
|
+
async function sealHttpCommit(input) {
|
|
408
|
+
if (!commitOutbox)
|
|
409
|
+
return null;
|
|
410
|
+
const scopeNamespace = await resolveHttpOutboxScope();
|
|
411
|
+
if (!scopeNamespace) {
|
|
412
|
+
throw new AbloValidationError('HTTP durable-write scope was not resolved', {
|
|
413
|
+
code: 'write_options_invalid',
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
const recordId = httpCommitEnvelopeRecordId(input.idempotencyKey, scopeNamespace);
|
|
417
|
+
const legacyRecordId = httpCommitEnvelopeRecordId(input.idempotencyKey);
|
|
418
|
+
const existingRows = await commitOutbox.list();
|
|
419
|
+
const existingRaw = existingRows.find((row) => typeof row === 'object' &&
|
|
420
|
+
row !== null &&
|
|
421
|
+
(row.id === recordId ||
|
|
422
|
+
row.id === legacyRecordId));
|
|
423
|
+
const serializedBody = canonicalHttpCommitBody(input.body);
|
|
424
|
+
if (existingRaw !== undefined) {
|
|
425
|
+
const existing = durableHttpCommitEnvelopeSchema.parse(existingRaw);
|
|
426
|
+
if (isHttpCommitReplayExpired(existing)) {
|
|
427
|
+
throw new AbloIdempotencyError('This saved HTTP write is older than the server idempotency window and cannot be retried safely.', { code: 'idempotency_conflict' });
|
|
428
|
+
}
|
|
429
|
+
if (existing.scopeNamespace !== scopeNamespace ||
|
|
430
|
+
existing.request.method !== input.method ||
|
|
431
|
+
existing.request.path !== input.path ||
|
|
432
|
+
existing.request.body !== serializedBody) {
|
|
433
|
+
throw new AbloIdempotencyError('Idempotency key reused with a different HTTP commit request', { code: 'idempotency_conflict' });
|
|
434
|
+
}
|
|
435
|
+
return existing;
|
|
436
|
+
}
|
|
437
|
+
const envelope = createDurableHttpCommitEnvelope({
|
|
438
|
+
idempotencyKey: input.idempotencyKey,
|
|
439
|
+
request: { method: input.method, path: input.path, body: input.body },
|
|
440
|
+
scopeNamespace,
|
|
441
|
+
sequence: nextHttpCommitSequence(),
|
|
442
|
+
});
|
|
443
|
+
await commitOutbox.seal(envelope, []);
|
|
444
|
+
return envelope;
|
|
445
|
+
}
|
|
446
|
+
async function dispatchHttpCommit(input, beforeSettlement) {
|
|
447
|
+
return runInHttpCommitLane(async () => {
|
|
448
|
+
await ready();
|
|
449
|
+
// `ready()` covers startup. Re-draining here makes every later write wait
|
|
450
|
+
// behind an ambiguous predecessor from this same process.
|
|
451
|
+
const replayed = await replayHttpCommitOutbox();
|
|
452
|
+
const prior = replayed.get(input.idempotencyKey);
|
|
453
|
+
if (prior) {
|
|
454
|
+
const serializedBody = canonicalHttpCommitBody(input.body);
|
|
455
|
+
if (prior.envelope.request.method !== input.method ||
|
|
456
|
+
prior.envelope.request.path !== input.path ||
|
|
457
|
+
prior.envelope.request.body !== serializedBody) {
|
|
458
|
+
throw new AbloIdempotencyError('Idempotency key reused with a different HTTP commit request', { code: 'idempotency_conflict' });
|
|
459
|
+
}
|
|
460
|
+
await beforeSettlement?.(prior.response);
|
|
461
|
+
return prior.response;
|
|
462
|
+
}
|
|
463
|
+
const durableEnvelope = await sealHttpCommit(input);
|
|
464
|
+
const requestBody = durableEnvelope?.request.body ?? canonicalHttpCommitBody(input.body);
|
|
465
|
+
let response;
|
|
466
|
+
try {
|
|
467
|
+
const raw = await requestJson(input.path, {
|
|
468
|
+
method: input.method,
|
|
469
|
+
idempotencyKey: input.idempotencyKey,
|
|
470
|
+
...(durableEnvelope
|
|
471
|
+
? { sealedProtocolVersion: durableEnvelope.protocolVersion }
|
|
472
|
+
: {}),
|
|
473
|
+
body: requestBody,
|
|
474
|
+
}, true);
|
|
475
|
+
response = parseSuccessfulCommitResponse(raw, input.idempotencyKey);
|
|
476
|
+
}
|
|
477
|
+
catch (error) {
|
|
478
|
+
if (durableEnvelope && isDefinitiveHttpRejection(error)) {
|
|
479
|
+
await settleHttpEnvelope(durableEnvelope.id);
|
|
480
|
+
}
|
|
481
|
+
throw error;
|
|
482
|
+
}
|
|
483
|
+
// A model-create readback can participate in settlement: if it fails,
|
|
484
|
+
// retain the exact write so a same-key retry recovers the generated id.
|
|
485
|
+
await beforeSettlement?.(response);
|
|
486
|
+
if (durableEnvelope)
|
|
487
|
+
await settleHttpEnvelope(durableEnvelope.id);
|
|
488
|
+
return response;
|
|
489
|
+
});
|
|
490
|
+
}
|
|
212
491
|
function createClientTxId(idempotencyKey) {
|
|
213
492
|
if (idempotencyKey && idempotencyKey.length > 0)
|
|
214
493
|
return idempotencyKey;
|
|
@@ -221,29 +500,19 @@ export function createProtocolClient(options) {
|
|
|
221
500
|
? `int_${crypto.randomUUID()}`
|
|
222
501
|
: `int_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
223
502
|
}
|
|
224
|
-
function createModelId() {
|
|
503
|
+
function createModelId(modelName, idempotencyKey) {
|
|
504
|
+
if (idempotencyKey) {
|
|
505
|
+
return uuidv5(`${modelName}:${idempotencyKey}`, 'aa4ba6d4-bf0b-5b38-9c45-116f79a6e548');
|
|
506
|
+
}
|
|
225
507
|
return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
|
226
508
|
? crypto.randomUUID()
|
|
227
509
|
: `id_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
228
510
|
}
|
|
229
|
-
function childClient(authToken) {
|
|
230
|
-
return createProtocolClient({
|
|
231
|
-
...options,
|
|
232
|
-
apiKey: null,
|
|
233
|
-
authToken,
|
|
234
|
-
schema: null,
|
|
235
|
-
});
|
|
236
|
-
}
|
|
237
511
|
function normalizeCommitOperation(op, defaults) {
|
|
238
|
-
const model = op.model ?? op.target?.model;
|
|
239
|
-
if (!model) {
|
|
240
|
-
throw new AbloValidationError('Commit operation requires `model` or `target.model`.', { code: 'commit_operation_model_required' });
|
|
241
|
-
}
|
|
242
|
-
const id = op.id ?? op.target?.id ?? null;
|
|
243
512
|
return {
|
|
244
513
|
action: op.action,
|
|
245
|
-
model,
|
|
246
|
-
id,
|
|
514
|
+
model: op.model,
|
|
515
|
+
id: op.id ?? null,
|
|
247
516
|
data: op.data ?? null,
|
|
248
517
|
transactionId: op.transactionId ?? null,
|
|
249
518
|
readAt: op.readAt ?? defaults.readAt ?? null,
|
|
@@ -251,20 +520,10 @@ export function createProtocolClient(options) {
|
|
|
251
520
|
};
|
|
252
521
|
}
|
|
253
522
|
function normalizeCommitOperations(commitOptions) {
|
|
254
|
-
if (commitOptions.
|
|
255
|
-
throw new AbloValidationError('
|
|
523
|
+
if (commitOptions.operations.length === 0) {
|
|
524
|
+
throw new AbloValidationError('Commit requires a non-empty `operations` array.', { code: 'commit_operation_required' });
|
|
256
525
|
}
|
|
257
|
-
|
|
258
|
-
? [commitOptions.operation]
|
|
259
|
-
: commitOptions.operations ?? [];
|
|
260
|
-
if (inputOperations.length === 0) {
|
|
261
|
-
throw new AbloValidationError('Commit requires at least one operation.', { code: 'commit_operation_required' });
|
|
262
|
-
}
|
|
263
|
-
return inputOperations.map((op) => normalizeCommitOperation(op, commitOptions));
|
|
264
|
-
}
|
|
265
|
-
async function listClaims(target) {
|
|
266
|
-
const state = await listClaimState(target);
|
|
267
|
-
return state.active;
|
|
526
|
+
return commitOptions.operations.map((op) => normalizeCommitOperation(op, commitOptions));
|
|
268
527
|
}
|
|
269
528
|
async function listClaimState(target) {
|
|
270
529
|
const params = new URLSearchParams();
|
|
@@ -281,54 +540,6 @@ export function createProtocolClient(options) {
|
|
|
281
540
|
queue: body.queue ?? [],
|
|
282
541
|
};
|
|
283
542
|
}
|
|
284
|
-
function delay(ms, signal) {
|
|
285
|
-
if (signal?.aborted) {
|
|
286
|
-
return Promise.reject(new AbloConnectionError('Claim wait aborted.', {
|
|
287
|
-
code: 'claim_wait_aborted',
|
|
288
|
-
cause: signal.reason,
|
|
289
|
-
}));
|
|
290
|
-
}
|
|
291
|
-
return new Promise((resolve, reject) => {
|
|
292
|
-
const timeout = setTimeout(done, ms);
|
|
293
|
-
function cleanup() {
|
|
294
|
-
clearTimeout(timeout);
|
|
295
|
-
signal?.removeEventListener('abort', onAbort);
|
|
296
|
-
}
|
|
297
|
-
function done() {
|
|
298
|
-
cleanup();
|
|
299
|
-
resolve();
|
|
300
|
-
}
|
|
301
|
-
function onAbort() {
|
|
302
|
-
cleanup();
|
|
303
|
-
reject(new AbloConnectionError('Claim wait aborted.', {
|
|
304
|
-
code: 'claim_wait_aborted',
|
|
305
|
-
cause: signal?.reason,
|
|
306
|
-
}));
|
|
307
|
-
}
|
|
308
|
-
signal?.addEventListener('abort', onAbort, { once: true });
|
|
309
|
-
});
|
|
310
|
-
}
|
|
311
|
-
async function waitForNoClaims(target, options) {
|
|
312
|
-
const startedAt = Date.now();
|
|
313
|
-
const pollInterval = options?.pollInterval;
|
|
314
|
-
for (;;) {
|
|
315
|
-
const claims = await listClaims(target);
|
|
316
|
-
if (claims.length === 0)
|
|
317
|
-
return;
|
|
318
|
-
if (pollInterval == null) {
|
|
319
|
-
throw new AbloValidationError('Cannot wait for claims over the HTTP client without `pollInterval`. ' +
|
|
320
|
-
'Use the schema client for event-driven claim waits, pass `ifClaimed: "return"`, ' +
|
|
321
|
-
'or provide an explicit poll interval for this runtime.', { code: 'claim_wait_poll_interval_required' });
|
|
322
|
-
}
|
|
323
|
-
if (options?.timeout != null && Date.now() - startedAt >= options.timeout) {
|
|
324
|
-
throw claimedError(target, claims, 'model_claimed_timeout');
|
|
325
|
-
}
|
|
326
|
-
const remaining = options?.timeout == null
|
|
327
|
-
? pollInterval
|
|
328
|
-
: Math.max(0, Math.min(pollInterval, options.timeout - (Date.now() - startedAt)));
|
|
329
|
-
await delay(remaining, options?.signal);
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
543
|
async function applyClaimedPolicy(target, options, defaultPolicy = 'return') {
|
|
333
544
|
const policy = options?.ifClaimed ?? defaultPolicy;
|
|
334
545
|
if (policy === 'return')
|
|
@@ -358,17 +569,17 @@ export function createProtocolClient(options) {
|
|
|
358
569
|
readAt: commitOptions.readAt ?? claim?.readAt ?? null,
|
|
359
570
|
onStale: commitOptions.onStale ?? (claim?.readAt !== undefined ? 'reject' : null),
|
|
360
571
|
});
|
|
572
|
+
const requestBody = {
|
|
573
|
+
operations,
|
|
574
|
+
reads: commitOptions.reads,
|
|
575
|
+
};
|
|
361
576
|
let body;
|
|
362
577
|
try {
|
|
363
|
-
body = await
|
|
578
|
+
body = await dispatchHttpCommit({
|
|
579
|
+
path: '/v1/commits',
|
|
364
580
|
method: 'POST',
|
|
365
581
|
idempotencyKey: clientTxId,
|
|
366
|
-
body:
|
|
367
|
-
clientTxId,
|
|
368
|
-
idempotencyKey: clientTxId,
|
|
369
|
-
claim: normalizeClaimId(commitOptions.claimRef) ?? claim?.id,
|
|
370
|
-
operations,
|
|
371
|
-
}),
|
|
582
|
+
body: requestBody,
|
|
372
583
|
});
|
|
373
584
|
}
|
|
374
585
|
catch (error) {
|
|
@@ -387,192 +598,18 @@ export function createProtocolClient(options) {
|
|
|
387
598
|
// the rejection body (already thrown).
|
|
388
599
|
const status = body.status === 'queued' ? 'queued' : 'confirmed';
|
|
389
600
|
return {
|
|
390
|
-
id: body.id ?? body.clientTxId
|
|
601
|
+
id: body.id ?? body.clientTxId,
|
|
391
602
|
status,
|
|
392
603
|
lastSyncId: body.lastSyncId,
|
|
604
|
+
...(body.notifications && body.notifications.length > 0
|
|
605
|
+
? { notifications: body.notifications }
|
|
606
|
+
: {}),
|
|
393
607
|
...(body.missingIds && body.missingIds.length > 0
|
|
394
608
|
? { missingIds: body.missingIds }
|
|
395
609
|
: {}),
|
|
396
610
|
};
|
|
397
611
|
},
|
|
398
612
|
};
|
|
399
|
-
const capabilities = {
|
|
400
|
-
async create(capabilityOptions) {
|
|
401
|
-
const ttlSeconds = capabilityOptions.ttlSeconds ??
|
|
402
|
-
capabilityOptions.leaseSeconds ??
|
|
403
|
-
toSeconds(capabilityOptions.ttl ?? capabilityOptions.lease ?? DEFAULT_AGENT_LEASE);
|
|
404
|
-
const body = await requestJson('/v1/capabilities', {
|
|
405
|
-
method: 'POST',
|
|
406
|
-
body: JSON.stringify({
|
|
407
|
-
participantKind: capabilityOptions.participantKind ?? 'agent',
|
|
408
|
-
participantId: capabilityOptions.participantId,
|
|
409
|
-
syncGroups: capabilityOptions.syncGroups,
|
|
410
|
-
operations: capabilityOptions.operations,
|
|
411
|
-
ttlSeconds,
|
|
412
|
-
label: capabilityOptions.label,
|
|
413
|
-
wideScope: capabilityOptions.wideScope,
|
|
414
|
-
userMeta: capabilityOptions.userMeta,
|
|
415
|
-
}),
|
|
416
|
-
});
|
|
417
|
-
const id = body.capabilityId ?? body.id;
|
|
418
|
-
if (!id) {
|
|
419
|
-
throw new AbloValidationError('Capability create response did not include an id.', { code: 'capability_id_missing' });
|
|
420
|
-
}
|
|
421
|
-
return {
|
|
422
|
-
id,
|
|
423
|
-
token: body.token,
|
|
424
|
-
expiresAt: body.expiresAt,
|
|
425
|
-
organizationId: body.organizationId,
|
|
426
|
-
scope: body.scope,
|
|
427
|
-
userMeta: body.userMeta,
|
|
428
|
-
client: () => childClient(body.token),
|
|
429
|
-
};
|
|
430
|
-
},
|
|
431
|
-
async retrieve(id) {
|
|
432
|
-
const body = await requestJson(`/v1/capabilities/${encodeURIComponent(id)}`, { method: 'GET' });
|
|
433
|
-
return {
|
|
434
|
-
id: body.capabilityId ?? body.id ?? id,
|
|
435
|
-
organizationId: body.organizationId,
|
|
436
|
-
participantKind: body.participantKind,
|
|
437
|
-
participantId: body.participantId,
|
|
438
|
-
label: body.label,
|
|
439
|
-
status: body.status,
|
|
440
|
-
issuedAt: body.issuedAt,
|
|
441
|
-
expiresAt: body.expiresAt,
|
|
442
|
-
revokedAt: body.revokedAt,
|
|
443
|
-
lastUsedAt: body.lastUsedAt,
|
|
444
|
-
operations: body.operations ?? [],
|
|
445
|
-
syncGroups: body.syncGroups ?? [],
|
|
446
|
-
};
|
|
447
|
-
},
|
|
448
|
-
async revoke(id) {
|
|
449
|
-
const body = await requestJson(`/v1/capabilities/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
450
|
-
return {
|
|
451
|
-
id: body.capabilityId ?? body.id ?? id,
|
|
452
|
-
deleted: body.deleted ?? true,
|
|
453
|
-
activeSessionsClosed: body.activeSessionsClosed,
|
|
454
|
-
};
|
|
455
|
-
},
|
|
456
|
-
async rotate(id, rotateOptions = {}) {
|
|
457
|
-
const graceSeconds = rotateOptions.graceSeconds ??
|
|
458
|
-
(rotateOptions.grace !== undefined ? toSeconds(rotateOptions.grace) : undefined);
|
|
459
|
-
const leaseSeconds = rotateOptions.leaseSeconds ??
|
|
460
|
-
(rotateOptions.lease !== undefined ? toSeconds(rotateOptions.lease) : undefined);
|
|
461
|
-
const body = await requestJson(`/v1/capabilities/${encodeURIComponent(id)}/rotate`, {
|
|
462
|
-
method: 'POST',
|
|
463
|
-
body: JSON.stringify({
|
|
464
|
-
...(graceSeconds !== undefined ? { graceSeconds } : {}),
|
|
465
|
-
...(leaseSeconds !== undefined ? { ttlSeconds: leaseSeconds } : {}),
|
|
466
|
-
}),
|
|
467
|
-
});
|
|
468
|
-
const newId = body.capabilityId ?? body.id;
|
|
469
|
-
if (!newId) {
|
|
470
|
-
throw new AbloValidationError('Capability rotate response did not include an id.', { code: 'capability_id_missing' });
|
|
471
|
-
}
|
|
472
|
-
return {
|
|
473
|
-
id: newId,
|
|
474
|
-
token: body.token,
|
|
475
|
-
expiresAt: body.expiresAt,
|
|
476
|
-
organizationId: body.organizationId,
|
|
477
|
-
scope: body.scope,
|
|
478
|
-
rotatedFrom: {
|
|
479
|
-
id: body.rotatedFrom.capabilityId ?? body.rotatedFrom.id ?? id,
|
|
480
|
-
expiresAt: body.rotatedFrom.expiresAt,
|
|
481
|
-
},
|
|
482
|
-
client: () => childClient(body.token),
|
|
483
|
-
};
|
|
484
|
-
},
|
|
485
|
-
mint(options) {
|
|
486
|
-
return capabilities.create(options);
|
|
487
|
-
},
|
|
488
|
-
};
|
|
489
|
-
const claims = {
|
|
490
|
-
async create(claimOptions) {
|
|
491
|
-
const claimId = createClaimId();
|
|
492
|
-
const body = await requestJson('/v1/claims', {
|
|
493
|
-
method: 'POST',
|
|
494
|
-
body: JSON.stringify({
|
|
495
|
-
claimId,
|
|
496
|
-
target: claimOptions.target,
|
|
497
|
-
reason: claimOptions.reason,
|
|
498
|
-
ttl: claimOptions.ttl,
|
|
499
|
-
queue: claimOptions.queue,
|
|
500
|
-
}),
|
|
501
|
-
});
|
|
502
|
-
// The fair-queue grant is pushed over a WebSocket (`claim_granted`), which
|
|
503
|
-
// this stateless HTTP client doesn't hold. Returning a handle here would be
|
|
504
|
-
// a phantom holder — a lease we can't confirm is ours. So a queued response
|
|
505
|
-
// is surfaced as a typed claimed signal; callers that need to wait in line
|
|
506
|
-
// use the realtime (WebSocket-backed) `ablo.<model>.claim`.
|
|
507
|
-
if (body.status === 'queued') {
|
|
508
|
-
throw new AbloClaimedError(`Target ${claimOptions.target.model}/${claimOptions.target.id} is held; ` +
|
|
509
|
-
`queued at position ${body.position ?? 0}. The HTTP client can't await ` +
|
|
510
|
-
`the grant (no socket) — use the realtime client to wait in line.`, { code: 'claim_queued' });
|
|
511
|
-
}
|
|
512
|
-
const id = body.claim?.id ?? claimId;
|
|
513
|
-
let released = false;
|
|
514
|
-
const release = async () => {
|
|
515
|
-
if (released)
|
|
516
|
-
return;
|
|
517
|
-
released = true;
|
|
518
|
-
await requestJson(`/v1/claims/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
519
|
-
};
|
|
520
|
-
// The by-id twin of the model-scoped heartbeat — same reply contract.
|
|
521
|
-
const heartbeat = async (beatOptions) => {
|
|
522
|
-
const resolved = resolveHeartbeatOptions(beatOptions);
|
|
523
|
-
const reply = await requestJson(`/v1/claims/${encodeURIComponent(id)}/heartbeat`, {
|
|
524
|
-
method: 'POST',
|
|
525
|
-
body: JSON.stringify({
|
|
526
|
-
...(resolved.ttl !== undefined ? { ttl: resolved.ttl } : {}),
|
|
527
|
-
...(resolved.details !== undefined
|
|
528
|
-
? { details: resolved.details }
|
|
529
|
-
: {}),
|
|
530
|
-
}),
|
|
531
|
-
});
|
|
532
|
-
return heldHeartbeatReply(reply, `claim ${id}`);
|
|
533
|
-
};
|
|
534
|
-
return {
|
|
535
|
-
object: 'claim',
|
|
536
|
-
id,
|
|
537
|
-
heartbeat,
|
|
538
|
-
reason: claimOptions.reason,
|
|
539
|
-
target: {
|
|
540
|
-
type: claimOptions.target.model,
|
|
541
|
-
id: claimOptions.target.id,
|
|
542
|
-
...(claimOptions.target.field ? { field: claimOptions.target.field } : {}),
|
|
543
|
-
...(claimOptions.target.path ? { path: claimOptions.target.path } : {}),
|
|
544
|
-
...(claimOptions.target.range ? { range: claimOptions.target.range } : {}),
|
|
545
|
-
...(claimOptions.target.meta ? { meta: claimOptions.target.meta } : {}),
|
|
546
|
-
},
|
|
547
|
-
release,
|
|
548
|
-
revoke: () => {
|
|
549
|
-
void release().catch(() => { });
|
|
550
|
-
},
|
|
551
|
-
[Symbol.asyncDispose]: release,
|
|
552
|
-
};
|
|
553
|
-
},
|
|
554
|
-
list: listClaims,
|
|
555
|
-
waitFor(target, options) {
|
|
556
|
-
return waitForNoClaims(target, options);
|
|
557
|
-
},
|
|
558
|
-
async heartbeatAll(options) {
|
|
559
|
-
const reply = await requestJson('/v1/claims/heartbeat', {
|
|
560
|
-
method: 'POST',
|
|
561
|
-
body: JSON.stringify(options?.ttl !== undefined ? { ttl: options.ttl } : {}),
|
|
562
|
-
});
|
|
563
|
-
return (reply.results ?? []).flatMap((entry) => typeof entry.claimId === 'string' && typeof entry.expiresAt === 'number'
|
|
564
|
-
? [
|
|
565
|
-
{
|
|
566
|
-
claimId: entry.claimId,
|
|
567
|
-
expiresAt: entry.expiresAt,
|
|
568
|
-
...(entry.queueDepth !== undefined
|
|
569
|
-
? { queueDepth: entry.queueDepth }
|
|
570
|
-
: {}),
|
|
571
|
-
},
|
|
572
|
-
]
|
|
573
|
-
: []);
|
|
574
|
-
},
|
|
575
|
-
};
|
|
576
613
|
async function listModel(modelName, options) {
|
|
577
614
|
const params = new URLSearchParams();
|
|
578
615
|
if (options?.limit !== undefined)
|
|
@@ -628,7 +665,7 @@ export function createProtocolClient(options) {
|
|
|
628
665
|
* multi-operation envelopes; this helper handles the one-operation,
|
|
629
666
|
* one-record case.
|
|
630
667
|
*/
|
|
631
|
-
async function mutateModel(action, modelName, id, data, options) {
|
|
668
|
+
async function mutateModel(action, modelName, id, data, options, beforeSettlement) {
|
|
632
669
|
assertWriteOptions(options && {
|
|
633
670
|
idempotencyKey: options.idempotencyKey,
|
|
634
671
|
readAt: options.readAt,
|
|
@@ -644,15 +681,15 @@ export function createProtocolClient(options) {
|
|
|
644
681
|
const method = action === 'create' ? 'POST' : action === 'update' ? 'PATCH' : 'DELETE';
|
|
645
682
|
// A carried claim handle supplies the stale-guard defaults — one claim
|
|
646
683
|
// vocabulary across the WS proxy, `commits.create`, and these routes.
|
|
647
|
-
const
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
684
|
+
const rawClaim = options?.claim;
|
|
685
|
+
const claimHandle = typeof rawClaim === 'object' &&
|
|
686
|
+
rawClaim !== null &&
|
|
687
|
+
rawClaim.object === 'claim' &&
|
|
688
|
+
typeof rawClaim.id === 'string'
|
|
689
|
+
? rawClaim
|
|
652
690
|
: undefined;
|
|
653
691
|
const readAt = options?.readAt ?? claimHandle?.readAt;
|
|
654
692
|
const requestBody = {
|
|
655
|
-
idempotencyKey: clientTxId,
|
|
656
693
|
claim: normalizeClaimId(options?.claimRef) ?? claimHandle?.id,
|
|
657
694
|
onStale: options?.onStale ?? (claimHandle?.readAt !== undefined ? 'reject' : undefined),
|
|
658
695
|
readAt,
|
|
@@ -663,11 +700,12 @@ export function createProtocolClient(options) {
|
|
|
663
700
|
requestBody.data = data;
|
|
664
701
|
let body;
|
|
665
702
|
try {
|
|
666
|
-
body = await
|
|
703
|
+
body = await dispatchHttpCommit({
|
|
704
|
+
path,
|
|
667
705
|
method,
|
|
668
706
|
idempotencyKey: clientTxId,
|
|
669
|
-
body:
|
|
670
|
-
});
|
|
707
|
+
body: requestBody,
|
|
708
|
+
}, beforeSettlement);
|
|
671
709
|
}
|
|
672
710
|
catch (error) {
|
|
673
711
|
// The per-model write door (`ablo.<model>.update/create/delete`). Capture
|
|
@@ -680,7 +718,7 @@ export function createProtocolClient(options) {
|
|
|
680
718
|
// subset; `'rejected'` only appears on a thrown rejection body.
|
|
681
719
|
const status = body.status === 'queued' ? 'queued' : 'confirmed';
|
|
682
720
|
return {
|
|
683
|
-
id: body.serverTxId
|
|
721
|
+
id: body.serverTxId,
|
|
684
722
|
status,
|
|
685
723
|
lastSyncId: body.lastSyncId,
|
|
686
724
|
};
|
|
@@ -809,11 +847,14 @@ export function createProtocolClient(options) {
|
|
|
809
847
|
state: async (params) => {
|
|
810
848
|
const res = await claimsForEntity(params);
|
|
811
849
|
const first = res.claims?.[0];
|
|
812
|
-
return first
|
|
850
|
+
return first ? claimFromModelClaim(first) : null;
|
|
813
851
|
},
|
|
814
852
|
queue: async (params) => {
|
|
815
853
|
const res = await claimsForEntity(params);
|
|
816
|
-
return {
|
|
854
|
+
return {
|
|
855
|
+
object: 'list',
|
|
856
|
+
data: (res.queue ?? []).map(claimFromModelClaim),
|
|
857
|
+
};
|
|
817
858
|
},
|
|
818
859
|
reorder: async (params) => {
|
|
819
860
|
await requestJson(`${claimPath(params.id)}/reorder`, {
|
|
@@ -885,7 +926,7 @@ export function createProtocolClient(options) {
|
|
|
885
926
|
return listModel(name, options);
|
|
886
927
|
},
|
|
887
928
|
async create(params) {
|
|
888
|
-
const id = params.id ?? createModelId();
|
|
929
|
+
const id = params.id ?? createModelId(name, params.idempotencyKey);
|
|
889
930
|
return withMutationClaim(id, params, async (options) => {
|
|
890
931
|
await applyClaimedPolicy({ model: name, id }, options);
|
|
891
932
|
// Confirm the write, then return the row — the obvious expectation of
|
|
@@ -893,15 +934,23 @@ export function createProtocolClient(options) {
|
|
|
893
934
|
// back is the authoritative server row, so it carries the framework
|
|
894
935
|
// defaults (createdAt, createdBy, …) and, for an idempotent re-create of
|
|
895
936
|
// an existing id, the existing row rather than the caller's input.
|
|
937
|
+
let created;
|
|
896
938
|
await mutateModel('create', name, id, params.data, {
|
|
897
939
|
...options,
|
|
898
940
|
wait: options?.wait ?? 'confirmed',
|
|
941
|
+
}, async () => {
|
|
942
|
+
const read = await retrieveModel(name, { id });
|
|
943
|
+
if (read.data === undefined) {
|
|
944
|
+
throw new AbloNotFoundError(`create ${name}/${id} did not yield a readable row (the write did not confirm).`, [id]);
|
|
945
|
+
}
|
|
946
|
+
created = read.data;
|
|
899
947
|
});
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
948
|
+
if (created === undefined) {
|
|
949
|
+
throw new AbloConnectionError('Create settlement did not return its row.', {
|
|
950
|
+
code: 'commit_no_result',
|
|
951
|
+
});
|
|
903
952
|
}
|
|
904
|
-
return
|
|
953
|
+
return created;
|
|
905
954
|
});
|
|
906
955
|
},
|
|
907
956
|
update: updateModel,
|
|
@@ -915,11 +964,12 @@ export function createProtocolClient(options) {
|
|
|
915
964
|
}
|
|
916
965
|
return {
|
|
917
966
|
ready,
|
|
918
|
-
async
|
|
967
|
+
waitForFlush: () => runInHttpCommitLane(async () => {
|
|
968
|
+
await ready();
|
|
969
|
+
await replayHttpCommitOutbox();
|
|
970
|
+
}),
|
|
919
971
|
async dispose() { },
|
|
920
972
|
async purge() { },
|
|
921
|
-
capabilities,
|
|
922
|
-
claims,
|
|
923
973
|
commits,
|
|
924
974
|
model,
|
|
925
975
|
sessions: {
|
|
@@ -935,6 +985,9 @@ export function createProtocolClient(options) {
|
|
|
935
985
|
apiKey,
|
|
936
986
|
baseUrl: apiBaseUrl,
|
|
937
987
|
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
988
|
+
...(options.modelTypenames
|
|
989
|
+
? { modelTypenames: options.modelTypenames }
|
|
990
|
+
: {}),
|
|
938
991
|
});
|
|
939
992
|
},
|
|
940
993
|
},
|