@abloatai/transaction 0.55.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 (213) hide show
  1. package/CONVENTIONS.md +34 -0
  2. package/dist/auth/hostedEndpoints.d.ts +21 -5
  3. package/dist/auth/hostedEndpoints.d.ts.map +1 -1
  4. package/dist/auth/hostedEndpoints.js +21 -5
  5. package/dist/auth/hostedEndpoints.js.map +1 -1
  6. package/dist/auth/index.d.ts +1 -1
  7. package/dist/auth/index.d.ts.map +1 -1
  8. package/dist/auth/index.js +1 -1
  9. package/dist/auth/index.js.map +1 -1
  10. package/dist/coordination/index.d.ts +2 -2
  11. package/dist/coordination/index.d.ts.map +1 -1
  12. package/dist/coordination/index.js +1 -1
  13. package/dist/coordination/index.js.map +1 -1
  14. package/dist/coordination/schema.d.ts +0 -3
  15. package/dist/coordination/schema.d.ts.map +1 -1
  16. package/dist/coordination/schema.js +4 -5
  17. package/dist/coordination/schema.js.map +1 -1
  18. package/dist/errorCodes.d.ts +1 -0
  19. package/dist/errorCodes.d.ts.map +1 -1
  20. package/dist/errorCodes.js +1 -0
  21. package/dist/errorCodes.js.map +1 -1
  22. package/dist/errors.d.ts.map +1 -1
  23. package/dist/errors.js +4 -1
  24. package/dist/errors.js.map +1 -1
  25. package/dist/log/syncDeltaRow.d.ts +3 -3
  26. package/dist/readSetContext.d.ts.map +1 -1
  27. package/dist/readSetContext.js +8 -3
  28. package/dist/readSetContext.js.map +1 -1
  29. package/dist/resources/httpResources.d.ts +43 -22
  30. package/dist/resources/httpResources.d.ts.map +1 -1
  31. package/dist/resources/httpResources.js +81 -4
  32. package/dist/resources/httpResources.js.map +1 -1
  33. package/dist/resources/modelCreate.d.ts +21 -0
  34. package/dist/resources/modelCreate.d.ts.map +1 -0
  35. package/dist/resources/modelCreate.js +49 -0
  36. package/dist/resources/modelCreate.js.map +1 -0
  37. package/dist/resources/modelOperations.d.ts +24 -2
  38. package/dist/resources/modelOperations.d.ts.map +1 -1
  39. package/dist/resources/modelOperations.js.map +1 -1
  40. package/dist/resources/writeOptionsSchema.d.ts +14 -0
  41. package/dist/resources/writeOptionsSchema.d.ts.map +1 -1
  42. package/dist/resources/writeOptionsSchema.js +23 -2
  43. package/dist/resources/writeOptionsSchema.js.map +1 -1
  44. package/dist/schema/audit.d.ts +15 -0
  45. package/dist/schema/audit.d.ts.map +1 -0
  46. package/dist/schema/audit.js +90 -0
  47. package/dist/schema/audit.js.map +1 -0
  48. package/dist/schema/ddl.d.ts.map +1 -1
  49. package/dist/schema/ddl.js +68 -1
  50. package/dist/schema/ddl.js.map +1 -1
  51. package/dist/schema/index.d.ts +3 -1
  52. package/dist/schema/index.d.ts.map +1 -1
  53. package/dist/schema/index.js +3 -1
  54. package/dist/schema/index.js.map +1 -1
  55. package/dist/schema/model.d.ts +14 -0
  56. package/dist/schema/model.d.ts.map +1 -1
  57. package/dist/schema/model.js +2 -0
  58. package/dist/schema/model.js.map +1 -1
  59. package/dist/schema/openapi.d.ts.map +1 -1
  60. package/dist/schema/openapi.js +104 -2
  61. package/dist/schema/openapi.js.map +1 -1
  62. package/dist/schema/roles.d.ts +27 -0
  63. package/dist/schema/roles.d.ts.map +1 -1
  64. package/dist/schema/roles.js +40 -0
  65. package/dist/schema/roles.js.map +1 -1
  66. package/dist/schema/schema.d.ts +23 -4
  67. package/dist/schema/schema.d.ts.map +1 -1
  68. package/dist/schema/schema.js +30 -1
  69. package/dist/schema/schema.js.map +1 -1
  70. package/dist/schema/serialize.d.ts +4 -1
  71. package/dist/schema/serialize.d.ts.map +1 -1
  72. package/dist/schema/serialize.js +5 -1
  73. package/dist/schema/serialize.js.map +1 -1
  74. package/dist/schema/subject.d.ts +18 -0
  75. package/dist/schema/subject.d.ts.map +1 -0
  76. package/dist/schema/subject.js +27 -0
  77. package/dist/schema/subject.js.map +1 -0
  78. package/dist/server/adapter.d.ts +2 -0
  79. package/dist/server/adapter.d.ts.map +1 -1
  80. package/dist/server/readConfig.d.ts +3 -0
  81. package/dist/server/readConfig.d.ts.map +1 -1
  82. package/dist/server/readConfig.js +0 -21
  83. package/dist/server/readConfig.js.map +1 -1
  84. package/dist/source/adapters/drizzle.d.ts.map +1 -1
  85. package/dist/source/adapters/drizzle.js +37 -6
  86. package/dist/source/adapters/drizzle.js.map +1 -1
  87. package/dist/source/adapters/kysely.d.ts +2 -0
  88. package/dist/source/adapters/kysely.d.ts.map +1 -1
  89. package/dist/source/adapters/kysely.js +33 -5
  90. package/dist/source/adapters/kysely.js.map +1 -1
  91. package/dist/source/adapters/kyselyMutationCore.d.ts +4 -1
  92. package/dist/source/adapters/kyselyMutationCore.d.ts.map +1 -1
  93. package/dist/source/adapters/kyselyMutationCore.js +17 -8
  94. package/dist/source/adapters/kyselyMutationCore.js.map +1 -1
  95. package/dist/source/adapters/memory.d.ts.map +1 -1
  96. package/dist/source/adapters/memory.js +1 -0
  97. package/dist/source/adapters/memory.js.map +1 -1
  98. package/dist/source/adapters/prisma.d.ts.map +1 -1
  99. package/dist/source/adapters/prisma.js +59 -7
  100. package/dist/source/adapters/prisma.js.map +1 -1
  101. package/dist/source/contract.d.ts +14 -0
  102. package/dist/source/contract.d.ts.map +1 -1
  103. package/dist/source/contract.js +10 -0
  104. package/dist/source/contract.js.map +1 -1
  105. package/dist/source/factory.d.ts +7 -1
  106. package/dist/source/factory.d.ts.map +1 -1
  107. package/dist/source/factory.js +116 -5
  108. package/dist/source/factory.js.map +1 -1
  109. package/dist/source/index.d.ts +2 -1
  110. package/dist/source/index.d.ts.map +1 -1
  111. package/dist/source/index.js +1 -0
  112. package/dist/source/index.js.map +1 -1
  113. package/dist/source/migrations.d.ts.map +1 -1
  114. package/dist/source/migrations.js +21 -0
  115. package/dist/source/migrations.js.map +1 -1
  116. package/dist/source/subjectAuthorization.d.ts +16 -0
  117. package/dist/source/subjectAuthorization.d.ts.map +1 -0
  118. package/dist/source/subjectAuthorization.js +126 -0
  119. package/dist/source/subjectAuthorization.js.map +1 -0
  120. package/dist/source/types.d.ts +52 -2
  121. package/dist/source/types.d.ts.map +1 -1
  122. package/dist/source/types.js +1 -0
  123. package/dist/source/types.js.map +1 -1
  124. package/dist/syncLog/contract.d.ts +45 -3
  125. package/dist/syncLog/contract.d.ts.map +1 -1
  126. package/dist/syncLog/contract.js +46 -4
  127. package/dist/syncLog/contract.js.map +1 -1
  128. package/dist/testing/fixtures/httpResponses.d.ts +6 -0
  129. package/dist/testing/fixtures/httpResponses.d.ts.map +1 -1
  130. package/dist/testing/fixtures/httpResponses.js +1 -0
  131. package/dist/testing/fixtures/httpResponses.js.map +1 -1
  132. package/dist/transport/httpClient.d.ts +8 -1
  133. package/dist/transport/httpClient.d.ts.map +1 -1
  134. package/dist/transport/httpClient.js +51 -20
  135. package/dist/transport/httpClient.js.map +1 -1
  136. package/dist/transport/httpCommitRequest.d.ts +45 -0
  137. package/dist/transport/httpCommitRequest.d.ts.map +1 -0
  138. package/dist/transport/httpCommitRequest.js +67 -0
  139. package/dist/transport/httpCommitRequest.js.map +1 -0
  140. package/dist/transport/httpTransport.d.ts.map +1 -1
  141. package/dist/transport/httpTransport.js +69 -106
  142. package/dist/transport/httpTransport.js.map +1 -1
  143. package/dist/transport/httpTransportHelpers.d.ts +10 -0
  144. package/dist/transport/httpTransportHelpers.d.ts.map +1 -0
  145. package/dist/transport/httpTransportHelpers.js +46 -0
  146. package/dist/transport/httpTransportHelpers.js.map +1 -0
  147. package/dist/wire/apiLifecycle.d.ts +63 -0
  148. package/dist/wire/apiLifecycle.d.ts.map +1 -0
  149. package/dist/wire/apiLifecycle.js +89 -0
  150. package/dist/wire/apiLifecycle.js.map +1 -0
  151. package/dist/wire/auth.d.ts.map +1 -1
  152. package/dist/wire/auth.js +6 -3
  153. package/dist/wire/auth.js.map +1 -1
  154. package/dist/wire/commit.d.ts +74 -9
  155. package/dist/wire/commit.d.ts.map +1 -1
  156. package/dist/wire/commit.js +18 -5
  157. package/dist/wire/commit.js.map +1 -1
  158. package/dist/wire/index.d.ts +5 -2
  159. package/dist/wire/index.d.ts.map +1 -1
  160. package/dist/wire/index.js +12 -1
  161. package/dist/wire/index.js.map +1 -1
  162. package/dist/wire/modelMutations.js +3 -2
  163. package/dist/wire/modelMutations.js.map +1 -1
  164. package/dist/wire/rateLimit.d.ts +82 -0
  165. package/dist/wire/rateLimit.d.ts.map +1 -0
  166. package/dist/wire/rateLimit.js +142 -0
  167. package/dist/wire/rateLimit.js.map +1 -0
  168. package/package.json +1 -1
  169. package/src/auth/hostedEndpoints.ts +23 -5
  170. package/src/auth/index.ts +2 -0
  171. package/src/coordination/index.ts +0 -2
  172. package/src/coordination/schema.ts +4 -7
  173. package/src/errorCodes.ts +6 -0
  174. package/src/errors.ts +4 -1
  175. package/src/readSetContext.ts +8 -2
  176. package/src/resources/httpResources.ts +116 -25
  177. package/src/resources/modelCreate.ts +73 -0
  178. package/src/resources/modelOperations.ts +29 -2
  179. package/src/resources/writeOptionsSchema.ts +30 -2
  180. package/src/schema/audit.ts +121 -0
  181. package/src/schema/ddl.ts +71 -1
  182. package/src/schema/index.ts +16 -0
  183. package/src/schema/model.ts +17 -0
  184. package/src/schema/openapi.ts +123 -2
  185. package/src/schema/roles.ts +53 -0
  186. package/src/schema/schema.ts +68 -3
  187. package/src/schema/serialize.ts +8 -1
  188. package/src/schema/subject.ts +43 -0
  189. package/src/server/adapter.ts +2 -0
  190. package/src/server/readConfig.ts +4 -0
  191. package/src/source/adapters/drizzle.ts +50 -6
  192. package/src/source/adapters/kysely.ts +47 -4
  193. package/src/source/adapters/kyselyMutationCore.ts +21 -9
  194. package/src/source/adapters/memory.ts +1 -0
  195. package/src/source/adapters/prisma.ts +70 -7
  196. package/src/source/contract.ts +11 -0
  197. package/src/source/factory.ts +141 -5
  198. package/src/source/index.ts +6 -0
  199. package/src/source/migrations.ts +21 -0
  200. package/src/source/subjectAuthorization.ts +182 -0
  201. package/src/source/types.ts +56 -2
  202. package/src/syncLog/contract.ts +47 -5
  203. package/src/testing/fixtures/httpResponses.ts +7 -0
  204. package/src/transport/httpClient.ts +85 -23
  205. package/src/transport/httpCommitRequest.ts +104 -0
  206. package/src/transport/httpTransport.ts +92 -142
  207. package/src/transport/httpTransportHelpers.ts +63 -0
  208. package/src/wire/apiLifecycle.ts +94 -0
  209. package/src/wire/auth.ts +6 -3
  210. package/src/wire/commit.ts +20 -5
  211. package/src/wire/index.ts +31 -0
  212. package/src/wire/modelMutations.ts +2 -2
  213. package/src/wire/rateLimit.ts +155 -0
