@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
package/src/schema/openapi.ts
CHANGED
|
@@ -53,6 +53,20 @@ import {
|
|
|
53
53
|
claimReleaseReplySchema,
|
|
54
54
|
} from '../wire/claims.js';
|
|
55
55
|
import { errorEnvelopeSchema } from '../wire/errorEnvelope.js';
|
|
56
|
+
// The lifecycle promise and the rate-limit fields are contract, not prose about
|
|
57
|
+
// contract: the document renders the same constants the server emits, so the
|
|
58
|
+
// published policy cannot describe a signal the runtime does not send.
|
|
59
|
+
import {
|
|
60
|
+
API_DEPRECATION_HEADER,
|
|
61
|
+
API_LIFECYCLE,
|
|
62
|
+
API_SUNSET_HEADER,
|
|
63
|
+
API_VERSION_HEADER,
|
|
64
|
+
} from '../wire/apiLifecycle.js';
|
|
65
|
+
import {
|
|
66
|
+
RATE_LIMIT_HEADER,
|
|
67
|
+
RATE_LIMIT_POLICY_HEADER,
|
|
68
|
+
RETRY_AFTER_HEADER,
|
|
69
|
+
} from '../wire/rateLimit.js';
|
|
56
70
|
import { modelReadResponseSchema, modelListResponseSchema } from '../wire/modelResponses.js';
|
|
57
71
|
import { modelMutationRequestSchema } from '../wire/modelMutations.js';
|
|
58
72
|
import { logListResponseSchema, logQuerySchema } from '../wire/feedEvent.js';
|
|
@@ -400,6 +414,106 @@ const claimIdParam = (): Json => ({
|
|
|
400
414
|
*/
|
|
401
415
|
const commitBody = (): Json => jsonBody(derive(commitRequestSchema, 'input'));
|
|
402
416
|
|
|
417
|
+
/**
|
|
418
|
+
* The response headers every operation carries, declared once under
|
|
419
|
+
* `components/headers` and referenced from each response.
|
|
420
|
+
*
|
|
421
|
+
* A header a caller is expected to act on has to be IN the document to be
|
|
422
|
+
* actionable: a generated client surfaces what the spec declares and drops what
|
|
423
|
+
* it does not, so an undeclared `RateLimit` is a header the caller never sees
|
|
424
|
+
* and therefore never paces against. Declaring them here rather than at each
|
|
425
|
+
* response is what keeps one description of each — the reason the names are
|
|
426
|
+
* imported rather than typed out.
|
|
427
|
+
*/
|
|
428
|
+
const RESPONSE_HEADER_COMPONENTS: Readonly<Record<string, Json>> = {
|
|
429
|
+
AbloVersion: {
|
|
430
|
+
description:
|
|
431
|
+
'The date-stamped contract version this response was served under. Record ' +
|
|
432
|
+
'the value your integration was built against; a change means something ' +
|
|
433
|
+
'observable was added.',
|
|
434
|
+
schema: { type: 'string', examples: ['2026-08-15'] },
|
|
435
|
+
},
|
|
436
|
+
RateLimitPolicy: {
|
|
437
|
+
description:
|
|
438
|
+
'The standing allowance for this credential kind, as a Structured Fields ' +
|
|
439
|
+
'List: `"secret";q=600;w=12`. It does not move between responses, so read ' +
|
|
440
|
+
'it once and pace against it.',
|
|
441
|
+
schema: { type: 'string', examples: ['"secret";q=600;w=12'] },
|
|
442
|
+
},
|
|
443
|
+
RateLimit: {
|
|
444
|
+
description:
|
|
445
|
+
'This caller\u2019s live position in the allowance: `r` units left, `t` ' +
|
|
446
|
+
'seconds until they refill. Present once the request is attributed to a ' +
|
|
447
|
+
'credential.',
|
|
448
|
+
schema: { type: 'string', examples: ['"secret";r=412;t=8'] },
|
|
449
|
+
},
|
|
450
|
+
RetryAfter: {
|
|
451
|
+
description: 'Whole seconds to wait before retrying. Sent with 429 and 503.',
|
|
452
|
+
schema: { type: 'integer', minimum: 1, examples: [8] },
|
|
453
|
+
},
|
|
454
|
+
RequestId: {
|
|
455
|
+
description:
|
|
456
|
+
'Correlation id for this request, repeated in the error envelope\u2019s ' +
|
|
457
|
+
'`request_id`. Quote it in a support request.',
|
|
458
|
+
schema: { type: 'string', examples: ['req_52bb7f46-17bc-4f2d-9988-89d1398d2990'] },
|
|
459
|
+
},
|
|
460
|
+
Deprecation: {
|
|
461
|
+
description:
|
|
462
|
+
'Present only on a route being withdrawn: an sf-Date of when the ' +
|
|
463
|
+
'deprecation took effect (RFC 9745). The route still answers normally.',
|
|
464
|
+
schema: { type: 'string', examples: ['@1774483200'] },
|
|
465
|
+
},
|
|
466
|
+
Sunset: {
|
|
467
|
+
description:
|
|
468
|
+
'Present only on a route being withdrawn: the HTTP-date it stops ' +
|
|
469
|
+
'answering (RFC 8594). Never less than the published notice window after ' +
|
|
470
|
+
`\`${API_DEPRECATION_HEADER}\`.`,
|
|
471
|
+
schema: { type: 'string', examples: ['Tue, 08 Sep 2026 00:00:00 GMT'] },
|
|
472
|
+
},
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
const headerRef = (name: string): Json => ({ $ref: `#/components/headers/${name}` });
|
|
476
|
+
|
|
477
|
+
/** Carried by every response, whatever its status. */
|
|
478
|
+
const UNIVERSAL_RESPONSE_HEADERS: Readonly<Record<string, Json>> = {
|
|
479
|
+
[API_VERSION_HEADER]: headerRef('AbloVersion'),
|
|
480
|
+
'X-Request-Id': headerRef('RequestId'),
|
|
481
|
+
[RATE_LIMIT_POLICY_HEADER]: headerRef('RateLimitPolicy'),
|
|
482
|
+
[RATE_LIMIT_HEADER]: headerRef('RateLimit'),
|
|
483
|
+
[API_DEPRECATION_HEADER]: headerRef('Deprecation'),
|
|
484
|
+
[API_SUNSET_HEADER]: headerRef('Sunset'),
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
/** Statuses where a wait is what resolves the failure, so `Retry-After` is sent. */
|
|
488
|
+
const RETRY_AFTER_STATUSES = new Set(['429', '503']);
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Stamp the documented headers onto every response of every operation.
|
|
492
|
+
*
|
|
493
|
+
* Runs after the responses are built — including the canonical error ones — so
|
|
494
|
+
* a response added later cannot quietly ship without them.
|
|
495
|
+
*/
|
|
496
|
+
function attachResponseHeaders(paths: Json): void {
|
|
497
|
+
for (const rawPathItem of Object.values(paths)) {
|
|
498
|
+
const pathItem = rawPathItem as Json;
|
|
499
|
+
for (const [method, rawOperation] of Object.entries(pathItem)) {
|
|
500
|
+
if (!HTTP_METHODS.has(method)) continue;
|
|
501
|
+
const operation = rawOperation as Json;
|
|
502
|
+
const responses = (operation.responses ?? {}) as Json;
|
|
503
|
+
for (const [status, rawResponse] of Object.entries(responses)) {
|
|
504
|
+
const response = rawResponse as Json;
|
|
505
|
+
response.headers = {
|
|
506
|
+
...UNIVERSAL_RESPONSE_HEADERS,
|
|
507
|
+
...(RETRY_AFTER_STATUSES.has(status)
|
|
508
|
+
? { [RETRY_AFTER_HEADER]: headerRef('RetryAfter') }
|
|
509
|
+
: {}),
|
|
510
|
+
...((response.headers as Json | undefined) ?? {}),
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
403
517
|
/** The envelope shared by both specs — same server, same auth, same version. */
|
|
404
518
|
function envelope(options: SchemaToOpenApiOptions, description: string, paths: Json, schemas: Record<string, Json>): Json {
|
|
405
519
|
return {
|
|
@@ -407,7 +521,11 @@ function envelope(options: SchemaToOpenApiOptions, description: string, paths: J
|
|
|
407
521
|
info: {
|
|
408
522
|
title: options.title ?? 'Ablo API',
|
|
409
523
|
version: options.version ?? 'development',
|
|
410
|
-
|
|
524
|
+
// The lifecycle policy travels WITH the routes it governs. An agent
|
|
525
|
+
// deciding whether to integrate needs to know the surface will not move
|
|
526
|
+
// under it, and a policy published somewhere else is one it has to go
|
|
527
|
+
// find — so the document that describes the calls describes the promise.
|
|
528
|
+
description: `${description}\n\n${API_LIFECYCLE}`,
|
|
411
529
|
license: {
|
|
412
530
|
name: 'Apache License 2.0',
|
|
413
531
|
identifier: 'Apache-2.0',
|
|
@@ -417,8 +535,9 @@ function envelope(options: SchemaToOpenApiOptions, description: string, paths: J
|
|
|
417
535
|
security: [{ bearerAuth: [] }],
|
|
418
536
|
components: {
|
|
419
537
|
securitySchemes: {
|
|
420
|
-
bearerAuth: { type: 'http', scheme: 'bearer', description: 'Your Ablo API key (sk_
|
|
538
|
+
bearerAuth: { type: 'http', scheme: 'bearer', description: 'Your Ablo API key (sk_\u2026 / rk_\u2026).' },
|
|
421
539
|
},
|
|
540
|
+
headers: { ...RESPONSE_HEADER_COMPONENTS },
|
|
422
541
|
schemas,
|
|
423
542
|
},
|
|
424
543
|
paths,
|
|
@@ -950,6 +1069,7 @@ export function abloOpenApi(options: SchemaToOpenApiOptions = {}): Json {
|
|
|
950
1069
|
|
|
951
1070
|
applyOperationIds(paths, ABLO_OPERATION_IDS);
|
|
952
1071
|
attachCanonicalErrors(paths);
|
|
1072
|
+
attachResponseHeaders(paths);
|
|
953
1073
|
|
|
954
1074
|
return envelope(
|
|
955
1075
|
options,
|
|
@@ -1078,6 +1198,7 @@ export function schemaToOpenApi<S extends SchemaRecord>(
|
|
|
1078
1198
|
operationIds['GET /v1/commits/{id}'] = 'getCommit';
|
|
1079
1199
|
applyOperationIds(paths, operationIds);
|
|
1080
1200
|
attachCanonicalErrors(paths);
|
|
1201
|
+
attachResponseHeaders(paths);
|
|
1081
1202
|
|
|
1082
1203
|
Object.assign(schemas, abloComponentSchemas());
|
|
1083
1204
|
|
package/src/schema/roles.ts
CHANGED
|
@@ -219,6 +219,8 @@ export const groupsInputSchema = z.object({
|
|
|
219
219
|
root: scopeSchema.optional(),
|
|
220
220
|
grants: grantsRefSchema.optional(),
|
|
221
221
|
roles: z.union([entityRoleSchema, z.array(entityRoleSchema)]).optional(),
|
|
222
|
+
/** Acknowledge that these groups are delivery routing, not row access. */
|
|
223
|
+
routingOnly: z.literal(true).optional(),
|
|
222
224
|
});
|
|
223
225
|
export type GroupsInput = z.infer<typeof groupsInputSchema>;
|
|
224
226
|
|
|
@@ -314,6 +316,57 @@ export function composeEntitySyncGroups(
|
|
|
314
316
|
return Array.from(out);
|
|
315
317
|
}
|
|
316
318
|
|
|
319
|
+
/** The row fields needed to derive its durable delivery groups. */
|
|
320
|
+
export interface RecordSyncGroupSpec {
|
|
321
|
+
readonly subject?: { readonly kind: string; readonly field: string };
|
|
322
|
+
readonly selfKind?: string;
|
|
323
|
+
readonly parents: readonly { readonly kind: string; readonly field: string }[];
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** A subject-scoped row cannot be routed safely without its declared subject. */
|
|
327
|
+
export class InvalidRecordSubjectError extends Error {
|
|
328
|
+
readonly field: string;
|
|
329
|
+
|
|
330
|
+
constructor(field: string) {
|
|
331
|
+
super(`Record lacks non-empty subject field "${field}".`);
|
|
332
|
+
this.name = 'InvalidRecordSubjectError';
|
|
333
|
+
this.field = field;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Derive the exact durable delivery groups for a row.
|
|
339
|
+
*
|
|
340
|
+
* Delivery matching is OR-based, so a declared subject is an exclusive
|
|
341
|
+
* authorization route: self, parent, entity-role, writer, and transitive groups
|
|
342
|
+
* must not become alternate ways to receive the row. Additional groups are
|
|
343
|
+
* accepted here so every producer applies that rule in this one place.
|
|
344
|
+
*/
|
|
345
|
+
export function syncGroupsForRow(
|
|
346
|
+
spec: RecordSyncGroupSpec | undefined,
|
|
347
|
+
record: Readonly<Record<string, unknown>>,
|
|
348
|
+
additionalGroups: readonly string[] = [],
|
|
349
|
+
): string[] {
|
|
350
|
+
if (spec?.subject) {
|
|
351
|
+
const value = record[spec.subject.field];
|
|
352
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
353
|
+
throw new InvalidRecordSubjectError(spec.subject.field);
|
|
354
|
+
}
|
|
355
|
+
return [syncGroup(spec.subject.kind, value)];
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const groups = new Set<string>(additionalGroups);
|
|
359
|
+
if (!spec) return [...groups];
|
|
360
|
+
if (spec.selfKind && typeof record.id === 'string' && record.id.length > 0) {
|
|
361
|
+
groups.add(syncGroup(spec.selfKind, record.id));
|
|
362
|
+
}
|
|
363
|
+
for (const parent of spec.parents) {
|
|
364
|
+
const value = record[parent.field];
|
|
365
|
+
if (value) groups.add(syncGroup(parent.kind, String(value)));
|
|
366
|
+
}
|
|
367
|
+
return [...groups];
|
|
368
|
+
}
|
|
369
|
+
|
|
317
370
|
/**
|
|
318
371
|
* Narrow a client's requested sync-group set to the groups it is actually
|
|
319
372
|
* allowed to subscribe to. This helper is fully generic — it never inspects the
|
package/src/schema/schema.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { AbloValidationError } from '../errors.js';
|
|
|
27
27
|
import type { IdentityRole } from './roles.js';
|
|
28
28
|
import { fieldRef, type FieldRef } from './fieldRef.js';
|
|
29
29
|
import { scopeSchema, grantsRefSchema } from './roles.js';
|
|
30
|
+
import { subjectRuleSchema } from './subject.js';
|
|
30
31
|
|
|
31
32
|
// Sync-group roles (identity and entity) are defined in `./roles.js` and
|
|
32
33
|
// re-exported here so they can also be imported from this module. See
|
|
@@ -49,6 +50,9 @@ export {
|
|
|
49
50
|
extractEntityIds,
|
|
50
51
|
composeIdentitySyncGroups,
|
|
51
52
|
composeEntitySyncGroups,
|
|
53
|
+
syncGroupsForRow,
|
|
54
|
+
InvalidRecordSubjectError,
|
|
55
|
+
type RecordSyncGroupSpec,
|
|
52
56
|
intersectRequestedWithAllowed,
|
|
53
57
|
syncGroup,
|
|
54
58
|
identityAnchor,
|
|
@@ -150,6 +154,7 @@ export const RESERVED_SESSION_SETTINGS: readonly string[] = [
|
|
|
150
154
|
'app.current_participant_id',
|
|
151
155
|
'app.current_participant_kind',
|
|
152
156
|
'app.current_user_id',
|
|
157
|
+
'app.current_subject_groups',
|
|
153
158
|
];
|
|
154
159
|
|
|
155
160
|
export interface DefineSchemaOptions {
|
|
@@ -523,14 +528,34 @@ export type InsertValue<S extends Schema, ModelName extends keyof S['models']> =
|
|
|
523
528
|
export type UpsertValue<S extends Schema, ModelName extends keyof S['models']> =
|
|
524
529
|
InsertValue<S, ModelName>;
|
|
525
530
|
|
|
531
|
+
/**
|
|
532
|
+
* A patch over a row's fields: send a field to change it, omit it to leave it,
|
|
533
|
+
* send `null` to CLEAR it.
|
|
534
|
+
*
|
|
535
|
+
* The third of those is why this is a mapped type rather than `Partial`.
|
|
536
|
+
* Omitting a field and clearing a field are different intentions that
|
|
537
|
+
* `Partial` spells the same way, because `undefined` is what an absent key
|
|
538
|
+
* already means: it is dropped from the payload, so a caller who wrote
|
|
539
|
+
* `{ assigneeId: undefined }` to unassign kept the old assignee, and the
|
|
540
|
+
* unassign, the un-project and the cleared due date all did nothing without
|
|
541
|
+
* erroring. `null` is the value that clears, so `null` is what the type has to
|
|
542
|
+
* accept — otherwise the only way to write a working clear is to cast the
|
|
543
|
+
* payload, and a cast is what people reached for.
|
|
544
|
+
*
|
|
545
|
+
* Only a field the schema lets be absent is clearable. A required field cannot
|
|
546
|
+
* be nulled, which is the same rule the column has.
|
|
547
|
+
*/
|
|
548
|
+
export type Clearable<T> = {
|
|
549
|
+
[K in keyof T]?: undefined extends T[K] ? T[K] | null : T[K];
|
|
550
|
+
};
|
|
551
|
+
|
|
526
552
|
/**
|
|
527
553
|
* The value type for updating an existing row. `id` is required (identifies
|
|
528
|
-
* the row to update);
|
|
529
|
-
* are changed).
|
|
554
|
+
* the row to update); every other field is a {@link Clearable} patch.
|
|
530
555
|
*/
|
|
531
556
|
export type UpdateValue<S extends Schema, ModelName extends keyof S['models']> =
|
|
532
557
|
S['models'][ModelName] extends ModelDef<infer Shape>
|
|
533
|
-
? { id: string } &
|
|
558
|
+
? { id: string } & Clearable<z.input<z.ZodObject<Shape>>>
|
|
534
559
|
: never;
|
|
535
560
|
|
|
536
561
|
/**
|
|
@@ -708,6 +733,7 @@ export function defineSchema<const S extends SchemaRecord>(
|
|
|
708
733
|
}
|
|
709
734
|
|
|
710
735
|
validateSyncGroupSchema(resolvedModels);
|
|
736
|
+
validateSubjectSchema(resolvedModels);
|
|
711
737
|
validateSessionSettings(options?.sessionSettings ?? {});
|
|
712
738
|
|
|
713
739
|
return {
|
|
@@ -721,6 +747,45 @@ export function defineSchema<const S extends SchemaRecord>(
|
|
|
721
747
|
};
|
|
722
748
|
}
|
|
723
749
|
|
|
750
|
+
function validateSubjectSchema(models: Record<string, ModelDef>): void {
|
|
751
|
+
for (const [name, def] of Object.entries(models)) {
|
|
752
|
+
if (!def.subject) continue;
|
|
753
|
+
const parsed = subjectRuleSchema.safeParse(def.subject);
|
|
754
|
+
if (!parsed.success) {
|
|
755
|
+
throw new AbloValidationError(
|
|
756
|
+
`Model "${name}": subject must be { field, group } with valid identifiers.`,
|
|
757
|
+
{ code: 'schema_definition_invalid', param: `${name}.subject` },
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
if (!(def.subject.field in def.fields)) {
|
|
761
|
+
throw new AbloValidationError(
|
|
762
|
+
`Model "${name}": subject.field "${def.subject.field}" is not a declared field on this model.`,
|
|
763
|
+
{ code: 'schema_definition_invalid', param: `${name}.subject.field` },
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
const field = def.shape[def.subject.field];
|
|
767
|
+
const meta = def.fields[def.subject.field];
|
|
768
|
+
if (!field || !meta) {
|
|
769
|
+
throw new AbloValidationError(
|
|
770
|
+
`Model "${name}": subject.field "${def.subject.field}" is not a declared field on this model.`,
|
|
771
|
+
{ code: 'schema_definition_invalid', param: `${name}.subject.field` },
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
const subjectField = field as z.ZodType;
|
|
775
|
+
const acceptsInvalidValue =
|
|
776
|
+
subjectField.safeParse('').success ||
|
|
777
|
+
subjectField.safeParse(null).success ||
|
|
778
|
+
subjectField.safeParse(undefined).success ||
|
|
779
|
+
subjectField.safeParse(1).success;
|
|
780
|
+
if (!['string', 'enum'].includes(meta.type) || meta.isOptional || acceptsInvalidValue) {
|
|
781
|
+
throw new AbloValidationError(
|
|
782
|
+
`Model "${name}": subject.field "${def.subject.field}" must be a required, non-empty string field.`,
|
|
783
|
+
{ code: 'schema_definition_invalid', param: `${name}.subject.field` },
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
724
789
|
/**
|
|
725
790
|
* Reject session-setting mappings that couldn't do what the author intends —
|
|
726
791
|
* caught here at definition time rather than silently dropped on the write path.
|
package/src/schema/serialize.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* What round-trips:
|
|
14
14
|
* - all model routing and scoping metadata: typename, tableName, load,
|
|
15
15
|
* mutable, the `tenancy` descriptor, bootstrap hints, scope, grants,
|
|
16
|
-
* entityRoles, the `conflict` disposition map, persist, autoFill,
|
|
16
|
+
* entityRoles, the routing-only acknowledgement, the `conflict` disposition map, persist, autoFill,
|
|
17
17
|
* requiredFields, and lazyObservable. The authoring shorthands (`policy`
|
|
18
18
|
* and `groups`) are normalized into these canonical fields when the model
|
|
19
19
|
* is built, so only the canonical fields cross here.
|
|
@@ -33,6 +33,7 @@ import type { FieldMeta } from './field.js';
|
|
|
33
33
|
import { buildFieldRefs } from './schema.js';
|
|
34
34
|
import type { Tenancy } from './tenancy.js';
|
|
35
35
|
import type { ModelResidency } from './residency.js';
|
|
36
|
+
import type { SubjectRule } from './subject.js';
|
|
36
37
|
import type {
|
|
37
38
|
ModelDef,
|
|
38
39
|
RelationRecord,
|
|
@@ -80,6 +81,7 @@ export interface ModelJSON {
|
|
|
80
81
|
readonly typename: string;
|
|
81
82
|
readonly tableName?: string;
|
|
82
83
|
readonly tenancy: Tenancy;
|
|
84
|
+
readonly subject?: SubjectRule;
|
|
83
85
|
/** The database plane the model's rows live in. Optional for backward
|
|
84
86
|
* compatibility: when absent (an artifact written before this field existed)
|
|
85
87
|
* it reads as `tenant`, the default. See {@link ModelResidency}. */
|
|
@@ -87,6 +89,7 @@ export interface ModelJSON {
|
|
|
87
89
|
readonly scope?: boolean | string;
|
|
88
90
|
readonly grants?: GrantsRef;
|
|
89
91
|
readonly entityRoles?: readonly EntityRole[];
|
|
92
|
+
readonly routingOnly?: true;
|
|
90
93
|
/** The declared write-conflict disposition per committer kind. When absent,
|
|
91
94
|
* the engine falls back to its default. */
|
|
92
95
|
readonly conflict?: ConflictAxis;
|
|
@@ -136,10 +139,12 @@ function modelToJSON(def: ModelDef): ModelJSON {
|
|
|
136
139
|
typename: def.typename ?? '',
|
|
137
140
|
tableName: def.tableName,
|
|
138
141
|
tenancy: def.tenancy,
|
|
142
|
+
subject: def.subject,
|
|
139
143
|
plane: def.plane,
|
|
140
144
|
scope: def.scope,
|
|
141
145
|
grants: def.grants,
|
|
142
146
|
entityRoles: def.entityRoles,
|
|
147
|
+
routingOnly: def.routingOnly,
|
|
143
148
|
conflict: def.conflict,
|
|
144
149
|
bootstrapLimit: def.bootstrapLimit,
|
|
145
150
|
bootstrapOrderBy: def.bootstrapOrderBy,
|
|
@@ -272,12 +277,14 @@ function modelFromJSON(json: ModelJSON): ModelDef {
|
|
|
272
277
|
persist: json.persist,
|
|
273
278
|
tableName: json.tableName,
|
|
274
279
|
tenancy: json.tenancy,
|
|
280
|
+
subject: json.subject,
|
|
275
281
|
// Absent in older artifacts → default `tenant`, matching the model builder
|
|
276
282
|
// and provisioning defaults so the round-trip stays stable.
|
|
277
283
|
plane: json.plane ?? 'tenant',
|
|
278
284
|
scope: json.scope,
|
|
279
285
|
grants: json.grants,
|
|
280
286
|
entityRoles: json.entityRoles,
|
|
287
|
+
routingOnly: json.routingOnly,
|
|
281
288
|
// Absent in older artifacts → undefined, so the commit path falls through to
|
|
282
289
|
// the function registry or the engine default.
|
|
283
290
|
conflict: json.conflict,
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Row/subject authorization for one model.
|
|
5
|
+
*
|
|
6
|
+
* A row is authorized exactly when the authenticated request carries the sync
|
|
7
|
+
* group `${group}:${row[field]}`. Both values are plain schema data so the rule
|
|
8
|
+
* survives serialization and can be compiled by every storage plane.
|
|
9
|
+
*/
|
|
10
|
+
export const subjectRuleSchema = z.strictObject({
|
|
11
|
+
field: z.string().regex(
|
|
12
|
+
/^[a-zA-Z_][a-zA-Z0-9_]*$/,
|
|
13
|
+
'subject.field must name a model field',
|
|
14
|
+
),
|
|
15
|
+
group: z.string().regex(
|
|
16
|
+
/^[a-z][a-z0-9_]*$/,
|
|
17
|
+
'subject.group must be a lowercase identifier, e.g. "workspace"',
|
|
18
|
+
),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export type SubjectRule = z.infer<typeof subjectRuleSchema>;
|
|
22
|
+
|
|
23
|
+
/** The exact trusted group a row must match, or null for a malformed row. */
|
|
24
|
+
export function subjectGroupForRow(
|
|
25
|
+
rule: SubjectRule,
|
|
26
|
+
row: Readonly<Record<string, unknown>>,
|
|
27
|
+
): string | null {
|
|
28
|
+
const value = row[rule.field];
|
|
29
|
+
return typeof value === 'string' && value.length > 0
|
|
30
|
+
? `${rule.group}:${value}`
|
|
31
|
+
: null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Fail-closed row check shared by endpoint adapters and in-memory log folds. */
|
|
35
|
+
export function subjectAuthorized(
|
|
36
|
+
rule: SubjectRule | undefined,
|
|
37
|
+
row: Readonly<Record<string, unknown>>,
|
|
38
|
+
groups: readonly string[] | null | undefined,
|
|
39
|
+
): boolean {
|
|
40
|
+
if (!rule) return true;
|
|
41
|
+
const required = subjectGroupForRow(rule, row);
|
|
42
|
+
return required !== null && (groups ?? []).includes(required);
|
|
43
|
+
}
|
package/src/server/adapter.ts
CHANGED
|
@@ -25,6 +25,7 @@ import type { ServerSyncDelta } from '../wire/delta.js';
|
|
|
25
25
|
import type { BootstrapModel } from './readConfig.js';
|
|
26
26
|
import type { CommitContext, CommitExecutionResult } from './commit.js';
|
|
27
27
|
import type { StorageMode } from './storageMode.js';
|
|
28
|
+
import type { SubjectRule } from '../schema/subject.js';
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* A canonical database row: one record, keyed by column name. The value type is
|
|
@@ -141,6 +142,7 @@ export type ReadRequest =
|
|
|
141
142
|
readonly sourceModel?: string;
|
|
142
143
|
/** `__typename` stamped on each returned row. */
|
|
143
144
|
readonly typename: string;
|
|
145
|
+
readonly subject?: SubjectRule;
|
|
144
146
|
readonly query: SourceListQuery;
|
|
145
147
|
readonly scope?: SourceRequestContext;
|
|
146
148
|
/** Runs the query against a hosted database: compile, take the tenant pool, apply row-level security, unpack the rows. */
|
package/src/server/readConfig.ts
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
* Plain data, no database driver — it feeds the read side of the data adapter
|
|
20
20
|
* contract, and your query builder reads it to load a model's initial rows.
|
|
21
21
|
*/
|
|
22
|
+
import type { SubjectRule } from '../schema/subject.js';
|
|
23
|
+
|
|
22
24
|
|
|
23
25
|
/** A mapping from a declared field to a physical column, with the alias to apply after a `SELECT *`. */
|
|
24
26
|
export interface ColumnOverride {
|
|
@@ -88,6 +90,8 @@ export interface ReadModelShape {
|
|
|
88
90
|
readonly orgColumn?: string;
|
|
89
91
|
/** Parent-table scoping for rows with no tenancy column of their own. */
|
|
90
92
|
readonly scopedVia?: ParentScope;
|
|
93
|
+
/** Credential-bound row authorization compiled by every read plane. */
|
|
94
|
+
readonly subject?: SubjectRule;
|
|
91
95
|
/** Client-facing field name → physical DB column for declared fields. */
|
|
92
96
|
readonly fieldColumns?: Readonly<Record<string, string>>;
|
|
93
97
|
/** Physical-column aliases needed after SELECT * for `.from(...)` fields. */
|
|
@@ -58,6 +58,15 @@ import { toSchemaJSON } from '../../schema/serialize.js';
|
|
|
58
58
|
import { camelToSnake, snakeToCamel } from '../../schema/ddl.js';
|
|
59
59
|
import { tenancyColumn } from '../../schema/tenancy.js';
|
|
60
60
|
import { ABLO_POSTGRES_COMMIT_ECHO_PREFIX } from '../types.js';
|
|
61
|
+
import {
|
|
62
|
+
authorizeSourceChange,
|
|
63
|
+
authorizeSourceRead,
|
|
64
|
+
lockSourceSubjectCreates,
|
|
65
|
+
rethrowStrictCreateConflict,
|
|
66
|
+
sourceSyncGroups,
|
|
67
|
+
sourceSubjectRule,
|
|
68
|
+
sourceSubjectValues,
|
|
69
|
+
} from '../subjectAuthorization.js';
|
|
61
70
|
|
|
62
71
|
/** The subset of a Drizzle database/transaction handle the adapter calls. */
|
|
63
72
|
export interface DrizzleLike {
|
|
@@ -214,11 +223,18 @@ export function drizzleDataSource<S extends SchemaRecord>(
|
|
|
214
223
|
const table = sql.identifier(mc.table);
|
|
215
224
|
if (req.kind === 'load') {
|
|
216
225
|
const rows = rowsOf(await db.execute(sql`SELECT * FROM ${table} WHERE id = ${req.id} LIMIT 1`));
|
|
217
|
-
return rows.map((r) => toFields(mc, r));
|
|
226
|
+
return authorizeSourceRead(schema, req, rows.map((r) => toFields(mc, r)));
|
|
218
227
|
}
|
|
219
228
|
const limit = req.query?.limit ?? 1000;
|
|
220
|
-
const
|
|
221
|
-
|
|
229
|
+
const rule = sourceSubjectRule(schema, req.model);
|
|
230
|
+
const subjects = sourceSubjectValues(rule, req.scope?.syncGroups);
|
|
231
|
+
if (subjects?.length === 0) return [];
|
|
232
|
+
const rows = rowsOf(await db.execute(subjects
|
|
233
|
+
? sql`SELECT * FROM ${table}
|
|
234
|
+
WHERE ${sql.identifier(columnFor(mc, rule!.field))} = ANY(${subjects})
|
|
235
|
+
LIMIT ${limit}`
|
|
236
|
+
: sql`SELECT * FROM ${table} LIMIT ${limit}`));
|
|
237
|
+
return authorizeSourceRead(schema, req, rows.map((r) => toFields(mc, r)));
|
|
222
238
|
},
|
|
223
239
|
|
|
224
240
|
async commit(change: ChangeSet): Promise<AdapterCommitResult> {
|
|
@@ -238,19 +254,46 @@ export function drizzleDataSource<S extends SchemaRecord>(
|
|
|
238
254
|
return { rows: cachedRow.response as Row[] };
|
|
239
255
|
}
|
|
240
256
|
|
|
257
|
+
await lockSourceSubjectCreates(schema, change, async (_operation, key) => {
|
|
258
|
+
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${key}, 0))`);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
await authorizeSourceChange(schema, change, async (operation) => {
|
|
262
|
+
if (!operation.id) return null;
|
|
263
|
+
const mc = modelColumns(operation.model);
|
|
264
|
+
const found = rowsOf(
|
|
265
|
+
await tx.execute(
|
|
266
|
+
sql`SELECT * FROM ${sql.identifier(mc.table)} WHERE id = ${operation.id} LIMIT 1 FOR UPDATE`,
|
|
267
|
+
),
|
|
268
|
+
)[0];
|
|
269
|
+
return found ? toFields(mc, found) : null;
|
|
270
|
+
});
|
|
271
|
+
|
|
241
272
|
const rows: Row[] = [];
|
|
242
273
|
for (const [index, op] of change.operations.entries()) {
|
|
243
|
-
|
|
274
|
+
let row: Row;
|
|
275
|
+
try {
|
|
276
|
+
row = await applyOperation(tx, op);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
if (op.type === 'CREATE') rethrowStrictCreateConflict(error, op);
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
244
281
|
rows.push(row);
|
|
245
282
|
const entityId = String(row.id ?? rowId(op));
|
|
283
|
+
const syncGroups = sourceSyncGroups(schema, op.model, row);
|
|
284
|
+
const syncGroupsSql = sql`ARRAY[${sql.join(
|
|
285
|
+
syncGroups.map((group) => sql`${group}`),
|
|
286
|
+
sql`, `,
|
|
287
|
+
)}]::text[]`;
|
|
246
288
|
await tx.execute(sql`
|
|
247
289
|
INSERT INTO ablo_outbox (
|
|
248
|
-
id, model, entity_id, type, data,
|
|
290
|
+
id, model, entity_id, type, data, sync_groups,
|
|
249
291
|
correlation_id, transaction_id, occurred_at
|
|
250
292
|
)
|
|
251
293
|
VALUES (
|
|
252
294
|
${`${change.correlationId}:${index}`}, ${op.model}, ${entityId}, ${op.type},
|
|
253
295
|
${op.type === 'DELETE' ? null : JSON.stringify(row)}::jsonb,
|
|
296
|
+
${syncGroupsSql},
|
|
254
297
|
${change.correlationId}, ${op.transactionId ?? null}, ${Date.now()}
|
|
255
298
|
)`);
|
|
256
299
|
}
|
|
@@ -274,7 +317,7 @@ export function drizzleDataSource<S extends SchemaRecord>(
|
|
|
274
317
|
const after = cursor ?? '0';
|
|
275
318
|
const rows = rowsOf(
|
|
276
319
|
await db.execute(sql`
|
|
277
|
-
SELECT cursor, id, model, entity_id, type, data, organization_id,
|
|
320
|
+
SELECT cursor, id, model, entity_id, type, data, sync_groups, organization_id,
|
|
278
321
|
client_tx_id, correlation_id, transaction_id, occurred_at
|
|
279
322
|
FROM ablo_outbox WHERE cursor > ${after} ORDER BY cursor ASC LIMIT ${limit}`),
|
|
280
323
|
);
|
|
@@ -285,6 +328,7 @@ export function drizzleDataSource<S extends SchemaRecord>(
|
|
|
285
328
|
entityId: r.entity_id,
|
|
286
329
|
type: r.type,
|
|
287
330
|
data: r.data ?? null,
|
|
331
|
+
syncGroups: r.sync_groups ?? [],
|
|
288
332
|
organizationId: r.organization_id ?? null,
|
|
289
333
|
clientTxId: r.client_tx_id ?? null,
|
|
290
334
|
correlationId: r.correlation_id ?? null,
|