@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.
Files changed (118) hide show
  1. package/CHANGELOG.md +31 -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
@@ -6,10 +6,11 @@
6
6
  * mapper is complete when it passes the same suite this one passes.
7
7
  *
8
8
  * It models the semantics minimally but faithfully: one row store per model, an
9
- * idempotency ledger keyed by `clientTxId`, and an append-only outbox with a
9
+ * idempotency ledger keyed by scoped correlation, and an append-only outbox with a
10
10
  * monotonic cursor.
11
11
  */
12
12
  import { AbloValidationError } from '../../errors.js';
13
+ import { assertSourceIdempotencyIntent, sourceChangeIntentHash, } from '../idempotency.js';
13
14
  function rowId(op) {
14
15
  const id = op.id ?? op.input?.id;
15
16
  if (typeof id !== 'string' || id.length === 0) {
@@ -20,7 +21,7 @@ function rowId(op) {
20
21
  export function memoryDataSource() {
21
22
  /** model → (id → row). */
22
23
  const store = new Map();
23
- /** clientTxIdthe rows that commit returned (idempotency ledger). */
24
+ /** Permanent correlationId intent hash + original response ledger. */
24
25
  const idempotency = new Map();
25
26
  /** Append-only outbox; `cursor` is the 1-based index as a string. */
26
27
  const outbox = [];
@@ -62,7 +63,13 @@ export function memoryDataSource() {
62
63
  }
63
64
  };
64
65
  return {
65
- capabilities: { transactions: true, propose: false, schemaIntrospection: false },
66
+ capabilities: {
67
+ transactions: true,
68
+ propose: false,
69
+ schemaIntrospection: false,
70
+ postgresWalEcho: false,
71
+ outboxEvents: true,
72
+ },
66
73
  migrations() {
67
74
  // Nothing to create in memory. A database-backed adapter returns the SQL for its ablo_idempotency and ablo_outbox tables here.
68
75
  return [];
@@ -82,26 +89,33 @@ export function memoryDataSource() {
82
89
  return rows;
83
90
  },
84
91
  async commit(change) {
85
- // Idempotency: a duplicate clientTxId returns the original rows, no re-apply.
86
- const cached = idempotency.get(change.clientTxId);
87
- if (cached)
88
- return { rows: cached };
92
+ if (change.echo) {
93
+ throw new AbloValidationError('memoryDataSource cannot emit a Postgres WAL commit echo');
94
+ }
95
+ // Idempotency: a duplicate scoped correlation returns the original rows.
96
+ const requestHash = sourceChangeIntentHash(change);
97
+ const cached = idempotency.get(change.correlationId);
98
+ if (cached) {
99
+ assertSourceIdempotencyIntent(cached.requestHash, requestHash);
100
+ return { rows: cached.rows };
101
+ }
89
102
  const rows = [];
90
103
  for (const [index, op] of change.operations.entries()) {
91
104
  const row = applyOperation(op);
92
105
  rows.push(row);
93
106
  // Transactional outbox: one event per op, monotonic cursor.
94
107
  outbox.push({
95
- id: `${change.clientTxId}:${index}`,
108
+ id: `${change.correlationId}:${index}`,
96
109
  model: op.model,
97
110
  entityId: String(row.id ?? rowId(op)),
98
111
  type: op.type,
99
112
  data: op.type === 'DELETE' ? null : row,
100
- clientTxId: change.clientTxId,
113
+ correlationId: change.correlationId,
114
+ ...(op.transactionId ? { transactionId: op.transactionId } : {}),
101
115
  cursor: String(outbox.length + 1),
102
116
  });
103
117
  }
104
- idempotency.set(change.clientTxId, rows);
118
+ idempotency.set(change.correlationId, { requestHash, rows });
105
119
  return { rows };
106
120
  },
107
121
  async events(cursor, limit) {
@@ -16,6 +16,8 @@
16
16
  import { AbloValidationError } from '../../errors.js';
17
17
  import { outboxEventSchema } from '../contract.js';
18
18
  import { adapterTableMigrations } from '../migrations.js';
19
+ import { assertSourceIdempotencyIntent, assertSourceIdempotencyRetention, sourceChangeIntentHash, } from '../idempotency.js';
20
+ import { ABLO_POSTGRES_COMMIT_ECHO_PREFIX, } from '../types.js';
19
21
  const lowerFirst = (s) => (s ? s.charAt(0).toLowerCase() + s.slice(1) : s);
20
22
  /**
21
23
  * Resolves a model's Prisma delegate by name. This is the one unavoidable cast in
@@ -119,7 +121,13 @@ export function prismaDataSource(prisma, schema, options = {}) {
119
121
  }
120
122
  };
121
123
  return {
122
- capabilities: { transactions: true, propose: false, schemaIntrospection: true },
124
+ capabilities: {
125
+ transactions: true,
126
+ propose: false,
127
+ schemaIntrospection: true,
128
+ postgresWalEcho: true,
129
+ outboxEvents: true,
130
+ },
123
131
  migrations() {
124
132
  return adapterTableMigrations();
125
133
  },
@@ -132,28 +140,45 @@ export function prismaDataSource(prisma, schema, options = {}) {
132
140
  return delegate.findMany(findManyArgs(req.query));
133
141
  },
134
142
  async commit(change) {
143
+ const requestHash = sourceChangeIntentHash(change);
135
144
  return prisma.$transaction(async (tx) => {
136
- // Idempotency: a duplicate clientTxId returns the original rows, no re-apply.
137
- const cached = await tx.$queryRawUnsafe(`SELECT response FROM ablo_idempotency WHERE client_tx_id = $1 LIMIT 1`, change.clientTxId);
145
+ // Idempotency: a duplicate scoped correlation returns the original rows.
146
+ const cached = await tx.$queryRawUnsafe(`SELECT response, request_hash AS "requestHash", expires_at AS "expiresAt"
147
+ FROM ablo_idempotency WHERE client_tx_id = $1 LIMIT 1`, change.correlationId);
138
148
  const cachedRow = cached[0];
139
- if (cachedRow)
149
+ if (cachedRow) {
150
+ assertSourceIdempotencyIntent(cachedRow.requestHash, requestHash);
151
+ assertSourceIdempotencyRetention(cachedRow.expiresAt);
140
152
  return { rows: cachedRow.response };
153
+ }
141
154
  const rows = [];
142
155
  for (const [index, op] of change.operations.entries()) {
143
156
  const row = await applyOperation(tx, op);
144
157
  rows.push(row);
145
158
  const entityId = String(row.id ?? rowId(op));
146
159
  // Transactional outbox: one event per operation, written in this same transaction.
147
- await tx.$executeRawUnsafe(`INSERT INTO ablo_outbox (id, model, entity_id, type, data, client_tx_id, occurred_at)
148
- VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7)`, `${change.clientTxId}:${index}`, op.model, entityId, op.type, JSON.stringify(op.type === 'DELETE' ? null : row), change.clientTxId, Date.now());
160
+ await tx.$executeRawUnsafe(`INSERT INTO ablo_outbox (
161
+ id, model, entity_id, type, data,
162
+ correlation_id, transaction_id, occurred_at
163
+ ) VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7, $8)`, `${change.correlationId}:${index}`, op.model, entityId, op.type, JSON.stringify(op.type === 'DELETE' ? null : row), change.correlationId, op.transactionId ?? null, Date.now());
164
+ }
165
+ await tx.$executeRawUnsafe(`INSERT INTO ablo_idempotency (client_tx_id, response, request_hash)
166
+ VALUES ($1, $2::jsonb, $3)`, change.correlationId, JSON.stringify(rows), requestHash);
167
+ if (change.echo?.kind === 'postgres-wal') {
168
+ // Cast the returned LSN to text. `pg_logical_emit_message` yields a
169
+ // `pg_lsn`, and Prisma's driver adapter can't deserialize that OID
170
+ // ("Failed to deserialize column of type 'pg_lsn'"); the value is
171
+ // discarded anyway, so text is the safe carrier. (postgres.js-backed
172
+ // adapters read pg_lsn as a string and don't need this.)
173
+ await tx.$queryRawUnsafe(`SELECT pg_logical_emit_message(true, $1::text, $2::text)::text`, ABLO_POSTGRES_COMMIT_ECHO_PREFIX, change.echo.payload);
149
174
  }
150
- await tx.$executeRawUnsafe(`INSERT INTO ablo_idempotency (client_tx_id, response) VALUES ($1, $2::jsonb)`, change.clientTxId, JSON.stringify(rows));
151
175
  return { rows };
152
176
  });
153
177
  },
154
178
  async events(cursor, limit) {
155
179
  const after = cursor ? cursor : '0';
156
- const rows = await prisma.$queryRawUnsafe(`SELECT cursor, id, model, entity_id, type, data, organization_id, client_tx_id, occurred_at
180
+ const rows = await prisma.$queryRawUnsafe(`SELECT cursor, id, model, entity_id, type, data, organization_id,
181
+ client_tx_id, correlation_id, transaction_id, occurred_at
157
182
  FROM ablo_outbox WHERE cursor > $1 ORDER BY cursor ASC LIMIT $2`, after, limit);
158
183
  const events = rows.map((r) => outboxEventSchema.parse({
159
184
  id: r.id,
@@ -163,6 +188,8 @@ export function prismaDataSource(prisma, schema, options = {}) {
163
188
  data: r.data ?? null,
164
189
  organizationId: r.organization_id ?? null,
165
190
  clientTxId: r.client_tx_id ?? null,
191
+ correlationId: r.correlation_id ?? null,
192
+ transactionId: r.transaction_id ?? null,
166
193
  occurredAt: r.occurred_at != null ? Number(r.occurred_at) : null,
167
194
  cursor: String(r.cursor),
168
195
  }));
@@ -1,39 +1,37 @@
1
1
  /**
2
- * The conformance suite for data-source adapters: a shared set of tests that checks
3
- * whether an adapter is correct. Every adapter in this package (Prisma, Drizzle,
4
- * Kysely) and any adapter you write yourself runs this suite to confirm it upholds
5
- * the guarantees {@link DataSourceAdapter} promises. An adapter is complete when it
6
- * passes, not merely when it compiles.
2
+ * Capability-split conformance for customer-database mutation wrappers.
7
3
  *
8
- * The suite is runner-agnostic: each check is a plain async function that throws,
9
- * via `node:assert`, on failure. {@link runDataSourceTests} registers the checks
10
- * with whichever `it` or `test` function you pass, so it runs under vitest, jest,
11
- * or `node:test`:
12
- *
13
- * import { it } from 'vitest';
14
- * runDataSourceTests(memoryDataSource, it);
15
- *
16
- * The checks cover the adapter contract: commit idempotency, read-after-write, and
17
- * the transactional outbox with its cursor. They do not cover request-signature or
18
- * scope rejection, which the HTTP handler enforces before the adapter is ever
19
- * called and which is tested separately.
4
+ * `mutationConformanceChecks` covers only guarantees shared by direct and
5
+ * endpoint: schema-mapped DML, reads, permanent scoped idempotency, canonical
6
+ * request-hash conflicts, and concurrent replay. `endpointConformanceChecks`
7
+ * covers the endpoint-only transactional outbox and events cursor. Direct
8
+ * wrappers must not run (or claim) those outbox guarantees because WAL is their
9
+ * sole source feed.
20
10
  */
21
- import type { DataSourceAdapter } from './adapter.js';
22
- /** A factory that returns a fresh adapter. Each check calls it to start from clean state. */
11
+ import type { DataSourceAdapter, MutationAdapter } from './adapter.js';
12
+ import type { ChangeSet } from './contract.js';
13
+ export type MakeMutationAdapter = () => MutationAdapter | Promise<MutationAdapter>;
23
14
  export type MakeAdapter = () => DataSourceAdapter | Promise<DataSourceAdapter>;
24
- /** A single conformance check. `run` throws on failure. */
25
15
  export interface ConformanceCheck {
26
16
  readonly name: string;
27
17
  run(): Promise<void>;
28
18
  }
29
- /**
30
- * Builds the list of conformance checks for an adapter. Call this to run the checks
31
- * yourself, or use {@link runDataSourceTests} to register them with a test runner.
32
- */
19
+ export interface MutationConformanceOptions {
20
+ /**
21
+ * Add transport-required evidence without changing the shared checks. Direct
22
+ * wrappers use this to attach their logical marker; endpoint wrappers need no
23
+ * decoration.
24
+ */
25
+ readonly prepareChange?: (change: ChangeSet) => ChangeSet;
26
+ }
27
+ /** Guarantees shared by direct and endpoint mutation wrappers. */
28
+ export declare function mutationConformanceChecks(make: MakeMutationAdapter, options?: MutationConformanceOptions): ConformanceCheck[];
29
+ /** Guarantees that apply only to endpoint wrappers with an outbox/events feed. */
30
+ export declare function endpointConformanceChecks(make: MakeAdapter): ConformanceCheck[];
31
+ /** Compatibility name for the complete endpoint adapter suite. */
33
32
  export declare function dataSourceConformanceChecks(make: MakeAdapter): ConformanceCheck[];
34
- /**
35
- * Register the conformance checks with a test runner's `it`/`test` function.
36
- * `register(name, fn)` pass vitest/jest `it` or `node:test` `test`.
37
- */
33
+ export declare function runMutationTests(make: MakeMutationAdapter, register: (name: string, fn: () => Promise<void>) => void, options?: MutationConformanceOptions): void;
34
+ export declare function runEndpointDataSourceTests(make: MakeAdapter, register: (name: string, fn: () => Promise<void>) => void): void;
35
+ /** Backward-compatible complete endpoint suite. */
38
36
  export declare function runDataSourceTests(make: MakeAdapter, register: (name: string, fn: () => Promise<void>) => void): void;
39
37
  export { memoryDataSource } from './adapters/memory.js';
@@ -1,39 +1,33 @@
1
1
  /**
2
- * The conformance suite for data-source adapters: a shared set of tests that checks
3
- * whether an adapter is correct. Every adapter in this package (Prisma, Drizzle,
4
- * Kysely) and any adapter you write yourself runs this suite to confirm it upholds
5
- * the guarantees {@link DataSourceAdapter} promises. An adapter is complete when it
6
- * passes, not merely when it compiles.
2
+ * Capability-split conformance for customer-database mutation wrappers.
7
3
  *
8
- * The suite is runner-agnostic: each check is a plain async function that throws,
9
- * via `node:assert`, on failure. {@link runDataSourceTests} registers the checks
10
- * with whichever `it` or `test` function you pass, so it runs under vitest, jest,
11
- * or `node:test`:
12
- *
13
- * import { it } from 'vitest';
14
- * runDataSourceTests(memoryDataSource, it);
15
- *
16
- * The checks cover the adapter contract: commit idempotency, read-after-write, and
17
- * the transactional outbox with its cursor. They do not cover request-signature or
18
- * scope rejection, which the HTTP handler enforces before the adapter is ever
19
- * called and which is tested separately.
4
+ * `mutationConformanceChecks` covers only guarantees shared by direct and
5
+ * endpoint: schema-mapped DML, reads, permanent scoped idempotency, canonical
6
+ * request-hash conflicts, and concurrent replay. `endpointConformanceChecks`
7
+ * covers the endpoint-only transactional outbox and events cursor. Direct
8
+ * wrappers must not run (or claim) those outbox guarantees because WAL is their
9
+ * sole source feed.
20
10
  */
21
11
  import assert from 'node:assert/strict';
22
- const change = (clientTxId, ops) => ({
23
- clientTxId,
24
- operations: ops,
12
+ const change = (correlationId, operations, intentHash) => ({
13
+ correlationId,
14
+ operations,
15
+ ...(intentHash ? { intentHash } : {}),
25
16
  });
26
- /**
27
- * Builds the list of conformance checks for an adapter. Call this to run the checks
28
- * yourself, or use {@link runDataSourceTests} to register them with a test runner.
29
- */
30
- export function dataSourceConformanceChecks(make) {
17
+ /** Guarantees shared by direct and endpoint mutation wrappers. */
18
+ export function mutationConformanceChecks(make, options = {}) {
19
+ const request = (correlationId, operations, intentHash) => {
20
+ const value = change(correlationId, operations, intentHash);
21
+ return options.prepareChange?.(value) ?? value;
22
+ };
31
23
  return [
32
24
  {
33
25
  name: 'commit applies a CREATE and returns the canonical row',
34
26
  run: async () => {
35
27
  const adapter = await make();
36
- const result = await adapter.commit(change('tx_create', [{ type: 'CREATE', model: 'task', id: 't1', input: { title: 'A' } }]));
28
+ const result = await adapter.commit(request('corr_create', [
29
+ { type: 'CREATE', model: 'task', id: 't1', input: { title: 'A' } },
30
+ ]));
37
31
  assert.equal(result.rows.length, 1, 'one row returned');
38
32
  const created = result.rows[0];
39
33
  assert.ok(created, 'one row returned');
@@ -45,7 +39,9 @@ export function dataSourceConformanceChecks(make) {
45
39
  name: 'read load returns a committed row, and null-equivalent for an unknown id',
46
40
  run: async () => {
47
41
  const adapter = await make();
48
- await adapter.commit(change('tx1', [{ type: 'CREATE', model: 'task', id: 't1', input: { title: 'A' } }]));
42
+ await adapter.commit(request('corr_load', [
43
+ { type: 'CREATE', model: 'task', id: 't1', input: { title: 'A' } },
44
+ ]));
49
45
  const found = await adapter.read({ kind: 'load', model: 'task', id: 't1' });
50
46
  assert.equal(found.length, 1);
51
47
  assert.equal(found[0]?.title, 'A');
@@ -57,86 +53,163 @@ export function dataSourceConformanceChecks(make) {
57
53
  name: 'read list returns committed rows',
58
54
  run: async () => {
59
55
  const adapter = await make();
60
- await adapter.commit(change('tx_list', [
56
+ await adapter.commit(request('corr_list', [
61
57
  { type: 'CREATE', model: 'task', id: 't1', input: { title: 'A' } },
62
58
  { type: 'CREATE', model: 'task', id: 't2', input: { title: 'B' } },
63
59
  ]));
64
60
  const rows = await adapter.read({ kind: 'list', model: 'task' });
65
- const ids = rows.map((r) => (r).id).sort();
66
- assert.deepEqual(ids, ['t1', 't2']);
61
+ assert.deepEqual(rows.map((row) => row.id).sort(), ['t1', 't2']);
67
62
  },
68
63
  },
69
64
  {
70
- name: 'duplicate clientTxId is idempotent same rows, applied once',
65
+ name: 'same scoped correlation and intent replays the original response without DML',
71
66
  run: async () => {
72
67
  const adapter = await make();
73
- const cs = change('tx_dup', [{ type: 'CREATE', model: 'task', id: 't1', input: { title: 'A', n: 1 } }]);
74
- const first = await adapter.commit(cs);
75
- const second = await adapter.commit(cs);
68
+ const commitRequest = request('corr_replay', [
69
+ { type: 'CREATE', model: 'task', id: 't1', input: { title: 'A', n: 1 } },
70
+ ]);
71
+ const first = await adapter.commit(commitRequest);
72
+ const second = await adapter.commit(commitRequest);
76
73
  assert.deepEqual(second.rows, first.rows, 'replay returns the original rows');
77
- // Applied once: still exactly one row, and the outbox did not double up.
78
74
  const rows = await adapter.read({ kind: 'list', model: 'task' });
79
75
  assert.equal(rows.length, 1, 'no duplicate row');
76
+ },
77
+ },
78
+ {
79
+ name: 'same scoped correlation with a different canonical intent rejects',
80
+ run: async () => {
81
+ const adapter = await make();
82
+ await adapter.commit(request('corr_conflict', [{ type: 'CREATE', model: 'task', id: 't1', input: { title: 'A' } }], 'a'.repeat(64)));
83
+ await assert.rejects(adapter.commit(request('corr_conflict', [{ type: 'UPDATE', model: 'task', id: 't1', input: { title: 'B' } }], 'b'.repeat(64))), (error) => typeof error === 'object' &&
84
+ error !== null &&
85
+ 'code' in error &&
86
+ error.code === 'idempotency_conflict');
87
+ },
88
+ },
89
+ {
90
+ name: 'concurrent identical requests apply once and replay one response',
91
+ run: async () => {
92
+ const adapter = await make();
93
+ const commitRequest = request('corr_concurrent', [
94
+ { type: 'CREATE', model: 'task', id: 't1', input: { title: 'A' } },
95
+ ]);
96
+ const [first, second] = await Promise.all([
97
+ adapter.commit(commitRequest),
98
+ adapter.commit(commitRequest),
99
+ ]);
100
+ assert.deepEqual(second.rows, first.rows);
101
+ const rows = await adapter.read({ kind: 'list', model: 'task' });
102
+ assert.equal(rows.length, 1, 'concurrent retries produced one row');
103
+ },
104
+ },
105
+ {
106
+ name: 'a later mutation under a new scoped correlation is applied',
107
+ run: async () => {
108
+ const adapter = await make();
109
+ await adapter.commit(request('corr_create_2', [
110
+ { type: 'CREATE', model: 'task', id: 't1', input: { title: 'A' } },
111
+ ]));
112
+ await adapter.commit(request('corr_update_2', [
113
+ { type: 'UPDATE', model: 'task', id: 't1', input: { title: 'B' } },
114
+ ]));
115
+ const found = await adapter.read({ kind: 'load', model: 'task', id: 't1' });
116
+ assert.equal(found[0]?.title, 'B', 'update applied');
117
+ },
118
+ },
119
+ ];
120
+ }
121
+ /** Guarantees that apply only to endpoint wrappers with an outbox/events feed. */
122
+ export function endpointConformanceChecks(make) {
123
+ return [
124
+ {
125
+ name: 'endpoint replay does not append its correlated outbox event twice',
126
+ run: async () => {
127
+ const adapter = await make();
128
+ assert.equal(adapter.capabilities.outboxEvents, true);
129
+ const request = change('corr_outbox_replay', [
130
+ {
131
+ type: 'CREATE',
132
+ model: 'task',
133
+ id: 't1',
134
+ input: { title: 'A' },
135
+ transactionId: 'op_outbox_replay',
136
+ },
137
+ ]);
138
+ await adapter.commit(request);
139
+ await adapter.commit(request);
80
140
  const page = await adapter.events(null, 100);
81
- const forTx = page.events.filter((e) => e.clientTxId === 'tx_dup');
82
- assert.equal(forTx.length, 1, 'outbox not double-appended on replay');
141
+ assert.equal(page.events.filter((event) => event.correlationId === 'corr_outbox_replay').length, 1);
83
142
  },
84
143
  },
85
144
  {
86
- name: 'commit appends outbox events with the originating clientTxId',
145
+ name: 'endpoint outbox exposes explicit correlation and operation identity',
87
146
  run: async () => {
88
147
  const adapter = await make();
89
- await adapter.commit(change('tx_evt', [{ type: 'CREATE', model: 'task', id: 't1', input: { title: 'A' } }]));
148
+ assert.equal(adapter.capabilities.outboxEvents, true);
149
+ await adapter.commit(change('corr_event', [
150
+ {
151
+ type: 'CREATE',
152
+ model: 'task',
153
+ id: 't1',
154
+ input: { title: 'A' },
155
+ transactionId: 'op_event',
156
+ },
157
+ ]));
90
158
  const page = await adapter.events(null, 100);
91
- assert.ok(page.events.length >= 1, 'at least one event');
92
- const evt = page.events.find((e) => e.entityId === 't1');
93
- assert.ok(evt, 'event for the committed row');
94
- assert.equal(evt.model, 'task');
95
- assert.equal(evt.type, 'CREATE');
96
- assert.equal(evt.clientTxId, 'tx_evt');
159
+ const event = page.events.find((candidate) => candidate.entityId === 't1');
160
+ assert.ok(event, 'event for the committed row');
161
+ assert.equal(event.model, 'task');
162
+ assert.equal(event.type, 'CREATE');
163
+ assert.equal(event.correlationId, 'corr_event');
164
+ assert.equal(event.transactionId, 'op_event');
165
+ assert.equal(event.clientTxId ?? null, null, 'legacy echo identity stays empty');
97
166
  },
98
167
  },
99
168
  {
100
- name: 'events cursor advances and never re-delivers a page',
169
+ name: 'endpoint events cursor advances and never re-delivers a page',
101
170
  run: async () => {
102
171
  const adapter = await make();
103
- await adapter.commit(change('tx_a', [{ type: 'CREATE', model: 'task', id: 't1', input: {} }]));
104
- await adapter.commit(change('tx_b', [{ type: 'CREATE', model: 'task', id: 't2', input: {} }]));
172
+ await adapter.commit(change('corr_page_a', [
173
+ { type: 'CREATE', model: 'task', id: 't1', input: {} },
174
+ ]));
175
+ await adapter.commit(change('corr_page_b', [
176
+ { type: 'CREATE', model: 'task', id: 't2', input: {} },
177
+ ]));
105
178
  const first = await adapter.events(null, 1);
106
179
  assert.equal(first.events.length, 1, 'respects limit');
107
180
  assert.ok(first.nextCursor, 'returns a cursor');
108
181
  const second = await adapter.events(first.nextCursor, 100);
109
- // No overlap: the second page starts strictly after the first cursor.
110
- const firstIds = new Set(first.events.map((e) => e.id));
111
- for (const e of second.events) {
112
- assert.ok(!firstIds.has(e.id), `event ${e.id} re-delivered across cursor`);
182
+ const firstIds = new Set(first.events.map((event) => event.id));
183
+ for (const event of second.events) {
184
+ assert.ok(!firstIds.has(event.id), `event ${event.id} re-delivered across cursor`);
113
185
  }
114
- // Draining to the end yields a stable terminal cursor.
115
186
  const drained = await adapter.events(second.nextCursor ?? first.nextCursor, 100);
116
187
  assert.equal(drained.events.length, 0, 'fully drained');
117
188
  },
118
189
  },
119
- {
120
- name: 'a later UPDATE under a new clientTxId is applied (idempotency is per-transaction)',
121
- run: async () => {
122
- const adapter = await make();
123
- await adapter.commit(change('tx_c1', [{ type: 'CREATE', model: 'task', id: 't1', input: { title: 'A' } }]));
124
- await adapter.commit(change('tx_u1', [{ type: 'UPDATE', model: 'task', id: 't1', input: { title: 'B' } }]));
125
- const found = await adapter.read({ kind: 'load', model: 'task', id: 't1' });
126
- assert.equal(found[0]?.title, 'B', 'update applied');
127
- },
128
- },
129
190
  ];
130
191
  }
131
- /**
132
- * Register the conformance checks with a test runner's `it`/`test` function.
133
- * `register(name, fn)` — pass vitest/jest `it` or `node:test` `test`.
134
- */
192
+ /** Compatibility name for the complete endpoint adapter suite. */
193
+ export function dataSourceConformanceChecks(make) {
194
+ return [
195
+ ...mutationConformanceChecks(make),
196
+ ...endpointConformanceChecks(make),
197
+ ];
198
+ }
199
+ export function runMutationTests(make, register, options = {}) {
200
+ for (const check of mutationConformanceChecks(make, options)) {
201
+ register(check.name, check.run);
202
+ }
203
+ }
204
+ export function runEndpointDataSourceTests(make, register) {
205
+ for (const check of endpointConformanceChecks(make)) {
206
+ register(check.name, check.run);
207
+ }
208
+ }
209
+ /** Backward-compatible complete endpoint suite. */
135
210
  export function runDataSourceTests(make, register) {
136
211
  for (const check of dataSourceConformanceChecks(make)) {
137
212
  register(check.name, check.run);
138
213
  }
139
214
  }
140
- // Re-export the reference adapter so `@abloatai/ablo/source/conformance`
141
- // exposes both the suite and the in-memory double in one import.
142
215
  export { memoryDataSource } from './adapters/memory.js';
@@ -47,10 +47,41 @@ export declare const operationSchema: z.ZodObject<{
47
47
  }>>>;
48
48
  }, z.core.$strip>;
49
49
  export type Operation = z.infer<typeof operationSchema>;
50
+ /** The supported customer-transaction echo mechanism. */
51
+ export declare const sourceCommitEchoSchema: z.ZodObject<{
52
+ kind: z.ZodLiteral<"postgres-wal">;
53
+ payload: z.ZodString;
54
+ }, z.core.$strip>;
55
+ export declare const sourceCommitEchoOperationSchema: z.ZodObject<{
56
+ model: z.ZodString;
57
+ id: z.ZodString;
58
+ action: z.ZodEnum<{
59
+ I: "I";
60
+ U: "U";
61
+ D: "D";
62
+ }>;
63
+ transactionId: z.ZodString;
64
+ }, z.core.$strict>;
65
+ export declare const sourceCommitEchoMarkerSchema: z.ZodObject<{
66
+ version: z.ZodLiteral<1>;
67
+ correlationId: z.ZodString;
68
+ operations: z.ZodReadonly<z.ZodArray<z.ZodObject<{
69
+ model: z.ZodString;
70
+ id: z.ZodString;
71
+ action: z.ZodEnum<{
72
+ I: "I";
73
+ U: "U";
74
+ D: "D";
75
+ }>;
76
+ transactionId: z.ZodString;
77
+ }, z.core.$strict>>>;
78
+ }, z.core.$strict>;
79
+ export type SourceCommitEchoMarkerWire = z.infer<typeof sourceCommitEchoMarkerSchema>;
50
80
  /**
51
- * The unit an adapter commits atomically: one or more operations grouped under a
52
- * single `clientTxId`. That `clientTxId` is the idempotency key, so committing the
53
- * same change set twice applies it once and returns the same result.
81
+ * The unit a mutation wrapper commits atomically. `correlationId` is derived by
82
+ * Ablo from the authenticated plane, participant, and public idempotency key; it
83
+ * is the customer-ledger identity. It is intentionally not a raw caller-authored
84
+ * `clientTxId` or an operation transaction id.
54
85
  */
55
86
  export declare const changeSetSchema: z.ZodObject<{
56
87
  operations: z.ZodArray<z.ZodObject<{
@@ -72,7 +103,12 @@ export declare const changeSetSchema: z.ZodObject<{
72
103
  notify: "notify";
73
104
  }>>>;
74
105
  }, z.core.$strip>>;
75
- clientTxId: z.ZodString;
106
+ correlationId: z.ZodString;
107
+ intentHash: z.ZodOptional<z.ZodString>;
108
+ echo: z.ZodOptional<z.ZodObject<{
109
+ kind: z.ZodLiteral<"postgres-wal">;
110
+ payload: z.ZodString;
111
+ }, z.core.$strip>>;
76
112
  }, z.core.$strip>;
77
113
  export type ChangeSet = z.infer<typeof changeSetSchema>;
78
114
  /**
@@ -96,6 +132,8 @@ export declare const outboxEventSchema: z.ZodObject<{
96
132
  data: z.ZodOptional<z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
97
133
  organizationId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
98
134
  clientTxId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
135
+ correlationId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
136
+ transactionId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
99
137
  occurredAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
100
138
  cursor: z.ZodString;
101
139
  }, z.core.$strip>;
@@ -116,6 +154,8 @@ export declare const eventsPageSchema: z.ZodObject<{
116
154
  data: z.ZodOptional<z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
117
155
  organizationId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
118
156
  clientTxId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
157
+ correlationId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
158
+ transactionId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
119
159
  occurredAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
120
160
  cursor: z.ZodString;
121
161
  }, z.core.$strip>>;
@@ -137,5 +177,7 @@ export declare const adapterCapabilitiesSchema: z.ZodObject<{
137
177
  transactions: z.ZodBoolean;
138
178
  propose: z.ZodBoolean;
139
179
  schemaIntrospection: z.ZodBoolean;
180
+ postgresWalEcho: z.ZodOptional<z.ZodBoolean>;
181
+ outboxEvents: z.ZodBoolean;
140
182
  }, z.core.$strip>;
141
183
  export type AdapterCapabilities = z.infer<typeof adapterCapabilitiesSchema>;