@abloatai/transaction 0.56.0 → 0.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONVENTIONS.md +34 -0
- package/dist/coordination/index.d.ts +2 -2
- package/dist/coordination/index.d.ts.map +1 -1
- package/dist/coordination/index.js +1 -1
- package/dist/coordination/index.js.map +1 -1
- package/dist/coordination/schema.d.ts +0 -7
- package/dist/coordination/schema.d.ts.map +1 -1
- package/dist/coordination/schema.js +0 -4
- package/dist/coordination/schema.js.map +1 -1
- package/dist/errorCodes.d.ts +1 -0
- package/dist/errorCodes.d.ts.map +1 -1
- package/dist/errorCodes.js +1 -0
- package/dist/errorCodes.js.map +1 -1
- package/dist/readSetContext.d.ts.map +1 -1
- package/dist/readSetContext.js +8 -3
- package/dist/readSetContext.js.map +1 -1
- package/dist/resources/httpResources.d.ts +43 -22
- package/dist/resources/httpResources.d.ts.map +1 -1
- package/dist/resources/httpResources.js +81 -4
- package/dist/resources/httpResources.js.map +1 -1
- package/dist/resources/modelCreate.d.ts +21 -0
- package/dist/resources/modelCreate.d.ts.map +1 -0
- package/dist/resources/modelCreate.js +49 -0
- package/dist/resources/modelCreate.js.map +1 -0
- package/dist/resources/modelOperations.d.ts +24 -2
- package/dist/resources/modelOperations.d.ts.map +1 -1
- package/dist/resources/modelOperations.js.map +1 -1
- package/dist/resources/writeOptionsSchema.d.ts +14 -0
- package/dist/resources/writeOptionsSchema.d.ts.map +1 -1
- package/dist/resources/writeOptionsSchema.js +20 -0
- package/dist/resources/writeOptionsSchema.js.map +1 -1
- package/dist/schema/audit.d.ts +15 -0
- package/dist/schema/audit.d.ts.map +1 -0
- package/dist/schema/audit.js +90 -0
- package/dist/schema/audit.js.map +1 -0
- package/dist/schema/ddl.d.ts.map +1 -1
- package/dist/schema/ddl.js +68 -1
- package/dist/schema/ddl.js.map +1 -1
- package/dist/schema/index.d.ts +3 -1
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +3 -1
- package/dist/schema/index.js.map +1 -1
- package/dist/schema/model.d.ts +14 -0
- package/dist/schema/model.d.ts.map +1 -1
- package/dist/schema/model.js +2 -0
- package/dist/schema/model.js.map +1 -1
- package/dist/schema/openapi.d.ts.map +1 -1
- package/dist/schema/openapi.js +104 -2
- package/dist/schema/openapi.js.map +1 -1
- package/dist/schema/roles.d.ts +27 -0
- package/dist/schema/roles.d.ts.map +1 -1
- package/dist/schema/roles.js +40 -0
- package/dist/schema/roles.js.map +1 -1
- package/dist/schema/schema.d.ts +23 -4
- package/dist/schema/schema.d.ts.map +1 -1
- package/dist/schema/schema.js +30 -1
- package/dist/schema/schema.js.map +1 -1
- package/dist/schema/serialize.d.ts +4 -1
- package/dist/schema/serialize.d.ts.map +1 -1
- package/dist/schema/serialize.js +5 -1
- package/dist/schema/serialize.js.map +1 -1
- package/dist/schema/subject.d.ts +18 -0
- package/dist/schema/subject.d.ts.map +1 -0
- package/dist/schema/subject.js +27 -0
- package/dist/schema/subject.js.map +1 -0
- package/dist/server/adapter.d.ts +2 -0
- package/dist/server/adapter.d.ts.map +1 -1
- package/dist/server/readConfig.d.ts +3 -0
- package/dist/server/readConfig.d.ts.map +1 -1
- package/dist/server/readConfig.js +0 -21
- package/dist/server/readConfig.js.map +1 -1
- package/dist/source/adapters/drizzle.d.ts.map +1 -1
- package/dist/source/adapters/drizzle.js +37 -6
- package/dist/source/adapters/drizzle.js.map +1 -1
- package/dist/source/adapters/kysely.d.ts +2 -0
- package/dist/source/adapters/kysely.d.ts.map +1 -1
- package/dist/source/adapters/kysely.js +33 -5
- package/dist/source/adapters/kysely.js.map +1 -1
- package/dist/source/adapters/kyselyMutationCore.d.ts +4 -1
- package/dist/source/adapters/kyselyMutationCore.d.ts.map +1 -1
- package/dist/source/adapters/kyselyMutationCore.js +17 -8
- package/dist/source/adapters/kyselyMutationCore.js.map +1 -1
- package/dist/source/adapters/memory.d.ts.map +1 -1
- package/dist/source/adapters/memory.js +1 -0
- package/dist/source/adapters/memory.js.map +1 -1
- package/dist/source/adapters/prisma.d.ts.map +1 -1
- package/dist/source/adapters/prisma.js +59 -7
- package/dist/source/adapters/prisma.js.map +1 -1
- package/dist/source/contract.d.ts +14 -0
- package/dist/source/contract.d.ts.map +1 -1
- package/dist/source/contract.js +10 -0
- package/dist/source/contract.js.map +1 -1
- package/dist/source/factory.d.ts +7 -1
- package/dist/source/factory.d.ts.map +1 -1
- package/dist/source/factory.js +116 -5
- package/dist/source/factory.js.map +1 -1
- package/dist/source/index.d.ts +2 -1
- package/dist/source/index.d.ts.map +1 -1
- package/dist/source/index.js +1 -0
- package/dist/source/index.js.map +1 -1
- package/dist/source/migrations.d.ts.map +1 -1
- package/dist/source/migrations.js +21 -0
- package/dist/source/migrations.js.map +1 -1
- package/dist/source/subjectAuthorization.d.ts +16 -0
- package/dist/source/subjectAuthorization.d.ts.map +1 -0
- package/dist/source/subjectAuthorization.js +126 -0
- package/dist/source/subjectAuthorization.js.map +1 -0
- package/dist/source/types.d.ts +52 -2
- package/dist/source/types.d.ts.map +1 -1
- package/dist/source/types.js +1 -0
- package/dist/source/types.js.map +1 -1
- package/dist/syncLog/contract.d.ts +0 -6
- package/dist/syncLog/contract.d.ts.map +1 -1
- package/dist/syncLog/contract.js +0 -4
- package/dist/syncLog/contract.js.map +1 -1
- package/dist/testing/fixtures/httpResponses.d.ts +6 -0
- package/dist/testing/fixtures/httpResponses.d.ts.map +1 -1
- package/dist/testing/fixtures/httpResponses.js +1 -0
- package/dist/testing/fixtures/httpResponses.js.map +1 -1
- package/dist/transport/httpClient.d.ts +8 -1
- package/dist/transport/httpClient.d.ts.map +1 -1
- package/dist/transport/httpClient.js +51 -20
- package/dist/transport/httpClient.js.map +1 -1
- package/dist/transport/httpCommitRequest.d.ts +45 -0
- package/dist/transport/httpCommitRequest.d.ts.map +1 -0
- package/dist/transport/httpCommitRequest.js +67 -0
- package/dist/transport/httpCommitRequest.js.map +1 -0
- package/dist/transport/httpTransport.d.ts.map +1 -1
- package/dist/transport/httpTransport.js +69 -106
- package/dist/transport/httpTransport.js.map +1 -1
- package/dist/transport/httpTransportHelpers.d.ts +10 -0
- package/dist/transport/httpTransportHelpers.d.ts.map +1 -0
- package/dist/transport/httpTransportHelpers.js +46 -0
- package/dist/transport/httpTransportHelpers.js.map +1 -0
- package/dist/wire/apiLifecycle.d.ts +63 -0
- package/dist/wire/apiLifecycle.d.ts.map +1 -0
- package/dist/wire/apiLifecycle.js +89 -0
- package/dist/wire/apiLifecycle.js.map +1 -0
- package/dist/wire/commit.d.ts +74 -9
- package/dist/wire/commit.d.ts.map +1 -1
- package/dist/wire/commit.js +13 -1
- package/dist/wire/commit.js.map +1 -1
- package/dist/wire/index.d.ts +5 -2
- package/dist/wire/index.d.ts.map +1 -1
- package/dist/wire/index.js +12 -1
- package/dist/wire/index.js.map +1 -1
- package/dist/wire/rateLimit.d.ts +82 -0
- package/dist/wire/rateLimit.d.ts.map +1 -0
- package/dist/wire/rateLimit.js +142 -0
- package/dist/wire/rateLimit.js.map +1 -0
- package/package.json +1 -1
- package/src/coordination/index.ts +0 -2
- package/src/coordination/schema.ts +0 -7
- package/src/errorCodes.ts +6 -0
- package/src/readSetContext.ts +8 -2
- package/src/resources/httpResources.ts +116 -25
- package/src/resources/modelCreate.ts +73 -0
- package/src/resources/modelOperations.ts +29 -2
- package/src/resources/writeOptionsSchema.ts +28 -0
- package/src/schema/audit.ts +121 -0
- package/src/schema/ddl.ts +71 -1
- package/src/schema/index.ts +16 -0
- package/src/schema/model.ts +17 -0
- package/src/schema/openapi.ts +123 -2
- package/src/schema/roles.ts +53 -0
- package/src/schema/schema.ts +68 -3
- package/src/schema/serialize.ts +8 -1
- package/src/schema/subject.ts +43 -0
- package/src/server/adapter.ts +2 -0
- package/src/server/readConfig.ts +4 -0
- package/src/source/adapters/drizzle.ts +50 -6
- package/src/source/adapters/kysely.ts +47 -4
- package/src/source/adapters/kyselyMutationCore.ts +21 -9
- package/src/source/adapters/memory.ts +1 -0
- package/src/source/adapters/prisma.ts +70 -7
- package/src/source/contract.ts +11 -0
- package/src/source/factory.ts +141 -5
- package/src/source/index.ts +6 -0
- package/src/source/migrations.ts +21 -0
- package/src/source/subjectAuthorization.ts +182 -0
- package/src/source/types.ts +56 -2
- package/src/syncLog/contract.ts +0 -7
- package/src/testing/fixtures/httpResponses.ts +7 -0
- package/src/transport/httpClient.ts +85 -23
- package/src/transport/httpCommitRequest.ts +104 -0
- package/src/transport/httpTransport.ts +92 -142
- package/src/transport/httpTransportHelpers.ts +63 -0
- package/src/wire/apiLifecycle.ts +94 -0
- package/src/wire/commit.ts +16 -1
- package/src/wire/index.ts +31 -0
- package/src/wire/rateLimit.ts +155 -0
|
@@ -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
|
-
*
|
|
137
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
210
|
-
|
|
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:
|
|
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
|
|
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
|
}
|
package/src/schema/index.ts
CHANGED
|
@@ -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
|
|
package/src/schema/model.ts
CHANGED
|
@@ -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,
|