@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
@@ -19,9 +19,12 @@ import type {
19
19
  CommitRecordList,
20
20
  CommitRecordListOptions,
21
21
  CommitRecordWhere,
22
+ CommitOperationBody,
23
+ ModelOperationAction,
22
24
  } from '../wire/commit.js';
23
25
  import type { LogListResponse, LogQuery } from '../wire/feedEvent.js';
24
26
  import type { ModelListEvidence } from '../wire/modelResponses.js';
27
+ import { AbloValidationError } from '../errors.js';
25
28
  // Re-exported, not redeclared. `wire/commit.ts` owns the commit-status vocabulary
26
29
  // and derives the waitable subset from it; this module serves that name to SDK
27
30
  // consumers. Restating the subset here as its own union produced a type that
@@ -63,6 +66,7 @@ import type {
63
66
  AwaitedClaimMethod,
64
67
  ModelTrackParams,
65
68
  ModelTrackResult,
69
+ ModelCreateManyParams,
66
70
  ServerReadOptions,
67
71
  } from './modelOperations.js';
68
72
 
@@ -77,12 +81,7 @@ import type {
77
81
  * `claim({ id })` — a durable claim handle for coordinated writes
78
82
  */
79
83
 
80
- export type ModelOperationAction =
81
- | 'create'
82
- | 'update'
83
- | 'delete'
84
- | 'archive'
85
- | 'unarchive';
84
+ export type { ModelOperationAction };
86
85
 
87
86
 
88
87
  /** @internal Transport envelope; the public typed client returns the row. */
@@ -130,20 +129,119 @@ export interface HttpTransportList<T = Record<string, unknown>> {
130
129
  * and a complete one the same value, so the caller with 500 matching rows got
131
130
  * 20 and no way to find out.
132
131
  */
133
- export type ModelList<T> = T[] & Pick<HttpTransportList<T>, 'hasMore' | 'nextCursor'>;
132
+ export type ModelList<T> = T[] & Pick<HttpTransportList<T>, 'hasMore' | 'nextCursor'> &
133
+ AsyncIterable<T>;
134
134
 
135
135
  /**
136
- * Attach the page state to the rows. Non-enumerable so the result stays
137
- * indistinguishable from a plain array everywhere the properties aren't read.
136
+ * How far a `for await` over a list will walk before it gives up.
137
+ *
138
+ * A cursor that stops advancing would otherwise spin forever. The bound is
139
+ * high enough that no real collection reaches it and low enough that a broken
140
+ * server is a failed read rather than a hung process.
141
+ */
142
+ const AUTO_PAGE_LIMIT = 10_000;
143
+ const LIST_ALL_PAGE_LIMIT = 100;
144
+
145
+ const nextPageFor = new WeakMap<object, (cursor: string) => Promise<ModelList<unknown>>>();
146
+
147
+ export interface ModelListWalkOptions {
148
+ readonly maxPages?: number;
149
+ readonly signal?: AbortSignal;
150
+ }
151
+
152
+ async function* walkModelList<T>(
153
+ first: ModelList<T>,
154
+ options: ModelListWalkOptions = {},
155
+ ): AsyncGenerator<T> {
156
+ const maxPages = options.maxPages ?? AUTO_PAGE_LIMIT;
157
+ if (!Number.isInteger(maxPages) || maxPages < 1) {
158
+ throw new AbloValidationError('maxPages must be a positive integer.', {
159
+ code: 'invalid_options',
160
+ param: 'maxPages',
161
+ });
162
+ }
163
+
164
+ let current = first;
165
+ for (let visited = 0; visited < maxPages; visited += 1) {
166
+ options.signal?.throwIfAborted();
167
+ for (let i = 0; i < current.length; i += 1) {
168
+ options.signal?.throwIfAborted();
169
+ yield current[i] as T;
170
+ }
171
+ const cursor = current.nextCursor;
172
+ const fetchNext = nextPageFor.get(current) as
173
+ | ((nextCursor: string) => Promise<ModelList<T>>)
174
+ | undefined;
175
+ if (!current.hasMore || cursor === null || fetchNext === undefined) return;
176
+ const next = await fetchNext(cursor);
177
+ if (next.nextCursor === cursor) {
178
+ throw new AbloValidationError(
179
+ `Walking this list received the same continuation cursor twice (${JSON.stringify(cursor)}). ` +
180
+ 'The collection may be incomplete, so traversal stopped with an error.',
181
+ { code: 'malformed_response', param: 'nextCursor' },
182
+ );
183
+ }
184
+ current = next;
185
+ }
186
+ throw new AbloValidationError(
187
+ `Walking this list passed ${maxPages} pages without reaching the end. ` +
188
+ `Narrow the read with \`where\` or raise \`maxPages\` deliberately.`,
189
+ { code: 'invalid_options', param: 'maxPages' },
190
+ );
191
+ }
192
+
193
+ /** Collect a complete list through the same guarded cursor loop as async iteration. */
194
+ export async function collectModelList<T>(
195
+ first: ModelList<T>,
196
+ options: ModelListWalkOptions = {},
197
+ ): Promise<T[]> {
198
+ const rows: T[] = [];
199
+ for await (const row of walkModelList(first, {
200
+ maxPages: options.maxPages ?? LIST_ALL_PAGE_LIMIT,
201
+ signal: options.signal,
202
+ })) rows.push(row);
203
+ return rows;
204
+ }
205
+
206
+ /**
207
+ * Attach the page state to the rows, and make the list walk its own pages.
208
+ *
209
+ * `hasMore` and `nextCursor` are non-enumerable, so the result stays
210
+ * indistinguishable from a plain array everywhere they are not read: it maps,
211
+ * filters, spreads, and `JSON.stringify`s exactly as the rows always did.
212
+ *
213
+ * The async iterator is the answer to the question that shape raises. A list
214
+ * read is a page — the server applies a default size and caps the largest —
215
+ * and a page of 20 looks precisely like a complete answer of 20, so every
216
+ * caller either checked `hasMore` or, far more often, reasoned about a
217
+ * truncated set without knowing it. Hand-rolled page walkers were the common
218
+ * result, and each one re-derived the same cursor loop and the same
219
+ * non-advancing-cursor guard.
220
+ *
221
+ * So iterate the value to get the page, and `for await` it to get the
222
+ * collection:
223
+ *
224
+ * ```ts
225
+ * const page = await ablo.issue.list({ where: { teamId } });
226
+ * for (const issue of page) … // the 20 rows that came back
227
+ * for await (const issue of page) … // every issue, paged as it goes
228
+ * ```
138
229
  */
139
230
  export function modelList<T>(
140
231
  rows: readonly T[],
141
232
  page: Pick<HttpTransportList<unknown>, 'hasMore' | 'nextCursor'>,
233
+ /** Reads the page after `cursor`. Omitted where no transport can follow. */
234
+ fetchNext?: (cursor: string) => Promise<ModelList<T>>,
142
235
  ): ModelList<T> {
143
- return Object.defineProperties([...rows], {
236
+ const list = Object.defineProperties([...rows], {
144
237
  hasMore: { value: page.hasMore, enumerable: false },
145
238
  nextCursor: { value: page.nextCursor, enumerable: false },
239
+ [Symbol.asyncIterator]: { value: () => walkModelList(list), enumerable: false },
146
240
  }) as ModelList<T>;
241
+ if (fetchNext) {
242
+ nextPageFor.set(list, fetchNext as (cursor: string) => Promise<ModelList<unknown>>);
243
+ }
244
+ return list;
147
245
  }
148
246
 
149
247
  export type IfClaimedPolicy = 'return' | 'fail';
@@ -206,21 +304,8 @@ export interface ClaimCreateOptions {
206
304
  readonly onStatus?: (event: ClaimAttemptEvent) => void;
207
305
  }
208
306
 
209
- export interface CommitOperationInput {
210
- readonly action: ModelOperationAction;
211
- /** The model name — matches `ablo.<model>` and the schema's `model()`. */
212
- readonly model: string;
213
- readonly id?: string | null;
214
- readonly data?: Record<string, unknown> | null;
215
- readonly where?: Record<string, unknown> | null;
216
- readonly transactionId?: string | null;
217
- /** Claim identity derived from a held claim; not an application id. */
218
- readonly claimId?: string | null;
219
- readonly readAt?: number | null;
220
- readonly onStale?: OnStaleMode | null;
221
- /** Fencing token (Option B) from the batch's claim handle; server-validated. */
222
- readonly fenceToken?: number | null;
223
- }
307
+ /** Public commit operation inferred from the canonical request-body schema. */
308
+ export type CommitOperationInput = CommitOperationBody;
224
309
 
225
310
  export interface CommitCreateOptions {
226
311
  readonly idempotencyKey?: string | null;
@@ -402,6 +487,12 @@ export interface HttpTransportModel<
402
487
  * returned, not the input.
403
488
  */
404
489
  create(params: ModelMutationOptions & { readonly data: Record<string, unknown>; readonly id?: string | null }): Promise<T>;
490
+ /**
491
+ * Creates many rows as one atomic commit and returns them, in the caller's
492
+ * order. One rejected row declines the batch. The rows are the server's own,
493
+ * carried back on the commit rather than read again afterwards.
494
+ */
495
+ createMany(params: ModelCreateManyParams<Record<string, unknown>>): Promise<T[]>;
405
496
  update(params: ModelMutationOptions & { readonly id: string; readonly data: Record<string, unknown> }): Promise<CommitReceipt>;
406
497
  /**
407
498
  * Update under contention with a function of the latest state —
@@ -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
 
@@ -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,