@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.
Files changed (118) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +3 -2
  3. package/dist/BaseSyncedStore.js +3 -1
  4. package/dist/Database.js +29 -2
  5. package/dist/SyncClient.d.ts +5 -3
  6. package/dist/SyncClient.js +8 -4
  7. package/dist/ai-sdk/coordinatedTool.d.ts +0 -1
  8. package/dist/ai-sdk/coordinatedTool.js +0 -1
  9. package/dist/ai-sdk/coordinationContext.js +1 -5
  10. package/dist/cli.cjs +377 -84
  11. package/dist/client/Ablo.d.ts +1 -1
  12. package/dist/client/Ablo.js +24 -36
  13. package/dist/client/auth.d.ts +8 -18
  14. package/dist/client/auth.js +16 -96
  15. package/dist/client/createModelProxy.d.ts +16 -11
  16. package/dist/client/createModelProxy.js +162 -20
  17. package/dist/client/httpClient.d.ts +1 -1
  18. package/dist/client/httpTransport.js +263 -89
  19. package/dist/client/options.d.ts +0 -15
  20. package/dist/client/resourceTypes.d.ts +13 -26
  21. package/dist/client/schemaConfig.js +5 -0
  22. package/dist/client/writeOptionsSchema.d.ts +1 -0
  23. package/dist/client/writeOptionsSchema.js +11 -2
  24. package/dist/client/wsMutationExecutor.d.ts +3 -5
  25. package/dist/coordination/schema.d.ts +17 -13
  26. package/dist/coordination/schema.js +36 -10
  27. package/dist/errorCodes.d.ts +9 -4
  28. package/dist/errorCodes.js +10 -5
  29. package/dist/errors.js +4 -9
  30. package/dist/index.d.ts +4 -0
  31. package/dist/interfaces/index.d.ts +31 -19
  32. package/dist/schema/index.d.ts +1 -1
  33. package/dist/schema/index.js +4 -1
  34. package/dist/schema/schema.d.ts +13 -0
  35. package/dist/schema/select.js +7 -0
  36. package/dist/schema/syncDeltaRow.d.ts +31 -13
  37. package/dist/schema/syncDeltaRow.js +33 -15
  38. package/dist/schema/tenancy.d.ts +52 -16
  39. package/dist/schema/tenancy.js +86 -25
  40. package/dist/server/commit.d.ts +8 -24
  41. package/dist/server/index.d.ts +1 -0
  42. package/dist/server/index.js +1 -0
  43. package/dist/server/readConfig.d.ts +10 -0
  44. package/dist/source/adapter.d.ts +26 -15
  45. package/dist/source/adapter.js +5 -3
  46. package/dist/source/adapters/drizzle.js +35 -9
  47. package/dist/source/adapters/kysely.d.ts +33 -61
  48. package/dist/source/adapters/kysely.js +170 -177
  49. package/dist/source/adapters/kyselyMutationCore.d.ts +76 -0
  50. package/dist/source/adapters/kyselyMutationCore.js +125 -0
  51. package/dist/source/adapters/memory.d.ts +1 -1
  52. package/dist/source/adapters/memory.js +24 -10
  53. package/dist/source/adapters/prisma.js +35 -8
  54. package/dist/source/conformance.d.ts +26 -28
  55. package/dist/source/conformance.js +142 -69
  56. package/dist/source/contract.d.ts +46 -4
  57. package/dist/source/contract.js +66 -5
  58. package/dist/source/factory.d.ts +3 -3
  59. package/dist/source/factory.js +24 -3
  60. package/dist/source/idempotency.d.ts +46 -0
  61. package/dist/source/idempotency.js +129 -0
  62. package/dist/source/index.d.ts +6 -4
  63. package/dist/source/index.js +5 -3
  64. package/dist/source/migrations.d.ts +9 -5
  65. package/dist/source/migrations.js +39 -6
  66. package/dist/source/types.d.ts +88 -14
  67. package/dist/source/types.js +16 -0
  68. package/dist/surface.d.ts +1 -1
  69. package/dist/surface.js +0 -1
  70. package/dist/sync/BootstrapFetcher.js +7 -0
  71. package/dist/sync/SyncWebSocket.d.ts +5 -27
  72. package/dist/sync/awaitClaimGrant.d.ts +6 -0
  73. package/dist/sync/awaitClaimGrant.js +12 -2
  74. package/dist/sync/commitFrames.d.ts +2 -4
  75. package/dist/sync/commitFrames.js +1 -0
  76. package/dist/sync/createClaimStream.d.ts +1 -1
  77. package/dist/sync/createClaimStream.js +13 -15
  78. package/dist/sync/deltaPipeline.d.ts +1 -1
  79. package/dist/sync/deltaPipeline.js +1 -1
  80. package/dist/sync/participants.d.ts +2 -3
  81. package/dist/sync/participants.js +2 -2
  82. package/dist/sync/wsFrameHandlers.js +35 -15
  83. package/dist/testing/mocks/MockMutationExecutor.d.ts +21 -0
  84. package/dist/testing/mocks/MockMutationExecutor.js +53 -1
  85. package/dist/transactions/TransactionQueue.d.ts +61 -9
  86. package/dist/transactions/TransactionQueue.js +502 -114
  87. package/dist/transactions/commitEnvelope.d.ts +4 -0
  88. package/dist/transactions/commitEnvelope.js +25 -0
  89. package/dist/transactions/commitPayload.d.ts +23 -5
  90. package/dist/transactions/commitPayload.js +3 -0
  91. package/dist/transactions/deltaConfirmation.d.ts +2 -1
  92. package/dist/transactions/deltaConfirmation.js +22 -4
  93. package/dist/transactions/durableWriteStore.d.ts +8 -1
  94. package/dist/transactions/httpCommitEnvelope.d.ts +3 -1
  95. package/dist/transactions/httpCommitEnvelope.js +30 -0
  96. package/dist/transactions/replayValidation.d.ts +4 -0
  97. package/dist/transactions/replayValidation.js +6 -0
  98. package/dist/types/streams.d.ts +29 -17
  99. package/dist/wire/commit.d.ts +671 -0
  100. package/dist/wire/commit.js +300 -0
  101. package/dist/wire/delta.d.ts +4 -0
  102. package/dist/wire/delta.js +9 -0
  103. package/dist/wire/frames.d.ts +17 -45
  104. package/dist/wire/index.d.ts +2 -0
  105. package/dist/wire/index.js +4 -0
  106. package/docs/agent-messaging.md +4 -5
  107. package/docs/agents.md +1 -2
  108. package/docs/api-keys.md +1 -2
  109. package/docs/client-behavior.md +4 -6
  110. package/docs/coordination.md +64 -9
  111. package/docs/data-sources.md +4 -7
  112. package/docs/integration-guide.md +6 -8
  113. package/docs/migration.md +0 -3
  114. package/docs/quickstart.md +7 -8
  115. package/docs/schema-contract.md +7 -8
  116. package/package.json +4 -4
  117. package/dist/client/registerDataSource.d.ts +0 -19
  118. package/dist/client/registerDataSource.js +0 -66
