@abloatai/transaction 0.50.0 → 0.52.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/dist/ablo.d.ts +1 -1
- package/dist/ablo.js +1 -1
- package/dist/ai-sdk/updateTool.d.ts +1 -1
- package/dist/ai-sdk/updateTool.js +1 -1
- package/dist/auth/capability.d.ts +2 -2
- package/dist/auth/capability.js +1 -1
- package/dist/auth/index.d.ts +9 -3
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +25 -20
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/schemas.d.ts +24 -1
- package/dist/auth/schemas.d.ts.map +1 -1
- package/dist/auth/schemas.js +8 -2
- package/dist/auth/schemas.js.map +1 -1
- package/dist/auth/sessionIssuanceProtocol.d.ts +41 -0
- package/dist/auth/sessionIssuanceProtocol.d.ts.map +1 -0
- package/dist/auth/sessionIssuanceProtocol.js +48 -0
- package/dist/auth/sessionIssuanceProtocol.js.map +1 -0
- package/dist/auth/sessionMint.d.ts.map +1 -1
- package/dist/auth/sessionMint.js +3 -2
- package/dist/auth/sessionMint.js.map +1 -1
- package/dist/branches.d.ts +4 -4
- package/dist/coordination/schema.d.ts +3 -2
- package/dist/coordination/schema.d.ts.map +1 -1
- package/dist/coordination/schema.js +7 -2
- package/dist/coordination/schema.js.map +1 -1
- package/dist/errorCodes.d.ts +3 -2
- package/dist/errorCodes.d.ts.map +1 -1
- package/dist/errorCodes.js +4 -3
- package/dist/errorCodes.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +1 -1
- package/dist/resources/httpResources.d.ts +11 -2
- package/dist/resources/httpResources.d.ts.map +1 -1
- package/dist/resources/modelOperations.d.ts +6 -6
- package/dist/schema/ddl.d.ts.map +1 -1
- package/dist/schema/ddl.js +9 -12
- package/dist/schema/ddl.js.map +1 -1
- package/dist/schema/field.d.ts +3 -3
- package/dist/schema/field.js +2 -2
- package/dist/schema/fieldRef.d.ts +1 -1
- package/dist/schema/generate.d.ts +2 -3
- package/dist/schema/generate.d.ts.map +1 -1
- package/dist/schema/generate.js +2 -8
- package/dist/schema/generate.js.map +1 -1
- package/dist/schema/index.d.ts +2 -2
- package/dist/schema/index.js +3 -3
- package/dist/schema/model.d.ts +7 -7
- package/dist/schema/model.js +2 -2
- package/dist/schema/openapi.d.ts.map +1 -1
- package/dist/schema/openapi.js +4 -7
- package/dist/schema/openapi.js.map +1 -1
- package/dist/schema/queries.d.ts +1 -1
- package/dist/schema/queries.js +1 -1
- package/dist/schema/relation.d.ts +8 -8
- package/dist/schema/relation.js +5 -5
- package/dist/schema/schema.d.ts +24 -37
- package/dist/schema/schema.d.ts.map +1 -1
- package/dist/schema/schema.js +15 -28
- package/dist/schema/schema.js.map +1 -1
- package/dist/schema/sugar.d.ts +7 -7
- package/dist/schema/sugar.js +5 -5
- package/dist/server/commit.d.ts +5 -1
- package/dist/server/commit.d.ts.map +1 -1
- package/dist/source/adapters/drizzle.d.ts.map +1 -1
- package/dist/source/adapters/drizzle.js +5 -0
- package/dist/source/adapters/drizzle.js.map +1 -1
- package/dist/source/adapters/kysely.d.ts.map +1 -1
- package/dist/source/adapters/kysely.js +33 -8
- package/dist/source/adapters/kysely.js.map +1 -1
- package/dist/source/adapters/kyselyMutationCore.d.ts +1 -1
- package/dist/source/adapters/kyselyMutationCore.d.ts.map +1 -1
- package/dist/source/adapters/kyselyMutationCore.js +58 -20
- package/dist/source/adapters/kyselyMutationCore.js.map +1 -1
- package/dist/source/adapters/memory.d.ts.map +1 -1
- package/dist/source/adapters/memory.js +5 -0
- package/dist/source/adapters/memory.js.map +1 -1
- package/dist/source/adapters/prisma.d.ts.map +1 -1
- package/dist/source/adapters/prisma.js +6 -1
- package/dist/source/adapters/prisma.js.map +1 -1
- package/dist/source/conformance.js +21 -21
- package/dist/source/contract.d.ts +17 -0
- package/dist/source/contract.d.ts.map +1 -1
- package/dist/source/contract.js +31 -0
- package/dist/source/contract.js.map +1 -1
- package/dist/source/index.d.ts +1 -1
- package/dist/source/index.d.ts.map +1 -1
- package/dist/source/index.js +1 -1
- package/dist/source/index.js.map +1 -1
- package/dist/source/types.d.ts +1 -0
- package/dist/source/types.d.ts.map +1 -1
- package/dist/source/types.js.map +1 -1
- package/dist/transactionLayer.d.ts +3 -3
- package/dist/transactionLayer.js +3 -3
- package/dist/transport/httpClient.d.ts +6 -2
- package/dist/transport/httpClient.d.ts.map +1 -1
- package/dist/transport/httpClient.js.map +1 -1
- package/dist/transport/httpTransport.d.ts +1 -0
- package/dist/transport/httpTransport.d.ts.map +1 -1
- package/dist/transport/httpTransport.js +50 -4
- package/dist/transport/httpTransport.js.map +1 -1
- package/dist/transport/wsFrameHandlers.d.ts.map +1 -1
- package/dist/transport/wsFrameHandlers.js +3 -0
- package/dist/transport/wsFrameHandlers.js.map +1 -1
- package/dist/types/streams.d.ts +2 -2
- package/dist/wire/accountResponses.js +2 -2
- package/dist/wire/accountResponses.js.map +1 -1
- package/dist/wire/auth.d.ts.map +1 -1
- package/dist/wire/auth.js +4 -3
- package/dist/wire/auth.js.map +1 -1
- package/dist/wire/commit.d.ts +144 -0
- package/dist/wire/commit.d.ts.map +1 -1
- package/dist/wire/commit.js +22 -1
- package/dist/wire/commit.js.map +1 -1
- package/dist/wire/frames.d.ts +4 -0
- package/dist/wire/frames.d.ts.map +1 -1
- package/dist/wire/inboundFrames.d.ts +22 -0
- package/dist/wire/inboundFrames.d.ts.map +1 -1
- package/dist/wire/index.d.ts +2 -2
- package/dist/wire/index.d.ts.map +1 -1
- package/dist/wire/index.js +1 -1
- package/dist/wire/index.js.map +1 -1
- package/dist/wire/protocol.d.ts +1 -1
- package/dist/wire/protocol.js +1 -1
- package/package.json +1 -1
- package/src/ablo.ts +1 -1
- package/src/ai-sdk/updateTool.ts +1 -1
- package/src/auth/capability.ts +3 -3
- package/src/auth/index.ts +54 -22
- package/src/auth/schemas.ts +16 -2
- package/src/auth/sessionIssuanceProtocol.ts +57 -0
- package/src/auth/sessionMint.ts +3 -2
- package/src/coordination/schema.ts +7 -2
- package/src/errorCodes.ts +10 -4
- package/src/errors.ts +1 -1
- package/src/resources/httpResources.ts +11 -2
- package/src/resources/modelOperations.ts +6 -6
- package/src/schema/ddl.ts +9 -13
- package/src/schema/field.ts +3 -3
- package/src/schema/fieldRef.ts +1 -1
- package/src/schema/generate.ts +2 -8
- package/src/schema/index.ts +3 -3
- package/src/schema/model.ts +7 -7
- package/src/schema/openapi.ts +4 -7
- package/src/schema/queries.ts +1 -1
- package/src/schema/relation.ts +8 -8
- package/src/schema/residency.ts +0 -1
- package/src/schema/schema.ts +33 -58
- package/src/schema/sugar.ts +7 -7
- package/src/server/commit.ts +5 -1
- package/src/source/adapters/drizzle.ts +5 -0
- package/src/source/adapters/kysely.ts +48 -10
- package/src/source/adapters/kyselyMutationCore.ts +69 -22
- package/src/source/adapters/memory.ts +5 -0
- package/src/source/adapters/prisma.ts +6 -1
- package/src/source/conformance.ts +21 -21
- package/src/source/contract.ts +34 -0
- package/src/source/index.ts +2 -0
- package/src/source/types.ts +1 -0
- package/src/transactionLayer.ts +3 -3
- package/src/transport/httpClient.ts +6 -2
- package/src/transport/httpTransport.ts +59 -4
- package/src/transport/wsFrameHandlers.ts +3 -0
- package/src/types/global.ts +1 -1
- package/src/types/streams.ts +2 -2
- package/src/wire/accountResponses.ts +2 -2
- package/src/wire/auth.ts +4 -3
- package/src/wire/commit.ts +31 -1
- package/src/wire/index.ts +3 -0
- package/src/wire/protocol.ts +1 -1
|
@@ -32,6 +32,8 @@ import {
|
|
|
32
32
|
changeSetSchema,
|
|
33
33
|
outboxEventSchema,
|
|
34
34
|
sourceCommitEchoMarkerSchema,
|
|
35
|
+
sourceCommitEchoIntentSchema,
|
|
36
|
+
type SourceCommitEchoIntent,
|
|
35
37
|
} from '../contract.js';
|
|
36
38
|
import {
|
|
37
39
|
assertSourceIdempotencyIntent,
|
|
@@ -135,19 +137,17 @@ function markerAction(type: ChangeSet['operations'][number]['type']): 'I' | 'U'
|
|
|
135
137
|
return 'U';
|
|
136
138
|
}
|
|
137
139
|
|
|
138
|
-
function
|
|
140
|
+
function parseEchoIntent(
|
|
139
141
|
change: ChangeSet,
|
|
140
142
|
markerModelFor: (operationModel: string) => string,
|
|
141
|
-
|
|
143
|
+
required: boolean,
|
|
144
|
+
): SourceCommitEchoIntent | undefined {
|
|
142
145
|
const payload = change.echo?.payload;
|
|
143
146
|
if (!payload) {
|
|
144
|
-
|
|
145
|
-
'A direct Kysely mutation requires a transactional Postgres logical marker',
|
|
146
|
-
{ code: 'source_adapter_misconfigured' },
|
|
147
|
-
);
|
|
147
|
+
return undefined;
|
|
148
148
|
}
|
|
149
149
|
try {
|
|
150
|
-
const marker =
|
|
150
|
+
const marker = sourceCommitEchoIntentSchema.parse(JSON.parse(payload) as unknown);
|
|
151
151
|
if (marker.correlationId !== change.correlationId) {
|
|
152
152
|
throw new Error('marker correlation does not match the ledger key');
|
|
153
153
|
}
|
|
@@ -163,15 +163,18 @@ function assertDirectLogicalMarker(
|
|
|
163
163
|
if (
|
|
164
164
|
!markerOperation ||
|
|
165
165
|
markerOperation.model !== markerModelFor(operation.model) ||
|
|
166
|
-
markerOperation.id !== kyselyOperationRowId(operation) ||
|
|
166
|
+
(markerOperation.id != null && markerOperation.id !== kyselyOperationRowId(operation)) ||
|
|
167
|
+
(markerOperation.id == null && operation.type !== 'CREATE') ||
|
|
167
168
|
markerOperation.action !== markerAction(operation.type) ||
|
|
168
169
|
markerOperation.transactionId !== operation.transactionId
|
|
169
170
|
) {
|
|
170
171
|
throw new Error(`marker operation ${index} does not match the mutation`);
|
|
171
172
|
}
|
|
172
173
|
}
|
|
174
|
+
return marker;
|
|
173
175
|
} catch (error) {
|
|
174
176
|
if (error instanceof AbloValidationError) throw error;
|
|
177
|
+
if (!required) return undefined;
|
|
175
178
|
throw new AbloValidationError(
|
|
176
179
|
`The direct Postgres logical marker is invalid: ${error instanceof Error ? error.message : 'unknown marker error'}`,
|
|
177
180
|
{ code: 'source_adapter_misconfigured' },
|
|
@@ -179,6 +182,32 @@ function assertDirectLogicalMarker(
|
|
|
179
182
|
}
|
|
180
183
|
}
|
|
181
184
|
|
|
185
|
+
function resolveEchoMarker(
|
|
186
|
+
intent: SourceCommitEchoIntent,
|
|
187
|
+
rows: readonly Row[],
|
|
188
|
+
) {
|
|
189
|
+
return sourceCommitEchoMarkerSchema.parse({
|
|
190
|
+
...intent,
|
|
191
|
+
operations: intent.operations.map((operation, index) => {
|
|
192
|
+
const returnedId = rows[index]?.id;
|
|
193
|
+
const id = operation.id ?? returnedId;
|
|
194
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
195
|
+
throw new AbloValidationError(
|
|
196
|
+
`source operation ${index} did not return a canonical id for WAL correlation`,
|
|
197
|
+
{ code: 'source_adapter_misconfigured' },
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
if (returnedId != null && String(returnedId) !== id) {
|
|
201
|
+
throw new AbloValidationError(
|
|
202
|
+
`source operation ${index} returned an id that does not match its WAL correlation`,
|
|
203
|
+
{ code: 'source_adapter_misconfigured' },
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
return { ...operation, id };
|
|
207
|
+
}),
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
182
211
|
/**
|
|
183
212
|
* Build the shared transaction policy around one Kysely mutation core. Exported
|
|
184
213
|
* for engine integrations that need to inject a prebuilt core while retaining
|
|
@@ -226,7 +255,13 @@ export function createKyselyMutationAdapter(
|
|
|
226
255
|
|
|
227
256
|
async commit(change: ChangeSet): Promise<AdapterCommitResult> {
|
|
228
257
|
const request = changeSetSchema.parse(change);
|
|
229
|
-
if (mode === 'direct'
|
|
258
|
+
if (mode === 'direct' && !request.echo) {
|
|
259
|
+
throw new AbloValidationError(
|
|
260
|
+
'A direct Kysely mutation requires a transactional Postgres logical marker',
|
|
261
|
+
{ code: 'source_adapter_misconfigured' },
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
const echoIntent = parseEchoIntent(request, markerModelFor, mode === 'direct');
|
|
230
265
|
|
|
231
266
|
const requestHash = sourceChangeIntentHash(request);
|
|
232
267
|
return db.transaction().execute(async (transaction) => {
|
|
@@ -280,8 +315,11 @@ export function createKyselyMutationAdapter(
|
|
|
280
315
|
completeLedgerQuery(request.correlationId, rows),
|
|
281
316
|
);
|
|
282
317
|
if (request.echo?.kind === 'postgres-wal') {
|
|
318
|
+
const payload = echoIntent
|
|
319
|
+
? JSON.stringify(resolveEchoMarker(echoIntent, rows))
|
|
320
|
+
: request.echo.payload;
|
|
283
321
|
await transaction.executeQuery(
|
|
284
|
-
postgresLogicalMarkerQuery(
|
|
322
|
+
postgresLogicalMarkerQuery(payload),
|
|
285
323
|
);
|
|
286
324
|
}
|
|
287
325
|
return { rows };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Reusable Kysely row-mutation core.
|
|
3
3
|
*
|
|
4
|
-
* This is the single schema-field ↔
|
|
4
|
+
* This is the single schema-field ↔ database-column translation used by both
|
|
5
5
|
* Kysely transports. It deliberately knows nothing about outboxes, logical
|
|
6
6
|
* markers, or idempotency: wrappers compose those transaction policies around
|
|
7
7
|
* the same `applyOperation` implementation, so direct and endpoint DML cannot
|
|
@@ -133,6 +133,11 @@ export function kyselyOperationRowId(operation: Operation): string {
|
|
|
133
133
|
return id;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
function suppliedOperationRowId(operation: Operation): string | undefined {
|
|
137
|
+
const id = operation.id ?? operation.input?.id;
|
|
138
|
+
return typeof id === 'string' && id.length > 0 ? id : undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
136
141
|
/** The transport-independent Kysely field/column mutation boundary. */
|
|
137
142
|
export interface KyselyMutationCore {
|
|
138
143
|
read(request: AdapterReadRequest): Promise<readonly Row[]>;
|
|
@@ -159,16 +164,23 @@ export function createKyselyMutationCore<S extends SchemaRecord>(
|
|
|
159
164
|
columns.fieldToColumn.get(field) ?? camelToSnake(field);
|
|
160
165
|
const fieldFor = (columns: ModelColumns, column: string): string =>
|
|
161
166
|
columns.columnToField.get(column) ?? snakeToCamel(column);
|
|
162
|
-
|
|
163
167
|
const toColumns = (columns: ModelColumns, row: Row): Row => {
|
|
164
168
|
const out: Row = {};
|
|
165
|
-
for (const key of Object.keys(row))
|
|
169
|
+
for (const key of Object.keys(row)) {
|
|
170
|
+
out[columnFor(columns, key)] = row[key];
|
|
171
|
+
}
|
|
166
172
|
return out;
|
|
167
173
|
};
|
|
168
174
|
|
|
169
175
|
const toFields = (columns: ModelColumns, row: Row): Row => {
|
|
170
176
|
const out: Row = {};
|
|
171
|
-
for (const key of Object.keys(row))
|
|
177
|
+
for (const key of Object.keys(row)) {
|
|
178
|
+
const field = fieldFor(columns, key);
|
|
179
|
+
const value = row[key];
|
|
180
|
+
out[field] = field === 'id' && value !== null && value !== undefined
|
|
181
|
+
? String(value)
|
|
182
|
+
: value;
|
|
183
|
+
}
|
|
172
184
|
return out;
|
|
173
185
|
};
|
|
174
186
|
|
|
@@ -179,7 +191,7 @@ export function createKyselyMutationCore<S extends SchemaRecord>(
|
|
|
179
191
|
const rows = await db
|
|
180
192
|
.selectFrom(columns.table)
|
|
181
193
|
.selectAll()
|
|
182
|
-
.where('id', '=', request.id)
|
|
194
|
+
.where(columnFor(columns, 'id'), '=', request.id)
|
|
183
195
|
.limit(1)
|
|
184
196
|
.execute();
|
|
185
197
|
return rows.map((row) => toFields(columns, row));
|
|
@@ -194,13 +206,53 @@ export function createKyselyMutationCore<S extends SchemaRecord>(
|
|
|
194
206
|
|
|
195
207
|
async applyOperation(transaction, operation): Promise<Row> {
|
|
196
208
|
const columns = modelColumns(operation.model);
|
|
197
|
-
const id = kyselyOperationRowId(operation);
|
|
198
209
|
const input = operation.input ?? {};
|
|
210
|
+
if (operation.where && operation.type !== 'UPDATE') {
|
|
211
|
+
throw new AbloValidationError('where is supported only for UPDATE operations', {
|
|
212
|
+
code: 'commit_operation_invalid',
|
|
213
|
+
param: 'where',
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (operation.type === 'CREATE') {
|
|
218
|
+
const suppliedId = suppliedOperationRowId(operation);
|
|
219
|
+
const createInput = operation.id == null
|
|
220
|
+
? Object.fromEntries(Object.entries(input).filter(([field]) => field !== 'id'))
|
|
221
|
+
: input;
|
|
222
|
+
const inserted = await transaction
|
|
223
|
+
.insertInto(columns.table)
|
|
224
|
+
.values(toColumns(columns, {
|
|
225
|
+
...createInput,
|
|
226
|
+
...(operation.id == null
|
|
227
|
+
? {}
|
|
228
|
+
: suppliedId
|
|
229
|
+
? { id: suppliedId }
|
|
230
|
+
: {}),
|
|
231
|
+
}))
|
|
232
|
+
.returningAll()
|
|
233
|
+
.execute();
|
|
234
|
+
if (!inserted[0]) {
|
|
235
|
+
throw new AbloValidationError(
|
|
236
|
+
`${operation.type} on "${operation.model}" returned no source row`,
|
|
237
|
+
{ code: 'source_adapter_misconfigured' },
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
const row = toFields(columns, inserted[0]);
|
|
241
|
+
if (typeof row.id !== 'string' || row.id.length === 0) {
|
|
242
|
+
throw new AbloValidationError(
|
|
243
|
+
`${operation.type} on "${operation.model}" did not return a canonical id`,
|
|
244
|
+
{ code: 'source_adapter_misconfigured' },
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
return row;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const id = kyselyOperationRowId(operation);
|
|
199
251
|
|
|
200
252
|
if (operation.type === 'DELETE') {
|
|
201
253
|
const deleted = await transaction
|
|
202
254
|
.deleteFrom(columns.table)
|
|
203
|
-
.where('id', '=', id)
|
|
255
|
+
.where(columnFor(columns, 'id'), '=', id)
|
|
204
256
|
.returningAll()
|
|
205
257
|
.execute();
|
|
206
258
|
if (!deleted[0]) {
|
|
@@ -212,30 +264,25 @@ export function createKyselyMutationCore<S extends SchemaRecord>(
|
|
|
212
264
|
return toFields(columns, deleted[0]);
|
|
213
265
|
}
|
|
214
266
|
|
|
215
|
-
if (operation.type === 'CREATE') {
|
|
216
|
-
const inserted = await transaction
|
|
217
|
-
.insertInto(columns.table)
|
|
218
|
-
.values(toColumns(columns, { id, ...input }))
|
|
219
|
-
.returningAll()
|
|
220
|
-
.execute();
|
|
221
|
-
return inserted[0] ? toFields(columns, inserted[0]) : { id, ...input };
|
|
222
|
-
}
|
|
223
|
-
|
|
224
267
|
const patch = toColumns(columns, {
|
|
225
268
|
...input,
|
|
226
269
|
...(operation.type === 'ARCHIVE' ? { archivedAt: new Date() } : {}),
|
|
227
270
|
...(operation.type === 'UNARCHIVE' ? { archivedAt: null } : {}),
|
|
228
271
|
});
|
|
229
|
-
|
|
272
|
+
let update = transaction
|
|
230
273
|
.updateTable(columns.table)
|
|
231
274
|
.set(patch)
|
|
232
|
-
.where('id', '=', id)
|
|
233
|
-
|
|
234
|
-
.
|
|
275
|
+
.where(columnFor(columns, 'id'), '=', id);
|
|
276
|
+
for (const [column, value] of Object.entries(toColumns(columns, operation.where ?? {}))) {
|
|
277
|
+
update = update.where(column, '=', value);
|
|
278
|
+
}
|
|
279
|
+
const updated = await update.returningAll().execute();
|
|
235
280
|
if (!updated[0]) {
|
|
236
281
|
throw new AbloValidationError(
|
|
237
|
-
|
|
238
|
-
|
|
282
|
+
operation.where
|
|
283
|
+
? `The conditional ${operation.type} on "${operation.model}/${id}" did not match`
|
|
284
|
+
: `${operation.type} on "${operation.model}/${id}" matched no source row`,
|
|
285
|
+
{ code: operation.where ? 'precondition_failed' : 'mutate_update_entity_not_found' },
|
|
239
286
|
);
|
|
240
287
|
}
|
|
241
288
|
return toFields(columns, updated[0]);
|
|
@@ -54,6 +54,11 @@ export function memoryDataSource(): DataSourceAdapter {
|
|
|
54
54
|
};
|
|
55
55
|
|
|
56
56
|
const applyOperation = (op: Operation): Row => {
|
|
57
|
+
if (op.where) {
|
|
58
|
+
throw new AbloValidationError('The memory adapter does not support conditional operations', {
|
|
59
|
+
code: 'source_adapter_misconfigured',
|
|
60
|
+
});
|
|
61
|
+
}
|
|
57
62
|
const m = modelStore(op.model);
|
|
58
63
|
const id = rowId(op);
|
|
59
64
|
switch (op.type) {
|
|
@@ -80,7 +80,7 @@ const lowerFirst = (s: string): string => (s ? s.charAt(0).toLowerCase() + s.sli
|
|
|
80
80
|
* the adapter, and it reflects a real limit of the type system rather than a
|
|
81
81
|
* shortcut. Writes inside `prisma.$transaction(tx => …)` must go through the
|
|
82
82
|
* transactional client `tx`, and the model is known only as a runtime string.
|
|
83
|
-
* Prisma keys its client by fixed property names (`{
|
|
83
|
+
* Prisma keys its client by fixed property names (`{ item: ItemDelegate; … }`), so
|
|
84
84
|
* a dynamic `tx[name]` lookup is `unknown` to the compiler: there is no static key
|
|
85
85
|
* to infer from a string. The cast is checked at runtime immediately afterward by
|
|
86
86
|
* confirming that `findMany` is a function on the resolved delegate.
|
|
@@ -149,6 +149,11 @@ export function prismaDataSource<S extends SchemaRecord>(
|
|
|
149
149
|
void schema; // held for typed reads and model validation
|
|
150
150
|
|
|
151
151
|
const applyOperation = async (tx: PrismaRaw, op: Operation): Promise<Row> => {
|
|
152
|
+
if (op.where) {
|
|
153
|
+
throw new AbloValidationError('The Prisma adapter does not support conditional operations', {
|
|
154
|
+
code: 'source_adapter_misconfigured',
|
|
155
|
+
});
|
|
156
|
+
}
|
|
152
157
|
const delegate = delegateFor(tx, delegateName(op.model));
|
|
153
158
|
const id = rowId(op);
|
|
154
159
|
switch (op.type) {
|
|
@@ -75,7 +75,7 @@ export function mutationConformanceChecks(
|
|
|
75
75
|
const adapter = await make();
|
|
76
76
|
const result = await adapter.commit(
|
|
77
77
|
request('corr_create', [
|
|
78
|
-
{ type: 'CREATE', model: '
|
|
78
|
+
{ type: 'CREATE', model: 'item', id: 't1', input: { title: 'A' } },
|
|
79
79
|
]),
|
|
80
80
|
);
|
|
81
81
|
assert.equal(result.rows.length, 1, 'one row returned');
|
|
@@ -90,12 +90,12 @@ export function mutationConformanceChecks(
|
|
|
90
90
|
run: async () => {
|
|
91
91
|
const adapter = await make();
|
|
92
92
|
await adapter.commit(request('corr_load', [
|
|
93
|
-
{ type: 'CREATE', model: '
|
|
93
|
+
{ type: 'CREATE', model: 'item', id: 't1', input: { title: 'A' } },
|
|
94
94
|
]));
|
|
95
|
-
const found = await adapter.read({ kind: 'load', model: '
|
|
95
|
+
const found = await adapter.read({ kind: 'load', model: 'item', id: 't1' });
|
|
96
96
|
assert.equal(found.length, 1);
|
|
97
97
|
assert.equal(found[0]?.title, 'A');
|
|
98
|
-
const missing = await adapter.read({ kind: 'load', model: '
|
|
98
|
+
const missing = await adapter.read({ kind: 'load', model: 'item', id: 'nope' });
|
|
99
99
|
assert.equal(missing.length, 0, 'unknown id reads empty');
|
|
100
100
|
},
|
|
101
101
|
},
|
|
@@ -104,10 +104,10 @@ export function mutationConformanceChecks(
|
|
|
104
104
|
run: async () => {
|
|
105
105
|
const adapter = await make();
|
|
106
106
|
await adapter.commit(request('corr_list', [
|
|
107
|
-
{ type: 'CREATE', model: '
|
|
108
|
-
{ type: 'CREATE', model: '
|
|
107
|
+
{ type: 'CREATE', model: 'item', id: 't1', input: { title: 'A' } },
|
|
108
|
+
{ type: 'CREATE', model: 'item', id: 't2', input: { title: 'B' } },
|
|
109
109
|
]));
|
|
110
|
-
const rows = await adapter.read({ kind: 'list', model: '
|
|
110
|
+
const rows = await adapter.read({ kind: 'list', model: 'item' });
|
|
111
111
|
assert.deepEqual(rows.map((row) => row.id).sort(), ['t1', 't2']);
|
|
112
112
|
},
|
|
113
113
|
},
|
|
@@ -116,12 +116,12 @@ export function mutationConformanceChecks(
|
|
|
116
116
|
run: async () => {
|
|
117
117
|
const adapter = await make();
|
|
118
118
|
const commitRequest = request('corr_replay', [
|
|
119
|
-
{ type: 'CREATE', model: '
|
|
119
|
+
{ type: 'CREATE', model: 'item', id: 't1', input: { title: 'A', n: 1 } },
|
|
120
120
|
]);
|
|
121
121
|
const first = await adapter.commit(commitRequest);
|
|
122
122
|
const second = await adapter.commit(commitRequest);
|
|
123
123
|
assert.deepEqual(second.rows, first.rows, 'replay returns the original rows');
|
|
124
|
-
const rows = await adapter.read({ kind: 'list', model: '
|
|
124
|
+
const rows = await adapter.read({ kind: 'list', model: 'item' });
|
|
125
125
|
assert.equal(rows.length, 1, 'no duplicate row');
|
|
126
126
|
},
|
|
127
127
|
},
|
|
@@ -131,13 +131,13 @@ export function mutationConformanceChecks(
|
|
|
131
131
|
const adapter = await make();
|
|
132
132
|
await adapter.commit(request(
|
|
133
133
|
'corr_conflict',
|
|
134
|
-
[{ type: 'CREATE', model: '
|
|
134
|
+
[{ type: 'CREATE', model: 'item', id: 't1', input: { title: 'A' } }],
|
|
135
135
|
'a'.repeat(64),
|
|
136
136
|
));
|
|
137
137
|
await assert.rejects(
|
|
138
138
|
adapter.commit(request(
|
|
139
139
|
'corr_conflict',
|
|
140
|
-
[{ type: 'UPDATE', model: '
|
|
140
|
+
[{ type: 'UPDATE', model: 'item', id: 't1', input: { title: 'B' } }],
|
|
141
141
|
'b'.repeat(64),
|
|
142
142
|
)),
|
|
143
143
|
(error: unknown) =>
|
|
@@ -153,14 +153,14 @@ export function mutationConformanceChecks(
|
|
|
153
153
|
run: async () => {
|
|
154
154
|
const adapter = await make();
|
|
155
155
|
const commitRequest = request('corr_concurrent', [
|
|
156
|
-
{ type: 'CREATE', model: '
|
|
156
|
+
{ type: 'CREATE', model: 'item', id: 't1', input: { title: 'A' } },
|
|
157
157
|
]);
|
|
158
158
|
const [first, second] = await Promise.all([
|
|
159
159
|
adapter.commit(commitRequest),
|
|
160
160
|
adapter.commit(commitRequest),
|
|
161
161
|
]);
|
|
162
162
|
assert.deepEqual(second.rows, first.rows);
|
|
163
|
-
const rows = await adapter.read({ kind: 'list', model: '
|
|
163
|
+
const rows = await adapter.read({ kind: 'list', model: 'item' });
|
|
164
164
|
assert.equal(rows.length, 1, 'concurrent retries produced one row');
|
|
165
165
|
},
|
|
166
166
|
},
|
|
@@ -169,12 +169,12 @@ export function mutationConformanceChecks(
|
|
|
169
169
|
run: async () => {
|
|
170
170
|
const adapter = await make();
|
|
171
171
|
await adapter.commit(request('corr_create_2', [
|
|
172
|
-
{ type: 'CREATE', model: '
|
|
172
|
+
{ type: 'CREATE', model: 'item', id: 't1', input: { title: 'A' } },
|
|
173
173
|
]));
|
|
174
174
|
await adapter.commit(request('corr_update_2', [
|
|
175
|
-
{ type: 'UPDATE', model: '
|
|
175
|
+
{ type: 'UPDATE', model: 'item', id: 't1', input: { title: 'B' } },
|
|
176
176
|
]));
|
|
177
|
-
const found = await adapter.read({ kind: 'load', model: '
|
|
177
|
+
const found = await adapter.read({ kind: 'load', model: 'item', id: 't1' });
|
|
178
178
|
assert.equal(found[0]?.title, 'B', 'update applied');
|
|
179
179
|
},
|
|
180
180
|
},
|
|
@@ -200,7 +200,7 @@ export function endpointConformanceChecks(make: MakeAdapter): ConformanceCheck[]
|
|
|
200
200
|
const request = change('corr_outbox_replay', [
|
|
201
201
|
{
|
|
202
202
|
type: 'CREATE',
|
|
203
|
-
model: '
|
|
203
|
+
model: 'item',
|
|
204
204
|
id: 't1',
|
|
205
205
|
input: { title: 'A' },
|
|
206
206
|
transactionId: 'op_outbox_replay',
|
|
@@ -223,7 +223,7 @@ export function endpointConformanceChecks(make: MakeAdapter): ConformanceCheck[]
|
|
|
223
223
|
await adapter.commit(change('corr_event', [
|
|
224
224
|
{
|
|
225
225
|
type: 'CREATE',
|
|
226
|
-
model: '
|
|
226
|
+
model: 'item',
|
|
227
227
|
id: 't1',
|
|
228
228
|
input: { title: 'A' },
|
|
229
229
|
transactionId: 'op_event',
|
|
@@ -232,7 +232,7 @@ export function endpointConformanceChecks(make: MakeAdapter): ConformanceCheck[]
|
|
|
232
232
|
const page = await adapter.events(null, 100);
|
|
233
233
|
const event = page.events.find((candidate) => candidate.entityId === 't1');
|
|
234
234
|
assert.ok(event, 'event for the committed row');
|
|
235
|
-
assert.equal(event.model, '
|
|
235
|
+
assert.equal(event.model, 'item');
|
|
236
236
|
assert.equal(event.type, 'CREATE');
|
|
237
237
|
assert.equal(event.correlationId, 'corr_event');
|
|
238
238
|
assert.equal(event.transactionId, 'op_event');
|
|
@@ -244,10 +244,10 @@ export function endpointConformanceChecks(make: MakeAdapter): ConformanceCheck[]
|
|
|
244
244
|
run: async () => {
|
|
245
245
|
const adapter = await make();
|
|
246
246
|
await adapter.commit(change('corr_page_a', [
|
|
247
|
-
{ type: 'CREATE', model: '
|
|
247
|
+
{ type: 'CREATE', model: 'item', id: 't1', input: {} },
|
|
248
248
|
]));
|
|
249
249
|
await adapter.commit(change('corr_page_b', [
|
|
250
|
-
{ type: 'CREATE', model: '
|
|
250
|
+
{ type: 'CREATE', model: 'item', id: 't2', input: {} },
|
|
251
251
|
]));
|
|
252
252
|
|
|
253
253
|
const first = await adapter.events(null, 1);
|
package/src/source/contract.ts
CHANGED
|
@@ -79,6 +79,40 @@ export const sourceCommitEchoOperationSchema = z.strictObject({
|
|
|
79
79
|
transactionId: z.string().min(1).max(ABLO_SOURCE_CLIENT_TX_ID_MAX_LENGTH),
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Pre-write correlation evidence. A database-generated CREATE has no row id
|
|
84
|
+
* until its INSERT returns, so the adapter receives the same operation shape
|
|
85
|
+
* with that one value absent and finalizes it inside the writing transaction.
|
|
86
|
+
*/
|
|
87
|
+
const sourceCommitEchoIntentOperationSchema = sourceCommitEchoOperationSchema.extend({
|
|
88
|
+
id: z.string().min(1).nullish(),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
export const sourceCommitEchoIntentSchema = z
|
|
92
|
+
.strictObject({
|
|
93
|
+
version: z.literal(1),
|
|
94
|
+
correlationId: correlationIdSchema,
|
|
95
|
+
operations: z
|
|
96
|
+
.array(sourceCommitEchoIntentOperationSchema)
|
|
97
|
+
.min(1)
|
|
98
|
+
.max(ABLO_SOURCE_ECHO_MAX_OPERATIONS)
|
|
99
|
+
.readonly(),
|
|
100
|
+
})
|
|
101
|
+
.superRefine(({ operations }, ctx) => {
|
|
102
|
+
const seen = new Set<string>();
|
|
103
|
+
for (const [index, operation] of operations.entries()) {
|
|
104
|
+
if (seen.has(operation.transactionId)) {
|
|
105
|
+
ctx.addIssue({
|
|
106
|
+
code: 'custom',
|
|
107
|
+
path: ['operations', index, 'transactionId'],
|
|
108
|
+
message: 'WAL echo operation transactionIds must be unique',
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
seen.add(operation.transactionId);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
export type SourceCommitEchoIntent = z.infer<typeof sourceCommitEchoIntentSchema>;
|
|
115
|
+
|
|
82
116
|
export const sourceCommitEchoMarkerSchema = z
|
|
83
117
|
.strictObject({
|
|
84
118
|
version: z.literal(1),
|
package/src/source/index.ts
CHANGED
|
@@ -129,6 +129,7 @@ export {
|
|
|
129
129
|
sourceCommitEchoSchema,
|
|
130
130
|
sourceCommitEchoOperationSchema,
|
|
131
131
|
sourceCommitEchoMarkerSchema,
|
|
132
|
+
sourceCommitEchoIntentSchema,
|
|
132
133
|
changeSetSchema,
|
|
133
134
|
outboxEventSchema,
|
|
134
135
|
eventsPageSchema,
|
|
@@ -141,6 +142,7 @@ export {
|
|
|
141
142
|
type Migration,
|
|
142
143
|
type AdapterCapabilities,
|
|
143
144
|
type SourceCommitEchoMarkerWire,
|
|
145
|
+
type SourceCommitEchoIntent,
|
|
144
146
|
} from './contract.js';
|
|
145
147
|
export { prismaDataSource, type PrismaLike, type PrismaDataSourceOptions } from './adapters/prisma.js';
|
|
146
148
|
export {
|
package/src/source/types.ts
CHANGED
|
@@ -105,6 +105,7 @@ export interface SourceOperation {
|
|
|
105
105
|
readonly model: string;
|
|
106
106
|
readonly id?: string | null;
|
|
107
107
|
readonly input?: Record<string, unknown> | null;
|
|
108
|
+
readonly where?: Record<string, unknown> | null;
|
|
108
109
|
readonly transactionId?: string | null;
|
|
109
110
|
readonly readAt?: number | null;
|
|
110
111
|
readonly onStale?: OnStaleMode | null;
|
package/src/transactionLayer.ts
CHANGED
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
* over a socket, and it joins the seam when its core carrier type exists —
|
|
18
18
|
* see docs/plans/transaction-core-language.md, Tier 3.
|
|
19
19
|
*
|
|
20
|
-
* This is the contract, not a runtime
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* This is the contract, not a runtime. The headless HTTP client implements it
|
|
21
|
+
* as a proxy to the hosted authority; other compositions gain the same
|
|
22
|
+
* compile-time conformance pin against this interface.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import type { CommitMessage } from './wire/frames.js';
|
|
@@ -166,10 +166,10 @@ export interface HttpModelClient<T, C = T> {
|
|
|
166
166
|
* duplicated.
|
|
167
167
|
*
|
|
168
168
|
* ```ts
|
|
169
|
-
* await ablo.
|
|
169
|
+
* await ablo.items.track({ id: 'item_42' });
|
|
170
170
|
* // …minutes of other work later, on the next write…
|
|
171
171
|
* const res = await ablo.commits.create({ operations: [ … ] });
|
|
172
|
-
* res.notifications; // populated if
|
|
172
|
+
* res.notifications; // populated if item_42 moved in the meantime
|
|
173
173
|
* ```
|
|
174
174
|
*
|
|
175
175
|
* The returned `notifications` are only the tracks that had ALREADY fired at
|
|
@@ -209,6 +209,10 @@ export type AbloHttpClient<S extends SchemaRecord> = {
|
|
|
209
209
|
readonly logs: HttpLogsResource;
|
|
210
210
|
/** Server-confirmed authority of the credential, populated by `ready()`. */
|
|
211
211
|
readonly identity: EffectiveAuthority | null;
|
|
212
|
+
/**
|
|
213
|
+
* Waits for requests already using this client and commits already scheduled
|
|
214
|
+
* on its commit lane. Stop accepting application work before calling it.
|
|
215
|
+
*/
|
|
212
216
|
dispose(): Promise<void>;
|
|
213
217
|
/** Resolves the bearer credential this client authenticates with, or `null` if none is set. */
|
|
214
218
|
getAuthToken(): Promise<string | null>;
|