@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.
- package/CONVENTIONS.md +34 -0
- package/dist/auth/hostedEndpoints.d.ts +21 -5
- package/dist/auth/hostedEndpoints.d.ts.map +1 -1
- package/dist/auth/hostedEndpoints.js +21 -5
- package/dist/auth/hostedEndpoints.js.map +1 -1
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +1 -1
- package/dist/auth/index.js.map +1 -1
- 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 -3
- package/dist/coordination/schema.d.ts.map +1 -1
- package/dist/coordination/schema.js +4 -5
- 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/errors.d.ts.map +1 -1
- package/dist/errors.js +4 -1
- package/dist/errors.js.map +1 -1
- package/dist/log/syncDeltaRow.d.ts +3 -3
- 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 +23 -2
- 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 +45 -3
- package/dist/syncLog/contract.d.ts.map +1 -1
- package/dist/syncLog/contract.js +46 -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/auth.d.ts.map +1 -1
- package/dist/wire/auth.js +6 -3
- package/dist/wire/auth.js.map +1 -1
- package/dist/wire/commit.d.ts +74 -9
- package/dist/wire/commit.d.ts.map +1 -1
- package/dist/wire/commit.js +18 -5
- 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/modelMutations.js +3 -2
- package/dist/wire/modelMutations.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/auth/hostedEndpoints.ts +23 -5
- package/src/auth/index.ts +2 -0
- package/src/coordination/index.ts +0 -2
- package/src/coordination/schema.ts +4 -7
- package/src/errorCodes.ts +6 -0
- package/src/errors.ts +4 -1
- 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 +30 -2
- 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 +47 -5
- 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/auth.ts +6 -3
- package/src/wire/commit.ts +20 -5
- package/src/wire/index.ts +31 -0
- package/src/wire/modelMutations.ts +2 -2
- 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
|
-
|
|
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
|
|
|
@@ -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
|
+
}
|