@abloatai/transaction 0.56.0 → 0.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/CONVENTIONS.md +34 -0
  2. package/dist/coordination/index.d.ts +2 -2
  3. package/dist/coordination/index.d.ts.map +1 -1
  4. package/dist/coordination/index.js +1 -1
  5. package/dist/coordination/index.js.map +1 -1
  6. package/dist/coordination/schema.d.ts +0 -7
  7. package/dist/coordination/schema.d.ts.map +1 -1
  8. package/dist/coordination/schema.js +0 -4
  9. package/dist/coordination/schema.js.map +1 -1
  10. package/dist/errorCodes.d.ts +1 -0
  11. package/dist/errorCodes.d.ts.map +1 -1
  12. package/dist/errorCodes.js +1 -0
  13. package/dist/errorCodes.js.map +1 -1
  14. package/dist/readSetContext.d.ts.map +1 -1
  15. package/dist/readSetContext.js +8 -3
  16. package/dist/readSetContext.js.map +1 -1
  17. package/dist/resources/httpResources.d.ts +43 -22
  18. package/dist/resources/httpResources.d.ts.map +1 -1
  19. package/dist/resources/httpResources.js +81 -4
  20. package/dist/resources/httpResources.js.map +1 -1
  21. package/dist/resources/modelCreate.d.ts +21 -0
  22. package/dist/resources/modelCreate.d.ts.map +1 -0
  23. package/dist/resources/modelCreate.js +49 -0
  24. package/dist/resources/modelCreate.js.map +1 -0
  25. package/dist/resources/modelOperations.d.ts +24 -2
  26. package/dist/resources/modelOperations.d.ts.map +1 -1
  27. package/dist/resources/modelOperations.js.map +1 -1
  28. package/dist/resources/writeOptionsSchema.d.ts +14 -0
  29. package/dist/resources/writeOptionsSchema.d.ts.map +1 -1
  30. package/dist/resources/writeOptionsSchema.js +20 -0
  31. package/dist/resources/writeOptionsSchema.js.map +1 -1
  32. package/dist/schema/audit.d.ts +15 -0
  33. package/dist/schema/audit.d.ts.map +1 -0
  34. package/dist/schema/audit.js +90 -0
  35. package/dist/schema/audit.js.map +1 -0
  36. package/dist/schema/ddl.d.ts.map +1 -1
  37. package/dist/schema/ddl.js +68 -1
  38. package/dist/schema/ddl.js.map +1 -1
  39. package/dist/schema/index.d.ts +3 -1
  40. package/dist/schema/index.d.ts.map +1 -1
  41. package/dist/schema/index.js +3 -1
  42. package/dist/schema/index.js.map +1 -1
  43. package/dist/schema/model.d.ts +14 -0
  44. package/dist/schema/model.d.ts.map +1 -1
  45. package/dist/schema/model.js +2 -0
  46. package/dist/schema/model.js.map +1 -1
  47. package/dist/schema/openapi.d.ts.map +1 -1
  48. package/dist/schema/openapi.js +104 -2
  49. package/dist/schema/openapi.js.map +1 -1
  50. package/dist/schema/roles.d.ts +27 -0
  51. package/dist/schema/roles.d.ts.map +1 -1
  52. package/dist/schema/roles.js +40 -0
  53. package/dist/schema/roles.js.map +1 -1
  54. package/dist/schema/schema.d.ts +23 -4
  55. package/dist/schema/schema.d.ts.map +1 -1
  56. package/dist/schema/schema.js +30 -1
  57. package/dist/schema/schema.js.map +1 -1
  58. package/dist/schema/serialize.d.ts +4 -1
  59. package/dist/schema/serialize.d.ts.map +1 -1
  60. package/dist/schema/serialize.js +5 -1
  61. package/dist/schema/serialize.js.map +1 -1
  62. package/dist/schema/subject.d.ts +18 -0
  63. package/dist/schema/subject.d.ts.map +1 -0
  64. package/dist/schema/subject.js +27 -0
  65. package/dist/schema/subject.js.map +1 -0
  66. package/dist/server/adapter.d.ts +2 -0
  67. package/dist/server/adapter.d.ts.map +1 -1
  68. package/dist/server/readConfig.d.ts +3 -0
  69. package/dist/server/readConfig.d.ts.map +1 -1
  70. package/dist/server/readConfig.js +0 -21
  71. package/dist/server/readConfig.js.map +1 -1
  72. package/dist/source/adapters/drizzle.d.ts.map +1 -1
  73. package/dist/source/adapters/drizzle.js +37 -6
  74. package/dist/source/adapters/drizzle.js.map +1 -1
  75. package/dist/source/adapters/kysely.d.ts +2 -0
  76. package/dist/source/adapters/kysely.d.ts.map +1 -1
  77. package/dist/source/adapters/kysely.js +33 -5
  78. package/dist/source/adapters/kysely.js.map +1 -1
  79. package/dist/source/adapters/kyselyMutationCore.d.ts +4 -1
  80. package/dist/source/adapters/kyselyMutationCore.d.ts.map +1 -1
  81. package/dist/source/adapters/kyselyMutationCore.js +17 -8
  82. package/dist/source/adapters/kyselyMutationCore.js.map +1 -1
  83. package/dist/source/adapters/memory.d.ts.map +1 -1
  84. package/dist/source/adapters/memory.js +1 -0
  85. package/dist/source/adapters/memory.js.map +1 -1
  86. package/dist/source/adapters/prisma.d.ts.map +1 -1
  87. package/dist/source/adapters/prisma.js +59 -7
  88. package/dist/source/adapters/prisma.js.map +1 -1
  89. package/dist/source/contract.d.ts +14 -0
  90. package/dist/source/contract.d.ts.map +1 -1
  91. package/dist/source/contract.js +10 -0
  92. package/dist/source/contract.js.map +1 -1
  93. package/dist/source/factory.d.ts +7 -1
  94. package/dist/source/factory.d.ts.map +1 -1
  95. package/dist/source/factory.js +116 -5
  96. package/dist/source/factory.js.map +1 -1
  97. package/dist/source/index.d.ts +2 -1
  98. package/dist/source/index.d.ts.map +1 -1
  99. package/dist/source/index.js +1 -0
  100. package/dist/source/index.js.map +1 -1
  101. package/dist/source/migrations.d.ts.map +1 -1
  102. package/dist/source/migrations.js +21 -0
  103. package/dist/source/migrations.js.map +1 -1
  104. package/dist/source/subjectAuthorization.d.ts +16 -0
  105. package/dist/source/subjectAuthorization.d.ts.map +1 -0
  106. package/dist/source/subjectAuthorization.js +126 -0
  107. package/dist/source/subjectAuthorization.js.map +1 -0
  108. package/dist/source/types.d.ts +52 -2
  109. package/dist/source/types.d.ts.map +1 -1
  110. package/dist/source/types.js +1 -0
  111. package/dist/source/types.js.map +1 -1
  112. package/dist/syncLog/contract.d.ts +0 -6
  113. package/dist/syncLog/contract.d.ts.map +1 -1
  114. package/dist/syncLog/contract.js +0 -4
  115. package/dist/syncLog/contract.js.map +1 -1
  116. package/dist/testing/fixtures/httpResponses.d.ts +6 -0
  117. package/dist/testing/fixtures/httpResponses.d.ts.map +1 -1
  118. package/dist/testing/fixtures/httpResponses.js +1 -0
  119. package/dist/testing/fixtures/httpResponses.js.map +1 -1
  120. package/dist/transport/httpClient.d.ts +8 -1
  121. package/dist/transport/httpClient.d.ts.map +1 -1
  122. package/dist/transport/httpClient.js +51 -20
  123. package/dist/transport/httpClient.js.map +1 -1
  124. package/dist/transport/httpCommitRequest.d.ts +45 -0
  125. package/dist/transport/httpCommitRequest.d.ts.map +1 -0
  126. package/dist/transport/httpCommitRequest.js +67 -0
  127. package/dist/transport/httpCommitRequest.js.map +1 -0
  128. package/dist/transport/httpTransport.d.ts.map +1 -1
  129. package/dist/transport/httpTransport.js +69 -106
  130. package/dist/transport/httpTransport.js.map +1 -1
  131. package/dist/transport/httpTransportHelpers.d.ts +10 -0
  132. package/dist/transport/httpTransportHelpers.d.ts.map +1 -0
  133. package/dist/transport/httpTransportHelpers.js +46 -0
  134. package/dist/transport/httpTransportHelpers.js.map +1 -0
  135. package/dist/wire/apiLifecycle.d.ts +63 -0
  136. package/dist/wire/apiLifecycle.d.ts.map +1 -0
  137. package/dist/wire/apiLifecycle.js +89 -0
  138. package/dist/wire/apiLifecycle.js.map +1 -0
  139. package/dist/wire/commit.d.ts +74 -9
  140. package/dist/wire/commit.d.ts.map +1 -1
  141. package/dist/wire/commit.js +13 -1
  142. package/dist/wire/commit.js.map +1 -1
  143. package/dist/wire/index.d.ts +5 -2
  144. package/dist/wire/index.d.ts.map +1 -1
  145. package/dist/wire/index.js +12 -1
  146. package/dist/wire/index.js.map +1 -1
  147. package/dist/wire/rateLimit.d.ts +82 -0
  148. package/dist/wire/rateLimit.d.ts.map +1 -0
  149. package/dist/wire/rateLimit.js +142 -0
  150. package/dist/wire/rateLimit.js.map +1 -0
  151. package/package.json +1 -1
  152. package/src/coordination/index.ts +0 -2
  153. package/src/coordination/schema.ts +0 -7
  154. package/src/errorCodes.ts +6 -0
  155. package/src/readSetContext.ts +8 -2
  156. package/src/resources/httpResources.ts +116 -25
  157. package/src/resources/modelCreate.ts +73 -0
  158. package/src/resources/modelOperations.ts +29 -2
  159. package/src/resources/writeOptionsSchema.ts +28 -0
  160. package/src/schema/audit.ts +121 -0
  161. package/src/schema/ddl.ts +71 -1
  162. package/src/schema/index.ts +16 -0
  163. package/src/schema/model.ts +17 -0
  164. package/src/schema/openapi.ts +123 -2
  165. package/src/schema/roles.ts +53 -0
  166. package/src/schema/schema.ts +68 -3
  167. package/src/schema/serialize.ts +8 -1
  168. package/src/schema/subject.ts +43 -0
  169. package/src/server/adapter.ts +2 -0
  170. package/src/server/readConfig.ts +4 -0
  171. package/src/source/adapters/drizzle.ts +50 -6
  172. package/src/source/adapters/kysely.ts +47 -4
  173. package/src/source/adapters/kyselyMutationCore.ts +21 -9
  174. package/src/source/adapters/memory.ts +1 -0
  175. package/src/source/adapters/prisma.ts +70 -7
  176. package/src/source/contract.ts +11 -0
  177. package/src/source/factory.ts +141 -5
  178. package/src/source/index.ts +6 -0
  179. package/src/source/migrations.ts +21 -0
  180. package/src/source/subjectAuthorization.ts +182 -0
  181. package/src/source/types.ts +56 -2
  182. package/src/syncLog/contract.ts +0 -7
  183. package/src/testing/fixtures/httpResponses.ts +7 -0
  184. package/src/transport/httpClient.ts +85 -23
  185. package/src/transport/httpCommitRequest.ts +104 -0
  186. package/src/transport/httpTransport.ts +92 -142
  187. package/src/transport/httpTransportHelpers.ts +63 -0
  188. package/src/wire/apiLifecycle.ts +94 -0
  189. package/src/wire/commit.ts +16 -1
  190. package/src/wire/index.ts +31 -0
  191. package/src/wire/rateLimit.ts +155 -0
@@ -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
- description,
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_ / rk_).' },
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
 
@@ -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
@@ -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); all other fields are optional (only provided fields
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 } & Partial<z.input<z.ZodObject<Shape>>>
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.
@@ -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
+ }
@@ -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. */
@@ -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 rows = rowsOf(await db.execute(sql`SELECT * FROM ${table} LIMIT ${limit}`));
221
- return rows.map((r) => toFields(mc, r));
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
- const row = await applyOperation(tx, op);
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,