@abloatai/transaction 0.51.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 +6 -1
- 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.js +2 -2
- 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 +3 -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/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 +51 -20
- package/src/auth/schemas.ts +16 -2
- package/src/auth/sessionIssuanceProtocol.ts +57 -0
- package/src/auth/sessionMint.ts +2 -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 +3 -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/commit.ts +31 -1
- package/src/wire/index.ts +3 -0
- package/src/wire/protocol.ts +1 -1
|
@@ -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>;
|
|
@@ -203,6 +203,7 @@ const DEFAULT_CLAIM_TTL_MS = 60_000;
|
|
|
203
203
|
export interface HttpTransport {
|
|
204
204
|
ready(): Promise<void>;
|
|
205
205
|
waitForFlush(): Promise<void>;
|
|
206
|
+
/** Drains scheduled commits and active requests. */
|
|
206
207
|
dispose(): Promise<void>;
|
|
207
208
|
purge(): Promise<void>;
|
|
208
209
|
readonly commits: CommitResource;
|
|
@@ -361,6 +362,26 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
|
|
|
361
362
|
let readyPromise: Promise<void> | null = null;
|
|
362
363
|
let effectiveAuthority: EffectiveAuthority | null = null;
|
|
363
364
|
let httpCommitLane: Promise<void> = Promise.resolve();
|
|
365
|
+
let activeRequests = 0;
|
|
366
|
+
let requestIdleWaiters: Array<() => void> = [];
|
|
367
|
+
let disposePromise: Promise<void> | null = null;
|
|
368
|
+
|
|
369
|
+
function requestStarted(): void {
|
|
370
|
+
activeRequests += 1;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function requestFinished(): void {
|
|
374
|
+
activeRequests -= 1;
|
|
375
|
+
if (activeRequests !== 0) return;
|
|
376
|
+
const waiters = requestIdleWaiters;
|
|
377
|
+
requestIdleWaiters = [];
|
|
378
|
+
for (const resolve of waiters) resolve();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function waitForRequestIdle(): Promise<void> {
|
|
382
|
+
if (activeRequests === 0) return Promise.resolve();
|
|
383
|
+
return new Promise((resolve) => requestIdleWaiters.push(resolve));
|
|
384
|
+
}
|
|
364
385
|
|
|
365
386
|
function runInHttpCommitLane<T>(work: () => Promise<T>): Promise<T> {
|
|
366
387
|
const result = httpCommitLane.then(work);
|
|
@@ -475,7 +496,7 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
|
|
|
475
496
|
* the commit paths do with their receipt schema. Everywhere else, go through
|
|
476
497
|
* {@link requestJson}, which will not let a response past unvalidated.
|
|
477
498
|
*/
|
|
478
|
-
async function
|
|
499
|
+
async function performRequest(
|
|
479
500
|
path: string,
|
|
480
501
|
init: RequestInit & {
|
|
481
502
|
readonly idempotencyKey?: string | null;
|
|
@@ -557,6 +578,22 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
|
|
|
557
578
|
return body;
|
|
558
579
|
}
|
|
559
580
|
|
|
581
|
+
async function requestRaw(
|
|
582
|
+
path: string,
|
|
583
|
+
init: RequestInit & {
|
|
584
|
+
readonly idempotencyKey?: string | null;
|
|
585
|
+
readonly sealedProtocolVersion?: number;
|
|
586
|
+
},
|
|
587
|
+
skipReady = false
|
|
588
|
+
): Promise<unknown> {
|
|
589
|
+
requestStarted();
|
|
590
|
+
try {
|
|
591
|
+
return await performRequest(path, init, skipReady);
|
|
592
|
+
} finally {
|
|
593
|
+
requestFinished();
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
560
597
|
/**
|
|
561
598
|
* Issues one request and validates its body against the route's schema.
|
|
562
599
|
*
|
|
@@ -729,6 +766,11 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
|
|
|
729
766
|
initial: CommitResponse
|
|
730
767
|
): Promise<CommitResponse> {
|
|
731
768
|
let current = initial;
|
|
769
|
+
const operationResults = initial.operationResults;
|
|
770
|
+
const withOperationResults = (receipt: CommitResponse): CommitResponse =>
|
|
771
|
+
operationResults?.length
|
|
772
|
+
? { ...receipt, operationResults }
|
|
773
|
+
: receipt;
|
|
732
774
|
const correlationId = initial.correlationId;
|
|
733
775
|
const deadlineAt = requestTimeoutMs > 0 ? Date.now() + requestTimeoutMs : null;
|
|
734
776
|
|
|
@@ -780,7 +822,7 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
|
|
|
780
822
|
if (confirmationDeadline) clearTimeout(confirmationDeadline);
|
|
781
823
|
}
|
|
782
824
|
|
|
783
|
-
if (current.status === 'confirmed') return current;
|
|
825
|
+
if (current.status === 'confirmed') return withOperationResults(current);
|
|
784
826
|
const delayMs =
|
|
785
827
|
deadlineAt === null
|
|
786
828
|
? HTTP_CONFIRMATION_POLL_INTERVAL_MS
|
|
@@ -791,7 +833,7 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
|
|
|
791
833
|
});
|
|
792
834
|
}
|
|
793
835
|
}
|
|
794
|
-
return current;
|
|
836
|
+
return withOperationResults(current);
|
|
795
837
|
}
|
|
796
838
|
|
|
797
839
|
async function replayHttpCommitOutbox(): Promise<Map<string, ReplayedHttpCommit>> {
|
|
@@ -1111,6 +1153,7 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
|
|
|
1111
1153
|
model: op.model,
|
|
1112
1154
|
id: op.id ?? null,
|
|
1113
1155
|
data: op.data ?? null,
|
|
1156
|
+
where: op.where ?? null,
|
|
1114
1157
|
transactionId: op.transactionId ?? null,
|
|
1115
1158
|
claimId:
|
|
1116
1159
|
op.claimId ?? claimIdFor(claim?.target, claim?.id, op.model, op.id ?? null),
|
|
@@ -1392,6 +1435,9 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
|
|
|
1392
1435
|
? { notifications: body.notifications }
|
|
1393
1436
|
: {}),
|
|
1394
1437
|
...(body.missingIds && body.missingIds.length > 0 ? { missingIds: body.missingIds } : {}),
|
|
1438
|
+
...(body.operationResults && body.operationResults.length > 0
|
|
1439
|
+
? { operationResults: body.operationResults }
|
|
1440
|
+
: {}),
|
|
1395
1441
|
};
|
|
1396
1442
|
},
|
|
1397
1443
|
get({ id }) {
|
|
@@ -2053,7 +2099,16 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
|
|
|
2053
2099
|
const replayed = await replayHttpCommitOutbox();
|
|
2054
2100
|
await confirmReplayedHttpCommits(replayed);
|
|
2055
2101
|
}),
|
|
2056
|
-
|
|
2102
|
+
dispose() {
|
|
2103
|
+
if (!disposePromise) {
|
|
2104
|
+
const scheduledCommits = httpCommitLane;
|
|
2105
|
+
disposePromise = (async () => {
|
|
2106
|
+
await scheduledCommits;
|
|
2107
|
+
await waitForRequestIdle();
|
|
2108
|
+
})();
|
|
2109
|
+
}
|
|
2110
|
+
return disposePromise;
|
|
2111
|
+
},
|
|
2057
2112
|
async purge() {},
|
|
2058
2113
|
commits,
|
|
2059
2114
|
claims,
|
|
@@ -222,6 +222,9 @@ const handleMutationResult: WsFrameHandler = (session, message) => {
|
|
|
222
222
|
...(receipt.missingIds && receipt.missingIds.length > 0
|
|
223
223
|
? { missingIds: receipt.missingIds }
|
|
224
224
|
: {}),
|
|
225
|
+
...(receipt.operationResults && receipt.operationResults.length > 0
|
|
226
|
+
? { operationResults: receipt.operationResults }
|
|
227
|
+
: {}),
|
|
225
228
|
}),
|
|
226
229
|
);
|
|
227
230
|
} else {
|
package/src/types/global.ts
CHANGED
|
@@ -51,7 +51,7 @@ export interface DefaultSyncShape {
|
|
|
51
51
|
// `models` is a `SchemaRecord` (not `Record<string, unknown>`) so that
|
|
52
52
|
// `ResolveSchema['models']` still satisfies the `R extends SchemaRecord`
|
|
53
53
|
// bound on the SDK hooks when no `Register` is present — e.g. a shared
|
|
54
|
-
// package (
|
|
54
|
+
// package (`feature packages`) typechecked standalone, with no
|
|
55
55
|
// app registration in scope. Without this the fallback wouldn't type-check
|
|
56
56
|
// against `useAblo<R>()`/`AbloProvider<R>` and every such package would need
|
|
57
57
|
// its own ambient registration.
|
package/src/types/streams.ts
CHANGED
|
@@ -629,12 +629,12 @@ export interface Claim<T = Record<string, unknown>, M = ResolveClaimMeta> {
|
|
|
629
629
|
revoke?: () => void;
|
|
630
630
|
/**
|
|
631
631
|
* Extend the lease past the liveness window — the "still working" signal
|
|
632
|
-
* for long-running
|
|
632
|
+
* for long-running items. Resolving means the lease is still yours, now
|
|
633
633
|
* good until the returned `expiresAt`; a lease that lapsed (and may have
|
|
634
634
|
* been granted to the next in line) rejects with an
|
|
635
635
|
* {@link ../errors.js AbloClaimedError} carrying code `claim_lost`, so the
|
|
636
636
|
* failed beat doubles as the loss notification. Each beat's extension is
|
|
637
|
-
* clamped server-side — hold a long
|
|
637
|
+
* clamped server-side — hold a long item by beating on a cadence (pass
|
|
638
638
|
* `heartbeat` when claiming to have the SDK do this for you), not by
|
|
639
639
|
* asking once for a huge window. Present only on a claim you hold.
|
|
640
640
|
*/
|
|
@@ -101,7 +101,7 @@ export const conflictAxisWireSchema = z.object({
|
|
|
101
101
|
|
|
102
102
|
/** One model in the deployed schema, as the schema read reports it. */
|
|
103
103
|
export const schemaModelResponseSchema = z.object({
|
|
104
|
-
/** The key local code addresses (`ablo.
|
|
104
|
+
/** The key local code addresses (`ablo.records`). */
|
|
105
105
|
key: z.string(),
|
|
106
106
|
/** The wire typename the engine routes and gates on. */
|
|
107
107
|
typename: z.string(),
|
|
@@ -122,7 +122,7 @@ export const schemaModelResponseSchema = z.object({
|
|
|
122
122
|
*
|
|
123
123
|
* The schema artifact has always carried these and this response dropped
|
|
124
124
|
* them, so a caller with no local schema declaration — which is every caller
|
|
125
|
-
* that is not TypeScript — had no way to learn that `
|
|
125
|
+
* that is not TypeScript — had no way to learn that `item.title` is a
|
|
126
126
|
* required string. It found out from a 400 at the end of a round trip.
|
|
127
127
|
*
|
|
128
128
|
* For an agent that is not merely slow, it is a reasoning detour: a typo
|