@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
@@ -0,0 +1,73 @@
1
+ /** Canonical create identity and result correlation shared by every client. */
2
+
3
+ import { v5 as uuidv5 } from 'uuid';
4
+ import { AbloConnectionError } from '../errors.js';
5
+ import type { CommitOperationResult } from '../wire/commit.js';
6
+
7
+ /** Resolve the two supported single-create id spellings. The sibling wins. */
8
+ export function resolveCreateId(
9
+ explicitId: string | null | undefined,
10
+ data: unknown,
11
+ ): string | undefined {
12
+ if (typeof explicitId === 'string' && explicitId.length > 0) return explicitId;
13
+ const inData =
14
+ typeof data === 'object' && data !== null
15
+ ? (data as { readonly id?: unknown }).id
16
+ : undefined;
17
+ return typeof inData === 'string' && inData.length > 0 ? inData : undefined;
18
+ }
19
+
20
+ /** Stable across transports and client instances when an idempotency key is supplied. */
21
+ export function createModelId(
22
+ modelName: string,
23
+ idempotencyKey?: string | null,
24
+ ): string {
25
+ if (idempotencyKey) {
26
+ return uuidv5(
27
+ `${modelName}:${idempotencyKey}`,
28
+ 'aa4ba6d4-bf0b-5b38-9c45-116f79a6e548',
29
+ );
30
+ }
31
+ return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
32
+ ? crypto.randomUUID()
33
+ : `id_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
34
+ }
35
+
36
+ /**
37
+ * Return server-authored create rows in input order.
38
+ *
39
+ * Fresh receipts carry operationResults. Durable idempotency replays redact
40
+ * row data, so that path verifies every expected id with a point read. It may
41
+ * fail under a write-only policy, but it can never report a partial collection
42
+ * as success.
43
+ */
44
+ export async function resolveCreatedRows<T>(input: {
45
+ readonly modelName: string;
46
+ readonly ids: readonly string[];
47
+ readonly operationResults?: readonly CommitOperationResult[];
48
+ readonly readRow: (id: string) => Promise<T | undefined>;
49
+ }): Promise<T[]> {
50
+ if (input.operationResults?.length === input.ids.length) {
51
+ const byId = new Map(
52
+ input.operationResults.map((result) => [
53
+ (result.row as { readonly id?: unknown }).id,
54
+ result.row,
55
+ ]),
56
+ );
57
+ const ordered = input.ids.map((id) => byId.get(id));
58
+ if (ordered.every((row): row is Record<string, unknown> => row !== undefined)) {
59
+ return ordered as T[];
60
+ }
61
+ }
62
+
63
+ const rows = await Promise.all(input.ids.map((id) => input.readRow(id)));
64
+ const missing = input.ids.filter((_id, index) => rows[index] === undefined);
65
+ if (missing.length > 0) {
66
+ throw new AbloConnectionError(
67
+ `${input.modelName} create confirmed, but ${missing.length} of ${input.ids.length} ` +
68
+ 'server rows could not be recovered from its receipt or verified by id.',
69
+ { code: 'commit_no_result', details: { missingIds: missing } },
70
+ );
71
+ }
72
+ return rows as T[];
73
+ }
@@ -15,7 +15,7 @@ import type { ResolveClaimMeta } from '../types/global.js';
15
15
  import type { AbloError } from '../errors.js';
16
16
  import type { StaleNotification, TrackDependency } from '../coordination/schema.js';
17
17
  import type { FieldRef, FieldSelector } from '../schema/fieldRef.js';
18
- import type { BaseModelFields } from '../schema/schema.js';
18
+ import type { BaseModelFields, Clearable } from '../schema/schema.js';
19
19
  import type { ClaimHeartbeatPlan } from '../coordination/claimHeartbeatLoop.js';
20
20
  import type { Duration } from '../utils/duration.js';
21
21
  import type {
@@ -232,6 +232,14 @@ export interface ServerReadOptions<T> {
232
232
  expand?: readonly string[];
233
233
  }
234
234
 
235
+ /** Options for an explicit complete collection traversal. */
236
+ export interface ListAllOptions<T> extends Omit<ServerReadOptions<T>, 'cursor'> {
237
+ /** Maximum pages to read before refusing an unexpectedly broad traversal. @default 100 */
238
+ maxPages?: number;
239
+ /** Stops traversal between page requests and row yields. */
240
+ signal?: AbortSignal;
241
+ }
242
+
235
243
  /** Options for the single-row async server read `get({ id })`. A subset of
236
244
  * {@link ServerReadOptions} — `where`/`limit`/`orderBy` are fixed by the id. */
237
245
  export type ServerRetrieveOptions = Pick<ServerReadOptions<unknown>, 'type' | 'expand'>;
@@ -588,6 +596,21 @@ export interface ModelCreateParams<T, CreateInput>
588
596
  readonly claim?: Claim<T> | ClaimTargetOptions<CreateInput> | null;
589
597
  }
590
598
 
599
+ /**
600
+ * Creating many rows at once: the same verb, handed a list.
601
+ *
602
+ * One atomic commit, so the batch lands whole or not at all, and the rows come
603
+ * back in the order they were given. There is no `id` beside `data` here the
604
+ * way there is for a single create, since one id cannot address many rows;
605
+ * write it into each row instead, which the create input has always allowed.
606
+ */
607
+ export type ModelCreateManyParams<CreateInput> = Pick<
608
+ ModelWriteOptions,
609
+ 'idempotencyKey' | 'reads' | 'track'
610
+ > & {
611
+ readonly data: readonly CreateInput[];
612
+ };
613
+
591
614
  export interface ModelUpdateParams<T, Fields = T>
592
615
  extends ModelWriteOptions {
593
616
  readonly id: string;
@@ -595,8 +618,12 @@ export interface ModelUpdateParams<T, Fields = T>
595
618
  * Patch only fields declared by the model's Zod input shape. Hydrated rows
596
619
  * also carry framework fields, relations, methods, and computed values; none
597
620
  * of those are writable data.
621
+ *
622
+ * Send a field to change it, omit it to leave it, send `null` to clear it —
623
+ * see {@link Clearable}. `undefined` is not a clear: it is dropped from the
624
+ * payload and the old value survives.
598
625
  */
599
- readonly data: Partial<ClaimableFields<Fields>>;
626
+ readonly data: Clearable<ClaimableFields<Fields>>;
600
627
  readonly claim?: Claim<T> | ClaimTargetOptions<Fields> | null;
601
628
  }
602
629
 
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  import { z } from 'zod';
20
+ import { logPositionSchema } from '../syncLog/contract.js';
20
21
  import type { MutationOptions } from '../resources/mutationOptions.js';
21
22
  import { AbloValidationError } from '../errors.js';
22
23
  import { commitWaitSchema } from '../wire/commit.js';
@@ -24,7 +25,6 @@ import {
24
25
  onStaleModeSchema,
25
26
  MAX_READ_SET_ENTRIES,
26
27
  readDependencyListSchema,
27
- readSetWatermarkSchema,
28
28
  readSetProjectionEntryCount,
29
29
  trackDependencyListSchema,
30
30
  } from '../coordination/schema.js';
@@ -46,7 +46,7 @@ export const writeOptionsSchema = z.object({
46
46
  /** Resolve when queued locally (default) or once the server confirms. */
47
47
  wait: commitWaitSchema.optional(),
48
48
  /** Stale guard: the sync watermark the caller's reasoning was based on. */
49
- readAt: readSetWatermarkSchema.nullish(),
49
+ readAt: logPositionSchema.nullish(),
50
50
  /** What the server does when the target moved past `readAt`. */
51
51
  onStale: onStaleModeSchema.nullish(),
52
52
  /** The held claim's fencing token (Option B), sourced from the claim handle
@@ -107,3 +107,31 @@ const _writeOptionsContractInSync: AssertExact<
107
107
  keyof WriteOptionsContract
108
108
  > = true;
109
109
  void _writeOptionsContractInSync;
110
+
111
+ /**
112
+ * Refuse a per-model write that does not name its row.
113
+ *
114
+ * `update` and `delete` address the row through the URL, so an absent `id`
115
+ * used to be spelled into the path by `encodeURIComponent` as the literal
116
+ * string `"undefined"`. The request was well-formed, it matched no row, and it
117
+ * came back as an ordinary receipt: `delete({ where: { id } })` reported
118
+ * success and deleted nothing. `{ where }` is the shape the commit protocol
119
+ * takes one layer down, so reaching for it here is an easy and quiet mistake.
120
+ *
121
+ * The typed surface already rejects it at compile time. This is the same
122
+ * refusal for callers who reach the transport without those types.
123
+ */
124
+ export function assertWriteTarget(
125
+ action: 'update' | 'delete',
126
+ modelName: string,
127
+ id: unknown,
128
+ ): void {
129
+ if (typeof id === 'string' && id.length > 0) return;
130
+ const namedAFilter =
131
+ typeof id === 'object' && id !== null && 'where' in (id as Record<string, unknown>);
132
+ throw new AbloValidationError(
133
+ `A ${modelName} ${action} has to name the row it acts on: ${action}({ id })` +
134
+ (namedAFilter ? ', not a `where` filter.' : '.'),
135
+ { code: 'invalid_body', param: 'id' },
136
+ );
137
+ }
@@ -0,0 +1,121 @@
1
+ import type { ModelJSON, SchemaJSON } from './serialize.js';
2
+ import { resolveTenancy } from './tenancy.js';
3
+
4
+ export interface SchemaAuditFinding {
5
+ readonly code: 'scope_routing_without_access_policy';
6
+ readonly severity: 'error';
7
+ readonly model: string;
8
+ readonly message: string;
9
+ readonly fix: string;
10
+ }
11
+
12
+ interface RoutingAnchor {
13
+ readonly description: string;
14
+ readonly column: string;
15
+ readonly parentTable?: string;
16
+ }
17
+
18
+ function tableName(key: string, model: ModelJSON): string {
19
+ return model.tableName ?? key;
20
+ }
21
+
22
+ function fieldColumn(model: ModelJSON, field: string): string {
23
+ return model.fields[field]?.column ?? field;
24
+ }
25
+
26
+ function camelToSnake(value: string): string {
27
+ return value.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
28
+ }
29
+
30
+ function reachesScope(
31
+ key: string,
32
+ models: SchemaJSON['models'],
33
+ seen: ReadonlySet<string> = new Set(),
34
+ ): boolean {
35
+ if (seen.has(key)) return false;
36
+ const model = models[key];
37
+ if (!model) return false;
38
+ if (model.scope) return true;
39
+ const nextSeen = new Set(seen).add(key);
40
+ return Object.values(model.relations).some(
41
+ (relation) => relation.type === 'belongsTo' && reachesScope(relation.target, models, nextSeen),
42
+ );
43
+ }
44
+
45
+ function routingAnchors(
46
+ model: ModelJSON,
47
+ models: SchemaJSON['models'],
48
+ ): readonly RoutingAnchor[] {
49
+ const anchors: RoutingAnchor[] = [];
50
+
51
+ if (model.scope) {
52
+ anchors.push({ description: `scope root ${String(model.scope)}`, column: 'id' });
53
+ }
54
+
55
+ for (const role of model.entityRoles ?? []) {
56
+ anchors.push({
57
+ description: `entity role ${role.kind}`,
58
+ column: fieldColumn(model, role.source.field),
59
+ });
60
+ }
61
+
62
+ for (const [relationName, relation] of Object.entries(model.relations)) {
63
+ if (relation.type !== 'belongsTo' || !reachesScope(relation.target, models)) continue;
64
+ const target = models[relation.target];
65
+ if (!target) continue;
66
+ anchors.push({
67
+ description: `parent scope ${relationName}`,
68
+ column: relation.foreignKeyColumn,
69
+ parentTable: tableName(relation.target, target),
70
+ });
71
+ }
72
+
73
+ return anchors;
74
+ }
75
+
76
+ function policyMatches(anchor: RoutingAnchor, model: ModelJSON): boolean {
77
+ const tenancy = resolveTenancy(model);
78
+ if (tenancy.kind === 'source') return true;
79
+ if (tenancy.kind === 'column') {
80
+ return tenancy.column === anchor.column || tenancy.column === camelToSnake(anchor.column);
81
+ }
82
+ if (tenancy.kind === 'parent') {
83
+ return tenancy.via.localKey === anchor.column &&
84
+ (anchor.parentTable === undefined || tenancy.via.parentTable === anchor.parentTable);
85
+ }
86
+ return false;
87
+ }
88
+
89
+ /**
90
+ * Finds mutable models whose sync-group routing is narrower than their row-read
91
+ * policy. A correctly filtered list or routed stream is not evidence that a
92
+ * foreign row is unreadable; only the policy is the authorization boundary.
93
+ */
94
+ export function auditSchemaAccessPolicies(schema: SchemaJSON): readonly SchemaAuditFinding[] {
95
+ const findings: SchemaAuditFinding[] = [];
96
+
97
+ for (const [key, model] of Object.entries(schema.models)) {
98
+ if (model.mutable === false || model.routingOnly === true) continue;
99
+ const mismatched = routingAnchors(model, schema.models)
100
+ .filter((anchor) => !policyMatches(anchor, model));
101
+ if (mismatched.length === 0) continue;
102
+
103
+ const policy = resolveTenancy(model);
104
+ const routedBy = mismatched.map(({ description, column }) => `${description} (${column})`).join(', ');
105
+ findings.push({
106
+ code: 'scope_routing_without_access_policy',
107
+ severity: 'error',
108
+ model: key,
109
+ message:
110
+ `Model "${key}" routes changes by ${routedBy}, but its row-access policy is ` +
111
+ `by ${policy.kind}. Sync groups route delivery; they do not authorize reads. ` +
112
+ `A filtered list or correctly routed live stream is not an authorization proof.`,
113
+ fix:
114
+ `Make policy follow the same customer/workspace boundary on every reachable model, ` +
115
+ `use policy: { by: 'source' } with one Ablo organization per hard tenant, or set ` +
116
+ `groups.routingOnly: true to explicitly acknowledge an intentionally broader read policy.`,
117
+ });
118
+ }
119
+
120
+ return findings;
121
+ }
package/src/schema/ddl.ts CHANGED
@@ -27,6 +27,7 @@ import type { SchemaJSON, ModelJSON } from './serialize.js';
27
27
  import type { MigrationStep, BackfillValue, FieldType } from './diff.js';
28
28
  import type { FieldMeta } from './field.js';
29
29
  import { resolveTenancy, tenancyColumn } from './tenancy.js';
30
+ import type { SubjectRule } from './subject.js';
30
31
 
31
32
  export interface ProvisionPlan {
32
33
  /** The Postgres schema the tables live in (`app_<id>` or `public`). */
@@ -119,6 +120,22 @@ export function sqlType(fieldType: ModelJSON['fields'][string]['type']): string
119
120
 
120
121
  const BASE_COLUMNS = new Set(['id']);
121
122
 
123
+ function subjectPredicate(model: ModelJSON, rule: SubjectRule): string {
124
+ const meta = model.fields[rule.field];
125
+ if (!meta) {
126
+ throw new AbloValidationError(`subject field ${JSON.stringify(rule.field)} is not declared`, {
127
+ code: 'schema_definition_invalid',
128
+ param: 'subject.field',
129
+ });
130
+ }
131
+ const column = meta.column ?? camelToSnake(rule.field);
132
+ const prefix = `${rule.group}:`.replace(/'/g, "''");
133
+ return (
134
+ `COALESCE(NULLIF(current_setting('app.current_subject_groups', true), ''), '[]')::jsonb ` +
135
+ `? ('${prefix}' || ${q(column)}::text)`
136
+ );
137
+ }
138
+
122
139
  // ── Foreign keys (relation-driven, sync-safe) ────────────────────────────────
123
140
 
124
141
  /**
@@ -324,7 +341,20 @@ export function generateProvisionPlan(
324
341
  statements.push(`ALTER TABLE ${qt} ENABLE ROW LEVEL SECURITY;`);
325
342
  statements.push(`ALTER TABLE ${qt} FORCE ROW LEVEL SECURITY;`);
326
343
  const policy = `${table}_tenant_isolation`;
327
- const predicate = `${q(orgCol)} = current_setting('app.current_org_id', true)`;
344
+ const predicates = [
345
+ `${q(orgCol)} = current_setting('app.current_org_id', true)`,
346
+ ...(model.subject ? [subjectPredicate(model, model.subject)] : []),
347
+ ];
348
+ const predicate = predicates.length === 1
349
+ ? predicates[0]!
350
+ : predicates.map((part) => `(${part})`).join(' AND ');
351
+ statements.push(`DROP POLICY IF EXISTS ${q(policy)} ON ${qt};`);
352
+ statements.push(`CREATE POLICY ${q(policy)} ON ${qt}\n USING (${predicate})\n WITH CHECK (${predicate});`);
353
+ } else if (model.subject) {
354
+ statements.push(`ALTER TABLE ${qt} ENABLE ROW LEVEL SECURITY;`);
355
+ statements.push(`ALTER TABLE ${qt} FORCE ROW LEVEL SECURITY;`);
356
+ const policy = `${table}_subject_isolation`;
357
+ const predicate = subjectPredicate(model, model.subject);
328
358
  statements.push(`DROP POLICY IF EXISTS ${q(policy)} ON ${qt};`);
329
359
  statements.push(`CREATE POLICY ${q(policy)} ON ${qt}\n USING (${predicate})\n WITH CHECK (${predicate});`);
330
360
  }
@@ -585,5 +615,45 @@ export function generateMigrationPlan(
585
615
  }
586
616
  }
587
617
 
618
+ // Subject authorization is metadata rather than a field diff. Reconcile when
619
+ // that metadata changes, or when the protected field's physical column
620
+ // changes. An identical empty migration must remain a true no-op.
621
+ for (const [key, model] of Object.entries(next.models)) {
622
+ if ((model.plane ?? 'tenant') === 'control') continue;
623
+ const previousModel = prev?.models[key];
624
+ const subjectChanged = JSON.stringify(previousModel?.subject) !== JSON.stringify(model.subject);
625
+ const subjectColumnChanged = steps.some((step) =>
626
+ 'model' in step && step.model === key &&
627
+ step.kind === 'alter_field' &&
628
+ (step.field === model.subject?.field || step.field === previousModel?.subject?.field) &&
629
+ step.changes.column !== undefined
630
+ );
631
+ if (!subjectChanged && !subjectColumnChanged) continue;
632
+ const table = model.tableName ?? key;
633
+ const qt = qtFor(table);
634
+ const orgCol = tenancyColumn(resolveTenancy(model));
635
+ const tenantPolicy = `${table}_tenant_isolation`;
636
+ const subjectPolicy = `${table}_subject_isolation`;
637
+ if (orgCol || model.subject) {
638
+ statements.push(`ALTER TABLE ${qt} ENABLE ROW LEVEL SECURITY;`);
639
+ statements.push(`ALTER TABLE ${qt} FORCE ROW LEVEL SECURITY;`);
640
+ statements.push(`DROP POLICY IF EXISTS ${q(tenantPolicy)} ON ${qt};`);
641
+ statements.push(`DROP POLICY IF EXISTS ${q(subjectPolicy)} ON ${qt};`);
642
+ const parts = [
643
+ ...(orgCol ? [`${q(orgCol)} = current_setting('app.current_org_id', true)`] : []),
644
+ ...(model.subject ? [subjectPredicate(model, model.subject)] : []),
645
+ ];
646
+ const predicate = parts.length === 1
647
+ ? parts[0]!
648
+ : parts.map((part) => `(${part})`).join(' AND ');
649
+ const policy = orgCol ? tenantPolicy : subjectPolicy;
650
+ statements.push(`CREATE POLICY ${q(policy)} ON ${qt}\n USING (${predicate})\n WITH CHECK (${predicate});`);
651
+ } else if (previousModel?.subject) {
652
+ statements.push(`DROP POLICY IF EXISTS ${q(subjectPolicy)} ON ${qt};`);
653
+ statements.push(`ALTER TABLE ${qt} NO FORCE ROW LEVEL SECURITY;`);
654
+ statements.push(`ALTER TABLE ${qt} DISABLE ROW LEVEL SECURITY;`);
655
+ }
656
+ }
657
+
588
658
  return { appSchema: targetSchema, statements, concurrent };
589
659
  }
@@ -154,12 +154,16 @@ export {
154
154
  type InsertValue,
155
155
  type UpsertValue,
156
156
  type UpdateValue,
157
+ type Clearable,
157
158
  type DeleteId,
158
159
  type DefineSchemaOptions,
159
160
  type Casing,
160
161
  type CasingConvention,
161
162
  type CasingFn,
162
163
  composeEntitySyncGroups,
164
+ syncGroupsForRow,
165
+ InvalidRecordSubjectError,
166
+ type RecordSyncGroupSpec,
163
167
  intersectRequestedWithAllowed,
164
168
  type IdentityRole,
165
169
  type IdentityContext,
@@ -210,6 +214,18 @@ export {
210
214
  type RelationJSON,
211
215
  } from './serialize.js';
212
216
 
217
+ export {
218
+ auditSchemaAccessPolicies,
219
+ type SchemaAuditFinding,
220
+ } from './audit.js';
221
+
222
+ export {
223
+ subjectRuleSchema,
224
+ subjectGroupForRow,
225
+ subjectAuthorized,
226
+ type SubjectRule,
227
+ } from './subject.js';
228
+
213
229
  // Schema projection — derive an app's subset from one canonical schema.
214
230
  export { selectModels, omitModels, omittedModelError } from './select.js';
215
231
 
@@ -21,6 +21,7 @@
21
21
  import { z } from 'zod';
22
22
  import type { RelationDef } from './relation.js';
23
23
  import type { EntityRole, GroupsInput } from './roles.js';
24
+ import type { SubjectRule } from './subject.js';
24
25
  import { getFieldMeta, inferFieldMetaFromZod, type FieldMeta } from './field.js';
25
26
  // Tenancy lives in `tenancy.ts`. Authoring uses the `policy` option
26
27
  // (`PolicyInput`), which `resolvePolicy` normalizes into the canonical `Tenancy`
@@ -152,6 +153,14 @@ export interface ModelOptions {
152
153
  */
153
154
  policy?: PolicyInput;
154
155
 
156
+ /**
157
+ * Credential-bound row authorization below organization tenancy. The named
158
+ * row field must match a trusted credential group: `{ field: 'workspaceId',
159
+ * group: 'workspace' }` authorizes only rows whose `workspaceId` has a
160
+ * matching `workspace:<id>` group on the request.
161
+ */
162
+ subject?: SubjectRule;
163
+
155
164
  /**
156
165
  * Which database a model's rows live in. `tenant` (the default) is tenant data
157
166
  * that provisioning places in the customer's own database; `control` is Ablo's own
@@ -176,6 +185,8 @@ export interface ModelOptions {
176
185
  * - `roles` — explicit record-to-group roles keyed on a plain field, for routing
177
186
  * that does not follow a relation, such as fanning a message into a recipient's
178
187
  * inbox. Accepts one role or many.
188
+ * - `routingOnly: true` — explicitly acknowledges that these delivery groups
189
+ * are intentionally narrower than the model's independently safe read policy.
179
190
  *
180
191
  * ```ts
181
192
  * // archiveMember: { userId, archiveId }
@@ -353,6 +364,8 @@ export interface ModelDef<
353
364
  /** The canonical tenancy descriptor for this model, normalized from the `policy`
354
365
  * option at build time. See {@link ModelOptions.policy}. */
355
366
  readonly tenancy: Tenancy;
367
+ /** Credential-bound row authorization. See {@link ModelOptions.subject}. */
368
+ readonly subject?: SubjectRule;
356
369
  /** Which database this model's rows live in — `tenant` (default) can be a
357
370
  * customer's own database; `control` is Ablo's. See {@link ModelOptions.plane}. */
358
371
  readonly plane?: ModelResidency;
@@ -362,6 +375,8 @@ export interface ModelDef<
362
375
  readonly grants?: GrantsRef;
363
376
  /** Explicit record-to-group roles, normalized to an array. See {@link ModelOptions.groups}. */
364
377
  readonly entityRoles?: readonly EntityRole[];
378
+ /** Explicit acknowledgement that sync groups are routing, not row access. */
379
+ readonly routingOnly?: true;
365
380
  /** The write-conflict disposition per committer kind, carried as plain data. See
366
381
  * {@link ModelOptions.conflict}. */
367
382
  readonly conflict?: ConflictAxis;
@@ -445,11 +460,13 @@ export function model<
445
460
  // Normalize the `policy` option into the canonical tenancy descriptor (defaults
446
461
  // to a row-local organization column).
447
462
  tenancy: resolvePolicy(options?.policy),
463
+ subject: options?.subject,
448
464
  plane: options?.plane ?? DEFAULT_RESIDENCY,
449
465
  // Unpack the `groups` option into the individual routing fields the server reads.
450
466
  scope: options?.groups?.root,
451
467
  grants: options?.groups?.grants,
452
468
  entityRoles: normalizeEntityRoles(options?.groups?.roles),
469
+ routingOnly: options?.groups?.routingOnly,
453
470
  // The conflict disposition is already plain data, so it passes through unchanged.
454
471
  conflict: options?.conflict,
455
472
  mutable: options?.mutable ?? true,
@@ -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