@abloatai/transaction 0.55.0 → 0.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/CONVENTIONS.md +34 -0
  2. package/dist/auth/hostedEndpoints.d.ts +21 -5
  3. package/dist/auth/hostedEndpoints.d.ts.map +1 -1
  4. package/dist/auth/hostedEndpoints.js +21 -5
  5. package/dist/auth/hostedEndpoints.js.map +1 -1
  6. package/dist/auth/index.d.ts +1 -1
  7. package/dist/auth/index.d.ts.map +1 -1
  8. package/dist/auth/index.js +1 -1
  9. package/dist/auth/index.js.map +1 -1
  10. package/dist/coordination/index.d.ts +2 -2
  11. package/dist/coordination/index.d.ts.map +1 -1
  12. package/dist/coordination/index.js +1 -1
  13. package/dist/coordination/index.js.map +1 -1
  14. package/dist/coordination/schema.d.ts +0 -3
  15. package/dist/coordination/schema.d.ts.map +1 -1
  16. package/dist/coordination/schema.js +4 -5
  17. package/dist/coordination/schema.js.map +1 -1
  18. package/dist/errorCodes.d.ts +1 -0
  19. package/dist/errorCodes.d.ts.map +1 -1
  20. package/dist/errorCodes.js +1 -0
  21. package/dist/errorCodes.js.map +1 -1
  22. package/dist/errors.d.ts.map +1 -1
  23. package/dist/errors.js +4 -1
  24. package/dist/errors.js.map +1 -1
  25. package/dist/log/syncDeltaRow.d.ts +3 -3
  26. package/dist/readSetContext.d.ts.map +1 -1
  27. package/dist/readSetContext.js +8 -3
  28. package/dist/readSetContext.js.map +1 -1
  29. package/dist/resources/httpResources.d.ts +43 -22
  30. package/dist/resources/httpResources.d.ts.map +1 -1
  31. package/dist/resources/httpResources.js +81 -4
  32. package/dist/resources/httpResources.js.map +1 -1
  33. package/dist/resources/modelCreate.d.ts +21 -0
  34. package/dist/resources/modelCreate.d.ts.map +1 -0
  35. package/dist/resources/modelCreate.js +49 -0
  36. package/dist/resources/modelCreate.js.map +1 -0
  37. package/dist/resources/modelOperations.d.ts +24 -2
  38. package/dist/resources/modelOperations.d.ts.map +1 -1
  39. package/dist/resources/modelOperations.js.map +1 -1
  40. package/dist/resources/writeOptionsSchema.d.ts +14 -0
  41. package/dist/resources/writeOptionsSchema.d.ts.map +1 -1
  42. package/dist/resources/writeOptionsSchema.js +23 -2
  43. package/dist/resources/writeOptionsSchema.js.map +1 -1
  44. package/dist/schema/audit.d.ts +15 -0
  45. package/dist/schema/audit.d.ts.map +1 -0
  46. package/dist/schema/audit.js +90 -0
  47. package/dist/schema/audit.js.map +1 -0
  48. package/dist/schema/ddl.d.ts.map +1 -1
  49. package/dist/schema/ddl.js +68 -1
  50. package/dist/schema/ddl.js.map +1 -1
  51. package/dist/schema/index.d.ts +3 -1
  52. package/dist/schema/index.d.ts.map +1 -1
  53. package/dist/schema/index.js +3 -1
  54. package/dist/schema/index.js.map +1 -1
  55. package/dist/schema/model.d.ts +14 -0
  56. package/dist/schema/model.d.ts.map +1 -1
  57. package/dist/schema/model.js +2 -0
  58. package/dist/schema/model.js.map +1 -1
  59. package/dist/schema/openapi.d.ts.map +1 -1
  60. package/dist/schema/openapi.js +104 -2
  61. package/dist/schema/openapi.js.map +1 -1
  62. package/dist/schema/roles.d.ts +27 -0
  63. package/dist/schema/roles.d.ts.map +1 -1
  64. package/dist/schema/roles.js +40 -0
  65. package/dist/schema/roles.js.map +1 -1
  66. package/dist/schema/schema.d.ts +23 -4
  67. package/dist/schema/schema.d.ts.map +1 -1
  68. package/dist/schema/schema.js +30 -1
  69. package/dist/schema/schema.js.map +1 -1
  70. package/dist/schema/serialize.d.ts +4 -1
  71. package/dist/schema/serialize.d.ts.map +1 -1
  72. package/dist/schema/serialize.js +5 -1
  73. package/dist/schema/serialize.js.map +1 -1
  74. package/dist/schema/subject.d.ts +18 -0
  75. package/dist/schema/subject.d.ts.map +1 -0
  76. package/dist/schema/subject.js +27 -0
  77. package/dist/schema/subject.js.map +1 -0
  78. package/dist/server/adapter.d.ts +2 -0
  79. package/dist/server/adapter.d.ts.map +1 -1
  80. package/dist/server/readConfig.d.ts +3 -0
  81. package/dist/server/readConfig.d.ts.map +1 -1
  82. package/dist/server/readConfig.js +0 -21
  83. package/dist/server/readConfig.js.map +1 -1
  84. package/dist/source/adapters/drizzle.d.ts.map +1 -1
  85. package/dist/source/adapters/drizzle.js +37 -6
  86. package/dist/source/adapters/drizzle.js.map +1 -1
  87. package/dist/source/adapters/kysely.d.ts +2 -0
  88. package/dist/source/adapters/kysely.d.ts.map +1 -1
  89. package/dist/source/adapters/kysely.js +33 -5
  90. package/dist/source/adapters/kysely.js.map +1 -1
  91. package/dist/source/adapters/kyselyMutationCore.d.ts +4 -1
  92. package/dist/source/adapters/kyselyMutationCore.d.ts.map +1 -1
  93. package/dist/source/adapters/kyselyMutationCore.js +17 -8
  94. package/dist/source/adapters/kyselyMutationCore.js.map +1 -1
  95. package/dist/source/adapters/memory.d.ts.map +1 -1
  96. package/dist/source/adapters/memory.js +1 -0
  97. package/dist/source/adapters/memory.js.map +1 -1
  98. package/dist/source/adapters/prisma.d.ts.map +1 -1
  99. package/dist/source/adapters/prisma.js +59 -7
  100. package/dist/source/adapters/prisma.js.map +1 -1
  101. package/dist/source/contract.d.ts +14 -0
  102. package/dist/source/contract.d.ts.map +1 -1
  103. package/dist/source/contract.js +10 -0
  104. package/dist/source/contract.js.map +1 -1
  105. package/dist/source/factory.d.ts +7 -1
  106. package/dist/source/factory.d.ts.map +1 -1
  107. package/dist/source/factory.js +116 -5
  108. package/dist/source/factory.js.map +1 -1
  109. package/dist/source/index.d.ts +2 -1
  110. package/dist/source/index.d.ts.map +1 -1
  111. package/dist/source/index.js +1 -0
  112. package/dist/source/index.js.map +1 -1
  113. package/dist/source/migrations.d.ts.map +1 -1
  114. package/dist/source/migrations.js +21 -0
  115. package/dist/source/migrations.js.map +1 -1
  116. package/dist/source/subjectAuthorization.d.ts +16 -0
  117. package/dist/source/subjectAuthorization.d.ts.map +1 -0
  118. package/dist/source/subjectAuthorization.js +126 -0
  119. package/dist/source/subjectAuthorization.js.map +1 -0
  120. package/dist/source/types.d.ts +52 -2
  121. package/dist/source/types.d.ts.map +1 -1
  122. package/dist/source/types.js +1 -0
  123. package/dist/source/types.js.map +1 -1
  124. package/dist/syncLog/contract.d.ts +45 -3
  125. package/dist/syncLog/contract.d.ts.map +1 -1
  126. package/dist/syncLog/contract.js +46 -4
  127. package/dist/syncLog/contract.js.map +1 -1
  128. package/dist/testing/fixtures/httpResponses.d.ts +6 -0
  129. package/dist/testing/fixtures/httpResponses.d.ts.map +1 -1
  130. package/dist/testing/fixtures/httpResponses.js +1 -0
  131. package/dist/testing/fixtures/httpResponses.js.map +1 -1
  132. package/dist/transport/httpClient.d.ts +8 -1
  133. package/dist/transport/httpClient.d.ts.map +1 -1
  134. package/dist/transport/httpClient.js +51 -20
  135. package/dist/transport/httpClient.js.map +1 -1
  136. package/dist/transport/httpCommitRequest.d.ts +45 -0
  137. package/dist/transport/httpCommitRequest.d.ts.map +1 -0
  138. package/dist/transport/httpCommitRequest.js +67 -0
  139. package/dist/transport/httpCommitRequest.js.map +1 -0
  140. package/dist/transport/httpTransport.d.ts.map +1 -1
  141. package/dist/transport/httpTransport.js +69 -106
  142. package/dist/transport/httpTransport.js.map +1 -1
  143. package/dist/transport/httpTransportHelpers.d.ts +10 -0
  144. package/dist/transport/httpTransportHelpers.d.ts.map +1 -0
  145. package/dist/transport/httpTransportHelpers.js +46 -0
  146. package/dist/transport/httpTransportHelpers.js.map +1 -0
  147. package/dist/wire/apiLifecycle.d.ts +63 -0
  148. package/dist/wire/apiLifecycle.d.ts.map +1 -0
  149. package/dist/wire/apiLifecycle.js +89 -0
  150. package/dist/wire/apiLifecycle.js.map +1 -0
  151. package/dist/wire/auth.d.ts.map +1 -1
  152. package/dist/wire/auth.js +6 -3
  153. package/dist/wire/auth.js.map +1 -1
  154. package/dist/wire/commit.d.ts +74 -9
  155. package/dist/wire/commit.d.ts.map +1 -1
  156. package/dist/wire/commit.js +18 -5
  157. package/dist/wire/commit.js.map +1 -1
  158. package/dist/wire/index.d.ts +5 -2
  159. package/dist/wire/index.d.ts.map +1 -1
  160. package/dist/wire/index.js +12 -1
  161. package/dist/wire/index.js.map +1 -1
  162. package/dist/wire/modelMutations.js +3 -2
  163. package/dist/wire/modelMutations.js.map +1 -1
  164. package/dist/wire/rateLimit.d.ts +82 -0
  165. package/dist/wire/rateLimit.d.ts.map +1 -0
  166. package/dist/wire/rateLimit.js +142 -0
  167. package/dist/wire/rateLimit.js.map +1 -0
  168. package/package.json +1 -1
  169. package/src/auth/hostedEndpoints.ts +23 -5
  170. package/src/auth/index.ts +2 -0
  171. package/src/coordination/index.ts +0 -2
  172. package/src/coordination/schema.ts +4 -7
  173. package/src/errorCodes.ts +6 -0
  174. package/src/errors.ts +4 -1
  175. package/src/readSetContext.ts +8 -2
  176. package/src/resources/httpResources.ts +116 -25
  177. package/src/resources/modelCreate.ts +73 -0
  178. package/src/resources/modelOperations.ts +29 -2
  179. package/src/resources/writeOptionsSchema.ts +30 -2
  180. package/src/schema/audit.ts +121 -0
  181. package/src/schema/ddl.ts +71 -1
  182. package/src/schema/index.ts +16 -0
  183. package/src/schema/model.ts +17 -0
  184. package/src/schema/openapi.ts +123 -2
  185. package/src/schema/roles.ts +53 -0
  186. package/src/schema/schema.ts +68 -3
  187. package/src/schema/serialize.ts +8 -1
  188. package/src/schema/subject.ts +43 -0
  189. package/src/server/adapter.ts +2 -0
  190. package/src/server/readConfig.ts +4 -0
  191. package/src/source/adapters/drizzle.ts +50 -6
  192. package/src/source/adapters/kysely.ts +47 -4
  193. package/src/source/adapters/kyselyMutationCore.ts +21 -9
  194. package/src/source/adapters/memory.ts +1 -0
  195. package/src/source/adapters/prisma.ts +70 -7
  196. package/src/source/contract.ts +11 -0
  197. package/src/source/factory.ts +141 -5
  198. package/src/source/index.ts +6 -0
  199. package/src/source/migrations.ts +21 -0
  200. package/src/source/subjectAuthorization.ts +182 -0
  201. package/src/source/types.ts +56 -2
  202. package/src/syncLog/contract.ts +47 -5
  203. package/src/testing/fixtures/httpResponses.ts +7 -0
  204. package/src/transport/httpClient.ts +85 -23
  205. package/src/transport/httpCommitRequest.ts +104 -0
  206. package/src/transport/httpTransport.ts +92 -142
  207. package/src/transport/httpTransportHelpers.ts +63 -0
  208. package/src/wire/apiLifecycle.ts +94 -0
  209. package/src/wire/auth.ts +6 -3
  210. package/src/wire/commit.ts +20 -5
  211. package/src/wire/index.ts +31 -0
  212. package/src/wire/modelMutations.ts +2 -2
  213. package/src/wire/rateLimit.ts +155 -0
