@abloatai/ablo 0.29.3 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/README.md +3 -2
- package/dist/BaseSyncedStore.js +3 -1
- package/dist/Database.js +29 -2
- package/dist/SyncClient.d.ts +5 -3
- package/dist/SyncClient.js +8 -4
- package/dist/ai-sdk/coordinatedTool.d.ts +0 -1
- package/dist/ai-sdk/coordinatedTool.js +0 -1
- package/dist/ai-sdk/coordinationContext.js +1 -5
- package/dist/cli.cjs +377 -84
- package/dist/client/Ablo.d.ts +1 -1
- package/dist/client/Ablo.js +24 -36
- package/dist/client/auth.d.ts +8 -18
- package/dist/client/auth.js +16 -96
- package/dist/client/createModelProxy.d.ts +16 -11
- package/dist/client/createModelProxy.js +162 -20
- package/dist/client/httpClient.d.ts +1 -1
- package/dist/client/httpTransport.js +263 -89
- package/dist/client/options.d.ts +0 -15
- package/dist/client/resourceTypes.d.ts +13 -26
- package/dist/client/schemaConfig.js +5 -0
- package/dist/client/writeOptionsSchema.d.ts +1 -0
- package/dist/client/writeOptionsSchema.js +11 -2
- package/dist/client/wsMutationExecutor.d.ts +3 -5
- package/dist/coordination/schema.d.ts +17 -13
- package/dist/coordination/schema.js +36 -10
- package/dist/errorCodes.d.ts +9 -4
- package/dist/errorCodes.js +10 -5
- package/dist/errors.js +4 -9
- package/dist/index.d.ts +4 -0
- package/dist/interfaces/index.d.ts +31 -19
- package/dist/schema/index.d.ts +1 -1
- package/dist/schema/index.js +4 -1
- package/dist/schema/schema.d.ts +13 -0
- package/dist/schema/select.js +7 -0
- package/dist/schema/syncDeltaRow.d.ts +31 -13
- package/dist/schema/syncDeltaRow.js +33 -15
- package/dist/schema/tenancy.d.ts +52 -16
- package/dist/schema/tenancy.js +86 -25
- package/dist/server/commit.d.ts +8 -24
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +1 -0
- package/dist/server/readConfig.d.ts +10 -0
- package/dist/source/adapter.d.ts +26 -15
- package/dist/source/adapter.js +5 -3
- package/dist/source/adapters/drizzle.js +35 -9
- package/dist/source/adapters/kysely.d.ts +33 -61
- package/dist/source/adapters/kysely.js +170 -177
- package/dist/source/adapters/kyselyMutationCore.d.ts +76 -0
- package/dist/source/adapters/kyselyMutationCore.js +125 -0
- package/dist/source/adapters/memory.d.ts +1 -1
- package/dist/source/adapters/memory.js +24 -10
- package/dist/source/adapters/prisma.js +35 -8
- package/dist/source/conformance.d.ts +26 -28
- package/dist/source/conformance.js +142 -69
- package/dist/source/contract.d.ts +46 -4
- package/dist/source/contract.js +66 -5
- package/dist/source/factory.d.ts +3 -3
- package/dist/source/factory.js +24 -3
- package/dist/source/idempotency.d.ts +46 -0
- package/dist/source/idempotency.js +129 -0
- package/dist/source/index.d.ts +6 -4
- package/dist/source/index.js +5 -3
- package/dist/source/migrations.d.ts +9 -5
- package/dist/source/migrations.js +39 -6
- package/dist/source/types.d.ts +88 -14
- package/dist/source/types.js +16 -0
- package/dist/surface.d.ts +1 -1
- package/dist/surface.js +0 -1
- package/dist/sync/BootstrapFetcher.js +7 -0
- package/dist/sync/SyncWebSocket.d.ts +5 -27
- package/dist/sync/awaitClaimGrant.d.ts +6 -0
- package/dist/sync/awaitClaimGrant.js +12 -2
- package/dist/sync/commitFrames.d.ts +2 -4
- package/dist/sync/commitFrames.js +1 -0
- package/dist/sync/createClaimStream.d.ts +1 -1
- package/dist/sync/createClaimStream.js +13 -15
- package/dist/sync/deltaPipeline.d.ts +1 -1
- package/dist/sync/deltaPipeline.js +1 -1
- package/dist/sync/participants.d.ts +2 -3
- package/dist/sync/participants.js +2 -2
- package/dist/sync/wsFrameHandlers.js +35 -15
- package/dist/testing/mocks/MockMutationExecutor.d.ts +21 -0
- package/dist/testing/mocks/MockMutationExecutor.js +53 -1
- package/dist/transactions/TransactionQueue.d.ts +61 -9
- package/dist/transactions/TransactionQueue.js +502 -114
- package/dist/transactions/commitEnvelope.d.ts +4 -0
- package/dist/transactions/commitEnvelope.js +25 -0
- package/dist/transactions/commitPayload.d.ts +23 -5
- package/dist/transactions/commitPayload.js +3 -0
- package/dist/transactions/deltaConfirmation.d.ts +2 -1
- package/dist/transactions/deltaConfirmation.js +22 -4
- package/dist/transactions/durableWriteStore.d.ts +8 -1
- package/dist/transactions/httpCommitEnvelope.d.ts +3 -1
- package/dist/transactions/httpCommitEnvelope.js +30 -0
- package/dist/transactions/replayValidation.d.ts +4 -0
- package/dist/transactions/replayValidation.js +6 -0
- package/dist/types/streams.d.ts +29 -17
- package/dist/wire/commit.d.ts +671 -0
- package/dist/wire/commit.js +300 -0
- package/dist/wire/delta.d.ts +4 -0
- package/dist/wire/delta.js +9 -0
- package/dist/wire/frames.d.ts +17 -45
- package/dist/wire/index.d.ts +2 -0
- package/dist/wire/index.js +4 -0
- package/docs/agent-messaging.md +4 -5
- package/docs/agents.md +1 -2
- package/docs/api-keys.md +1 -2
- package/docs/client-behavior.md +4 -6
- package/docs/coordination.md +64 -9
- package/docs/data-sources.md +4 -7
- package/docs/integration-guide.md +6 -8
- package/docs/migration.md +0 -3
- package/docs/quickstart.md +7 -8
- package/docs/schema-contract.md +7 -8
- package/package.json +4 -4
- package/dist/client/registerDataSource.d.ts +0 -19
- package/dist/client/registerDataSource.js +0 -66
package/dist/source/contract.js
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* at compile time so the schema and the interface cannot drift apart.
|
|
14
14
|
*/
|
|
15
15
|
import { z } from 'zod';
|
|
16
|
+
import { correlationIdSchema } from '../wire/commit.js';
|
|
17
|
+
import { ABLO_SOURCE_CLIENT_TX_ID_MAX_LENGTH, ABLO_SOURCE_ECHO_MAX_OPERATIONS, ABLO_SOURCE_ECHO_MAX_PAYLOAD_BYTES, } from './types.js';
|
|
16
18
|
const jsonObject = z.record(z.string(), z.unknown());
|
|
17
19
|
/** Mirrors `SourceOperation['type']`. */
|
|
18
20
|
export const operationTypeSchema = z.enum([
|
|
@@ -36,14 +38,54 @@ export const operationSchema = z.object({
|
|
|
36
38
|
readAt: z.number().nullish(),
|
|
37
39
|
onStale: z.enum(['reject', 'overwrite', 'notify']).nullish(),
|
|
38
40
|
});
|
|
41
|
+
/** The supported customer-transaction echo mechanism. */
|
|
42
|
+
export const sourceCommitEchoSchema = z.object({
|
|
43
|
+
kind: z.literal('postgres-wal'),
|
|
44
|
+
payload: z
|
|
45
|
+
.string()
|
|
46
|
+
.min(1)
|
|
47
|
+
.refine((value) => new TextEncoder().encode(value).byteLength <= ABLO_SOURCE_ECHO_MAX_PAYLOAD_BYTES, `WAL echo payload exceeds ${ABLO_SOURCE_ECHO_MAX_PAYLOAD_BYTES} UTF-8 bytes`),
|
|
48
|
+
});
|
|
49
|
+
export const sourceCommitEchoOperationSchema = z.strictObject({
|
|
50
|
+
model: z.string().min(1),
|
|
51
|
+
id: z.string().min(1),
|
|
52
|
+
action: z.enum(['I', 'U', 'D']),
|
|
53
|
+
transactionId: z.string().min(1).max(ABLO_SOURCE_CLIENT_TX_ID_MAX_LENGTH),
|
|
54
|
+
});
|
|
55
|
+
export const sourceCommitEchoMarkerSchema = z
|
|
56
|
+
.strictObject({
|
|
57
|
+
version: z.literal(1),
|
|
58
|
+
correlationId: correlationIdSchema,
|
|
59
|
+
operations: z
|
|
60
|
+
.array(sourceCommitEchoOperationSchema)
|
|
61
|
+
.min(1)
|
|
62
|
+
.max(ABLO_SOURCE_ECHO_MAX_OPERATIONS)
|
|
63
|
+
.readonly(),
|
|
64
|
+
})
|
|
65
|
+
.superRefine(({ operations }, ctx) => {
|
|
66
|
+
const seen = new Set();
|
|
67
|
+
for (const [index, operation] of operations.entries()) {
|
|
68
|
+
if (seen.has(operation.transactionId)) {
|
|
69
|
+
ctx.addIssue({
|
|
70
|
+
code: 'custom',
|
|
71
|
+
path: ['operations', index, 'transactionId'],
|
|
72
|
+
message: 'WAL echo operation transactionIds must be unique',
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
seen.add(operation.transactionId);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
39
78
|
/**
|
|
40
|
-
* The unit
|
|
41
|
-
*
|
|
42
|
-
*
|
|
79
|
+
* The unit a mutation wrapper commits atomically. `correlationId` is derived by
|
|
80
|
+
* Ablo from the authenticated plane, participant, and public idempotency key; it
|
|
81
|
+
* is the customer-ledger identity. It is intentionally not a raw caller-authored
|
|
82
|
+
* `clientTxId` or an operation transaction id.
|
|
43
83
|
*/
|
|
44
84
|
export const changeSetSchema = z.object({
|
|
45
85
|
operations: z.array(operationSchema).min(1),
|
|
46
|
-
|
|
86
|
+
correlationId: correlationIdSchema,
|
|
87
|
+
intentHash: z.string().regex(/^[a-f0-9]{64}$/).optional(),
|
|
88
|
+
echo: sourceCommitEchoSchema.optional(),
|
|
47
89
|
});
|
|
48
90
|
/**
|
|
49
91
|
* A row in the adapter's `ablo_outbox` table. The adapter writes it in the same
|
|
@@ -60,8 +102,16 @@ export const outboxEventSchema = z.object({
|
|
|
60
102
|
type: operationTypeSchema,
|
|
61
103
|
data: jsonObject.nullish(),
|
|
62
104
|
organizationId: z.string().nullish(),
|
|
63
|
-
/**
|
|
105
|
+
/** Legacy source transaction id. Never use this field to settle a queued commit. */
|
|
64
106
|
clientTxId: z.string().nullish(),
|
|
107
|
+
/** Scoped server-authored identity that correlates this event to a queued commit. */
|
|
108
|
+
correlationId: correlationIdSchema.nullish(),
|
|
109
|
+
/** Stable identity of this operation within the correlated source commit. */
|
|
110
|
+
transactionId: z
|
|
111
|
+
.string()
|
|
112
|
+
.min(1)
|
|
113
|
+
.max(ABLO_SOURCE_CLIENT_TX_ID_MAX_LENGTH)
|
|
114
|
+
.nullish(),
|
|
65
115
|
occurredAt: z.number().nullish(),
|
|
66
116
|
/** Monotonic ordering key (bigint as string). `events()` pages by `cursor > ?`. */
|
|
67
117
|
cursor: z.string().min(1),
|
|
@@ -90,9 +140,20 @@ export const adapterCapabilitiesSchema = z.object({
|
|
|
90
140
|
propose: z.boolean(),
|
|
91
141
|
/** The backend can be introspected for its schema. */
|
|
92
142
|
schemaIntrospection: z.boolean(),
|
|
143
|
+
/** The adapter can atomically emit the requested Postgres WAL marker. */
|
|
144
|
+
postgresWalEcho: z.boolean().optional(),
|
|
145
|
+
/** The wrapper atomically writes and serves an endpoint transactional outbox. */
|
|
146
|
+
outboxEvents: z.boolean(),
|
|
93
147
|
});
|
|
94
148
|
const _operationContractInSync = [
|
|
95
149
|
true,
|
|
96
150
|
true,
|
|
97
151
|
];
|
|
98
152
|
void _operationContractInSync;
|
|
153
|
+
const _echoContractInSync = [
|
|
154
|
+
true,
|
|
155
|
+
true,
|
|
156
|
+
];
|
|
157
|
+
void _echoContractInSync;
|
|
158
|
+
const _echoMarkerContractInSync = [true, true];
|
|
159
|
+
void _echoMarkerContractInSync;
|
package/dist/source/factory.d.ts
CHANGED
|
@@ -59,9 +59,9 @@ export type DataSourceOptions<S extends SchemaRecord, TAuth = unknown> = {
|
|
|
59
59
|
* Reports changes that happened outside the SDK — cron jobs, dashboard edits,
|
|
60
60
|
* batch imports — which Ablo polls for. Each event you return becomes a delta and
|
|
61
61
|
* fans out to connected clients. You can return your outbox rows directly: Ablo
|
|
62
|
-
* dedupes on
|
|
63
|
-
*
|
|
64
|
-
*
|
|
62
|
+
* dedupes on stable `event.id`, appends the authoritative event, and uses a
|
|
63
|
+
* server-authored `correlationId` plus per-operation `transactionId` to settle
|
|
64
|
+
* the matching queued write. Store both fields for mediated endpoint commits.
|
|
65
65
|
*/
|
|
66
66
|
readonly events?: SourceEventsHandler<TAuth>;
|
|
67
67
|
/**
|
package/dist/source/factory.js
CHANGED
|
@@ -38,16 +38,29 @@ async function handleViaAdapter(adapter, body, scope) {
|
|
|
38
38
|
return json({ rows });
|
|
39
39
|
}
|
|
40
40
|
if (body.type === 'commit') {
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
const correlationId = body.correlationId ?? body.clientTxId;
|
|
42
|
+
if (!correlationId) {
|
|
43
|
+
return json({
|
|
44
|
+
error: 'source_commit_requires_correlation_id',
|
|
45
|
+
message: 'commit requires a scoped correlationId for idempotency',
|
|
46
|
+
}, 400);
|
|
43
47
|
}
|
|
44
48
|
const parsed = changeSetSchema.safeParse({
|
|
45
49
|
operations: body.operations,
|
|
46
|
-
|
|
50
|
+
correlationId,
|
|
51
|
+
intentHash: body.intentHash,
|
|
52
|
+
echo: body.echo,
|
|
47
53
|
});
|
|
48
54
|
if (!parsed.success) {
|
|
49
55
|
return json({ error: 'source_commit_invalid', message: parsed.error.message }, 400);
|
|
50
56
|
}
|
|
57
|
+
if (parsed.data.echo?.kind === 'postgres-wal' &&
|
|
58
|
+
adapter.capabilities.postgresWalEcho !== true) {
|
|
59
|
+
return json({
|
|
60
|
+
error: 'source_commit_echo_not_supported',
|
|
61
|
+
message: 'This adapter cannot emit the required Postgres WAL commit echo',
|
|
62
|
+
}, 409);
|
|
63
|
+
}
|
|
51
64
|
const result = await adapter.commit(parsed.data);
|
|
52
65
|
return json({ rows: result.rows });
|
|
53
66
|
}
|
|
@@ -62,6 +75,8 @@ async function handleViaAdapter(adapter, body, scope) {
|
|
|
62
75
|
...(event.data !== undefined && event.data !== null ? { data: event.data } : {}),
|
|
63
76
|
...(event.organizationId ? { organizationId: event.organizationId } : {}),
|
|
64
77
|
...(event.clientTxId ? { clientTxId: event.clientTxId } : {}),
|
|
78
|
+
...(event.correlationId ? { correlationId: event.correlationId } : {}),
|
|
79
|
+
...(event.transactionId ? { transactionId: event.transactionId } : {}),
|
|
65
80
|
...(event.occurredAt !== undefined && event.occurredAt !== null
|
|
66
81
|
? { occurredAt: event.occurredAt }
|
|
67
82
|
: {}),
|
|
@@ -224,7 +239,10 @@ export function dataSource(options) {
|
|
|
224
239
|
if (options.commit) {
|
|
225
240
|
const result = await options.commit({
|
|
226
241
|
operations: body.operations,
|
|
242
|
+
correlationId: body.correlationId ?? body.clientTxId,
|
|
227
243
|
clientTxId: body.clientTxId,
|
|
244
|
+
intentHash: body.intentHash,
|
|
245
|
+
echo: body.echo,
|
|
228
246
|
context,
|
|
229
247
|
});
|
|
230
248
|
return json(result);
|
|
@@ -239,7 +257,10 @@ export function dataSource(options) {
|
|
|
239
257
|
}
|
|
240
258
|
const result = await handlers.commit({
|
|
241
259
|
operations: body.operations,
|
|
260
|
+
correlationId: body.correlationId ?? body.clientTxId,
|
|
242
261
|
clientTxId: body.clientTxId,
|
|
262
|
+
intentHash: body.intentHash,
|
|
263
|
+
echo: body.echo,
|
|
243
264
|
context,
|
|
244
265
|
});
|
|
245
266
|
return json(result);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Customer-side source idempotency helpers.
|
|
3
|
+
*
|
|
4
|
+
* Connected writes use a permanent, server-derived correlation key at the
|
|
5
|
+
* customer boundary. The matching intent hash must therefore also be
|
|
6
|
+
* permanent: replaying the key with a different request is a conflict, never a
|
|
7
|
+
* fresh execution. Ablo supplies the hash for signed source requests because
|
|
8
|
+
* it covers context/read guards that an adapter cannot reconstruct from the
|
|
9
|
+
* effective operation list alone. Direct adapter users get a canonical
|
|
10
|
+
* operations-only fallback.
|
|
11
|
+
*/
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
import type { ChangeSet, Operation } from './contract.js';
|
|
14
|
+
/** Runtime shape recovered from the internal storage envelope. */
|
|
15
|
+
export declare const sourceEchoTransactionIdSchema: z.ZodObject<{
|
|
16
|
+
correlationId: z.ZodString;
|
|
17
|
+
transactionId: z.ZodString;
|
|
18
|
+
}, z.core.$strict>;
|
|
19
|
+
export type SourceEchoTransactionId = z.infer<typeof sourceEchoTransactionIdSchema>;
|
|
20
|
+
/**
|
|
21
|
+
* Store both source-batch identity and the originating optimistic-write id in
|
|
22
|
+
* the existing `sync_deltas.transaction_id` column. The database value is an
|
|
23
|
+
* internal envelope: the server unwraps it before broadcasting, so clients
|
|
24
|
+
* still receive the exact operation transaction id they authored.
|
|
25
|
+
*
|
|
26
|
+
* Keeping the opaque, authenticated-scope correlation in the stored value is
|
|
27
|
+
* load-bearing for crash recovery. A raw caller may choose or reuse an
|
|
28
|
+
* operation transaction id; that value alone must never be able to satisfy a
|
|
29
|
+
* different participant's queued receipt.
|
|
30
|
+
*/
|
|
31
|
+
export declare function encodeSourceEchoTransactionId(correlationId: string, transactionId: string): string;
|
|
32
|
+
/** Decode a source-WAL transaction envelope, or return null for normal rows. */
|
|
33
|
+
export declare function decodeSourceEchoTransactionId(value: string | null): SourceEchoTransactionId | null;
|
|
34
|
+
/** Canonical operations-only fallback for direct adapter use. */
|
|
35
|
+
export declare function sourceOperationsIntentHash(operations: readonly Operation[]): string;
|
|
36
|
+
/** Resolve the hash every newly-written customer ledger row must persist. */
|
|
37
|
+
export declare function sourceChangeIntentHash(change: ChangeSet): string;
|
|
38
|
+
/** Fail closed for changed intent and legacy rows that lack hash evidence. */
|
|
39
|
+
export declare function assertSourceIdempotencyIntent(cachedHash: unknown, requestHash: string): void;
|
|
40
|
+
/**
|
|
41
|
+
* Enforce the initial permanent-retention contract. New rows use Postgres
|
|
42
|
+
* `infinity`; this check also fails explicitly if a future bounded-retention
|
|
43
|
+
* policy leaves an expired tombstone. Missing expiry evidence is accepted only
|
|
44
|
+
* for adapters reading rows written before the column existed.
|
|
45
|
+
*/
|
|
46
|
+
export declare function assertSourceIdempotencyRetention(expiresAt: unknown, now?: number): void;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Customer-side source idempotency helpers.
|
|
3
|
+
*
|
|
4
|
+
* Connected writes use a permanent, server-derived correlation key at the
|
|
5
|
+
* customer boundary. The matching intent hash must therefore also be
|
|
6
|
+
* permanent: replaying the key with a different request is a conflict, never a
|
|
7
|
+
* fresh execution. Ablo supplies the hash for signed source requests because
|
|
8
|
+
* it covers context/read guards that an adapter cannot reconstruct from the
|
|
9
|
+
* effective operation list alone. Direct adapter users get a canonical
|
|
10
|
+
* operations-only fallback.
|
|
11
|
+
*/
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
import { z } from 'zod';
|
|
14
|
+
import { AbloValidationError } from '../errors.js';
|
|
15
|
+
import { COMMIT_CORRELATION_ID_MAX_LENGTH, correlationIdSchema, } from '../wire/commit.js';
|
|
16
|
+
const SOURCE_ECHO_TRANSACTION_ID_PREFIX = 'ablo_echo_tx_v1:';
|
|
17
|
+
const sourceOperationTransactionIdSchema = z
|
|
18
|
+
.string()
|
|
19
|
+
.min(1)
|
|
20
|
+
.max(COMMIT_CORRELATION_ID_MAX_LENGTH);
|
|
21
|
+
/** Runtime shape recovered from the internal storage envelope. */
|
|
22
|
+
export const sourceEchoTransactionIdSchema = z.strictObject({
|
|
23
|
+
correlationId: correlationIdSchema,
|
|
24
|
+
transactionId: sourceOperationTransactionIdSchema,
|
|
25
|
+
});
|
|
26
|
+
const sourceEchoTransactionIdTupleSchema = z.tuple([
|
|
27
|
+
correlationIdSchema,
|
|
28
|
+
sourceOperationTransactionIdSchema,
|
|
29
|
+
]);
|
|
30
|
+
/**
|
|
31
|
+
* Store both source-batch identity and the originating optimistic-write id in
|
|
32
|
+
* the existing `sync_deltas.transaction_id` column. The database value is an
|
|
33
|
+
* internal envelope: the server unwraps it before broadcasting, so clients
|
|
34
|
+
* still receive the exact operation transaction id they authored.
|
|
35
|
+
*
|
|
36
|
+
* Keeping the opaque, authenticated-scope correlation in the stored value is
|
|
37
|
+
* load-bearing for crash recovery. A raw caller may choose or reuse an
|
|
38
|
+
* operation transaction id; that value alone must never be able to satisfy a
|
|
39
|
+
* different participant's queued receipt.
|
|
40
|
+
*/
|
|
41
|
+
export function encodeSourceEchoTransactionId(correlationId, transactionId) {
|
|
42
|
+
const tuple = sourceEchoTransactionIdTupleSchema.parse([
|
|
43
|
+
correlationId,
|
|
44
|
+
transactionId,
|
|
45
|
+
]);
|
|
46
|
+
return `${SOURCE_ECHO_TRANSACTION_ID_PREFIX}${JSON.stringify(tuple)}`;
|
|
47
|
+
}
|
|
48
|
+
/** Decode a source-WAL transaction envelope, or return null for normal rows. */
|
|
49
|
+
export function decodeSourceEchoTransactionId(value) {
|
|
50
|
+
if (!value?.startsWith(SOURCE_ECHO_TRANSACTION_ID_PREFIX))
|
|
51
|
+
return null;
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(value.slice(SOURCE_ECHO_TRANSACTION_ID_PREFIX.length));
|
|
54
|
+
const tuple = sourceEchoTransactionIdTupleSchema.safeParse(parsed);
|
|
55
|
+
if (!tuple.success)
|
|
56
|
+
return null;
|
|
57
|
+
return sourceEchoTransactionIdSchema.parse({
|
|
58
|
+
correlationId: tuple.data[0],
|
|
59
|
+
transactionId: tuple.data[1],
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function canonicalJson(value) {
|
|
67
|
+
if (value === null || typeof value !== 'object') {
|
|
68
|
+
const encoded = JSON.stringify(value);
|
|
69
|
+
return encoded ?? 'null';
|
|
70
|
+
}
|
|
71
|
+
if (Array.isArray(value)) {
|
|
72
|
+
return `[${value
|
|
73
|
+
.map((entry) => canonicalJson(entry === undefined ? null : entry))
|
|
74
|
+
.join(',')}]`;
|
|
75
|
+
}
|
|
76
|
+
const record = value;
|
|
77
|
+
return `{${Object.keys(record)
|
|
78
|
+
.filter((key) => record[key] !== undefined)
|
|
79
|
+
.sort()
|
|
80
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
|
|
81
|
+
.join(',')}}`;
|
|
82
|
+
}
|
|
83
|
+
/** Canonical operations-only fallback for direct adapter use. */
|
|
84
|
+
export function sourceOperationsIntentHash(operations) {
|
|
85
|
+
return createHash('sha256')
|
|
86
|
+
.update(canonicalJson(operations))
|
|
87
|
+
.digest('hex');
|
|
88
|
+
}
|
|
89
|
+
/** Resolve the hash every newly-written customer ledger row must persist. */
|
|
90
|
+
export function sourceChangeIntentHash(change) {
|
|
91
|
+
if (change.echo && !change.intentHash) {
|
|
92
|
+
throw new AbloValidationError('A WAL-correlated source commit requires Ablo intentHash evidence', { code: 'idempotency_conflict' });
|
|
93
|
+
}
|
|
94
|
+
return change.intentHash ?? sourceOperationsIntentHash(change.operations);
|
|
95
|
+
}
|
|
96
|
+
/** Fail closed for changed intent and legacy rows that lack hash evidence. */
|
|
97
|
+
export function assertSourceIdempotencyIntent(cachedHash, requestHash) {
|
|
98
|
+
if (cachedHash !== requestHash) {
|
|
99
|
+
throw new AbloValidationError(cachedHash == null
|
|
100
|
+
? 'The source idempotency row predates intent hashing and cannot be replayed safely'
|
|
101
|
+
: 'The source idempotency key was already used with a different request intent', { code: 'idempotency_conflict' });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Enforce the initial permanent-retention contract. New rows use Postgres
|
|
106
|
+
* `infinity`; this check also fails explicitly if a future bounded-retention
|
|
107
|
+
* policy leaves an expired tombstone. Missing expiry evidence is accepted only
|
|
108
|
+
* for adapters reading rows written before the column existed.
|
|
109
|
+
*/
|
|
110
|
+
export function assertSourceIdempotencyRetention(expiresAt, now = Date.now()) {
|
|
111
|
+
if (expiresAt == null ||
|
|
112
|
+
expiresAt === 'infinity' ||
|
|
113
|
+
expiresAt === Infinity) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const timestamp = expiresAt instanceof Date
|
|
117
|
+
? expiresAt.getTime()
|
|
118
|
+
: typeof expiresAt === 'number'
|
|
119
|
+
? expiresAt
|
|
120
|
+
: typeof expiresAt === 'string'
|
|
121
|
+
? Date.parse(expiresAt)
|
|
122
|
+
: Number.NaN;
|
|
123
|
+
if (!Number.isFinite(timestamp)) {
|
|
124
|
+
throw new AbloValidationError('The source idempotency retention evidence is invalid', { code: 'idempotency_conflict' });
|
|
125
|
+
}
|
|
126
|
+
if (timestamp <= now) {
|
|
127
|
+
throw new AbloValidationError('The source idempotency key has expired and cannot be executed again safely', { code: 'idempotency_key_expired' });
|
|
128
|
+
}
|
|
129
|
+
}
|
package/dist/source/index.d.ts
CHANGED
|
@@ -8,13 +8,15 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Import from here; the sibling modules import one another directly.
|
|
10
10
|
*/
|
|
11
|
-
export { sourceEventForOperation, type SourcePrimitive, type SourceWhere, type SourceListQuery, type SourceListPage, type SourceListResult, type SourceRequestContext, type SourceOperation, type SourceDelta, type SourceEvent, type SourceEventForOperationOptions, type SourceCommitResult, type SourceCommitParams, type SourceScope, type SourceEventsResult, type SourceEventsHandler, type SourceAuthorizeContext, type SourceHandlerContext, type SourceModelHandlers, type SourceCommitHandler, type SourceApiKey, type SourceLoadRequest, type SourceListRequest, type SourceCommitRequest, type SourceEventsRequest, type SourceRequest, type SourceResponse, } from './types.js';
|
|
11
|
+
export { sourceEventForOperation, ABLO_POSTGRES_COMMIT_ECHO_PREFIX, ABLO_SOURCE_CLIENT_TX_ID_MAX_LENGTH, ABLO_SOURCE_ECHO_MAX_OPERATIONS, ABLO_SOURCE_ECHO_MAX_PAYLOAD_BYTES, type SourcePrimitive, type SourceWhere, type SourceListQuery, type SourceListPage, type SourceListResult, type SourceRequestContext, type SourceOperation, type SourceDelta, type SourceEvent, type SourceEventForOperationOptions, type SourceCommitResult, type SourceCommitEcho, type SourceCommitEchoOperation, type SourceCommitEchoMarker, type SourceCommitParams, type SourceScope, type SourceEventsResult, type SourceEventsHandler, type SourceAuthorizeContext, type SourceHandlerContext, type SourceModelHandlers, type SourceCommitHandler, type SourceApiKey, type SourceLoadRequest, type SourceListRequest, type SourceCommitRequest, type SourceEventsRequest, type SourceRequest, type SourceResponse, } from './types.js';
|
|
12
12
|
export { ABLO_SOURCE_HEADERS, SourceSignatureError, signAbloSourceRequest, verifyAbloSourceRequest, type SourceSignatureOptions, type SourceSignatureVerificationOptions, type SourceSignatureVerificationResult, } from './signing.js';
|
|
13
13
|
export { dataSource, type DataSourceOptions, } from './factory.js';
|
|
14
14
|
export { createPushQueue, InMemoryPushQueueStorage, STANDARD_WEBHOOKS_RETRY_SCHEDULE, type PushQueue, type PushQueueItem, type PushQueueOptions, type PushQueueStorage, } from './pushQueue.js';
|
|
15
15
|
export { createSourceConnector, DEFAULT_RECONNECT_SCHEDULE, type SourceConnector, type SourceConnectorOptions, type ConnectorWebSocket, type ConnectorWebSocketFactory, type ConnectorStatus, } from './connector.js';
|
|
16
16
|
export { SOURCE_CONNECTOR_PROTOCOL_VERSION, SOURCE_CONNECTOR_WS_PATH, WS_SOURCE_SUBPROTOCOL, sourceConnectorSubprotocols, encodeFrame, decodeFrame, ConnectorProtocolError, connectorFrameSchema, type ConnectorFrame, type RegisterFrame, type ReadyFrame, type RequestFrame, type ResponseFrame, type ErrorFrame, } from './connectorProtocol.js';
|
|
17
|
-
export { type DataSourceAdapter, type AdapterReadRequest, type AdapterCommitResult, type Row as AdapterRow, } from './adapter.js';
|
|
18
|
-
export { operationSchema, operationTypeSchema, changeSetSchema, outboxEventSchema, eventsPageSchema, migrationSchema, adapterCapabilitiesSchema, type Operation, type ChangeSet, type OutboxEvent, type EventsPage, type Migration, type AdapterCapabilities, } from './contract.js';
|
|
17
|
+
export { type DataSourceAdapter, type MutationAdapter, type AdapterReadRequest, type AdapterCommitResult, type Row as AdapterRow, } from './adapter.js';
|
|
18
|
+
export { operationSchema, operationTypeSchema, sourceCommitEchoSchema, sourceCommitEchoOperationSchema, sourceCommitEchoMarkerSchema, changeSetSchema, outboxEventSchema, eventsPageSchema, migrationSchema, adapterCapabilitiesSchema, type Operation, type ChangeSet, type OutboxEvent, type EventsPage, type Migration, type AdapterCapabilities, type SourceCommitEchoMarkerWire, } from './contract.js';
|
|
19
19
|
export { prismaDataSource, type PrismaLike, type PrismaDataSourceOptions } from './adapters/prisma.js';
|
|
20
|
-
export { adapterTableMigrations } from './migrations.js';
|
|
20
|
+
export { adapterTableMigrations, endpointOutboxMigrations, idempotencyLedgerMigrations, } from './migrations.js';
|
|
21
|
+
export { createKyselyMutationAdapter, createKyselyMutationCore, kyselyDataSource, kyselyDirectMutation, kyselyOperationRowId, type KyselyCompiledQuery, type KyselyDeleteBuilder, type KyselyInsertBuilder, type KyselyInsertValuesBuilder, type KyselyLike, type KyselyMutationCore, type KyselyReturningExecutable, type KyselySelectBuilder, type KyselyTransactionBuilder, type KyselyUpdateBuilder, type KyselyUpdateSetBuilder, } from './adapters/kysely.js';
|
|
22
|
+
export { sourceOperationsIntentHash, sourceChangeIntentHash, assertSourceIdempotencyIntent, assertSourceIdempotencyRetention, sourceEchoTransactionIdSchema, encodeSourceEchoTransactionId, decodeSourceEchoTransactionId, type SourceEchoTransactionId, } from './idempotency.js';
|
package/dist/source/index.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Import from here; the sibling modules import one another directly.
|
|
10
10
|
*/
|
|
11
|
-
export { sourceEventForOperation, } from './types.js';
|
|
11
|
+
export { sourceEventForOperation, ABLO_POSTGRES_COMMIT_ECHO_PREFIX, ABLO_SOURCE_CLIENT_TX_ID_MAX_LENGTH, ABLO_SOURCE_ECHO_MAX_OPERATIONS, ABLO_SOURCE_ECHO_MAX_PAYLOAD_BYTES, } from './types.js';
|
|
12
12
|
export { ABLO_SOURCE_HEADERS, SourceSignatureError, signAbloSourceRequest, verifyAbloSourceRequest, } from './signing.js';
|
|
13
13
|
export { dataSource, } from './factory.js';
|
|
14
14
|
export { createPushQueue, InMemoryPushQueueStorage, STANDARD_WEBHOOKS_RETRY_SCHEDULE, } from './pushQueue.js';
|
|
@@ -20,6 +20,8 @@ export { createSourceConnector, DEFAULT_RECONNECT_SCHEDULE, } from './connector.
|
|
|
20
20
|
export { SOURCE_CONNECTOR_PROTOCOL_VERSION, SOURCE_CONNECTOR_WS_PATH, WS_SOURCE_SUBPROTOCOL, sourceConnectorSubprotocols, encodeFrame, decodeFrame, ConnectorProtocolError, connectorFrameSchema, } from './connectorProtocol.js';
|
|
21
21
|
// The Data Source adapter interface and its Zod contract, with per-ORM implementations.
|
|
22
22
|
export {} from './adapter.js';
|
|
23
|
-
export { operationSchema, operationTypeSchema, changeSetSchema, outboxEventSchema, eventsPageSchema, migrationSchema, adapterCapabilitiesSchema, } from './contract.js';
|
|
23
|
+
export { operationSchema, operationTypeSchema, sourceCommitEchoSchema, sourceCommitEchoOperationSchema, sourceCommitEchoMarkerSchema, changeSetSchema, outboxEventSchema, eventsPageSchema, migrationSchema, adapterCapabilitiesSchema, } from './contract.js';
|
|
24
24
|
export { prismaDataSource } from './adapters/prisma.js';
|
|
25
|
-
export { adapterTableMigrations } from './migrations.js';
|
|
25
|
+
export { adapterTableMigrations, endpointOutboxMigrations, idempotencyLedgerMigrations, } from './migrations.js';
|
|
26
|
+
export { createKyselyMutationAdapter, createKyselyMutationCore, kyselyDataSource, kyselyDirectMutation, kyselyOperationRowId, } from './adapters/kysely.js';
|
|
27
|
+
export { sourceOperationsIntentHash, sourceChangeIntentHash, assertSourceIdempotencyIntent, assertSourceIdempotencyRetention, sourceEchoTransactionIdSchema, encodeSourceEchoTransactionId, decodeSourceEchoTransactionId, } from './idempotency.js';
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The table-creation SQL every ORM adapter ships for its own infrastructure
|
|
3
|
-
* tables: `ablo_idempotency`,
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* tables: `ablo_idempotency`, whose legacy-named `client_tx_id` column stores the
|
|
4
|
+
* scoped server correlation, and (for endpoint wrappers only) `ablo_outbox`, the
|
|
5
|
+
* transactional outbox the `events()` feed reads. Direct wrappers install only
|
|
6
|
+
* the ledger migration because WAL is their authoritative feed.
|
|
7
7
|
*
|
|
8
8
|
* These are infrastructure tables, not model tables, and they exist only on your
|
|
9
9
|
* own database when you run a Data Source. Ablo's hosted storage does not use them;
|
|
10
10
|
* it records changes in its own `sync_deltas` log instead.
|
|
11
11
|
*/
|
|
12
12
|
import type { Migration } from './contract.js';
|
|
13
|
-
/**
|
|
13
|
+
/** The permanent ledger shared by direct and endpoint mutation wrappers. */
|
|
14
|
+
export declare function idempotencyLedgerMigrations(): readonly Migration[];
|
|
15
|
+
/** Endpoint-only transactional outbox and its correlation columns. */
|
|
16
|
+
export declare function endpointOutboxMigrations(): readonly Migration[];
|
|
17
|
+
/** Full endpoint adapter migrations (ledger + outbox). */
|
|
14
18
|
export declare function adapterTableMigrations(): readonly Migration[];
|
|
@@ -1,25 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The table-creation SQL every ORM adapter ships for its own infrastructure
|
|
3
|
-
* tables: `ablo_idempotency`,
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* tables: `ablo_idempotency`, whose legacy-named `client_tx_id` column stores the
|
|
4
|
+
* scoped server correlation, and (for endpoint wrappers only) `ablo_outbox`, the
|
|
5
|
+
* transactional outbox the `events()` feed reads. Direct wrappers install only
|
|
6
|
+
* the ledger migration because WAL is their authoritative feed.
|
|
7
7
|
*
|
|
8
8
|
* These are infrastructure tables, not model tables, and they exist only on your
|
|
9
9
|
* own database when you run a Data Source. Ablo's hosted storage does not use them;
|
|
10
10
|
* it records changes in its own `sync_deltas` log instead.
|
|
11
11
|
*/
|
|
12
|
-
/**
|
|
13
|
-
export function
|
|
12
|
+
/** The permanent ledger shared by direct and endpoint mutation wrappers. */
|
|
13
|
+
export function idempotencyLedgerMigrations() {
|
|
14
14
|
return [
|
|
15
15
|
{
|
|
16
16
|
name: 'ablo_idempotency',
|
|
17
17
|
up: `CREATE TABLE IF NOT EXISTS ablo_idempotency (
|
|
18
18
|
client_tx_id TEXT PRIMARY KEY,
|
|
19
19
|
response JSONB NOT NULL,
|
|
20
|
+
request_hash TEXT,
|
|
21
|
+
expires_at TIMESTAMPTZ NOT NULL DEFAULT 'infinity',
|
|
20
22
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
21
23
|
);`,
|
|
22
24
|
},
|
|
25
|
+
{
|
|
26
|
+
name: 'ablo_idempotency_request_hash',
|
|
27
|
+
// Nullable only for rows created by older adapter versions. New writes
|
|
28
|
+
// always populate it; replaying a legacy NULL row fails closed because
|
|
29
|
+
// the adapter cannot prove that the intent matches.
|
|
30
|
+
up: `ALTER TABLE ablo_idempotency
|
|
31
|
+
ADD COLUMN IF NOT EXISTS request_hash TEXT;`,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'ablo_idempotency_permanent_retention',
|
|
35
|
+
up: `ALTER TABLE ablo_idempotency
|
|
36
|
+
ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ NOT NULL DEFAULT 'infinity';`,
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
/** Endpoint-only transactional outbox and its correlation columns. */
|
|
41
|
+
export function endpointOutboxMigrations() {
|
|
42
|
+
return [
|
|
23
43
|
{
|
|
24
44
|
name: 'ablo_outbox',
|
|
25
45
|
up: `CREATE TABLE IF NOT EXISTS ablo_outbox (
|
|
@@ -31,9 +51,22 @@ export function adapterTableMigrations() {
|
|
|
31
51
|
data JSONB,
|
|
32
52
|
organization_id TEXT,
|
|
33
53
|
client_tx_id TEXT,
|
|
54
|
+
correlation_id TEXT,
|
|
55
|
+
transaction_id TEXT,
|
|
34
56
|
occurred_at BIGINT,
|
|
35
57
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
36
58
|
);`,
|
|
37
59
|
},
|
|
60
|
+
{
|
|
61
|
+
name: 'ablo_outbox_correlation',
|
|
62
|
+
up: `ALTER TABLE ablo_outbox
|
|
63
|
+
ADD COLUMN IF NOT EXISTS correlation_id TEXT;
|
|
64
|
+
ALTER TABLE ablo_outbox
|
|
65
|
+
ADD COLUMN IF NOT EXISTS transaction_id TEXT;`,
|
|
66
|
+
},
|
|
38
67
|
];
|
|
39
68
|
}
|
|
69
|
+
/** Full endpoint adapter migrations (ledger + outbox). */
|
|
70
|
+
export function adapterTableMigrations() {
|
|
71
|
+
return [...idempotencyLedgerMigrations(), ...endpointOutboxMigrations()];
|
|
72
|
+
}
|