@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.
Files changed (170) hide show
  1. package/dist/ablo.d.ts +1 -1
  2. package/dist/ablo.js +1 -1
  3. package/dist/ai-sdk/updateTool.d.ts +1 -1
  4. package/dist/ai-sdk/updateTool.js +1 -1
  5. package/dist/auth/capability.d.ts +2 -2
  6. package/dist/auth/capability.js +1 -1
  7. package/dist/auth/index.d.ts +9 -3
  8. package/dist/auth/index.d.ts.map +1 -1
  9. package/dist/auth/index.js +25 -20
  10. package/dist/auth/index.js.map +1 -1
  11. package/dist/auth/schemas.d.ts +24 -1
  12. package/dist/auth/schemas.d.ts.map +1 -1
  13. package/dist/auth/schemas.js +8 -2
  14. package/dist/auth/schemas.js.map +1 -1
  15. package/dist/auth/sessionIssuanceProtocol.d.ts +41 -0
  16. package/dist/auth/sessionIssuanceProtocol.d.ts.map +1 -0
  17. package/dist/auth/sessionIssuanceProtocol.js +48 -0
  18. package/dist/auth/sessionIssuanceProtocol.js.map +1 -0
  19. package/dist/auth/sessionMint.d.ts.map +1 -1
  20. package/dist/auth/sessionMint.js +3 -2
  21. package/dist/auth/sessionMint.js.map +1 -1
  22. package/dist/branches.d.ts +4 -4
  23. package/dist/coordination/schema.d.ts +3 -2
  24. package/dist/coordination/schema.d.ts.map +1 -1
  25. package/dist/coordination/schema.js +7 -2
  26. package/dist/coordination/schema.js.map +1 -1
  27. package/dist/errorCodes.d.ts +3 -2
  28. package/dist/errorCodes.d.ts.map +1 -1
  29. package/dist/errorCodes.js +4 -3
  30. package/dist/errorCodes.js.map +1 -1
  31. package/dist/errors.d.ts +1 -1
  32. package/dist/errors.js +1 -1
  33. package/dist/resources/httpResources.d.ts +11 -2
  34. package/dist/resources/httpResources.d.ts.map +1 -1
  35. package/dist/resources/modelOperations.d.ts +6 -6
  36. package/dist/schema/ddl.d.ts.map +1 -1
  37. package/dist/schema/ddl.js +9 -12
  38. package/dist/schema/ddl.js.map +1 -1
  39. package/dist/schema/field.d.ts +3 -3
  40. package/dist/schema/field.js +2 -2
  41. package/dist/schema/fieldRef.d.ts +1 -1
  42. package/dist/schema/generate.d.ts +2 -3
  43. package/dist/schema/generate.d.ts.map +1 -1
  44. package/dist/schema/generate.js +2 -8
  45. package/dist/schema/generate.js.map +1 -1
  46. package/dist/schema/index.d.ts +2 -2
  47. package/dist/schema/index.js +3 -3
  48. package/dist/schema/model.d.ts +7 -7
  49. package/dist/schema/model.js +2 -2
  50. package/dist/schema/openapi.d.ts.map +1 -1
  51. package/dist/schema/openapi.js +4 -7
  52. package/dist/schema/openapi.js.map +1 -1
  53. package/dist/schema/queries.d.ts +1 -1
  54. package/dist/schema/queries.js +1 -1
  55. package/dist/schema/relation.d.ts +8 -8
  56. package/dist/schema/relation.js +5 -5
  57. package/dist/schema/schema.d.ts +24 -37
  58. package/dist/schema/schema.d.ts.map +1 -1
  59. package/dist/schema/schema.js +15 -28
  60. package/dist/schema/schema.js.map +1 -1
  61. package/dist/schema/sugar.d.ts +7 -7
  62. package/dist/schema/sugar.js +5 -5
  63. package/dist/server/commit.d.ts +5 -1
  64. package/dist/server/commit.d.ts.map +1 -1
  65. package/dist/source/adapters/drizzle.d.ts.map +1 -1
  66. package/dist/source/adapters/drizzle.js +5 -0
  67. package/dist/source/adapters/drizzle.js.map +1 -1
  68. package/dist/source/adapters/kysely.d.ts.map +1 -1
  69. package/dist/source/adapters/kysely.js +33 -8
  70. package/dist/source/adapters/kysely.js.map +1 -1
  71. package/dist/source/adapters/kyselyMutationCore.d.ts +1 -1
  72. package/dist/source/adapters/kyselyMutationCore.d.ts.map +1 -1
  73. package/dist/source/adapters/kyselyMutationCore.js +58 -20
  74. package/dist/source/adapters/kyselyMutationCore.js.map +1 -1
  75. package/dist/source/adapters/memory.d.ts.map +1 -1
  76. package/dist/source/adapters/memory.js +5 -0
  77. package/dist/source/adapters/memory.js.map +1 -1
  78. package/dist/source/adapters/prisma.d.ts.map +1 -1
  79. package/dist/source/adapters/prisma.js +6 -1
  80. package/dist/source/adapters/prisma.js.map +1 -1
  81. package/dist/source/conformance.js +21 -21
  82. package/dist/source/contract.d.ts +17 -0
  83. package/dist/source/contract.d.ts.map +1 -1
  84. package/dist/source/contract.js +31 -0
  85. package/dist/source/contract.js.map +1 -1
  86. package/dist/source/index.d.ts +1 -1
  87. package/dist/source/index.d.ts.map +1 -1
  88. package/dist/source/index.js +1 -1
  89. package/dist/source/index.js.map +1 -1
  90. package/dist/source/types.d.ts +1 -0
  91. package/dist/source/types.d.ts.map +1 -1
  92. package/dist/source/types.js.map +1 -1
  93. package/dist/transactionLayer.d.ts +3 -3
  94. package/dist/transactionLayer.js +3 -3
  95. package/dist/transport/httpClient.d.ts +6 -2
  96. package/dist/transport/httpClient.d.ts.map +1 -1
  97. package/dist/transport/httpClient.js.map +1 -1
  98. package/dist/transport/httpTransport.d.ts +1 -0
  99. package/dist/transport/httpTransport.d.ts.map +1 -1
  100. package/dist/transport/httpTransport.js +50 -4
  101. package/dist/transport/httpTransport.js.map +1 -1
  102. package/dist/transport/wsFrameHandlers.d.ts.map +1 -1
  103. package/dist/transport/wsFrameHandlers.js +3 -0
  104. package/dist/transport/wsFrameHandlers.js.map +1 -1
  105. package/dist/types/streams.d.ts +2 -2
  106. package/dist/wire/accountResponses.js +2 -2
  107. package/dist/wire/accountResponses.js.map +1 -1
  108. package/dist/wire/auth.d.ts.map +1 -1
  109. package/dist/wire/auth.js +4 -3
  110. package/dist/wire/auth.js.map +1 -1
  111. package/dist/wire/commit.d.ts +144 -0
  112. package/dist/wire/commit.d.ts.map +1 -1
  113. package/dist/wire/commit.js +22 -1
  114. package/dist/wire/commit.js.map +1 -1
  115. package/dist/wire/frames.d.ts +4 -0
  116. package/dist/wire/frames.d.ts.map +1 -1
  117. package/dist/wire/inboundFrames.d.ts +22 -0
  118. package/dist/wire/inboundFrames.d.ts.map +1 -1
  119. package/dist/wire/index.d.ts +2 -2
  120. package/dist/wire/index.d.ts.map +1 -1
  121. package/dist/wire/index.js +1 -1
  122. package/dist/wire/index.js.map +1 -1
  123. package/dist/wire/protocol.d.ts +1 -1
  124. package/dist/wire/protocol.js +1 -1
  125. package/package.json +1 -1
  126. package/src/ablo.ts +1 -1
  127. package/src/ai-sdk/updateTool.ts +1 -1
  128. package/src/auth/capability.ts +3 -3
  129. package/src/auth/index.ts +54 -22
  130. package/src/auth/schemas.ts +16 -2
  131. package/src/auth/sessionIssuanceProtocol.ts +57 -0
  132. package/src/auth/sessionMint.ts +3 -2
  133. package/src/coordination/schema.ts +7 -2
  134. package/src/errorCodes.ts +10 -4
  135. package/src/errors.ts +1 -1
  136. package/src/resources/httpResources.ts +11 -2
  137. package/src/resources/modelOperations.ts +6 -6
  138. package/src/schema/ddl.ts +9 -13
  139. package/src/schema/field.ts +3 -3
  140. package/src/schema/fieldRef.ts +1 -1
  141. package/src/schema/generate.ts +2 -8
  142. package/src/schema/index.ts +3 -3
  143. package/src/schema/model.ts +7 -7
  144. package/src/schema/openapi.ts +4 -7
  145. package/src/schema/queries.ts +1 -1
  146. package/src/schema/relation.ts +8 -8
  147. package/src/schema/residency.ts +0 -1
  148. package/src/schema/schema.ts +33 -58
  149. package/src/schema/sugar.ts +7 -7
  150. package/src/server/commit.ts +5 -1
  151. package/src/source/adapters/drizzle.ts +5 -0
  152. package/src/source/adapters/kysely.ts +48 -10
  153. package/src/source/adapters/kyselyMutationCore.ts +69 -22
  154. package/src/source/adapters/memory.ts +5 -0
  155. package/src/source/adapters/prisma.ts +6 -1
  156. package/src/source/conformance.ts +21 -21
  157. package/src/source/contract.ts +34 -0
  158. package/src/source/index.ts +2 -0
  159. package/src/source/types.ts +1 -0
  160. package/src/transactionLayer.ts +3 -3
  161. package/src/transport/httpClient.ts +6 -2
  162. package/src/transport/httpTransport.ts +59 -4
  163. package/src/transport/wsFrameHandlers.ts +3 -0
  164. package/src/types/global.ts +1 -1
  165. package/src/types/streams.ts +2 -2
  166. package/src/wire/accountResponses.ts +2 -2
  167. package/src/wire/auth.ts +4 -3
  168. package/src/wire/commit.ts +31 -1
  169. package/src/wire/index.ts +3 -0
  170. 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 assertDirectLogicalMarker(
140
+ function parseEchoIntent(
139
141
  change: ChangeSet,
140
142
  markerModelFor: (operationModel: string) => string,
141
- ): void {
143
+ required: boolean,
144
+ ): SourceCommitEchoIntent | undefined {
142
145
  const payload = change.echo?.payload;
143
146
  if (!payload) {
144
- throw new AbloValidationError(
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 = sourceCommitEchoMarkerSchema.parse(JSON.parse(payload) as unknown);
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') assertDirectLogicalMarker(request, markerModelFor);
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(request.echo.payload),
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 ↔ physical-column translation used by both
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)) out[columnFor(columns, key)] = row[key];
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)) out[fieldFor(columns, key)] = row[key];
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
- const updated = await transaction
272
+ let update = transaction
230
273
  .updateTable(columns.table)