@@ -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
 
@@ -0,0 +1,182 @@
1
+ import { AbloPermissionError, AbloValidationError } from '../errors.js';
2
+ import {
3
+ composeEntitySyncGroups,
4
+ syncGroupsForRow,
5
+ InvalidRecordSubjectError,
6
+ scopeKindOf,
7
+ subjectAuthorized,
8
+ type Schema,
9
+ type SchemaRecord,
10
+ type SubjectRule,
11
+ } from '../schema/index.js';
12
+ import type { AdapterReadRequest, Row } from './adapter.js';
13
+ import type { ChangeSet, Operation } from './contract.js';
14
+
15
+ function sourceModelEntry<S extends SchemaRecord>(schema: Schema<S>, model: string) {
16
+ return Object.entries(schema.models).find(([key, def]) =>
17
+ key === model || key.toLowerCase() === model.toLowerCase() ||
18
+ def.typename === model || def.typename?.toLowerCase() === model.toLowerCase(),
19
+ );
20
+ }
21
+
22
+ function deny(): never {
23
+ throw new AbloPermissionError('The resolved scope does not cover the requested row.', {
24
+ code: 'capability_scope_denied',
25
+ httpStatus: 403,
26
+ });
27
+ }
28
+
29
+ export function sourceSubjectRule<S extends SchemaRecord>(
30
+ schema: Schema<S>,
31
+ model: string,
32
+ ): SubjectRule | undefined {
33
+ const entry = sourceModelEntry(schema, model);
34
+ return entry?.[1].subject;
35
+ }
36
+
37
+ /** Derive the durable record routes while the row is transactionally visible. */
38
+ export function sourceSyncGroups<S extends SchemaRecord>(
39
+ schema: Schema<S>,
40
+ model: string,
41
+ row: Row,
42
+ ): readonly string[] {
43
+ const entry = sourceModelEntry(schema, model);
44
+ if (!entry) return [];
45
+ const [key, definition] = entry;
46
+ const parents: { kind: string; field: string }[] = [];
47
+ const selfKind = scopeKindOf(definition, key);
48
+ for (const relation of Object.values(definition.relations ?? {})) {
49
+ if (relation.type !== 'belongsTo' || relation.options?.parent !== true) continue;
50
+ const target = schema.models[relation.target];
51
+ const kind = target && scopeKindOf(target, relation.target);
52
+ if (kind) parents.push({ kind, field: relation.foreignKey });
53
+ }
54
+ try {
55
+ return syncGroupsForRow(
56
+ {
57
+ selfKind,
58
+ parents,
59
+ ...(definition.subject
60
+ ? { subject: { kind: definition.subject.group, field: definition.subject.field } }
61
+ : {}),
62
+ },
63
+ row,
64
+ composeEntitySyncGroups(row, definition),
65
+ );
66
+ } catch (error) {
67
+ if (!(error instanceof InvalidRecordSubjectError)) throw error;
68
+ throw new AbloValidationError(
69
+ `Source row ${model}/${String(row.id ?? '')} lacks subject field "${error.field}".`,
70
+ { code: 'source_event_invalid', cause: error },
71
+ );
72
+ }
73
+ }
74
+
75
+ /** Subject values represented by a trusted group set, preserving group order. */
76
+ export function sourceSubjectValues(
77
+ rule: SubjectRule | undefined,
78
+ groups: readonly string[] | undefined,
79
+ ): readonly string[] | undefined {
80
+ if (!rule) return undefined;
81
+ const prefix = `${rule.group}:`;
82
+ return [...new Set(
83
+ (groups ?? [])
84
+ .filter((group) => group.startsWith(prefix))
85
+ .map((group) => group.slice(prefix.length))
86
+ .filter((value) => value.length > 0),
87
+ )];
88
+ }
89
+
90
+ /**
91
+ * Stable absent-row lock keys for caller-selected subject-scoped CREATE ids.
92
+ * Adapters acquire these in sorted order before loading current rows, so two
93
+ * authorized writers cannot both observe an absent id. Database uniqueness is
94
+ * still the final boundary against external writers that ignore this lock.
95
+ */
96
+ function sourceSubjectCreateLockOperations<S extends SchemaRecord>(
97
+ schema: Schema<S>,
98
+ change: ChangeSet,
99
+ ): readonly Operation[] {
100
+ return change.operations
101
+ .filter((operation) =>
102
+ operation.type === 'CREATE' &&
103
+ typeof operation.id === 'string' &&
104
+ operation.id.length > 0 &&
105
+ sourceSubjectRule(schema, operation.model) !== undefined)
106
+ .sort((left, right) =>
107
+ `${left.model.toLowerCase()}\u0000${left.id}`.localeCompare(
108
+ `${right.model.toLowerCase()}\u0000${right.id}`,
109
+ ));
110
+ }
111
+
112
+ export function sourceSubjectCreateLockKey(operation: Operation): string {
113
+ return `ablo:subject-create:${operation.model.toLowerCase()}:${operation.id ?? ''}`;
114
+ }
115
+
116
+ /** Acquire every absent-key CREATE lock in canonical order. */
117
+ export async function lockSourceSubjectCreates<S extends SchemaRecord>(
118
+ schema: Schema<S>,
119
+ change: ChangeSet,
120
+ acquire: (operation: Operation, key: string) => Promise<void>,
121
+ ): Promise<void> {
122
+ for (const operation of sourceSubjectCreateLockOperations(schema, change)) {
123
+ await acquire(operation, sourceSubjectCreateLockKey(operation));
124
+ }
125
+ }
126
+
127
+ function errorCode(error: unknown): unknown {
128
+ return error && typeof error === 'object' ? (error as { code?: unknown }).code : undefined;
129
+ }
130
+
131
+ /** Translate ORM/driver primary-key conflicts without exposing the winning row. */
132
+ export function rethrowStrictCreateConflict(error: unknown, operation: Operation): never {
133
+ const code = errorCode(error) ?? errorCode(
134
+ error && typeof error === 'object' ? (error as { cause?: unknown }).cause : undefined,
135
+ );
136
+ if (code === '23505' || code === 'P2002') {
137
+ throw new AbloValidationError(
138
+ `A row already exists for ${operation.model}/${operation.id ?? ''}.`,
139
+ { code: 'entity_already_exists', httpStatus: 409, cause: error },
140
+ );
141
+ }
142
+ throw error;
143
+ }
144
+
145
+ export function authorizeSourceRead<S extends SchemaRecord>(
146
+ schema: Schema<S>,
147
+ req: AdapterReadRequest,
148
+ rows: readonly Row[],
149
+ ): readonly Row[] {
150
+ const rule = sourceSubjectRule(schema, req.model);
151
+ if (!rule) return rows;
152
+ const groups = req.scope?.syncGroups;
153
+ const authorized = rows.filter((row) => subjectAuthorized(rule, row, groups));
154
+ if (req.kind === 'load' && rows.length > 0 && authorized.length === 0) deny();
155
+ return authorized;
156
+ }
157
+
158
+ function authorizePayload(rule: SubjectRule, row: Row, groups: readonly string[] | undefined): void {
159
+ if (!subjectAuthorized(rule, row, groups)) deny();
160
+ }
161
+
162
+ export async function authorizeSourceChange<S extends SchemaRecord>(
163
+ schema: Schema<S>,
164
+ change: ChangeSet,
165
+ load: (operation: Operation) => Promise<Row | null>,
166
+ ): Promise<void> {
167
+ for (const operation of change.operations) {
168
+ const rule = sourceSubjectRule(schema, operation.model);
169
+ if (!rule) continue;
170
+ const current = operation.id ? await load(operation) : null;
171
+ if (current) authorizePayload(rule, current, change.scope?.syncGroups);
172
+ if (operation.type === 'CREATE') {
173
+ authorizePayload(rule, operation.input ?? {}, change.scope?.syncGroups);
174
+ } else if (!current) {
175
+ deny();
176
+ }
177
+ if (current && operation.input && Object.hasOwn(operation.input, rule.field) &&
178
+ !Object.is(current[rule.field], operation.input[rule.field])) {
179
+ deny();
180
+ }
181
+ }
182
+ }