@abloatai/transaction 0.56.0 → 0.57.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 (191) hide show
  1. package/CONVENTIONS.md +34 -0
  2. package/dist/coordination/index.d.ts +2 -2
  3. package/dist/coordination/index.d.ts.map +1 -1
  4. package/dist/coordination/index.js +1 -1
  5. package/dist/coordination/index.js.map +1 -1
  6. package/dist/coordination/schema.d.ts +0 -7
  7. package/dist/coordination/schema.d.ts.map +1 -1
  8. package/dist/coordination/schema.js +0 -4
  9. package/dist/coordination/schema.js.map +1 -1
  10. package/dist/errorCodes.d.ts +1 -0
  11. package/dist/errorCodes.d.ts.map +1 -1
  12. package/dist/errorCodes.js +1 -0
  13. package/dist/errorCodes.js.map +1 -1
  14. package/dist/readSetContext.d.ts.map +1 -1
  15. package/dist/readSetContext.js +8 -3
  16. package/dist/readSetContext.js.map +1 -1
  17. package/dist/resources/httpResources.d.ts +43 -22
  18. package/dist/resources/httpResources.d.ts.map +1 -1
  19. package/dist/resources/httpResources.js +81 -4
  20. package/dist/resources/httpResources.js.map +1 -1
  21. package/dist/resources/modelCreate.d.ts +21 -0
  22. package/dist/resources/modelCreate.d.ts.map +1 -0
  23. package/dist/resources/modelCreate.js +49 -0
  24. package/dist/resources/modelCreate.js.map +1 -0
  25. package/dist/resources/modelOperations.d.ts +24 -2
  26. package/dist/resources/modelOperations.d.ts.map +1 -1
  27. package/dist/resources/modelOperations.js.map +1 -1
  28. package/dist/resources/writeOptionsSchema.d.ts +14 -0
  29. package/dist/resources/writeOptionsSchema.d.ts.map +1 -1
  30. package/dist/resources/writeOptionsSchema.js +20 -0
  31. package/dist/resources/writeOptionsSchema.js.map +1 -1
  32. package/dist/schema/audit.d.ts +15 -0
  33. package/dist/schema/audit.d.ts.map +1 -0
  34. package/dist/schema/audit.js +90 -0
  35. package/dist/schema/audit.js.map +1 -0
  36. package/dist/schema/ddl.d.ts.map +1 -1
  37. package/dist/schema/ddl.js +68 -1
  38. package/dist/schema/ddl.js.map +1 -1
  39. package/dist/schema/index.d.ts +3 -1
  40. package/dist/schema/index.d.ts.map +1 -1
  41. package/dist/schema/index.js +3 -1
  42. package/dist/schema/index.js.map +1 -1
  43. package/dist/schema/model.d.ts +14 -0
  44. package/dist/schema/model.d.ts.map +1 -1
  45. package/dist/schema/model.js +2 -0
  46. package/dist/schema/model.js.map +1 -1
  47. package/dist/schema/openapi.d.ts.map +1 -1
  48. package/dist/schema/openapi.js +104 -2
  49. package/dist/schema/openapi.js.map +1 -1
  50. package/dist/schema/roles.d.ts +27 -0
  51. package/dist/schema/roles.d.ts.map +1 -1
  52. package/dist/schema/roles.js +40 -0
  53. package/dist/schema/roles.js.map +1 -1
  54. package/dist/schema/schema.d.ts +23 -4
  55. package/dist/schema/schema.d.ts.map +1 -1
  56. package/dist/schema/schema.js +30 -1
  57. package/dist/schema/schema.js.map +1 -1
  58. package/dist/schema/serialize.d.ts +4 -1
  59. package/dist/schema/serialize.d.ts.map +1 -1
  60. package/dist/schema/serialize.js +5 -1
  61. package/dist/schema/serialize.js.map +1 -1
  62. package/dist/schema/subject.d.ts +18 -0
  63. package/dist/schema/subject.d.ts.map +1 -0
  64. package/dist/schema/subject.js +27 -0
  65. package/dist/schema/subject.js.map +1 -0
  66. package/dist/server/adapter.d.ts +2 -0
  67. package/dist/server/adapter.d.ts.map +1 -1
  68. package/dist/server/readConfig.d.ts +3 -0
  69. package/dist/server/readConfig.d.ts.map +1 -1
  70. package/dist/server/readConfig.js +0 -21
  71. package/dist/server/readConfig.js.map +1 -1
  72. package/dist/source/adapters/drizzle.d.ts.map +1 -1
  73. package/dist/source/adapters/drizzle.js +37 -6
  74. package/dist/source/adapters/drizzle.js.map +1 -1
  75. package/dist/source/adapters/kysely.d.ts +2 -0
  76. package/dist/source/adapters/kysely.d.ts.map +1 -1
  77. package/dist/source/adapters/kysely.js +33 -5
  78. package/dist/source/adapters/kysely.js.map +1 -1
  79. package/dist/source/adapters/kyselyMutationCore.d.ts +4 -1
  80. package/dist/source/adapters/kyselyMutationCore.d.ts.map +1 -1
  81. package/dist/source/adapters/kyselyMutationCore.js +17 -8
  82. package/dist/source/adapters/kyselyMutationCore.js.map +1 -1
  83. package/dist/source/adapters/memory.d.ts.map +1 -1
  84. package/dist/source/adapters/memory.js +1 -0
  85. package/dist/source/adapters/memory.js.map +1 -1
  86. package/dist/source/adapters/prisma.d.ts.map +1 -1
  87. package/dist/source/adapters/prisma.js +59 -7
  88. package/dist/source/adapters/prisma.js.map +1 -1
  89. package/dist/source/contract.d.ts +14 -0
  90. package/dist/source/contract.d.ts.map +1 -1
  91. package/dist/source/contract.js +10 -0
  92. package/dist/source/contract.js.map +1 -1
  93. package/dist/source/factory.d.ts +7 -1
  94. package/dist/source/factory.d.ts.map +1 -1
  95. package/dist/source/factory.js +116 -5
  96. package/dist/source/factory.js.map +1 -1
  97. package/dist/source/index.d.ts +2 -1
  98. package/dist/source/index.d.ts.map +1 -1
  99. package/dist/source/index.js +1 -0
  100. package/dist/source/index.js.map +1 -1
  101. package/dist/source/migrations.d.ts.map +1 -1
  102. package/dist/source/migrations.js +21 -0
  103. package/dist/source/migrations.js.map +1 -1
  104. package/dist/source/subjectAuthorization.d.ts +16 -0
  105. package/dist/source/subjectAuthorization.d.ts.map +1 -0
  106. package/dist/source/subjectAuthorization.js +126 -0
  107. package/dist/source/subjectAuthorization.js.map +1 -0
  108. package/dist/source/types.d.ts +52 -2
  109. package/dist/source/types.d.ts.map +1 -1
  110. package/dist/source/types.js +1 -0
  111. package/dist/source/types.js.map +1 -1
  112. package/dist/syncLog/contract.d.ts +0 -6
  113. package/dist/syncLog/contract.d.ts.map +1 -1
  114. package/dist/syncLog/contract.js +0 -4
  115. package/dist/syncLog/contract.js.map +1 -1
  116. package/dist/testing/fixtures/httpResponses.d.ts +6 -0
  117. package/dist/testing/fixtures/httpResponses.d.ts.map +1 -1
  118. package/dist/testing/fixtures/httpResponses.js +1 -0
  119. package/dist/testing/fixtures/httpResponses.js.map +1 -1
  120. package/dist/transport/httpClient.d.ts +8 -1
  121. package/dist/transport/httpClient.d.ts.map +1 -1
  122. package/dist/transport/httpClient.js +51 -20
  123. package/dist/transport/httpClient.js.map +1 -1
  124. package/dist/transport/httpCommitRequest.d.ts +45 -0
  125. package/dist/transport/httpCommitRequest.d.ts.map +1 -0
  126. package/dist/transport/httpCommitRequest.js +67 -0
  127. package/dist/transport/httpCommitRequest.js.map +1 -0
  128. package/dist/transport/httpTransport.d.ts.map +1 -1
  129. package/dist/transport/httpTransport.js +69 -106
  130. package/dist/transport/httpTransport.js.map +1 -1
  131. package/dist/transport/httpTransportHelpers.d.ts +10 -0
  132. package/dist/transport/httpTransportHelpers.d.ts.map +1 -0
  133. package/dist/transport/httpTransportHelpers.js +46 -0
  134. package/dist/transport/httpTransportHelpers.js.map +1 -0
  135. package/dist/wire/apiLifecycle.d.ts +63 -0
  136. package/dist/wire/apiLifecycle.d.ts.map +1 -0
  137. package/dist/wire/apiLifecycle.js +89 -0
  138. package/dist/wire/apiLifecycle.js.map +1 -0
  139. package/dist/wire/commit.d.ts +74 -9
  140. package/dist/wire/commit.d.ts.map +1 -1
  141. package/dist/wire/commit.js +13 -1
  142. package/dist/wire/commit.js.map +1 -1
  143. package/dist/wire/index.d.ts +5 -2
  144. package/dist/wire/index.d.ts.map +1 -1
  145. package/dist/wire/index.js +12 -1
  146. package/dist/wire/index.js.map +1 -1
  147. package/dist/wire/rateLimit.d.ts +82 -0
  148. package/dist/wire/rateLimit.d.ts.map +1 -0
  149. package/dist/wire/rateLimit.js +142 -0
  150. package/dist/wire/rateLimit.js.map +1 -0
  151. package/package.json +1 -1
  152. package/src/coordination/index.ts +0 -2
  153. package/src/coordination/schema.ts +0 -7
  154. package/src/errorCodes.ts +6 -0
  155. package/src/readSetContext.ts +8 -2
  156. package/src/resources/httpResources.ts +116 -25
  157. package/src/resources/modelCreate.ts +73 -0
  158. package/src/resources/modelOperations.ts +29 -2
  159. package/src/resources/writeOptionsSchema.ts +28 -0
  160. package/src/schema/audit.ts +121 -0
  161. package/src/schema/ddl.ts +71 -1
  162. package/src/schema/index.ts +16 -0
  163. package/src/schema/model.ts +17 -0
  164. package/src/schema/openapi.ts +123 -2
  165. package/src/schema/roles.ts +53 -0
  166. package/src/schema/schema.ts +68 -3
  167. package/src/schema/serialize.ts +8 -1
  168. package/src/schema/subject.ts +43 -0
  169. package/src/server/adapter.ts +2 -0
  170. package/src/server/readConfig.ts +4 -0
  171. package/src/source/adapters/drizzle.ts +50 -6
  172. package/src/source/adapters/kysely.ts +47 -4
  173. package/src/source/adapters/kyselyMutationCore.ts +21 -9
  174. package/src/source/adapters/memory.ts +1 -0
  175. package/src/source/adapters/prisma.ts +70 -7
  176. package/src/source/contract.ts +11 -0
  177. package/src/source/factory.ts +141 -5
  178. package/src/source/index.ts +6 -0
  179. package/src/source/migrations.ts +21 -0
  180. package/src/source/subjectAuthorization.ts +182 -0
  181. package/src/source/types.ts +56 -2
  182. package/src/syncLog/contract.ts +0 -7
  183. package/src/testing/fixtures/httpResponses.ts +7 -0
  184. package/src/transport/httpClient.ts +85 -23
  185. package/src/transport/httpCommitRequest.ts +104 -0
  186. package/src/transport/httpTransport.ts +92 -142
  187. package/src/transport/httpTransportHelpers.ts +63 -0
  188. package/src/wire/apiLifecycle.ts +94 -0
  189. package/src/wire/commit.ts +16 -1
  190. package/src/wire/index.ts +31 -0
  191. package/src/wire/rateLimit.ts +155 -0