231
274
  .set(patch)
232
- .where('id', '=', id)
233
- .returningAll()
234
- .execute();
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
- `${operation.type} on "${operation.model}/${id}" matched no source row`,
238
- { code: 'mutate_update_entity_not_found' },
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 (`{ task: TaskDelegate; … }`), so
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: 'task', id: 't1', input: { title: 'A' } },
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: 'task', id: 't1', input: { title: 'A' } },
93
+ { type: 'CREATE', model: 'item', id: 't1', input: { title: 'A' } },
94
94
  ]));
95
- const found = await adapter.read({ kind: 'load', model: 'task', id: 't1' });
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: 'task', id: 'nope' });
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: 'task', id: 't1', input: { title: 'A' } },
108
- { type: 'CREATE', model: 'task', id: 't2', input: { title: 'B' } },
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: 'task' });
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: 'task', id: 't1', input: { title: 'A', n: 1 } },
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: 'task' });
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: 'task', id: 't1', input: { title: 'A' } }],
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: 'task', id: 't1', input: { title: 'B' } }],
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: 'task', id: 't1', input: { title: 'A' } },
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: 'task' });
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: 'task', id: 't1', input: { title: 'A' } },
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: 'task', id: 't1', input: { title: 'B' } },
175
+ { type: 'UPDATE', model: 'item', id: 't1', input: { title: 'B' } },
176
176
  ]));
177
- const found = await adapter.read({ kind: 'load', model: 'task', id: 't1' });
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: 'task',
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: 'task',
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, 'task');
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: 'task', id: 't1', input: {} },
247
+ { type: 'CREATE', model: 'item', id: 't1', input: {} },
248
248
  ]));
249
249
  await adapter.commit(change('corr_page_b', [
250
- { type: 'CREATE', model: 'task', id: 't2', input: {} },
250
+ { type: 'CREATE', model: 'item', id: 't2', input: {} },
251
251
  ]));
252
252
 
253
253
  const first = await adapter.events(null, 1);
@@ -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),
@@ -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 {
@@ -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;
@@ -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: nothing in this package implements it
21
- * yet. The first explicit implementation (a headless client, a test harness)
22
- * gains a compile-time conformance pin against this interface.
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.tasks.track({ id: 'task_42' });
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 task_42 moved in the meantime
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>;