@@ -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,
@@ -46,6 +46,13 @@ import {
46
46
  idempotencyLedgerMigrations,
47
47
  } from '../migrations.js';
48
48
  import { ABLO_POSTGRES_COMMIT_ECHO_PREFIX } from '../types.js';
49
+ import {
50
+ authorizeSourceChange,
51
+ authorizeSourceRead,
52
+ lockSourceSubjectCreates,
53
+ rethrowStrictCreateConflict,
54
+ sourceSyncGroups,
55
+ } from '../subjectAuthorization.js';
49
56
  import {
50
57
  createKyselyMutationCore,
51
58
  kyselyOperationRowId,
@@ -259,6 +266,8 @@ export interface KyselyMutationAdapterOptions {
259
266
  * typenames equal its keys.
260
267
  */
261
268
  readonly markerModelFor?: (operationModel: string) => string;
269
+ /** Schema required to enforce row/subject authorization in this adapter. */
270
+ readonly schema?: Schema;
262
271
  }
263
272
 
264
273
  export function createKyselyMutationAdapter(
@@ -287,8 +296,9 @@ export function createKyselyMutationAdapter(
287
296
  : idempotencyLedgerMigrations();
288
297
  },
289
298
 
290
- read(request) {
291
- return core.read(request);
299
+ async read(request) {
300
+ const rows = await core.read(request);
301
+ return options.schema ? authorizeSourceRead(options.schema, request, rows) : rows;
292
302
  },
293
303
 
294
304
  async commit(change: ChangeSet): Promise<AdapterCommitResult> {
@@ -326,6 +336,25 @@ export function createKyselyMutationAdapter(
326
336
  return { rows: parseCachedRows(cachedRow.response) };
327
337
  }
328
338
 
339
+ if (options.schema) {
340
+ await lockSourceSubjectCreates(options.schema, request, async (_operation, key) => {
341
+ await transaction.executeQuery(rawQuery(
342
+ 'subject-create-lock',
343
+ 'SELECT pg_advisory_xact_lock(hashtextextended($1, 0))',
344
+ [key],
345
+ ));
346
+ });
347
+ await authorizeSourceChange(options.schema, request, async (operation) => {
348
+ if (!operation.id) return null;
349
+ const rows = await core.read(
350
+ { kind: 'load', model: operation.model, id: operation.id },
351
+ transaction,
352
+ { forUpdate: true },
353
+ );
354
+ return rows[0] ?? null;
355
+ });
356
+ }
357
+
329
358
  // Direct mode dispatches every operation before awaiting any of them, so
330
359
  // the driver pipelines them into one trip instead of paying a full
331
360
  // round trip per row. Postgres still executes them IN ORDER on the
@@ -339,9 +368,17 @@ export function createKyselyMutationAdapter(
339
368
  // returned row, so operation i+1's write cannot be dispatched before
340
369
  // operation i has answered.
341
370
  const rows: Row[] = [];
371
+ const applyStrict = async (operation: ChangeSet['operations'][number]): Promise<Row> => {
372
+ try {
373
+ return await core.applyOperation(transaction, operation);
374
+ } catch (error) {
375
+ if (operation.type === 'CREATE') rethrowStrictCreateConflict(error, operation);
376
+ throw error;
377
+ }
378
+ };
342
379
  if (mode === 'direct') {
343
380
  const dispatched = request.operations.map((operation) =>
344
- core.applyOperation(transaction, operation),
381
+ applyStrict(operation),
345
382
  );
346
383
  // Settle every dispatch before inspecting, so a later rejection is
347
384
  // never an unhandled rejection, then surface the FIRST failure in
@@ -354,7 +391,7 @@ export function createKyselyMutationAdapter(
354
391
  }
355
392
  } else {
356
393
  for (const [index, operation] of request.operations.entries()) {
357
- const row = await core.applyOperation(transaction, operation);
394
+ const row = await applyStrict(operation);
358
395
  rows.push(row);
359
396
 
360
397
  const entityId = String(row.id ?? kyselyOperationRowId(operation));
@@ -366,6 +403,9 @@ export function createKyselyMutationAdapter(
366
403
  entity_id: entityId,
367
404
  type: operation.type,
368
405
  data: operation.type === 'DELETE' ? null : JSON.stringify(row),
406
+ sync_groups: options.schema
407
+ ? sourceSyncGroups(options.schema, operation.model, row)
408
+ : [],
369
409
  correlation_id: request.correlationId,
370
410
  transaction_id: operation.transactionId ?? null,
371
411
  occurred_at: Date.now(),
@@ -402,6 +442,7 @@ export function kyselyDataSource<S extends SchemaRecord>(
402
442
  db,
403
443
  createKyselyMutationCore(db, schema),
404
444
  'endpoint',
445
+ { schema },
405
446
  );
406
447
 
407
448
  return defineDatabaseAdapter({
@@ -423,6 +464,7 @@ export function kyselyDataSource<S extends SchemaRecord>(
423
464
  entityId: row.entity_id,
424
465
  type: row.type,
425
466
  data: typeof row.data === 'string' ? JSON.parse(row.data) : row.data ?? null,
467
+ syncGroups: row.sync_groups ?? [],
426
468
  organizationId: row.organization_id ?? null,
427
469
  clientTxId: row.client_tx_id ?? null,
428
470
  correlationId: row.correlation_id ?? null,
@@ -458,6 +500,7 @@ export function kyselyDirectMutation<S extends SchemaRecord>(
458
500
  markerModels.get(operationModel) ??
459
501
  markerModels.get(operationModel.toLowerCase()) ??
460
502
  operationModel,
503
+ schema,
461
504
  },
462
505
  );
463
506
  }
@@ -15,6 +15,7 @@ import { camelToSnake, snakeToCamel } from '../../schema/ddl.js';
15
15
  import { tenancyColumn } from '../../schema/tenancy.js';
16
16
  import type { AdapterReadRequest, Row } from '../adapter.js';
17
17
  import type { Operation } from '../contract.js';
18
+ import { sourceSubjectRule, sourceSubjectValues } from '../subjectAuthorization.js';
18
19
 
19
20
  /** The subset of a Kysely instance, or transaction handle, used by the core. */
20
21
  export interface KyselyLike {
@@ -48,6 +49,7 @@ export interface KyselySelectBuilder {
48
49
  where(column: string, operator: string, value: unknown): KyselySelectBuilder;
49
50
  orderBy(column: string, direction: 'asc' | 'desc'): KyselySelectBuilder;
50
51
  limit(limit: number): KyselySelectBuilder;
52
+ forUpdate(): KyselySelectBuilder;
51
53
  execute(): Promise<readonly Row[]>;
52
54
  }
53
55
 
@@ -140,7 +142,11 @@ function suppliedOperationRowId(operation: Operation): string | undefined {
140
142
 
141
143
  /** The transport-independent Kysely field/column mutation boundary. */
142
144
  export interface KyselyMutationCore {
143
- read(request: AdapterReadRequest): Promise<readonly Row[]>;
145
+ read(
146
+ request: AdapterReadRequest,
147
+ database?: KyselyLike,
148
+ options?: { readonly forUpdate?: boolean },
149
+ ): Promise<readonly Row[]>;
144
150
  applyOperation(transaction: KyselyLike, operation: Operation): Promise<Row>;
145
151
  }
146
152
 
@@ -185,22 +191,28 @@ export function createKyselyMutationCore<S extends SchemaRecord>(
185
191
  };
186
192
 
187
193
  return {
188
- async read(request): Promise<readonly Row[]> {
194
+ async read(request, database = db, options): Promise<readonly Row[]> {
189
195
  const columns = modelColumns(request.model);
190
196
  if (request.kind === 'load') {
191
- const rows = await db
197
+ let query = database
192
198
  .selectFrom(columns.table)
193
199
  .selectAll()
194
200
  .where(columnFor(columns, 'id'), '=', request.id)
195
- .limit(1)
196
- .execute();
201
+ .limit(1);
202
+ if (options?.forUpdate) query = query.forUpdate();
203
+ const rows = await query.execute();
197
204
  return rows.map((row) => toFields(columns, row));
198
205
  }
199
- const rows = await db
206
+ let query = database
200
207
  .selectFrom(columns.table)
201
- .selectAll()
202
- .limit(request.query?.limit ?? 1000)
203
- .execute();
208
+ .selectAll();
209
+ const rule = sourceSubjectRule(schema, request.model);
210
+ const subjects = sourceSubjectValues(rule, request.scope?.syncGroups);
211
+ if (subjects) {
212
+ if (subjects.length === 0) return [];
213
+ query = query.where(columnFor(columns, rule!.field), 'in', subjects);
214
+ }
215
+ const rows = await query.limit(request.query?.limit ?? 1000).execute();
204
216
  return rows.map((row) => toFields(columns, row));
205
217
  },
206
218
 
@@ -141,6 +141,7 @@ export function memoryDataSource(): DataSourceAdapter {
141
141
  entityId: String(row.id ?? rowId(op)),
142
142
  type: op.type,
143
143
  data: op.type === 'DELETE' ? null : row,
144
+ syncGroups: [],
144
145
  correlationId: change.correlationId,
145
146
  ...(op.transactionId ? { transactionId: op.transactionId } : {}),
146
147
  cursor: String(outbox.length + 1),