@@ -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 an adapter commits atomically: one or more operations grouped under a
41
- * single `clientTxId`. That `clientTxId` is the idempotency key, so committing the
42
- * same change set twice applies it once and returns the same result.
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
- clientTxId: z.string().min(1),
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
- /** The originating transaction id, carried so Ablo can drop echoes of changes the SDK itself wrote. */
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;
@@ -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 the stable `event.id` and drops echoes of the SDK's own writes when a
63
- * row carries the originating `clientTxId`, so store both fields in your outbox
64
- * table.
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
  /**
@@ -38,16 +38,29 @@ async function handleViaAdapter(adapter, body, scope) {
38
38
  return json({ rows });
39
39
  }
40
40
  if (body.type === 'commit') {
41
- if (!body.clientTxId) {
42
- return json({ error: 'source_commit_requires_client_tx_id', message: 'commit requires a clientTxId for idempotency' }, 400);
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
- clientTxId: body.clientTxId,
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
+ }
@@ -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';
@@ -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`, which dedupes commits by `clientTxId`, and
4
- * `ablo_outbox`, the transactional outbox the `events()` feed reads. Defining it in
5
- * one place keeps the Prisma adapter, the Drizzle adapter, and `ablo migrate` in
6
- * agreement on the exact shape.
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
- /** Returns the adapter's table-creation migrations. The SQL is idempotent, guarded by `IF NOT EXISTS`. */
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`, which dedupes commits by `clientTxId`, and
4
- * `ablo_outbox`, the transactional outbox the `events()` feed reads. Defining it in
5
- * one place keeps the Prisma adapter, the Drizzle adapter, and `ablo migrate` in
6
- * agreement on the exact shape.
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
- /** Returns the adapter's table-creation migrations. The SQL is idempotent, guarded by `IF NOT EXISTS`. */
13
- export function adapterTableMigrations() {
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
+ }