@abloatai/ablo 0.29.2 → 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 +27 -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 +561 -90
- package/dist/client/Ablo.d.ts +1 -1
- package/dist/client/Ablo.js +24 -36
- package/dist/client/auth.d.ts +8 -18
- package/dist/client/auth.js +16 -96
- package/dist/client/createModelProxy.d.ts +16 -11
- package/dist/client/createModelProxy.js +162 -20
- package/dist/client/httpClient.d.ts +1 -1
- package/dist/client/httpTransport.js +263 -89
- package/dist/client/options.d.ts +0 -15
- package/dist/client/resourceTypes.d.ts +13 -26
- package/dist/client/schemaConfig.js +5 -0
- package/dist/client/writeOptionsSchema.d.ts +1 -0
- package/dist/client/writeOptionsSchema.js +11 -2
- package/dist/client/wsMutationExecutor.d.ts +3 -5
- package/dist/coordination/schema.d.ts +17 -13
- package/dist/coordination/schema.js +36 -10
- package/dist/errorCodes.d.ts +9 -4
- package/dist/errorCodes.js +10 -5
- package/dist/errors.js +4 -9
- package/dist/index.d.ts +4 -0
- package/dist/interfaces/index.d.ts +31 -19
- package/dist/schema/index.d.ts +1 -1
- package/dist/schema/index.js +4 -1
- package/dist/schema/schema.d.ts +13 -0
- package/dist/schema/select.js +7 -0
- package/dist/schema/syncDeltaRow.d.ts +31 -13
- package/dist/schema/syncDeltaRow.js +33 -15
- package/dist/schema/tenancy.d.ts +52 -16
- package/dist/schema/tenancy.js +86 -25
- package/dist/server/commit.d.ts +8 -24
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +1 -0
- package/dist/server/readConfig.d.ts +10 -0
- package/dist/source/adapter.d.ts +26 -15
- package/dist/source/adapter.js +5 -3
- package/dist/source/adapters/drizzle.js +35 -9
- package/dist/source/adapters/kysely.d.ts +33 -61
- package/dist/source/adapters/kysely.js +170 -177
- package/dist/source/adapters/kyselyMutationCore.d.ts +76 -0
- package/dist/source/adapters/kyselyMutationCore.js +125 -0
- package/dist/source/adapters/memory.d.ts +1 -1
- package/dist/source/adapters/memory.js +24 -10
- package/dist/source/adapters/prisma.js +35 -8
- package/dist/source/conformance.d.ts +26 -28
- package/dist/source/conformance.js +142 -69
- package/dist/source/contract.d.ts +46 -4
- package/dist/source/contract.js +66 -5
- package/dist/source/factory.d.ts +3 -3
- package/dist/source/factory.js +24 -3
- package/dist/source/idempotency.d.ts +46 -0
- package/dist/source/idempotency.js +129 -0
- package/dist/source/index.d.ts +6 -4
- package/dist/source/index.js +5 -3
- package/dist/source/migrations.d.ts +9 -5
- package/dist/source/migrations.js +39 -6
- package/dist/source/types.d.ts +88 -14
- package/dist/source/types.js +16 -0
- package/dist/surface.d.ts +1 -1
- package/dist/surface.js +0 -1
- package/dist/sync/BootstrapFetcher.js +7 -0
- package/dist/sync/SyncWebSocket.d.ts +5 -27
- package/dist/sync/awaitClaimGrant.d.ts +6 -0
- package/dist/sync/awaitClaimGrant.js +12 -2
- package/dist/sync/commitFrames.d.ts +2 -4
- package/dist/sync/commitFrames.js +1 -0
- package/dist/sync/createClaimStream.d.ts +1 -1
- package/dist/sync/createClaimStream.js +13 -15
- package/dist/sync/deltaPipeline.d.ts +1 -1
- package/dist/sync/deltaPipeline.js +1 -1
- package/dist/sync/participants.d.ts +2 -3
- package/dist/sync/participants.js +2 -2
- package/dist/sync/wsFrameHandlers.js +35 -15
- package/dist/testing/mocks/MockMutationExecutor.d.ts +21 -0
- package/dist/testing/mocks/MockMutationExecutor.js +53 -1
- package/dist/transactions/TransactionQueue.d.ts +61 -9
- package/dist/transactions/TransactionQueue.js +502 -114
- package/dist/transactions/commitEnvelope.d.ts +4 -0
- package/dist/transactions/commitEnvelope.js +25 -0
- package/dist/transactions/commitPayload.d.ts +23 -5
- package/dist/transactions/commitPayload.js +3 -0
- package/dist/transactions/deltaConfirmation.d.ts +2 -1
- package/dist/transactions/deltaConfirmation.js +22 -4
- package/dist/transactions/durableWriteStore.d.ts +8 -1
- package/dist/transactions/httpCommitEnvelope.d.ts +3 -1
- package/dist/transactions/httpCommitEnvelope.js +30 -0
- package/dist/transactions/replayValidation.d.ts +4 -0
- package/dist/transactions/replayValidation.js +6 -0
- package/dist/types/streams.d.ts +29 -17
- package/dist/wire/commit.d.ts +671 -0
- package/dist/wire/commit.js +300 -0
- package/dist/wire/delta.d.ts +4 -0
- package/dist/wire/delta.js +9 -0
- package/dist/wire/frames.d.ts +17 -45
- package/dist/wire/index.d.ts +2 -0
- package/dist/wire/index.js +4 -0
- package/docs/agent-messaging.md +4 -5
- package/docs/agents.md +1 -2
- package/docs/api-keys.md +1 -2
- package/docs/client-behavior.md +4 -6
- package/docs/coordination.md +64 -9
- package/docs/data-sources.md +4 -7
- package/docs/integration-guide.md +6 -8
- package/docs/migration.md +0 -3
- package/docs/quickstart.md +7 -8
- package/docs/schema-contract.md +7 -8
- package/package.json +4 -4
- package/dist/client/registerDataSource.d.ts +0 -19
- package/dist/client/registerDataSource.js +0 -66
|
@@ -6,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
|
|
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
|
-
/**
|
|
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: {
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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: {
|
|
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
|
|
137
|
-
const cached = await tx.$queryRawUnsafe(`SELECT response
|
|
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 (
|
|
148
|
-
|
|
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,
|
|
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
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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 = (
|
|
23
|
-
|
|
24
|
-
operations
|
|
12
|
+
const change = (correlationId, operations, intentHash) => ({
|
|
13
|
+
correlationId,
|
|
14
|
+
operations,
|
|
15
|
+
...(intentHash ? { intentHash } : {}),
|
|
25
16
|
});
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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: '
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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: '
|
|
145
|
+
name: 'endpoint outbox exposes explicit correlation and operation identity',
|
|
87
146
|
run: async () => {
|
|
88
147
|
const adapter = await make();
|
|
89
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
assert.
|
|
94
|
-
assert.equal(
|
|
95
|
-
assert.equal(
|
|
96
|
-
assert.equal(
|
|
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('
|
|
104
|
-
|
|
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
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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
|
-
|
|
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>;
|