@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.
- package/CONVENTIONS.md +34 -0
- package/dist/coordination/index.d.ts +2 -2
- package/dist/coordination/index.d.ts.map +1 -1
- package/dist/coordination/index.js +1 -1
- package/dist/coordination/index.js.map +1 -1
- package/dist/coordination/schema.d.ts +0 -7
- package/dist/coordination/schema.d.ts.map +1 -1
- package/dist/coordination/schema.js +0 -4
- package/dist/coordination/schema.js.map +1 -1
- package/dist/errorCodes.d.ts +1 -0
- package/dist/errorCodes.d.ts.map +1 -1
- package/dist/errorCodes.js +1 -0
- package/dist/errorCodes.js.map +1 -1
- package/dist/readSetContext.d.ts.map +1 -1
- package/dist/readSetContext.js +8 -3
- package/dist/readSetContext.js.map +1 -1
- package/dist/resources/httpResources.d.ts +43 -22
- package/dist/resources/httpResources.d.ts.map +1 -1
- package/dist/resources/httpResources.js +81 -4
- package/dist/resources/httpResources.js.map +1 -1
- package/dist/resources/modelCreate.d.ts +21 -0
- package/dist/resources/modelCreate.d.ts.map +1 -0
- package/dist/resources/modelCreate.js +49 -0
- package/dist/resources/modelCreate.js.map +1 -0
- package/dist/resources/modelOperations.d.ts +24 -2
- package/dist/resources/modelOperations.d.ts.map +1 -1
- package/dist/resources/modelOperations.js.map +1 -1
- package/dist/resources/writeOptionsSchema.d.ts +14 -0
- package/dist/resources/writeOptionsSchema.d.ts.map +1 -1
- package/dist/resources/writeOptionsSchema.js +20 -0
- package/dist/resources/writeOptionsSchema.js.map +1 -1
- package/dist/schema/audit.d.ts +15 -0
- package/dist/schema/audit.d.ts.map +1 -0
- package/dist/schema/audit.js +90 -0
- package/dist/schema/audit.js.map +1 -0
- package/dist/schema/ddl.d.ts.map +1 -1
- package/dist/schema/ddl.js +68 -1
- package/dist/schema/ddl.js.map +1 -1
- package/dist/schema/index.d.ts +3 -1
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +3 -1
- package/dist/schema/index.js.map +1 -1
- package/dist/schema/model.d.ts +14 -0
- package/dist/schema/model.d.ts.map +1 -1
- package/dist/schema/model.js +2 -0
- package/dist/schema/model.js.map +1 -1
- package/dist/schema/openapi.d.ts.map +1 -1
- package/dist/schema/openapi.js +104 -2
- package/dist/schema/openapi.js.map +1 -1
- package/dist/schema/roles.d.ts +27 -0
- package/dist/schema/roles.d.ts.map +1 -1
- package/dist/schema/roles.js +40 -0
- package/dist/schema/roles.js.map +1 -1
- package/dist/schema/schema.d.ts +23 -4
- package/dist/schema/schema.d.ts.map +1 -1
- package/dist/schema/schema.js +30 -1
- package/dist/schema/schema.js.map +1 -1
- package/dist/schema/serialize.d.ts +4 -1
- package/dist/schema/serialize.d.ts.map +1 -1
- package/dist/schema/serialize.js +5 -1
- package/dist/schema/serialize.js.map +1 -1
- package/dist/schema/subject.d.ts +18 -0
- package/dist/schema/subject.d.ts.map +1 -0
- package/dist/schema/subject.js +27 -0
- package/dist/schema/subject.js.map +1 -0
- package/dist/server/adapter.d.ts +2 -0
- package/dist/server/adapter.d.ts.map +1 -1
- package/dist/server/readConfig.d.ts +3 -0
- package/dist/server/readConfig.d.ts.map +1 -1
- package/dist/server/readConfig.js +0 -21
- package/dist/server/readConfig.js.map +1 -1
- package/dist/source/adapters/drizzle.d.ts.map +1 -1
- package/dist/source/adapters/drizzle.js +37 -6
- package/dist/source/adapters/drizzle.js.map +1 -1
- package/dist/source/adapters/kysely.d.ts +2 -0
- package/dist/source/adapters/kysely.d.ts.map +1 -1
- package/dist/source/adapters/kysely.js +33 -5
- package/dist/source/adapters/kysely.js.map +1 -1
- package/dist/source/adapters/kyselyMutationCore.d.ts +4 -1
- package/dist/source/adapters/kyselyMutationCore.d.ts.map +1 -1
- package/dist/source/adapters/kyselyMutationCore.js +17 -8
- package/dist/source/adapters/kyselyMutationCore.js.map +1 -1
- package/dist/source/adapters/memory.d.ts.map +1 -1
- package/dist/source/adapters/memory.js +1 -0
- package/dist/source/adapters/memory.js.map +1 -1
- package/dist/source/adapters/prisma.d.ts.map +1 -1
- package/dist/source/adapters/prisma.js +59 -7
- package/dist/source/adapters/prisma.js.map +1 -1
- package/dist/source/contract.d.ts +14 -0
- package/dist/source/contract.d.ts.map +1 -1
- package/dist/source/contract.js +10 -0
- package/dist/source/contract.js.map +1 -1
- package/dist/source/factory.d.ts +7 -1
- package/dist/source/factory.d.ts.map +1 -1
- package/dist/source/factory.js +116 -5
- package/dist/source/factory.js.map +1 -1
- package/dist/source/index.d.ts +2 -1
- package/dist/source/index.d.ts.map +1 -1
- package/dist/source/index.js +1 -0
- package/dist/source/index.js.map +1 -1
- package/dist/source/migrations.d.ts.map +1 -1
- package/dist/source/migrations.js +21 -0
- package/dist/source/migrations.js.map +1 -1
- package/dist/source/subjectAuthorization.d.ts +16 -0
- package/dist/source/subjectAuthorization.d.ts.map +1 -0
- package/dist/source/subjectAuthorization.js +126 -0
- package/dist/source/subjectAuthorization.js.map +1 -0
- package/dist/source/types.d.ts +52 -2
- package/dist/source/types.d.ts.map +1 -1
- package/dist/source/types.js +1 -0
- package/dist/source/types.js.map +1 -1
- package/dist/syncLog/contract.d.ts +0 -6
- package/dist/syncLog/contract.d.ts.map +1 -1
- package/dist/syncLog/contract.js +0 -4
- package/dist/syncLog/contract.js.map +1 -1
- package/dist/testing/fixtures/httpResponses.d.ts +6 -0
- package/dist/testing/fixtures/httpResponses.d.ts.map +1 -1
- package/dist/testing/fixtures/httpResponses.js +1 -0
- package/dist/testing/fixtures/httpResponses.js.map +1 -1
- package/dist/transport/httpClient.d.ts +8 -1
- package/dist/transport/httpClient.d.ts.map +1 -1
- package/dist/transport/httpClient.js +51 -20
- package/dist/transport/httpClient.js.map +1 -1
- package/dist/transport/httpCommitRequest.d.ts +45 -0
- package/dist/transport/httpCommitRequest.d.ts.map +1 -0
- package/dist/transport/httpCommitRequest.js +67 -0
- package/dist/transport/httpCommitRequest.js.map +1 -0
- package/dist/transport/httpTransport.d.ts.map +1 -1
- package/dist/transport/httpTransport.js +69 -106
- package/dist/transport/httpTransport.js.map +1 -1
- package/dist/transport/httpTransportHelpers.d.ts +10 -0
- package/dist/transport/httpTransportHelpers.d.ts.map +1 -0
- package/dist/transport/httpTransportHelpers.js +46 -0
- package/dist/transport/httpTransportHelpers.js.map +1 -0
- package/dist/wire/apiLifecycle.d.ts +63 -0
- package/dist/wire/apiLifecycle.d.ts.map +1 -0
- package/dist/wire/apiLifecycle.js +89 -0
- package/dist/wire/apiLifecycle.js.map +1 -0
- package/dist/wire/commit.d.ts +74 -9
- package/dist/wire/commit.d.ts.map +1 -1
- package/dist/wire/commit.js +13 -1
- package/dist/wire/commit.js.map +1 -1
- package/dist/wire/index.d.ts +5 -2
- package/dist/wire/index.d.ts.map +1 -1
- package/dist/wire/index.js +12 -1
- package/dist/wire/index.js.map +1 -1
- package/dist/wire/rateLimit.d.ts +82 -0
- package/dist/wire/rateLimit.d.ts.map +1 -0
- package/dist/wire/rateLimit.js +142 -0
- package/dist/wire/rateLimit.js.map +1 -0
- package/package.json +1 -1
- package/src/coordination/index.ts +0 -2
- package/src/coordination/schema.ts +0 -7
- package/src/errorCodes.ts +6 -0
- package/src/readSetContext.ts +8 -2
- package/src/resources/httpResources.ts +116 -25
- package/src/resources/modelCreate.ts +73 -0
- package/src/resources/modelOperations.ts +29 -2
- package/src/resources/writeOptionsSchema.ts +28 -0
- package/src/schema/audit.ts +121 -0
- package/src/schema/ddl.ts +71 -1
- package/src/schema/index.ts +16 -0
- package/src/schema/model.ts +17 -0
- package/src/schema/openapi.ts +123 -2
- package/src/schema/roles.ts +53 -0
- package/src/schema/schema.ts +68 -3
- package/src/schema/serialize.ts +8 -1
- package/src/schema/subject.ts +43 -0
- package/src/server/adapter.ts +2 -0
- package/src/server/readConfig.ts +4 -0
- package/src/source/adapters/drizzle.ts +50 -6
- package/src/source/adapters/kysely.ts +47 -4
- package/src/source/adapters/kyselyMutationCore.ts +21 -9
- package/src/source/adapters/memory.ts +1 -0
- package/src/source/adapters/prisma.ts +70 -7
- package/src/source/contract.ts +11 -0
- package/src/source/factory.ts +141 -5
- package/src/source/index.ts +6 -0
- package/src/source/migrations.ts +21 -0
- package/src/source/subjectAuthorization.ts +182 -0
- package/src/source/types.ts +56 -2
- package/src/syncLog/contract.ts +0 -7
- package/src/testing/fixtures/httpResponses.ts +7 -0
- package/src/transport/httpClient.ts +85 -23
- package/src/transport/httpCommitRequest.ts +104 -0
- package/src/transport/httpTransport.ts +92 -142
- package/src/transport/httpTransportHelpers.ts +63 -0
- package/src/wire/apiLifecycle.ts +94 -0
- package/src/wire/commit.ts +16 -1
- package/src/wire/index.ts +31 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
197
|
+
let query = database
|
|
192
198
|
.selectFrom(columns.table)
|
|
193
199
|
.selectAll()
|
|
194
200
|
.where(columnFor(columns, 'id'), '=', request.id)
|
|
195
|
-
.limit(1)
|
|
196
|
-
|
|
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
|
-
|
|
206
|
+
let query = database
|
|
200
207
|
.selectFrom(columns.table)
|
|
201
|
-
.selectAll()
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/src/source/contract.ts
CHANGED
|
@@ -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(),
|
package/src/source/factory.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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,
|
package/src/source/index.ts
CHANGED
|
@@ -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,
|
package/src/source/migrations.ts
CHANGED
|
@@ -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
|
|