@@ -46,6 +46,13 @@ import {
46
46
  idempotencyLedgerMigrations,
47
47
  } from '../migrations.js';
48
48
  import { ABLO_POSTGRES_COMMIT_ECHO_PREFIX } from '../types.js';
49
+ import {
50
+ authorizeSourceChange,
51
+ authorizeSourceRead,
52
+ lockSourceSubjectCreates,
53
+ rethrowStrictCreateConflict,
54
+ sourceSyncGroups,
55
+ } from '../subjectAuthorization.js';
49
56
  import {
50
57
  createKyselyMutationCore,
51
58
  kyselyOperationRowId,
@@ -259,6 +266,8 @@ export interface KyselyMutationAdapterOptions {
259
266
  * typenames equal its keys.
260
267
  */
261
268
  readonly markerModelFor?: (operationModel: string) => string;
269
+ /** Schema required to enforce row/subject authorization in this adapter. */
270
+ readonly schema?: Schema;
262
271
  }
263
272
 
264
273
  export function createKyselyMutationAdapter(
@@ -287,8 +296,9 @@ export function createKyselyMutationAdapter(
287
296
  : idempotencyLedgerMigrations();
288
297
  },
289
298
 
290
- read(request) {
291
- return core.read(request);
299
+ async read(request) {
300
+ const rows = await core.read(request);
301
+ return options.schema ? authorizeSourceRead(options.schema, request, rows) : rows;
292
302
  },
293
303
 
294
304
  async commit(change: ChangeSet): Promise<AdapterCommitResult> {
@@ -326,6 +336,25 @@ export function createKyselyMutationAdapter(
326
336
  return { rows: parseCachedRows(cachedRow.response) };
327
337
  }
328
338
 
339
+ if (options.schema) {
340
+ await lockSourceSubjectCreates(options.schema, request, async (_operation, key) => {
341
+ await transaction.executeQuery(rawQuery(
342
+ 'subject-create-lock',
343
+ 'SELECT pg_advisory_xact_lock(hashtextextended($1, 0))',
344
+ [key],
345
+ ));
346
+ });
347
+ await authorizeSourceChange(options.schema, request, async (operation) => {
348
+ if (!operation.id) return null;
349
+ const rows = await core.read(
350
+ { kind: 'load', model: operation.model, id: operation.id },
351
+ transaction,
352
+ { forUpdate: true },
353
+ );
354
+ return rows[0] ?? null;
355
+ });
356
+ }
357
+
329
358
  // Direct mode dispatches every operation before awaiting any of them, so
330
359
  // the driver pipelines them into one trip instead of paying a full
331
360
  // round trip per row. Postgres still executes them IN ORDER on the
@@ -339,9 +368,17 @@ export function createKyselyMutationAdapter(
339
368
  // returned row, so operation i+1's write cannot be dispatched before
340
369
  // operation i has answered.
341
370
  const rows: Row[] = [];
371
+ const applyStrict = async (operation: ChangeSet['operations'][number]): Promise<Row> => {
372
+ try {
373
+ return await core.applyOperation(transaction, operation);
374
+ } catch (error) {
375
+ if (operation.type === 'CREATE') rethrowStrictCreateConflict(error, operation);
376
+ throw error;
377
+ }
378
+ };
342
379
  if (mode === 'direct') {
343
380
  const dispatched = request.operations.map((operation) =>
344
- core.applyOperation(transaction, operation),
381
+ applyStrict(operation),
345
382
  );
346
383
  // Settle every dispatch before inspecting, so a later rejection is
347
384
  // never an unhandled rejection, then surface the FIRST failure in
@@ -354,7 +391,7 @@ export function createKyselyMutationAdapter(
354
391
  }
355
392
  } else {
356
393
  for (const [index, operation] of request.operations.entries()) {
357
- const row = await core.applyOperation(transaction, operation);
394
+ const row = await applyStrict(operation);
358
395
  rows.push(row);
359
396
 
360
397
  const entityId = String(row.id ?? kyselyOperationRowId(operation));
@@ -366,6 +403,9 @@ export function createKyselyMutationAdapter(
366
403
  entity_id: entityId,
367
404
  type: operation.type,
368
405
  data: operation.type === 'DELETE' ? null : JSON.stringify(row),
406
+ sync_groups: options.schema
407
+ ? sourceSyncGroups(options.schema, operation.model, row)
408
+ : [],
369
409
  correlation_id: request.correlationId,
370
410
  transaction_id: operation.transactionId ?? null,
371
411
  occurred_at: Date.now(),
@@ -402,6 +442,7 @@ export function kyselyDataSource<S extends SchemaRecord>(
402
442
  db,
403
443
  createKyselyMutationCore(db, schema),
404
444
  'endpoint',
445
+ { schema },
405
446
  );
406
447
 
407
448
  return defineDatabaseAdapter({
@@ -423,6 +464,7 @@ export function kyselyDataSource<S extends SchemaRecord>(
423
464
  entityId: row.entity_id,
424
465
  type: row.type,
425
466
  data: typeof row.data === 'string' ? JSON.parse(row.data) : row.data ?? null,
467
+ syncGroups: row.sync_groups ?? [],
426
468
  organizationId: row.organization_id ?? null,
427
469
  clientTxId: row.client_tx_id ?? null,
428
470
  correlationId: row.correlation_id ?? null,
@@ -458,6 +500,7 @@ export function kyselyDirectMutation<S extends SchemaRecord>(
458
500
  markerModels.get(operationModel) ??
459
501
  markerModels.get(operationModel.toLowerCase()) ??
460
502
  operationModel,
503
+ schema,
461
504
  },
462
505
  );
463
506
  }
@@ -15,6 +15,7 @@ import { camelToSnake, snakeToCamel } from '../../schema/ddl.js';
15
15
  import { tenancyColumn } from '../../schema/tenancy.js';
16
16
  import type { AdapterReadRequest, Row } from '../adapter.js';
17
17
  import type { Operation } from '../contract.js';
18
+ import { sourceSubjectRule, sourceSubjectValues } from '../subjectAuthorization.js';
18
19
 
19
20
  /** The subset of a Kysely instance, or transaction handle, used by the core. */
20
21
  export interface KyselyLike {
@@ -48,6 +49,7 @@ export interface KyselySelectBuilder {
48
49
  where(column: string, operator: string, value: unknown): KyselySelectBuilder;
49
50
  orderBy(column: string, direction: 'asc' | 'desc'): KyselySelectBuilder;
50
51
  limit(limit: number): KyselySelectBuilder;
52
+ forUpdate(): KyselySelectBuilder;
51
53
  execute(): Promise<readonly Row[]>;
52
54
  }
53
55
 
@@ -140,7 +142,11 @@ function suppliedOperationRowId(operation: Operation): string | undefined {
140
142
 
141
143
  /** The transport-independent Kysely field/column mutation boundary. */
142
144
  export interface KyselyMutationCore {
143
- read(request: AdapterReadRequest): Promise<readonly Row[]>;
145
+ read(
146
+ request: AdapterReadRequest,
147
+ database?: KyselyLike,
148
+ options?: { readonly forUpdate?: boolean },
149
+ ): Promise<readonly Row[]>;
144
150
  applyOperation(transaction: KyselyLike, operation: Operation): Promise<Row>;
145
151
  }
146
152
 
@@ -185,22 +191,28 @@ export function createKyselyMutationCore<S extends SchemaRecord>(
185
191
  };
186
192
 
187
193
  return {
188
- async read(request): Promise<readonly Row[]> {
194
+ async read(request, database = db, options): Promise<readonly Row[]> {
189
195
  const columns = modelColumns(request.model);
190
196
  if (request.kind === 'load') {
191
- const rows = await db
197
+ let query = database
192
198
  .selectFrom(columns.table)
193
199
  .selectAll()
194
200
  .where(columnFor(columns, 'id'), '=', request.id)
195
- .limit(1)
196
- .execute();
201
+ .limit(1);
202
+ if (options?.forUpdate) query = query.forUpdate();
203
+ const rows = await query.execute();
197
204
  return rows.map((row) => toFields(columns, row));
198
205
  }
199
- const rows = await db
206
+ let query = database
200
207
  .selectFrom(columns.table)
201
- .selectAll()
202
- .limit(request.query?.limit ?? 1000)
203
- .execute();
208
+ .selectAll();
209
+ const rule = sourceSubjectRule(schema, request.model);
210
+ const subjects = sourceSubjectValues(rule, request.scope?.syncGroups);
211
+ if (subjects) {
212
+ if (subjects.length === 0) return [];
213
+ query = query.where(columnFor(columns, rule!.field), 'in', subjects);
214
+ }
215
+ const rows = await query.limit(request.query?.limit ?? 1000).execute();
204
216
  return rows.map((row) => toFields(columns, row));
205
217
  },
206
218
 
@@ -141,6 +141,7 @@ export function memoryDataSource(): DataSourceAdapter {
141
141
  entityId: String(row.id ?? rowId(op)),
142
142
  type: op.type,
143
143
  data: op.type === 'DELETE' ? null : row,
144
+ syncGroups: [],
144
145
  correlationId: change.correlationId,
145
146
  ...(op.transactionId ? { transactionId: op.transactionId } : {}),
146
147
  cursor: String(outbox.length + 1),
@@ -33,11 +33,22 @@ import {
33
33
  sourceChangeIntentHash,
34
34
  } from '../idempotency.js';
35
35
  import type { SchemaRecord, Schema } from '../../schema/schema.js';
36
+ import { toSchemaJSON } from '../../schema/serialize.js';
37
+ import { camelToSnake } from '../../schema/ddl.js';
36
38
  import {
37
39
  ABLO_POSTGRES_COMMIT_ECHO_PREFIX,
38
40
  type SourceListQuery,
39
41
  type SourceWhere,
40
42
  } from '../types.js';
43
+ import {
44
+ authorizeSourceChange,
45
+ authorizeSourceRead,
46
+ lockSourceSubjectCreates,
47
+ rethrowStrictCreateConflict,
48
+ sourceSyncGroups,
49
+ sourceSubjectRule,
50
+ sourceSubjectValues,
51
+ } from '../subjectAuthorization.js';
41
52
 
42
53
  /** A Prisma model delegate — the subset of its methods the adapter calls. */
43
54
  export interface PrismaDelegate {
@@ -146,7 +157,19 @@ export function prismaDataSource<S extends SchemaRecord>(
146
157
  options: PrismaDataSourceOptions = {},
147
158
  ): DataSourceAdapter {
148
159
  const delegateName = options.delegateName ?? lowerFirst;
149
- void schema; // held for typed reads and model validation
160
+ const lockTargets = new Map<string, { table: string; subjectColumn: string }>();
161
+ for (const [key, definition] of Object.entries(toSchemaJSON(schema).models)) {
162
+ if (!definition.subject) continue;
163
+ const target = {
164
+ table: definition.tableName ?? key,
165
+ subjectColumn:
166
+ definition.fields[definition.subject.field]?.column ??
167
+ camelToSnake(definition.subject.field),
168
+ };
169
+ lockTargets.set(key.toLowerCase(), target);
170
+ if (definition.typename) lockTargets.set(definition.typename.toLowerCase(), target);
171
+ }
172
+ const quoted = (identifier: string): string => `"${identifier.replaceAll('"', '""')}"`;
150
173
 
151
174
  const applyOperation = async (tx: PrismaRaw, op: Operation): Promise<Row> => {
152
175
  if (op.where) {
@@ -188,9 +211,18 @@ export function prismaDataSource<S extends SchemaRecord>(
188
211
  const delegate = delegateFor(prisma, delegateName(req.model));
189
212
  if (req.kind === 'load') {
190
213
  const row = await delegate.findUnique({ where: { id: req.id } });
191
- return row ? [row] : [];
214
+ return authorizeSourceRead(schema, req, row ? [row] : []);
192
215
  }
193
- return delegate.findMany(findManyArgs(req.query));
216
+ const args = findManyArgs(req.query);
217
+ const rule = sourceSubjectRule(schema, req.model);
218
+ const subjects = sourceSubjectValues(rule, req.scope?.syncGroups);
219
+ if (subjects?.length === 0) return [];
220
+ if (subjects) {
221
+ args.where = {
222
+ AND: [args.where ?? {}, { [rule!.field]: { in: subjects } }],
223
+ };
224
+ }
225
+ return authorizeSourceRead(schema, req, await delegate.findMany(args));
194
226
  },
195
227
 
196
228
  async commit(change: ChangeSet): Promise<AdapterCommitResult> {
@@ -211,22 +243,52 @@ export function prismaDataSource<S extends SchemaRecord>(
211
243
  return { rows: cachedRow.response };
212
244
  }
213
245
 
246
+ await lockSourceSubjectCreates(schema, change, async (_operation, key) => {
247
+ await tx.$queryRawUnsafe(
248
+ `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`,
249
+ key,
250
+ );
251
+ });
252
+
253
+ await authorizeSourceChange(schema, change, async (operation) => {
254
+ if (!operation.id) return null;
255
+ const rule = sourceSubjectRule(schema, operation.model);
256
+ const target = lockTargets.get(operation.model.toLowerCase());
257
+ if (!rule || !target) return null;
258
+ const rows = await tx.$queryRawUnsafe<Row[]>(
259
+ `SELECT id, ${quoted(target.subjectColumn)} AS ${quoted(rule.field)}
260
+ FROM ${quoted(target.table)}
261
+ WHERE id = $1
262
+ LIMIT 1
263
+ FOR UPDATE`,
264
+ operation.id,
265
+ );
266
+ return rows[0] ?? null;
267
+ });
268
+
214
269
  const rows: Row[] = [];
215
270
  for (const [index, op] of change.operations.entries()) {
216
- const row = await applyOperation(tx, op);
271
+ let row: Row;
272
+ try {
273
+ row = await applyOperation(tx, op);
274
+ } catch (error) {
275
+ if (op.type === 'CREATE') rethrowStrictCreateConflict(error, op);
276
+ throw error;
277
+ }
217
278
  rows.push(row);
218
279
  const entityId = String(row.id ?? rowId(op));
219
280
  // Transactional outbox: one event per operation, written in this same transaction.
220
281
  await tx.$executeRawUnsafe(
221
282
  `INSERT INTO ablo_outbox (
222
- id, model, entity_id, type, data,
283
+ id, model, entity_id, type, data, sync_groups,
223
284
  correlation_id, transaction_id, occurred_at
224
- ) VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7, $8)`,
285
+ ) VALUES ($1, $2, $3, $4, $5::jsonb, $6::text[], $7, $8, $9)`,
225
286
  `${change.correlationId}:${index}`,
226
287
  op.model,
227
288
  entityId,
228
289
  op.type,
229
290
  JSON.stringify(op.type === 'DELETE' ? null : row),
291
+ sourceSyncGroups(schema, op.model, row),
230
292
  change.correlationId,
231
293
  op.transactionId ?? null,
232
294
  Date.now(),
@@ -260,7 +322,7 @@ export function prismaDataSource<S extends SchemaRecord>(
260
322
  async events(cursor: string | null, limit: number): Promise<EventsPage> {
261
323
  const after = cursor ? cursor : '0';
262
324
  const rows = await prisma.$queryRawUnsafe<Record<string, unknown>[]>(
263
- `SELECT cursor, id, model, entity_id, type, data, organization_id,
325
+ `SELECT cursor, id, model, entity_id, type, data, sync_groups, organization_id,
264
326
  client_tx_id, correlation_id, transaction_id, occurred_at
265
327
  FROM ablo_outbox WHERE cursor > $1 ORDER BY cursor ASC LIMIT $2`,
266
328
  after,
@@ -273,6 +335,7 @@ export function prismaDataSource<S extends SchemaRecord>(
273
335
  entityId: r.entity_id,
274
336
  type: r.type,
275
337
  data: r.data ?? null,
338
+ syncGroups: r.sync_groups ?? [],
276
339
  organizationId: r.organization_id ?? null,
277
340
  clientTxId: r.client_tx_id ?? null,
278
341
  correlationId: r.correlation_id ?? null,
@@ -29,6 +29,7 @@ import {
29
29
  type SourceCommitEchoMarker,
30
30
  type SourceOperation,
31
31
  } from './types.js';
32
+ import type { SourceRequestContext } from './types.js';
32
33
 
33
34
  const jsonObject = z.record(z.string(), z.unknown());
34
35
 
@@ -149,6 +150,14 @@ export const changeSetSchema = z.object({
149
150
  correlationId: correlationIdSchema,
150
151
  intentHash: z.string().regex(/^[a-f0-9]{64}$/).optional(),
151
152
  echo: sourceCommitEchoSchema.optional(),
153
+ scope: z.object({
154
+ participantId: z.string().optional(),
155
+ participantKind: z.enum(['user', 'agent', 'system']).optional(),
156
+ organizationId: z.string().optional(),
157
+ branchId: z.string().optional(),
158
+ projectId: z.string().optional(),
159
+ syncGroups: z.array(z.string()).optional(),
160
+ }).optional() satisfies z.ZodType<SourceRequestContext | undefined>,
152
161
  });
153
162
  export type ChangeSet = z.infer<typeof changeSetSchema>;
154
163
 
@@ -168,6 +177,8 @@ export const outboxEventSchema = z.object({
168
177
  /** The changed row, in the key shape `SourceEvent.data` defines: the model's
169
178
  * declared schema fields, never the table's physical column names. */
170
179
  data: jsonObject.nullish(),
180
+ /** Exact record routes captured in the same transaction as the row change. */
181
+ syncGroups: z.array(z.string().min(1)).readonly(),
171
182
  organizationId: z.string().nullish(),
172
183
  /** Legacy source transaction id. Never use this field to settle a queued commit. */
173
184
  clientTxId: z.string().nullish(),
@@ -12,7 +12,16 @@ import type {
12
12
  InferCreate,
13
13
  } from '../schema/schema.js';
14
14
  import type { DataSourceAdapter } from './adapter.js';
15
+ import type { AdapterReadRequest, Row } from './adapter.js';
15
16
  import { changeSetSchema } from './contract.js';
17
+ import { AbloError, AbloPermissionError } from '../errors.js';
18
+ import {
19
+ authorizeSourceChange,
20
+ authorizeSourceRead,
21
+ lockSourceSubjectCreates,
22
+ sourceSubjectRule,
23
+ sourceSubjectValues,
24
+ } from './subjectAuthorization.js';
16
25
  import {
17
26
  SourceSignatureError,
18
27
  verifyAbloSourceRequest,
@@ -31,6 +40,7 @@ import type {
31
40
  SourceRequest,
32
41
  SourceRequestContext,
33
42
  SourceScope,
43
+ SourceSubjectTransactionHandler,
34
44
  } from './types.js';
35
45
 
36
46
  type SourceModels<S extends SchemaRecord, TAuth> = Partial<{
@@ -86,6 +96,12 @@ export type DataSourceOptions<S extends SchemaRecord, TAuth = unknown> = {
86
96
  * run inside one transaction you control.
87
97
  */
88
98
  readonly commit?: SourceCommitHandler<TAuth>;
99
+ /**
100
+ * Required for a hand-written commit touching any subject-scoped model.
101
+ * The hook owns one customer-database transaction and calls `run` with a
102
+ * locking row loader and the batch commit bound to that same transaction.
103
+ */
104
+ readonly subjectTransaction?: SourceSubjectTransactionHandler<TAuth>;
89
105
  /**
90
106
  * Reports changes that happened outside the SDK — cron jobs, dashboard edits,
91
107
  * batch imports — which Ablo polls for. Each event you return becomes a delta and
@@ -165,6 +181,7 @@ async function handleViaAdapter(
165
181
  correlationId,
166
182
  intentHash: body.intentHash,
167
183
  echo: body.echo,
184
+ scope,
168
185
  });
169
186
  if (!parsed.success) {
170
187
  return json({ error: 'source_commit_invalid', message: parsed.error.message }, 400);
@@ -195,6 +212,7 @@ async function handleViaAdapter(
195
212
  entityId: event.entityId,
196
213
  type: event.type,
197
214
  ...(event.data !== undefined && event.data !== null ? { data: event.data } : {}),
215
+ syncGroups: event.syncGroups,
198
216
  ...(event.organizationId ? { organizationId: event.organizationId } : {}),
199
217
  ...(event.clientTxId ? { clientTxId: event.clientTxId } : {}),
200
218
  ...(event.correlationId ? { correlationId: event.correlationId } : {}),
@@ -277,6 +295,34 @@ function sameModel(operations: readonly SourceOperation[]): string | null {
277
295
  return operations.every((op) => op.model === first) ? first : null;
278
296
  }
279
297
 
298
+ function subjectDenial(error: unknown): Response | null {
299
+ return error instanceof AbloPermissionError && error.code === 'capability_scope_denied'
300
+ ? json({ error: error.code, message: error.message }, error.httpStatus ?? 403)
301
+ : null;
302
+ }
303
+
304
+ function typedSourceError(error: unknown): Response | null {
305
+ const denial = subjectDenial(error);
306
+ if (denial) return denial;
307
+ return error instanceof AbloError && error.code && error.httpStatus
308
+ ? json({ error: error.code, message: error.message }, error.httpStatus)
309
+ : null;
310
+ }
311
+
312
+ function parseHandWrittenChange<TAuth>(
313
+ body: Extract<SourceRequest, { type: 'commit' }>,
314
+ context: SourceHandlerContext<TAuth>,
315
+ ) {
316
+ const correlationId = body.correlationId ?? body.clientTxId;
317
+ return changeSetSchema.safeParse({
318
+ operations: body.operations,
319
+ correlationId,
320
+ intentHash: body.intentHash,
321
+ echo: body.echo,
322
+ scope: context.scope,
323
+ });
324
+ }
325
+
280
326
  /**
281
327
  * Creates a Data Source endpoint you host in front of your own database.
282
328
  *
@@ -368,7 +414,13 @@ export function dataSource<const S extends SchemaRecord, TAuth = unknown>(
368
414
  // generic layer where rows are plain JSON, so there is no per-model handler
369
415
  // lookup on this path.
370
416
  if (options.adapter) {
371
- return handleViaAdapter(options.adapter, body, context.scope);
417
+ try {
418
+ return await handleViaAdapter(options.adapter, body, context.scope);
419
+ } catch (error) {
420
+ const typed = typedSourceError(error);
421
+ if (typed) return typed;
422
+ throw error;
423
+ }
372
424
  }
373
425
 
374
426
  if (body.type === 'load') {
@@ -377,20 +429,104 @@ export function dataSource<const S extends SchemaRecord, TAuth = unknown>(
377
429
  return json({ error: 'source_load_not_configured', model: body.model }, 404);
378
430
  }
379
431
  const row = await handlers.load({ id: body.id, context });
380
- return json({ row });
432
+ try {
433
+ const authorized = authorizeSourceRead(
434
+ options.schema,
435
+ { kind: 'load', model: body.model, id: body.id, ...(context.scope ? { scope: context.scope } : {}) },
436
+ row ? [row as Row] : [],
437
+ );
438
+ return json({ row: authorized[0] ?? null });
439
+ } catch (error) {
440
+ const typed = typedSourceError(error);
441
+ if (typed) return typed;
442
+ throw error;
443
+ }
381
444
  }
382
445
 
383
446
  if (body.type === 'list') {
384
447
  const handlers = getModelHandlers(options, body.model);
385
- if (!handlers?.list) {
448
+ const rule = sourceSubjectRule(options.schema, body.model);
449
+ if (rule && !handlers?.subjectList) {
450
+ return json({
451
+ error: 'source_subject_list_not_configured',
452
+ message: `Subject-scoped model "${body.model}" requires subjectList() so filtering occurs before pagination.`,
453
+ }, 403);
454
+ }
455
+ if (!rule && !handlers?.list) {
386
456
  return json({ error: 'source_list_not_configured', model: body.model }, 404);
387
457
  }
388
- const result = await handlers.list({ query: body.query ?? {}, context });
458
+ const result = rule
459
+ ? await handlers!.subjectList!({
460
+ query: body.query ?? {},
461
+ subject: {
462
+ field: rule.field,
463
+ values: sourceSubjectValues(rule, context.scope?.syncGroups) ?? [],
464
+ },
465
+ context,
466
+ })
467
+ : await handlers!.list!({ query: body.query ?? {}, context });
389
468
  const normalized = normalizeListResult(result);
390
- return json(normalized);
469
+ const request: AdapterReadRequest = {
470
+ kind: 'list',
471
+ model: body.model,
472
+ ...(body.query ? { query: body.query } : {}),
473
+ ...(context.scope ? { scope: context.scope } : {}),
474
+ };
475
+ return json({
476
+ ...normalized,
477
+ rows: authorizeSourceRead(options.schema, request, normalized.rows as readonly Row[]),
478
+ });
391
479
  }
392
480
 
393
481
  if (body.type === 'commit') {
482
+ const hasSubject = body.operations.some((operation) =>
483
+ sourceSubjectRule(options.schema, operation.model));
484
+ if (hasSubject) {
485
+ if (!options.subjectTransaction) {
486
+ return json({
487
+ error: 'source_subject_transaction_required',
488
+ message: 'Subject-scoped custom commits require subjectTransaction() so authorization and mutation share one transaction.',
489
+ }, 403);
490
+ }
491
+ const parsed = parseHandWrittenChange(body, context);
492
+ if (!parsed.success) {
493
+ return json({ error: 'source_commit_invalid', message: parsed.error.message }, 400);
494
+ }
495
+ try {
496
+ let ranBoundary = false;
497
+ const result = await options.subjectTransaction(
498
+ {
499
+ operations: body.operations,
500
+ correlationId: body.correlationId ?? body.clientTxId,
501
+ clientTxId: body.clientTxId,
502
+ intentHash: body.intentHash,
503
+ echo: body.echo,
504
+ context,
505
+ },
506
+ async ({ lockCreate, load, commit }) => {
507
+ if (ranBoundary) {
508
+ throw new Error('subjectTransaction run() may be called only once');
509
+ }
510
+ ranBoundary = true;
511
+ await lockSourceSubjectCreates(
512
+ options.schema,
513
+ parsed.data,
514
+ (operation) => lockCreate(operation),
515
+ );
516
+ await authorizeSourceChange(options.schema, parsed.data, load);
517
+ return commit();
518
+ },
519
+ );
520
+ if (!ranBoundary) {
521
+ throw new Error('subjectTransaction must call run() inside its database transaction');
522
+ }
523
+ return json(result);
524
+ } catch (error) {
525
+ const typed = typedSourceError(error);
526
+ if (typed) return typed;
527
+ throw error;
528
+ }
529
+ }
394
530
  if (options.commit) {
395
531
  const result = await options.commit({
396
532
  operations: body.operations,
@@ -37,6 +37,8 @@ export {
37
37
  type SourceHandlerContext,
38
38
  type SourceModelHandlers,
39
39
  type SourceCommitHandler,
40
+ type SourceSubjectTransactionBoundary,
41
+ type SourceSubjectTransactionHandler,
40
42
  type SourceApiKey,
41
43
  type SourceLoadRequest,
42
44
  type SourceListRequest,
@@ -61,6 +63,10 @@ export {
61
63
  type DataSourceOptions,
62
64
  } from './factory.js';
63
65
 
66
+ export {
67
+ sourceSubjectCreateLockKey,
68
+ } from './subjectAuthorization.js';
69
+
64
70
  export {
65
71
  createPushQueue,
66
72
  InMemoryPushQueueStorage,
@@ -99,6 +99,7 @@ export function endpointOutboxMigrations(): readonly Migration[] {
99
99
  entity_id TEXT NOT NULL,
100
100
  type TEXT NOT NULL,
101
101
  data JSONB,
102
+ sync_groups TEXT[] NOT NULL,
102
103
  organization_id TEXT,
103
104
  client_tx_id TEXT,
104
105
  correlation_id TEXT,
@@ -114,6 +115,26 @@ export function endpointOutboxMigrations(): readonly Migration[] {
114
115
  ALTER TABLE ablo_outbox
115
116
  ADD COLUMN IF NOT EXISTS transaction_id TEXT;`,
116
117
  },
118
+ {
119
+ name: 'ablo_outbox_sync_groups',
120
+ up: `DO $$
121
+ BEGIN
122
+ IF NOT EXISTS (
123
+ SELECT 1 FROM information_schema.columns
124
+ WHERE table_schema = current_schema()
125
+ AND table_name = 'ablo_outbox'
126
+ AND column_name = 'sync_groups'
127
+ ) AND EXISTS (SELECT 1 FROM ablo_outbox) THEN
128
+ RAISE EXCEPTION USING
129
+ ERRCODE = '55000',
130
+ MESSAGE = 'ablo_outbox contains legacy events; verify the previous release consumed them, then purge those rows before adding sync_groups';
131
+ END IF;
132
+ END $$;
133
+ ALTER TABLE ablo_outbox
134
+ ADD COLUMN IF NOT EXISTS sync_groups TEXT[];
135
+ ALTER TABLE ablo_outbox
136
+ ALTER COLUMN sync_groups SET NOT NULL;`,
137
+ },
117
138
  ];
118
139
  }
119
140