@abloatai/ablo 0.29.3 → 0.30.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -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
|
@@ -6,17 +6,15 @@
|
|
|
6
6
|
* transport envelopes, watermarks, replay, and route details.
|
|
7
7
|
*/
|
|
8
8
|
import { AbloClaimedError, AbloAuthenticationError, AbloConnectionError, AbloIdempotencyError, AbloValidationError, AbloNotFoundError, claimedError, translateHttpError, } from '../errors.js';
|
|
9
|
-
import { z } from 'zod';
|
|
10
9
|
import { v5 as uuidv5 } from 'uuid';
|
|
11
10
|
import { reconcileFunctionalUpdate, } from './functionalUpdate.js';
|
|
12
|
-
import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl,
|
|
13
|
-
import { registerDataSource } from './registerDataSource.js';
|
|
11
|
+
import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, rejectRemovedDatabaseUrlOption, warnIfCliKeyMismatch, } from './auth.js';
|
|
14
12
|
import { PROTOCOL_VERSION, PROTOCOL_VERSION_HEADER } from '../wire/protocolVersion.js';
|
|
13
|
+
import { commitReceiptSchema } from '../wire/commit.js';
|
|
15
14
|
import { toMs } from '../utils/duration.js';
|
|
16
15
|
import { heartbeatCadenceMs, resolveHeartbeatOptions, startClaimHeartbeatLoop, } from './claimHeartbeatLoop.js';
|
|
17
16
|
import { mintSession } from './sessionMint.js';
|
|
18
17
|
import { parseIdentityResolveResponse } from '../auth/schemas.js';
|
|
19
|
-
import { staleNotificationSchema } from '../coordination/schema.js';
|
|
20
18
|
/**
|
|
21
19
|
* Interpret a heartbeat reply for a lease this handle HOLDS: anything other
|
|
22
20
|
* than `held` means the lease is no longer ours (a holder cannot be `queued`;
|
|
@@ -38,23 +36,9 @@ import { createDurableHttpCommitEnvelope, canonicalHttpCommitBody, durableHttpCo
|
|
|
38
36
|
import { resolveDurableWrites } from './durableWrites.js';
|
|
39
37
|
/** @internal Default per-request deadline for the private HTTP transport. */
|
|
40
38
|
export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
41
|
-
const
|
|
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();
|
|
39
|
+
const HTTP_CONFIRMATION_POLL_INTERVAL_MS = 250;
|
|
56
40
|
function parseSuccessfulCommitResponse(value, idempotencyKey) {
|
|
57
|
-
const parsed =
|
|
41
|
+
const parsed = commitReceiptSchema.safeParse(value);
|
|
58
42
|
if (!parsed.success || parsed.data.clientTxId !== idempotencyKey) {
|
|
59
43
|
throw new AbloConnectionError('The commit endpoint returned an invalid success receipt; its outcome remains pending and is safe to retry.', {
|
|
60
44
|
code: 'commit_no_result',
|
|
@@ -71,8 +55,8 @@ function claimFromModelClaim(claim) {
|
|
|
71
55
|
object: 'claim',
|
|
72
56
|
id: claim.id,
|
|
73
57
|
...(claim.status ? { status: claim.status } : {}),
|
|
74
|
-
|
|
75
|
-
|
|
58
|
+
// The server always stamps a description; default only for total safety.
|
|
59
|
+
description: claim.description ?? 'editing',
|
|
76
60
|
heldBy: claim.actor,
|
|
77
61
|
participantKind: claim.participantKind,
|
|
78
62
|
expiresAt: claim.expiresAt,
|
|
@@ -93,15 +77,10 @@ export function createHttpTransport(options) {
|
|
|
93
77
|
const authInput = { options, env };
|
|
94
78
|
const configuredApiKey = resolveApiKey(authInput);
|
|
95
79
|
const configuredAuthToken = resolveAuthToken(authInput);
|
|
96
|
-
const configuredDatabaseUrl = resolveDatabaseUrl(authInput);
|
|
97
|
-
// Nudge (once) if a stray DATABASE_URL is in the env but `databaseUrl` wasn't
|
|
98
|
-
// passed — no logger on this path, so the helper falls back to console.warn.
|
|
99
|
-
warnIfDatabaseUrlEnvIgnored(authInput);
|
|
100
|
-
warnIfDatabaseUrlDeprecated(authInput);
|
|
101
80
|
void warnIfCliKeyMismatch(authInput);
|
|
81
|
+
rejectRemovedDatabaseUrlOption(options);
|
|
102
82
|
assertBrowserSafety({
|
|
103
83
|
apiKey: configuredApiKey,
|
|
104
|
-
databaseUrl: configuredDatabaseUrl,
|
|
105
84
|
dangerouslyAllowBrowser: options.dangerouslyAllowBrowser,
|
|
106
85
|
});
|
|
107
86
|
// Observability hook for the stateless HTTP transport. The WebSocket transport
|
|
@@ -154,7 +133,8 @@ export function createHttpTransport(options) {
|
|
|
154
133
|
// constructor exposes the behavior as `durableWrites`.
|
|
155
134
|
const commitOutbox = durableWrites.store;
|
|
156
135
|
const durableWriteNamespace = durableWrites.namespace ?? 'http';
|
|
157
|
-
const legacyCommitOutboxScope = options
|
|
136
|
+
const legacyCommitOutboxScope = options
|
|
137
|
+
.commitOutboxScope;
|
|
158
138
|
const httpOutboxPlaneNamespace = canonicalHttpCommitBody({
|
|
159
139
|
apiBaseUrl,
|
|
160
140
|
defaultQuery: Object.entries(options.defaultQuery ?? {}).sort(([a], [b]) => a.localeCompare(b)),
|
|
@@ -197,14 +177,6 @@ export function createHttpTransport(options) {
|
|
|
197
177
|
if (readyPromise)
|
|
198
178
|
return readyPromise;
|
|
199
179
|
readyPromise = (async () => {
|
|
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
180
|
await resolveHttpOutboxScope();
|
|
209
181
|
await replayHttpCommitOutbox();
|
|
210
182
|
})();
|
|
@@ -270,7 +242,9 @@ export function createHttpTransport(options) {
|
|
|
270
242
|
// same pattern already guards `query/client.ts` and `BootstrapFetcher`.
|
|
271
243
|
const callerSignal = requestInit.signal ?? undefined;
|
|
272
244
|
const controller = new AbortController();
|
|
273
|
-
const onCallerAbort = () => {
|
|
245
|
+
const onCallerAbort = () => {
|
|
246
|
+
controller.abort(callerSignal?.reason);
|
|
247
|
+
};
|
|
274
248
|
if (callerSignal) {
|
|
275
249
|
if (callerSignal.aborted)
|
|
276
250
|
onCallerAbort();
|
|
@@ -349,6 +323,110 @@ export function createHttpTransport(options) {
|
|
|
349
323
|
throw new AbloConnectionError('The server settled the commit, but its local outbox record could not be cleared.', { code: 'db_not_opened', cause });
|
|
350
324
|
}
|
|
351
325
|
}
|
|
326
|
+
/**
|
|
327
|
+
* Persist the first queued source receipt before exposing acceptance to the
|
|
328
|
+
* caller. This is a monotonic upgrade of the same sealed request: connected
|
|
329
|
+
* source keys are permanent, so the envelope may safely remain replayable
|
|
330
|
+
* after the hosted 24-hour idempotency window while it awaits its WAL echo.
|
|
331
|
+
*/
|
|
332
|
+
async function persistHttpAcceptance(envelope, response) {
|
|
333
|
+
if (!commitOutbox || response.status !== 'queued')
|
|
334
|
+
return envelope;
|
|
335
|
+
const correlationId = response.correlationId;
|
|
336
|
+
if (!correlationId) {
|
|
337
|
+
throw new AbloConnectionError('The source accepted the commit without durable correlation evidence.', { code: 'commit_no_result' });
|
|
338
|
+
}
|
|
339
|
+
if (envelope.correlationId !== undefined && envelope.correlationId !== correlationId) {
|
|
340
|
+
throw new AbloIdempotencyError('The same HTTP commit replay returned a different source correlation.', { code: 'idempotency_conflict' });
|
|
341
|
+
}
|
|
342
|
+
if (envelope.acceptedAt !== undefined)
|
|
343
|
+
return envelope;
|
|
344
|
+
const accepted = durableHttpCommitEnvelopeSchema.parse({
|
|
345
|
+
...envelope,
|
|
346
|
+
acceptedAt: Date.now(),
|
|
347
|
+
correlationId,
|
|
348
|
+
});
|
|
349
|
+
try {
|
|
350
|
+
await commitOutbox.seal(accepted, []);
|
|
351
|
+
}
|
|
352
|
+
catch (cause) {
|
|
353
|
+
throw new AbloConnectionError('The source accepted the commit, but that acceptance could not be persisted locally.', { code: 'db_not_opened', cause });
|
|
354
|
+
}
|
|
355
|
+
return accepted;
|
|
356
|
+
}
|
|
357
|
+
function replicationLagTimeout(request, response) {
|
|
358
|
+
return new AbloConnectionError(`The source accepted commit ${request.idempotencyKey}, but its replication echo did not arrive within ${requestTimeoutMs}ms.`, {
|
|
359
|
+
code: 'replication_lag_timeout',
|
|
360
|
+
httpStatus: 504,
|
|
361
|
+
details: {
|
|
362
|
+
clientTxId: request.idempotencyKey,
|
|
363
|
+
...(response.correlationId ? { correlationId: response.correlationId } : {}),
|
|
364
|
+
timeoutMs: requestTimeoutMs,
|
|
365
|
+
accepted: true,
|
|
366
|
+
},
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Replays one byte-identical, idempotent HTTP commit until mutation-log
|
|
371
|
+
* replay reports the source echo as confirmed. `queued` is acceptance only:
|
|
372
|
+
* this loop never clears the durable envelope and never converts it into a
|
|
373
|
+
* successful `wait: 'confirmed'` result.
|
|
374
|
+
*/
|
|
375
|
+
async function pollHttpCommitConfirmation(request, initial) {
|
|
376
|
+
let current = initial;
|
|
377
|
+
const correlationId = initial.correlationId;
|
|
378
|
+
const deadlineAt = requestTimeoutMs > 0 ? Date.now() + requestTimeoutMs : null;
|
|
379
|
+
while (current.status === 'queued') {
|
|
380
|
+
const remaining = deadlineAt === null ? null : deadlineAt - Date.now();
|
|
381
|
+
if (remaining !== null && remaining <= 0) {
|
|
382
|
+
throw replicationLagTimeout(request, current);
|
|
383
|
+
}
|
|
384
|
+
const confirmationController = new AbortController();
|
|
385
|
+
const confirmationDeadline = remaining !== null
|
|
386
|
+
? setTimeout(() => {
|
|
387
|
+
confirmationController.abort();
|
|
388
|
+
}, remaining)
|
|
389
|
+
: null;
|
|
390
|
+
try {
|
|
391
|
+
const raw = await requestJson(request.path, {
|
|
392
|
+
method: request.method,
|
|
393
|
+
idempotencyKey: request.idempotencyKey,
|
|
394
|
+
...(request.sealedProtocolVersion !== undefined
|
|
395
|
+
? { sealedProtocolVersion: request.sealedProtocolVersion }
|
|
396
|
+
: {}),
|
|
397
|
+
body: request.body,
|
|
398
|
+
signal: confirmationController.signal,
|
|
399
|
+
}, true);
|
|
400
|
+
const next = parseSuccessfulCommitResponse(raw, request.idempotencyKey);
|
|
401
|
+
if (next.correlationId !== correlationId) {
|
|
402
|
+
throw new AbloIdempotencyError('The same HTTP commit replay returned different source correlation evidence.', { code: 'idempotency_conflict' });
|
|
403
|
+
}
|
|
404
|
+
current = next;
|
|
405
|
+
}
|
|
406
|
+
catch (error) {
|
|
407
|
+
if (confirmationController.signal.aborted ||
|
|
408
|
+
(deadlineAt !== null && Date.now() >= deadlineAt)) {
|
|
409
|
+
throw replicationLagTimeout(request, current);
|
|
410
|
+
}
|
|
411
|
+
throw error;
|
|
412
|
+
}
|
|
413
|
+
finally {
|
|
414
|
+
if (confirmationDeadline)
|
|
415
|
+
clearTimeout(confirmationDeadline);
|
|
416
|
+
}
|
|
417
|
+
if (current.status === 'confirmed')
|
|
418
|
+
return current;
|
|
419
|
+
const delayMs = deadlineAt === null
|
|
420
|
+
? HTTP_CONFIRMATION_POLL_INTERVAL_MS
|
|
421
|
+
: Math.min(HTTP_CONFIRMATION_POLL_INTERVAL_MS, Math.max(0, deadlineAt - Date.now()));
|
|
422
|
+
if (delayMs > 0) {
|
|
423
|
+
await new Promise((resolve) => {
|
|
424
|
+
setTimeout(resolve, delayMs);
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return current;
|
|
429
|
+
}
|
|
352
430
|
async function replayHttpCommitOutbox() {
|
|
353
431
|
const replayed = new Map();
|
|
354
432
|
if (!commitOutbox)
|
|
@@ -374,8 +452,7 @@ export function createHttpTransport(options) {
|
|
|
374
452
|
}
|
|
375
453
|
envelopes.push(parsed.data);
|
|
376
454
|
}
|
|
377
|
-
envelopes.sort((a, b) => (a.sequence ?? a.sealedAt * 1_000) -
|
|
378
|
-
(b.sequence ?? b.sealedAt * 1_000) ||
|
|
455
|
+
envelopes.sort((a, b) => (a.sequence ?? a.sealedAt * 1_000) - (b.sequence ?? b.sealedAt * 1_000) ||
|
|
379
456
|
a.id.localeCompare(b.id));
|
|
380
457
|
for (const envelope of envelopes) {
|
|
381
458
|
try {
|
|
@@ -386,8 +463,21 @@ export function createHttpTransport(options) {
|
|
|
386
463
|
body: envelope.request.body,
|
|
387
464
|
}, true);
|
|
388
465
|
const response = parseSuccessfulCommitResponse(raw, envelope.idempotencyKey);
|
|
389
|
-
|
|
390
|
-
|
|
466
|
+
if (envelope.correlationId !== undefined &&
|
|
467
|
+
response.correlationId !== envelope.correlationId) {
|
|
468
|
+
throw new AbloIdempotencyError('The saved HTTP commit replay returned different source correlation evidence.', { code: 'idempotency_conflict' });
|
|
469
|
+
}
|
|
470
|
+
const replayEnvelope = await persistHttpAcceptance(envelope, response);
|
|
471
|
+
// A queued source receipt is only acceptance. Keep the exact request
|
|
472
|
+
// durable so startup/retry can ask mutation-log replay whether its WAL
|
|
473
|
+
// echo has materialized; only confirmed is a definitive success.
|
|
474
|
+
if (response.status === 'confirmed') {
|
|
475
|
+
await settleHttpEnvelope(envelope.id);
|
|
476
|
+
}
|
|
477
|
+
replayed.set(envelope.idempotencyKey, {
|
|
478
|
+
envelope: replayEnvelope,
|
|
479
|
+
response,
|
|
480
|
+
});
|
|
391
481
|
}
|
|
392
482
|
catch (error) {
|
|
393
483
|
if (isDefinitiveHttpRejection(error)) {
|
|
@@ -398,6 +488,37 @@ export function createHttpTransport(options) {
|
|
|
398
488
|
}
|
|
399
489
|
return replayed;
|
|
400
490
|
}
|
|
491
|
+
/**
|
|
492
|
+
* A flush is stronger than startup replay: it promises that every retained
|
|
493
|
+
* envelope has reached a definitive outcome, not merely that the server
|
|
494
|
+
* accepted it for forwarding. Poll queued receipts through mutation-log
|
|
495
|
+
* replay and leave their envelopes intact if the confirmation deadline
|
|
496
|
+
* expires.
|
|
497
|
+
*/
|
|
498
|
+
async function confirmReplayedHttpCommits(replayed) {
|
|
499
|
+
for (const { envelope, response } of replayed.values()) {
|
|
500
|
+
if (response.status !== 'queued')
|
|
501
|
+
continue;
|
|
502
|
+
try {
|
|
503
|
+
const confirmed = await pollHttpCommitConfirmation({
|
|
504
|
+
idempotencyKey: envelope.idempotencyKey,
|
|
505
|
+
method: envelope.request.method,
|
|
506
|
+
path: envelope.request.path,
|
|
507
|
+
body: envelope.request.body,
|
|
508
|
+
sealedProtocolVersion: envelope.protocolVersion,
|
|
509
|
+
}, response);
|
|
510
|
+
if (confirmed.status === 'confirmed') {
|
|
511
|
+
await settleHttpEnvelope(envelope.id);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
catch (error) {
|
|
515
|
+
if (isDefinitiveHttpRejection(error)) {
|
|
516
|
+
await settleHttpEnvelope(envelope.id);
|
|
517
|
+
}
|
|
518
|
+
throw error;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
401
522
|
let lastHttpCommitSequence = 0;
|
|
402
523
|
function nextHttpCommitSequence() {
|
|
403
524
|
const wallSequence = Date.now() * 1_000;
|
|
@@ -457,22 +578,56 @@ export function createHttpTransport(options) {
|
|
|
457
578
|
prior.envelope.request.body !== serializedBody) {
|
|
458
579
|
throw new AbloIdempotencyError('Idempotency key reused with a different HTTP commit request', { code: 'idempotency_conflict' });
|
|
459
580
|
}
|
|
460
|
-
|
|
461
|
-
|
|
581
|
+
let priorResponse = prior.response;
|
|
582
|
+
if (priorResponse.status === 'queued' && input.wait === 'confirmed') {
|
|
583
|
+
try {
|
|
584
|
+
priorResponse = await pollHttpCommitConfirmation({
|
|
585
|
+
idempotencyKey: prior.envelope.idempotencyKey,
|
|
586
|
+
method: prior.envelope.request.method,
|
|
587
|
+
path: prior.envelope.request.path,
|
|
588
|
+
body: prior.envelope.request.body,
|
|
589
|
+
sealedProtocolVersion: prior.envelope.protocolVersion,
|
|
590
|
+
}, priorResponse);
|
|
591
|
+
}
|
|
592
|
+
catch (error) {
|
|
593
|
+
if (isDefinitiveHttpRejection(error)) {
|
|
594
|
+
await settleHttpEnvelope(prior.envelope.id);
|
|
595
|
+
}
|
|
596
|
+
throw error;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if (priorResponse.status === 'confirmed') {
|
|
600
|
+
await beforeSettlement?.(priorResponse);
|
|
601
|
+
await settleHttpEnvelope(prior.envelope.id);
|
|
602
|
+
}
|
|
603
|
+
return priorResponse;
|
|
462
604
|
}
|
|
463
605
|
const durableEnvelope = await sealHttpCommit(input);
|
|
464
606
|
const requestBody = durableEnvelope?.request.body ?? canonicalHttpCommitBody(input.body);
|
|
607
|
+
const exactRequest = {
|
|
608
|
+
idempotencyKey: input.idempotencyKey,
|
|
609
|
+
method: input.method,
|
|
610
|
+
path: input.path,
|
|
611
|
+
body: requestBody,
|
|
612
|
+
...(durableEnvelope ? { sealedProtocolVersion: durableEnvelope.protocolVersion } : {}),
|
|
613
|
+
};
|
|
465
614
|
let response;
|
|
466
615
|
try {
|
|
467
|
-
const raw = await requestJson(
|
|
468
|
-
method:
|
|
469
|
-
idempotencyKey:
|
|
470
|
-
...(
|
|
471
|
-
? { sealedProtocolVersion:
|
|
616
|
+
const raw = await requestJson(exactRequest.path, {
|
|
617
|
+
method: exactRequest.method,
|
|
618
|
+
idempotencyKey: exactRequest.idempotencyKey,
|
|
619
|
+
...(exactRequest.sealedProtocolVersion !== undefined
|
|
620
|
+
? { sealedProtocolVersion: exactRequest.sealedProtocolVersion }
|
|
472
621
|
: {}),
|
|
473
|
-
body:
|
|
622
|
+
body: exactRequest.body,
|
|
474
623
|
}, true);
|
|
475
624
|
response = parseSuccessfulCommitResponse(raw, input.idempotencyKey);
|
|
625
|
+
if (durableEnvelope && response.status === 'queued') {
|
|
626
|
+
await persistHttpAcceptance(durableEnvelope, response);
|
|
627
|
+
}
|
|
628
|
+
if (response.status === 'queued' && input.wait === 'confirmed') {
|
|
629
|
+
response = await pollHttpCommitConfirmation(exactRequest, response);
|
|
630
|
+
}
|
|
476
631
|
}
|
|
477
632
|
catch (error) {
|
|
478
633
|
if (durableEnvelope && isDefinitiveHttpRejection(error)) {
|
|
@@ -482,9 +637,13 @@ export function createHttpTransport(options) {
|
|
|
482
637
|
}
|
|
483
638
|
// A model-create readback can participate in settlement: if it fails,
|
|
484
639
|
// retain the exact write so a same-key retry recovers the generated id.
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
640
|
+
// A queued source receipt cannot be read back from the log yet and stays
|
|
641
|
+
// durable until a later confirmed replay.
|
|
642
|
+
if (response.status === 'confirmed') {
|
|
643
|
+
await beforeSettlement?.(response);
|
|
644
|
+
if (durableEnvelope)
|
|
645
|
+
await settleHttpEnvelope(durableEnvelope.id);
|
|
646
|
+
}
|
|
488
647
|
return response;
|
|
489
648
|
});
|
|
490
649
|
}
|
|
@@ -508,7 +667,7 @@ export function createHttpTransport(options) {
|
|
|
508
667
|
? crypto.randomUUID()
|
|
509
668
|
: `id_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
510
669
|
}
|
|
511
|
-
function normalizeCommitOperation(op, defaults) {
|
|
670
|
+
function normalizeCommitOperation(op, defaults, fenceToken) {
|
|
512
671
|
return {
|
|
513
672
|
action: op.action,
|
|
514
673
|
model: op.model,
|
|
@@ -517,13 +676,18 @@ export function createHttpTransport(options) {
|
|
|
517
676
|
transactionId: op.transactionId ?? null,
|
|
518
677
|
readAt: op.readAt ?? defaults.readAt ?? null,
|
|
519
678
|
onStale: op.onStale ?? defaults.onStale ?? null,
|
|
679
|
+
// The batch's claim (if any) supplies one token for every op, mirroring
|
|
680
|
+
// how it supplies the batch `readAt`.
|
|
681
|
+
fenceToken: op.fenceToken ?? fenceToken ?? null,
|
|
520
682
|
};
|
|
521
683
|
}
|
|
522
|
-
function normalizeCommitOperations(commitOptions) {
|
|
684
|
+
function normalizeCommitOperations(commitOptions, fenceToken) {
|
|
523
685
|
if (commitOptions.operations.length === 0) {
|
|
524
|
-
throw new AbloValidationError('Commit requires a non-empty `operations` array.', {
|
|
686
|
+
throw new AbloValidationError('Commit requires a non-empty `operations` array.', {
|
|
687
|
+
code: 'commit_operation_required',
|
|
688
|
+
});
|
|
525
689
|
}
|
|
526
|
-
return commitOptions.operations.map((op) => normalizeCommitOperation(op, commitOptions));
|
|
690
|
+
return commitOptions.operations.map((op) => normalizeCommitOperation(op, commitOptions, fenceToken));
|
|
527
691
|
}
|
|
528
692
|
async function listClaimState(target) {
|
|
529
693
|
const params = new URLSearchParams();
|
|
@@ -534,7 +698,9 @@ export function createHttpTransport(options) {
|
|
|
534
698
|
if (target?.field)
|
|
535
699
|
params.set('field', target.field);
|
|
536
700
|
const suffix = params.toString();
|
|
537
|
-
const body = await requestJson(`/v1/claims${suffix ? `?${suffix}` : ''}`, {
|
|
701
|
+
const body = await requestJson(`/v1/claims${suffix ? `?${suffix}` : ''}`, {
|
|
702
|
+
method: 'GET',
|
|
703
|
+
});
|
|
538
704
|
return {
|
|
539
705
|
active: body.claims ?? [],
|
|
540
706
|
queue: body.queue ?? [],
|
|
@@ -568,11 +734,12 @@ export function createHttpTransport(options) {
|
|
|
568
734
|
...commitOptions,
|
|
569
735
|
readAt: commitOptions.readAt ?? claim?.readAt ?? null,
|
|
570
736
|
onStale: commitOptions.onStale ?? (claim?.readAt !== undefined ? 'reject' : null),
|
|
571
|
-
});
|
|
737
|
+
}, claim?.fenceToken ?? null);
|
|
572
738
|
const requestBody = {
|
|
573
739
|
operations,
|
|
574
740
|
reads: commitOptions.reads,
|
|
575
741
|
};
|
|
742
|
+
const wait = commitOptions.wait ?? 'confirmed';
|
|
576
743
|
let body;
|
|
577
744
|
try {
|
|
578
745
|
body = await dispatchHttpCommit({
|
|
@@ -580,6 +747,7 @@ export function createHttpTransport(options) {
|
|
|
580
747
|
method: 'POST',
|
|
581
748
|
idempotencyKey: clientTxId,
|
|
582
749
|
body: requestBody,
|
|
750
|
+
wait,
|
|
583
751
|
});
|
|
584
752
|
}
|
|
585
753
|
catch (error) {
|
|
@@ -604,9 +772,7 @@ export function createHttpTransport(options) {
|
|
|
604
772
|
...(body.notifications && body.notifications.length > 0
|
|
605
773
|
? { notifications: body.notifications }
|
|
606
774
|
: {}),
|
|
607
|
-
...(body.missingIds && body.missingIds.length > 0
|
|
608
|
-
? { missingIds: body.missingIds }
|
|
609
|
-
: {}),
|
|
775
|
+
...(body.missingIds && body.missingIds.length > 0 ? { missingIds: body.missingIds } : {}),
|
|
610
776
|
};
|
|
611
777
|
},
|
|
612
778
|
};
|
|
@@ -693,6 +859,9 @@ export function createHttpTransport(options) {
|
|
|
693
859
|
claim: normalizeClaimId(options?.claimRef) ?? claimHandle?.id,
|
|
694
860
|
onStale: options?.onStale ?? (claimHandle?.readAt !== undefined ? 'reject' : undefined),
|
|
695
861
|
readAt,
|
|
862
|
+
// The claim's fencing token (Option B), so the per-model HTTP write door
|
|
863
|
+
// fences the same as the WS proxy and `commits.create`.
|
|
864
|
+
fenceToken: options?.fenceToken ?? claimHandle?.fenceToken,
|
|
696
865
|
};
|
|
697
866
|
if (action === 'create')
|
|
698
867
|
requestBody.id = id;
|
|
@@ -705,6 +874,7 @@ export function createHttpTransport(options) {
|
|
|
705
874
|
method,
|
|
706
875
|
idempotencyKey: clientTxId,
|
|
707
876
|
body: requestBody,
|
|
877
|
+
wait: options?.wait ?? 'confirmed',
|
|
708
878
|
}, beforeSettlement);
|
|
709
879
|
}
|
|
710
880
|
catch (error) {
|
|
@@ -733,18 +903,13 @@ export function createHttpTransport(options) {
|
|
|
733
903
|
value.object === 'claim' &&
|
|
734
904
|
typeof value.id === 'string' &&
|
|
735
905
|
typeof value.release === 'function';
|
|
736
|
-
const claimMeta = (options) =>
|
|
737
|
-
if (!options?.description)
|
|
738
|
-
return options?.meta;
|
|
739
|
-
return { ...(options.meta ?? {}), description: options.description };
|
|
740
|
-
};
|
|
906
|
+
const claimMeta = (options) => options?.meta;
|
|
741
907
|
const acquireClaim = async (params) => {
|
|
742
908
|
const body = await requestJson(claimPath(params.id), {
|
|
743
909
|
method: 'POST',
|
|
744
910
|
body: JSON.stringify({
|
|
745
|
-
|
|
911
|
+
description: params.description ?? 'editing',
|
|
746
912
|
...(params.ttl !== undefined ? { ttl: params.ttl } : {}),
|
|
747
|
-
...(params.description !== undefined ? { description: params.description } : {}),
|
|
748
913
|
...(claimMeta(params) ? { meta: claimMeta(params) } : {}),
|
|
749
914
|
// `queue` (default true) → queue behind the holder; false → fail-fast
|
|
750
915
|
// with AbloClaimedError (work-distribution dedup).
|
|
@@ -757,9 +922,13 @@ export function createHttpTransport(options) {
|
|
|
757
922
|
}
|
|
758
923
|
// `claimId` is the field name the queued response uses; check it alongside
|
|
759
924
|
// the other id shapes the response may carry.
|
|
760
|
-
|
|
925
|
+
const id = body.claim?.id ?? body.id ?? body.claimId ?? createClaimId();
|
|
926
|
+
const fenceToken = body.claim?.fenceToken;
|
|
927
|
+
return fenceToken !== undefined ? { id, fenceToken } : { id };
|
|
761
928
|
};
|
|
762
|
-
const releaseClaim = (params) => requestJson(claimPath(isClaimHandle(params) ? params.target.id : params.id), {
|
|
929
|
+
const releaseClaim = (params) => requestJson(claimPath(isClaimHandle(params) ? params.target.id : params.id), {
|
|
930
|
+
method: 'DELETE',
|
|
931
|
+
}).then(() => undefined);
|
|
763
932
|
// One beat on the held lease. A lapsed lease answers `claim_lost`
|
|
764
933
|
// (409), which the wire error mapping surfaces as AbloClaimedError —
|
|
765
934
|
// the definitive signal that stops the auto-heartbeat loop.
|
|
@@ -775,14 +944,14 @@ export function createHttpTransport(options) {
|
|
|
775
944
|
return heldHeartbeatReply(reply, `claim ${claimId} on ${name}/${id}`);
|
|
776
945
|
};
|
|
777
946
|
async function claimImpl(params) {
|
|
778
|
-
const claimId = await acquireClaim(params);
|
|
947
|
+
const { id: claimId, fenceToken } = await acquireClaim(params);
|
|
779
948
|
observability?.captureClaim({
|
|
780
949
|
phase: 'acquired',
|
|
781
950
|
claimId,
|
|
782
951
|
model: name,
|
|
783
952
|
id: params.id,
|
|
784
953
|
...(params.field ? { field: params.field } : {}),
|
|
785
|
-
reason: params.
|
|
954
|
+
reason: params.description ?? 'editing',
|
|
786
955
|
});
|
|
787
956
|
const { data, stamp } = await retrieveModel(name, { id: params.id });
|
|
788
957
|
// A held claim hands back a snapshot; the typed `HeldClaim.data` is `T`.
|
|
@@ -795,9 +964,7 @@ export function createHttpTransport(options) {
|
|
|
795
964
|
const resolved = resolveHeartbeatOptions(beatOptions);
|
|
796
965
|
const beat = await heartbeatClaim(params.id, claimId, {
|
|
797
966
|
ttl: resolved.ttl ?? params.ttl,
|
|
798
|
-
...(resolved.details !== undefined
|
|
799
|
-
? { details: resolved.details }
|
|
800
|
-
: {}),
|
|
967
|
+
...(resolved.details !== undefined ? { details: resolved.details } : {}),
|
|
801
968
|
});
|
|
802
969
|
params.onHeartbeat?.(beat);
|
|
803
970
|
return beat;
|
|
@@ -809,9 +976,7 @@ export function createHttpTransport(options) {
|
|
|
809
976
|
? startClaimHeartbeatLoop({
|
|
810
977
|
beat: () => heartbeat(),
|
|
811
978
|
intervalMs: heartbeatCadenceMs(params.ttl !== undefined ? toMs(params.ttl) : 60_000, params.heartbeat),
|
|
812
|
-
...(params.onHeartbeatLost
|
|
813
|
-
? { onLost: params.onHeartbeatLost }
|
|
814
|
-
: {}),
|
|
979
|
+
...(params.onHeartbeatLost ? { onLost: params.onHeartbeatLost } : {}),
|
|
815
980
|
})
|
|
816
981
|
: undefined;
|
|
817
982
|
const release = () => {
|
|
@@ -822,6 +987,7 @@ export function createHttpTransport(options) {
|
|
|
822
987
|
object: 'claim',
|
|
823
988
|
id: claimId,
|
|
824
989
|
readAt: stamp,
|
|
990
|
+
...(fenceToken !== undefined ? { fenceToken } : {}),
|
|
825
991
|
target: {
|
|
826
992
|
type: name,
|
|
827
993
|
id: params.id,
|
|
@@ -830,8 +996,7 @@ export function createHttpTransport(options) {
|
|
|
830
996
|
...(params.range ? { range: params.range } : {}),
|
|
831
997
|
...(claimMeta(params) ? { meta: claimMeta(params) } : {}),
|
|
832
998
|
},
|
|
833
|
-
|
|
834
|
-
...(params.description ? { description: params.description } : {}),
|
|
999
|
+
description: params.description ?? 'editing',
|
|
835
1000
|
data,
|
|
836
1001
|
release,
|
|
837
1002
|
revoke: () => {
|
|
@@ -861,7 +1026,9 @@ export function createHttpTransport(options) {
|
|
|
861
1026
|
method: 'POST',
|
|
862
1027
|
// The reorder route's payload is `{ heldBy, claimId }[]` — a Claim's id
|
|
863
1028
|
// is the claimId.
|
|
864
|
-
body: JSON.stringify({
|
|
1029
|
+
body: JSON.stringify({
|
|
1030
|
+
order: params.order.map((i) => ({ heldBy: i.heldBy, claimId: i.id })),
|
|
1031
|
+
}),
|
|
865
1032
|
});
|
|
866
1033
|
},
|
|
867
1034
|
});
|
|
@@ -875,12 +1042,17 @@ export function createHttpTransport(options) {
|
|
|
875
1042
|
// `isClaimHandle` ruled out the handle form above; the generic mismatch
|
|
876
1043
|
// (the union carries `Claim`, the guard narrows `Claim<T>`) keeps the
|
|
877
1044
|
// compiler from subtracting it, so narrow to the inline-options form.
|
|
878
|
-
const claimId = await acquireClaim({
|
|
1045
|
+
const { id: claimId, fenceToken } = await acquireClaim({
|
|
879
1046
|
id,
|
|
880
1047
|
...claimInput,
|
|
881
1048
|
});
|
|
882
1049
|
try {
|
|
883
|
-
return await run({
|
|
1050
|
+
return await run({
|
|
1051
|
+
...input,
|
|
1052
|
+
claimRef: { id: claimId },
|
|
1053
|
+
...(fenceToken !== undefined ? { fenceToken } : {}),
|
|
1054
|
+
claim: undefined,
|
|
1055
|
+
});
|
|
884
1056
|
}
|
|
885
1057
|
finally {
|
|
886
1058
|
await releaseClaim({ id }).catch(() => { });
|
|
@@ -937,7 +1109,10 @@ export function createHttpTransport(options) {
|
|
|
937
1109
|
let created;
|
|
938
1110
|
await mutateModel('create', name, id, params.data, {
|
|
939
1111
|
...options,
|
|
940
|
-
|
|
1112
|
+
// This method returns the authoritative row, not a receipt. A
|
|
1113
|
+
// queued source acceptance cannot satisfy that return contract,
|
|
1114
|
+
// even when the caller supplied `wait: 'queued'`.
|
|
1115
|
+
wait: 'confirmed',
|
|
941
1116
|
}, async () => {
|
|
942
1117
|
const read = await retrieveModel(name, { id });
|
|
943
1118
|
if (read.data === undefined) {
|
|
@@ -966,7 +1141,8 @@ export function createHttpTransport(options) {
|
|
|
966
1141
|
ready,
|
|
967
1142
|
waitForFlush: () => runInHttpCommitLane(async () => {
|
|
968
1143
|
await ready();
|
|
969
|
-
await replayHttpCommitOutbox();
|
|
1144
|
+
const replayed = await replayHttpCommitOutbox();
|
|
1145
|
+
await confirmReplayedHttpCommits(replayed);
|
|
970
1146
|
}),
|
|
971
1147
|
async dispose() { },
|
|
972
1148
|
async purge() { },
|
|
@@ -985,9 +1161,7 @@ export function createHttpTransport(options) {
|
|
|
985
1161
|
apiKey,
|
|
986
1162
|
baseUrl: apiBaseUrl,
|
|
987
1163
|
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
988
|
-
...(options.modelTypenames
|
|
989
|
-
? { modelTypenames: options.modelTypenames }
|
|
990
|
-
: {}),
|
|
1164
|
+
...(options.modelTypenames ? { modelTypenames: options.modelTypenames } : {}),
|
|
991
1165
|
});
|
|
992
1166
|
},
|
|
993
1167
|
},
|
package/dist/client/options.d.ts
CHANGED
|
@@ -89,21 +89,6 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
89
89
|
* route, and passing both is a validation error.
|
|
90
90
|
*/
|
|
91
91
|
authEndpoint?: string | ApiKeySetter | null | undefined;
|
|
92
|
-
/**
|
|
93
|
-
* @deprecated The direct connector lets Ablo dial into your Postgres and write to
|
|
94
|
-
* it directly. Prefer the signed data-source endpoint: keep your `DATABASE_URL`
|
|
95
|
-
* in your own app, expose `dataSource(...)`, and let your server own the write
|
|
96
|
-
* while Ablo coordinates the sync stream. Ablo hosts only the ordered
|
|
97
|
-
* `sync_deltas` log and coordination, never your rows. To keep the log in your own
|
|
98
|
-
* infrastructure as well, self-host the engine.
|
|
99
|
-
*
|
|
100
|
-
* Still honored at runtime for backward compatibility. It is server-only: because
|
|
101
|
-
* it carries credentials it is never sent from the browser, and constructing a
|
|
102
|
-
* client with both `databaseUrl` and `dangerouslyAllowBrowser` throws. If you do
|
|
103
|
-
* use it, supply a role that is neither a superuser nor `BYPASSRLS`; the connector
|
|
104
|
-
* rejects privileged roles that cannot enforce row-level security.
|
|
105
|
-
*/
|
|
106
|
-
databaseUrl?: string | null | undefined;
|
|
107
92
|
/**
|
|
108
93
|
* Local persistence mode. Pass `indexeddb` only when you want offline
|
|
109
94
|
* queueing and a reload-surviving browser cache.
|