@fabrika/db 0.0.0-stage → 0.0.1

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/src/orm.ts ADDED
@@ -0,0 +1,891 @@
1
+ // The mini-ORM at runtime: the handles `ctx.db` holds, which `orm-types.ts` types. They are built for one site of one
2
+ // invocation and run every statement in that invocation's session. Every call first checks that the work running now
3
+ // is that invocation, then refuses what the site may not do with the message its types give, then runs the store's
4
+ // own checks of kind, dimension and action against the capability.
5
+
6
+ import type { OperationNode } from 'kysely'
7
+ import { BinaryOperationNode, OperatorNode, ValueNode } from 'kysely'
8
+ import { freeze, hasOwn, inSet, isSafeInteger, nameSet, namesOf, ownDescriptor, ownKeys, ownNames, parseList, setOwn } from './builtins'
9
+ import { type CapabilityGrant, describeGrant, type RefGrant } from './capability'
10
+ import type { InvocationTransaction, Session } from './database'
11
+ import { columnOf, type Compare, type Contains, parseCondition, type Primitive, type SelectState, type TableShape } from './description'
12
+ import { CapabilityError, NotFoundError, type QueryRefusal, QueryRefusedError } from './errors'
13
+ import { linksPredicate, linkValues } from './links'
14
+ import { aggregateNode, allOf, anyOf, column, cursorAlias, equals, everything, lockedSelectNode, selectNode, withCursorColumns } from './nodes'
15
+ import type { DataKind, TargetForm, UnscopedMode } from './orm-types'
16
+ import { modelOf, namesAction, type TableModels, updatableBy, visibleFor } from './policy'
17
+ import { type SqlSite, type SqlTag, sqlTag } from './sql'
18
+ import {
19
+ actionShape,
20
+ aggregateRows,
21
+ type AggregateSpec,
22
+ appKeyPredicate,
23
+ appListPredicate,
24
+ appRecordPredicate,
25
+ appRowPredicate,
26
+ childObjectPredicate,
27
+ childScope,
28
+ type DataLayerRuntime,
29
+ insertIntoApplication,
30
+ insertIntoChild,
31
+ insertIntoDimension,
32
+ insertRow,
33
+ keyOf,
34
+ objectPredicate,
35
+ ownerOf,
36
+ parentPredicate,
37
+ reachColumn,
38
+ reachPredicate,
39
+ readValues,
40
+ removeRow,
41
+ type Runner,
42
+ runtimeOf,
43
+ scopedApplicationRead,
44
+ scopeTerms,
45
+ selectOne,
46
+ selfLinks,
47
+ type TableContext,
48
+ updateRow,
49
+ updatingApp,
50
+ updatingRef,
51
+ versionArgument,
52
+ } from './store'
53
+ import { answerDays } from './temporal'
54
+
55
+ /** A list page holds this many rows unless the caller asks for fewer, and never more than `MAX_LIST_LIMIT`. */
56
+ export const DEFAULT_LIST_LIMIT = 50
57
+ export const MAX_LIST_LIMIT = 500
58
+
59
+ /** The framework's own tables; no declaration reaches one. */
60
+ const FRAMEWORK_PREFIX = 'fabrika_'
61
+
62
+ /** What every handle of one site, in one invocation, is made for. */
63
+ export interface DataSite {
64
+ /** A query reads; a mutation also writes and locks; a job reads, and writes only in its fenced transaction. */
65
+ readonly kind: DataKind
66
+ /** The tables the site declares `unscoped`, each in its mode. */
67
+ readonly unscoped: Readonly<Record<string, UnscopedMode>>
68
+ /** Who an unscoped insert is stamped with (`createdBy`): no capability carries a principal for it. */
69
+ readonly principal: string
70
+ /** Throws unless the work running now is the invocation these handles were made for. */
71
+ check(): void
72
+ /** The session every statement of the site runs in. */
73
+ session(): Promise<Session>
74
+ }
75
+
76
+ /** The handles of one site; the framework composes `ctx.db` from them by the site's access form. */
77
+ export interface DataHandles {
78
+ /** `ctx.db.target` (or `ctx.db.created`, or a job's `ctx.db.on`/`.in`): a `Ref`'s row and subtree. */
79
+ target(capability: unknown, form: TargetForm): object
80
+ /** `ctx.db.within` (or a job's `ctx.db.over`): every table listed in a `Within`'s dimension. */
81
+ within(capability: unknown): object
82
+ /**
83
+ * `ctx.db.app`. Of an `app` endpoint: each application table, and each dimension and child table, read-only, by the
84
+ * capability's action held application-wide. Of an `on`, `in` or `over` endpoint (a `Ref` or a `Within`): each
85
+ * application table, read-only, every row, where the capability's action is its read action.
86
+ */
87
+ app(capability: unknown): object
88
+ /** `ctx.db.reference`. */
89
+ readonly reference: object
90
+ /** `ctx.db.unscoped`, or `undefined` for a site that declares none. */
91
+ readonly unscoped: object | undefined
92
+ }
93
+
94
+ /** The data side of one bound layer, which only the framework reaches (`@fabrika/db/framework`). */
95
+ export interface DataAccess {
96
+ /** One invocation's transaction over the bound connection; a query's is `READ ONLY`. */
97
+ invocation(readOnly: boolean): InvocationTransaction
98
+ /** A statement-at-a-time session: what a job's reads outside its transaction run in. */
99
+ direct(): Session
100
+ /** One transaction, fenced by the lease of the job attempt running now: a job's `ctx.transaction`. */
101
+ transaction<T>(work: (session: Session) => Promise<T>): Promise<T>
102
+ /** The handles of one site. Throws for a declaration this layer cannot honour. */
103
+ handles(site: DataSite): DataHandles
104
+ /** `ctx.sql` of a site that declares it, running in the site's session on the application's schema. */
105
+ sql(site: SqlSite): SqlTag
106
+ }
107
+
108
+ /** What `scoped` hands this module: the layer's tables and the way to its runtime. */
109
+ export interface DataLayerParts {
110
+ readonly models: TableModels
111
+ contextOf(table: string): TableContext
112
+ runtime(): DataLayerRuntime
113
+ newSession(runtime: DataLayerRuntime): Session
114
+ newInvocation(runtime: DataLayerRuntime, readOnly: boolean): InvocationTransaction
115
+ fenced<T>(runtime: DataLayerRuntime, work: (session: Session) => Promise<T>): Promise<T>
116
+ }
117
+
118
+ export function dataAccess(parts: DataLayerParts): DataAccess {
119
+ return freeze({
120
+ invocation: (readOnly: boolean) => parts.newInvocation(parts.runtime(), readOnly),
121
+ direct: () => parts.newSession(parts.runtime()),
122
+ transaction: <T>(work: (session: Session) => Promise<T>) => parts.fenced(parts.runtime(), work),
123
+ handles: (site: DataSite) => handlesOf(parts, site),
124
+ sql: (site: SqlSite) =>
125
+ sqlTag(
126
+ site,
127
+ parts.runtime().database.schema,
128
+ (capability, table, use) => {
129
+ if (!hasOwn(parts.models, table)) {
130
+ throw new QueryRefusedError('unknown_table', `ctx.sql.scope: ${table} is not a table of src/db/tables.ts`)
131
+ }
132
+ return scopeTerms(parts.contextOf(table), capability, use)
133
+ },
134
+ (table) => {
135
+ if (modelOf(parts.models, table)?.scope.mode === 'reference') {
136
+ throw new QueryRefusedError('read_only', `ctx.sql: ${table} is a reference table, which no statement writes: its rows come from a migration`)
137
+ }
138
+ },
139
+ ),
140
+ })
141
+ }
142
+
143
+ function runnerOf(site: DataSite): Runner {
144
+ return {
145
+ read: () => site.session(),
146
+ write: async (work) => work(await site.session()),
147
+ }
148
+ }
149
+
150
+ /** Every handle method checks its invocation first, so a captured one refuses before it reads its arguments. */
151
+ function bound<A extends unknown[], R>(site: DataSite, method: (...args: A) => Promise<R>): (...args: A) => Promise<R> {
152
+ return async (...args: A) => {
153
+ site.check()
154
+ return method(...args)
155
+ }
156
+ }
157
+
158
+ /** A method this site may not call: it throws the message its type gives, whatever the arguments. */
159
+ function refused(site: DataSite, reason: QueryRefusal, message: string): () => Promise<never> {
160
+ return async () => {
161
+ site.check()
162
+ throw new QueryRefusedError(reason, message)
163
+ }
164
+ }
165
+
166
+ /** The message of a write the site's kind refuses, or `undefined` in a mutation. */
167
+ function writeRefusal(site: DataSite, operation: 'insert' | 'update' | 'remove'): string | undefined {
168
+ if (site.kind === 'job') {
169
+ return 'a job writes inside ctx.transaction((tx) => …): use tx.db there'
170
+ }
171
+ if (site.kind === 'query') {
172
+ return `${operation} writes, so it runs only in a mutation: declare the endpoint with r.mutation`
173
+ }
174
+ return undefined
175
+ }
176
+
177
+ function writing<A extends unknown[], R>(
178
+ site: DataSite,
179
+ operation: 'insert' | 'update' | 'remove',
180
+ method: (...args: A) => Promise<R>,
181
+ ): (...args: A) => Promise<R> {
182
+ const refusal = writeRefusal(site, operation)
183
+ return refusal === undefined ? bound(site, method) : refused(site, 'read_only', refusal)
184
+ }
185
+
186
+ /** A `Ref` of `dimension` whose action the table names: a capability reads its own target and its subtree with it. */
187
+ function namedRef(context: TableContext, capability: unknown, dimension: string): RefGrant {
188
+ const grant = runtimeOf(context.connection).capabilities.resolve(capability)
189
+ const table = context.model.name
190
+ if (grant.kind !== 'ref' || grant.dimension !== dimension) {
191
+ throw new CapabilityError('wrong_capability', `a read of ${table} here needs a Ref of ${dimension}, got ${describeGrant(grant)}`)
192
+ }
193
+ if (!namesAction(context.model, grant.action)) {
194
+ throw new CapabilityError(
195
+ 'wrong_action',
196
+ `${table} does not name the action '${grant.action}': read it from an endpoint whose action ${table} names, or declare unscoped`,
197
+ )
198
+ }
199
+ return grant
200
+ }
201
+
202
+ function resolved(parts: DataLayerParts, capability: unknown): CapabilityGrant {
203
+ return parts.runtime().capabilities.resolve(capability)
204
+ }
205
+
206
+ function handlesOf(parts: DataLayerParts, site: DataSite): DataHandles {
207
+ const run = runnerOf(site)
208
+ const unscoped = unscopedTables(parts, site, run)
209
+ return freeze({
210
+ target: (capability: unknown, form: TargetForm) => {
211
+ site.check()
212
+ return scopeTarget(parts, site, run, capability, form)
213
+ },
214
+ within: (capability: unknown) => {
215
+ site.check()
216
+ return withinTables(parts, site, run, capability)
217
+ },
218
+ app: (capability: unknown) => {
219
+ site.check()
220
+ return applicationTables(parts, site, run, capability)
221
+ },
222
+ reference: referenceTables(parts, site, run),
223
+ unscoped,
224
+ })
225
+ }
226
+
227
+ /** Adds one table's handle; a table named like a target member would shadow it, so it is refused instead. */
228
+ function addTable(handle: Record<string, unknown>, table: string, rows: object): void {
229
+ if (hasOwn(handle, table)) {
230
+ throw new QueryRefusedError('invalid_name', `the table ${table} has the name of a ctx.db.target member; rename the table`)
231
+ }
232
+ setOwn(handle, table, rows)
233
+ }
234
+
235
+ function scopeTarget(parts: DataLayerParts, site: DataSite, run: Runner, capability: unknown, form: TargetForm): object {
236
+ const grant = resolved(parts, capability)
237
+ if (grant.kind !== 'ref') {
238
+ throw new CapabilityError('wrong_capability', 'ctx.db.target is built from a Ref; an over endpoint lists through ctx.db.within')
239
+ }
240
+ const dimension = grant.dimension
241
+ const owner = ownerOf(parts.models, dimension)
242
+ const handle: Record<string, unknown> = owner === undefined
243
+ ? unownedTarget(site, dimension)
244
+ : targetRow(parts.contextOf(owner.name), site, run, capability, dimension, form)
245
+ const tables = ownKeys(parts.models)
246
+ for (let index = 0; index < tables.length; index++) {
247
+ const model = modelOf(parts.models, tables[index] ?? '')
248
+ if (model === undefined) {
249
+ continue
250
+ }
251
+ const scope = model.scope
252
+ if (scope.mode === 'child' && owner !== undefined && scope.parent === owner.name) {
253
+ addTable(handle, model.name, childRows(parts.contextOf(model.name), site, run, capability))
254
+ } else if (scope.mode === 'dimension' && scope.container?.dimension === dimension) {
255
+ addTable(handle, model.name, containedRows(parts.contextOf(model.name), site, run, capability, dimension))
256
+ } else if (form === 'in' && scope.mode === 'dimension' && model === owner && scope.container === undefined) {
257
+ addTable(handle, model.name, ownerRow(parts.contextOf(model.name), site, run, capability))
258
+ }
259
+ }
260
+ return freeze(handle)
261
+ }
262
+
263
+ function unownedTarget(site: DataSite, dimension: string): Record<string, unknown> {
264
+ const message = `no table owns the dimension ${dimension}, so ctx.db.target has no row of its own`
265
+ const refuse = refused(site, 'unknown_table', message)
266
+ return { get: refuse, getForUpdate: refuse, update: refuse, remove: refuse }
267
+ }
268
+
269
+ /** The predicate of the target object, over the whole chain its `Ref` carries. */
270
+ function targetPredicate(context: TableContext, grant: RefGrant): OperationNode {
271
+ return linksPredicate(context.model.name, linkValues(context.model.name, selfLinks(context.model), grant))
272
+ }
273
+
274
+ function targetRow(
275
+ context: TableContext,
276
+ site: DataSite,
277
+ run: Runner,
278
+ capability: unknown,
279
+ dimension: string,
280
+ form: TargetForm,
281
+ ): Record<string, unknown> {
282
+ const onOnly = (operation: string) => `${operation} acts on one object: declare access with on`
283
+ const get = bound(site, async () => {
284
+ const grant = namedRef(context, capability, dimension)
285
+ return selectOne(context, run, targetPredicate(context, grant), visibleFor(context.model, grant.action))
286
+ })
287
+ const lock = bound(site, async () => {
288
+ const grant = namedRef(context, capability, dimension)
289
+ const scope = targetPredicate(context, grant)
290
+ const node = lockedSelectNode(context.model.name, visibleFor(context.model, grant.action), scope, context.model.dates)
291
+ const session = await run.read()
292
+ const rows = await session.run(node, { root: 'SelectQueryNode', table: context.model.name, joins: {}, scope, lock: 'update' })
293
+ const [row] = answerDays(context.model.name, context.model.dates, rows)
294
+ if (row === undefined) {
295
+ throw new NotFoundError(`${context.model.name} not found`)
296
+ }
297
+ return row
298
+ })
299
+ const getForUpdate = site.kind === 'job'
300
+ ? refused(site, 'read_only', 'a job writes inside ctx.transaction((tx) => …): use tx.db there')
301
+ : site.kind === 'query'
302
+ ? refused(site, 'read_only', 'getForUpdate locks the row, so it runs only in a mutation: declare the endpoint with r.mutation')
303
+ : form === 'on'
304
+ ? lock
305
+ : refused(site, 'row_address', 'getForUpdate locks one object: declare access with on')
306
+ const update = form === 'on'
307
+ ? writing(site, 'update', async (values: unknown, ...options: unknown[]) => {
308
+ const grant = updatingRef(context, capability, dimension)
309
+ const changes = readValues(values, updatableBy(context.model, grant.action), 'update', context.model)
310
+ return updateRow(context, run, { scope: targetPredicate(context, grant) }, changes, versionArgument(context, options))
311
+ })
312
+ : refused(site, 'row_address', onOnly('update'))
313
+ const remove = form === 'on'
314
+ ? writing(site, 'remove', async (...options: unknown[]) => {
315
+ const scope = objectPredicate(context, capability, 'delete')
316
+ await removeRow(context, run, { scope }, versionArgument(context, options))
317
+ })
318
+ : refused(site, 'row_address', onOnly('remove'))
319
+ return { get, getForUpdate, update, remove }
320
+ }
321
+
322
+ /** A child table of the target: its rows under the target, addressed by their own key. */
323
+ function childRows(context: TableContext, site: DataSite, run: Runner, capability: unknown): object {
324
+ const parent = childScope(context).parent.dimension
325
+ const key = keyOf(context.model)
326
+ return freeze({
327
+ list: bound(site, async (options?: unknown) => {
328
+ const grant = namedRef(context, capability, parent)
329
+ return listRows(context, run, parentPredicate(context, grant), visibleFor(context.model, grant.action), options, [key])
330
+ }),
331
+ get: bound(site, async (id: unknown) => {
332
+ const grant = namedRef(context, capability, parent)
333
+ if (typeof id !== 'string' || id.length === 0) {
334
+ throw new QueryRefusedError('row_address', `a ${context.model.name} row is addressed by its ${key}, a non-empty string`)
335
+ }
336
+ const scope = allOf([equals(context.model.name, key, id), parentPredicate(context, grant)])
337
+ return selectOne(context, run, scope, visibleFor(context.model, grant.action))
338
+ }),
339
+ insert: writing(site, 'insert', async (values: unknown) => insertIntoChild(context, run, capability, values)),
340
+ update: writing(site, 'update', async (id: unknown, values: unknown, ...options: unknown[]) => {
341
+ if (typeof id !== 'string' || id.length === 0) {
342
+ throw new QueryRefusedError('row_address', `a ${context.model.name} row is addressed by its parent Ref and its ${key}`)
343
+ }
344
+ const grant = updatingRef(context, capability, parent)
345
+ const changes = readValues(values, updatableBy(context.model, grant.action), 'update', context.model)
346
+ const scope = allOf([equals(context.model.name, key, id), parentPredicate(context, grant)])
347
+ return updateRow(context, run, { scope }, changes, versionArgument(context, options))
348
+ }),
349
+ remove: writing(site, 'remove', async (id: unknown, ...options: unknown[]) => {
350
+ await removeRow(context, run, { scope: childObjectPredicate(context, capability, id, 'delete') }, versionArgument(context, options))
351
+ }),
352
+ })
353
+ }
354
+
355
+ /** A dimension table the target contains: its rows listed in the target, and inserted into it. */
356
+ function containedRows(context: TableContext, site: DataSite, run: Runner, capability: unknown, dimension: string): object {
357
+ return freeze({
358
+ list: bound(site, async (options?: unknown) => {
359
+ const grant = namedRef(context, capability, dimension)
360
+ const scope = linksPredicate(context.model.name, linkValues(context.model.name, context.model.chain, grant))
361
+ return listRows(context, run, scope, visibleFor(context.model, grant.action), options, [keyOf(context.model)])
362
+ }),
363
+ insert: writing(site, 'insert', async (values: unknown) => insertIntoDimension(context, run, capability, values)),
364
+ })
365
+ }
366
+
367
+ /** A top-level scope's own row, keyed by the scope's value: what an `in` or `creates` endpoint inserts. */
368
+ function ownerRow(context: TableContext, site: DataSite, run: Runner, capability: unknown): object {
369
+ return freeze({ insert: writing(site, 'insert', async (values: unknown) => insertIntoDimension(context, run, capability, values)) })
370
+ }
371
+
372
+ function withinTables(parts: DataLayerParts, site: DataSite, run: Runner, capability: unknown): object {
373
+ const grant = resolved(parts, capability)
374
+ if (grant.kind !== 'within') {
375
+ throw new CapabilityError('wrong_capability', 'ctx.db.within is built from a Within; an on or in endpoint reaches its rows through ctx.db.target')
376
+ }
377
+ const handle: Record<string, unknown> = {}
378
+ const tables = ownKeys(parts.models)
379
+ for (let index = 0; index < tables.length; index++) {
380
+ const model = modelOf(parts.models, tables[index] ?? '')
381
+ const listable = model?.scope.mode === 'dimension' || model?.scope.mode === 'child'
382
+ if (model !== undefined && listable && reachColumn(model, grant.dimension) !== undefined) {
383
+ const context = parts.contextOf(model.name)
384
+ setOwn(handle, model.name, listedRows(context, site, run, () => reachPredicate(context, capability)))
385
+ }
386
+ }
387
+ return freeze(handle)
388
+ }
389
+
390
+ /** A table read through a `Within` by its read action: `undefined` from `scope` is an empty `Within`, which reads nothing. */
391
+ function listedRows(context: TableContext, site: DataSite, run: Runner, scope: () => OperationNode | undefined): object {
392
+ const visible = context.model.visible
393
+ const key = [keyOf(context.model)]
394
+ return freeze({
395
+ list: bound(site, async (options?: unknown) => listRows(context, run, scope(), visible, options, key)),
396
+ count: bound(site, async (options?: unknown) => countRows(context, run, scope(), visible, options)),
397
+ aggregate: bound(site, async <Row>(spec: AggregateSpec<Row>) => aggregateRows(context, run, scope(), context.shape, spec)),
398
+ })
399
+ }
400
+
401
+ function applicationTables(parts: DataLayerParts, site: DataSite, run: Runner, capability: unknown): object {
402
+ const grant = resolved(parts, capability)
403
+ if (grant.kind !== 'app') {
404
+ return scopedApplicationTables(parts, site, run, capability)
405
+ }
406
+ const handle: Record<string, unknown> = {}
407
+ const tables = ownKeys(parts.models)
408
+ for (let index = 0; index < tables.length; index++) {
409
+ const model = modelOf(parts.models, tables[index] ?? '')
410
+ if (model === undefined) {
411
+ continue
412
+ }
413
+ const context = parts.contextOf(model.name)
414
+ if (model.scope.mode === 'app') {
415
+ setOwn(handle, model.name, applicationRows(context, site, run, capability))
416
+ } else if (model.scope.mode === 'dimension' || model.scope.mode === 'child') {
417
+ setOwn(handle, model.name, appRecordRows(context, site, run, capability, grant.action))
418
+ }
419
+ }
420
+ return freeze(handle)
421
+ }
422
+
423
+ /**
424
+ * A dimension or child table under an `app` capability of `action`, read-only and every row, with the columns that
425
+ * action reads (`visibleFor`): listed and aggregated with its read action or an action of its `appRead`, counted with
426
+ * those or an action of its `appCount`.
427
+ */
428
+ function appRecordRows(context: TableContext, site: DataSite, run: Runner, capability: unknown, action: string): object {
429
+ const visible = visibleFor(context.model, action)
430
+ const shape = actionShape(context.model, visible)
431
+ const key = [keyOf(context.model)]
432
+ return freeze({
433
+ list: bound(site, async (options?: unknown) => listRows(context, run, appRecordPredicate(context, capability, 'rows'), visible, options, key)),
434
+ count: bound(site, async (options?: unknown) => countRows(context, run, appRecordPredicate(context, capability, 'counts'), visible, options)),
435
+ aggregate: bound(
436
+ site,
437
+ async <Row>(spec: AggregateSpec<Row>) => aggregateRows(context, run, appRecordPredicate(context, capability, 'rows'), shape, spec),
438
+ ),
439
+ })
440
+ }
441
+
442
+ /** `ctx.db.app` of a scoped site: every application table, each read only under its read action (`scopedApplicationRead`). */
443
+ function scopedApplicationTables(parts: DataLayerParts, site: DataSite, run: Runner, capability: unknown): object {
444
+ const handle: Record<string, unknown> = {}
445
+ const tables = ownKeys(parts.models)
446
+ for (let index = 0; index < tables.length; index++) {
447
+ const model = modelOf(parts.models, tables[index] ?? '')
448
+ if (model?.scope.mode === 'app') {
449
+ setOwn(handle, model.name, scopedApplicationRows(parts.contextOf(model.name), site, run, capability))
450
+ }
451
+ }
452
+ return freeze(handle)
453
+ }
454
+
455
+ /** An application table read from a scoped site: listed, counted, aggregated and read by key, every row, writing nothing. */
456
+ function scopedApplicationRows(context: TableContext, site: DataSite, run: Runner, capability: unknown): object {
457
+ const model = context.model
458
+ const visible = model.visible
459
+ const key = [keyOf(model)]
460
+ const scope = () => scopedApplicationRead(context, capability)
461
+ return freeze({
462
+ list: bound(site, async (options?: unknown) => listRows(context, run, scope(), visible, options, key)),
463
+ count: bound(site, async (options?: unknown) => countRows(context, run, scope(), visible, options)),
464
+ aggregate: bound(site, async <Row>(spec: AggregateSpec<Row>) => aggregateRows(context, run, scope(), context.shape, spec)),
465
+ get: bound(site, async (id: unknown) => {
466
+ scope()
467
+ return selectOne(context, run, appKeyPredicate(model, id), visible)
468
+ }),
469
+ })
470
+ }
471
+
472
+ /** An `app` capability whose action the application table names: it reads one row with the columns of its action. */
473
+ function namedApp(context: TableContext, capability: unknown): string {
474
+ const grant = runtimeOf(context.connection).capabilities.resolve(capability)
475
+ const table = context.model.name
476
+ if (grant.kind !== 'app') {
477
+ throw new CapabilityError('wrong_capability', `a read of ${table} here needs the capability of an app endpoint, got ${describeGrant(grant)}`)
478
+ }
479
+ if (!namesAction(context.model, grant.action)) {
480
+ throw new CapabilityError(
481
+ 'wrong_action',
482
+ `${table} does not name the action '${grant.action}': read it from an app endpoint whose action ${table} names`,
483
+ )
484
+ }
485
+ return grant.action
486
+ }
487
+
488
+ /**
489
+ * An application table under an `app` capability: listed, counted and aggregated with its read action; one row read
490
+ * (and locked) with any action it names; inserted, updated and removed with that operation's action.
491
+ */
492
+ function applicationRows(context: TableContext, site: DataSite, run: Runner, capability: unknown): object {
493
+ const model = context.model
494
+ const table = model.name
495
+ const visible = model.visible
496
+ const key = [keyOf(model)]
497
+ const getForUpdate = site.kind === 'job'
498
+ ? refused(site, 'read_only', 'a job writes inside ctx.transaction((tx) => …): use tx.db there')
499
+ : site.kind === 'query'
500
+ ? refused(site, 'read_only', 'getForUpdate locks the row, so it runs only in a mutation: declare the endpoint with r.mutation')
501
+ : bound(site, async (id: unknown) => {
502
+ const scope = appKeyPredicate(model, id)
503
+ const node = lockedSelectNode(table, visibleFor(model, namedApp(context, capability)), scope, model.dates)
504
+ const rows = await (await run.read()).run(node, { root: 'SelectQueryNode', table, joins: {}, scope, lock: 'update' })
505
+ const [row] = answerDays(table, model.dates, rows)
506
+ if (row === undefined) {
507
+ throw new NotFoundError(`${table} not found`)
508
+ }
509
+ return row
510
+ })
511
+ return freeze({
512
+ list: bound(site, async (options?: unknown) => listRows(context, run, appListPredicate(context, capability), visible, options, key)),
513
+ count: bound(site, async (options?: unknown) => countRows(context, run, appListPredicate(context, capability), visible, options)),
514
+ aggregate: bound(
515
+ site,
516
+ async <Row>(spec: AggregateSpec<Row>) => aggregateRows(context, run, appListPredicate(context, capability), context.shape, spec),
517
+ ),
518
+ get: bound(site, async (id: unknown) => {
519
+ const action = namedApp(context, capability)
520
+ return selectOne(context, run, appKeyPredicate(model, id), visibleFor(model, action))
521
+ }),
522
+ getForUpdate,
523
+ insert: writing(site, 'insert', async (values: unknown) => insertIntoApplication(context, run, capability, values)),
524
+ update: writing(site, 'update', async (id: unknown, values: unknown, ...options: unknown[]) => {
525
+ const grant = updatingApp(context, capability)
526
+ const changes = readValues(values, updatableBy(model, grant.action), 'update', model)
527
+ return updateRow(context, run, { scope: appKeyPredicate(model, id) }, changes, versionArgument(context, options))
528
+ }),
529
+ remove: writing(site, 'remove', async (id: unknown, ...options: unknown[]) => {
530
+ await removeRow(context, run, { scope: appRowPredicate(context, capability, id, 'delete') }, versionArgument(context, options))
531
+ }),
532
+ })
533
+ }
534
+
535
+ function referenceTables(parts: DataLayerParts, site: DataSite, run: Runner): object {
536
+ const handle: Record<string, unknown> = {}
537
+ const tables = ownKeys(parts.models)
538
+ for (let index = 0; index < tables.length; index++) {
539
+ const model = modelOf(parts.models, tables[index] ?? '')
540
+ if (model?.scope.mode === 'reference') {
541
+ const context = parts.contextOf(model.name)
542
+ setOwn(
543
+ handle,
544
+ model.name,
545
+ freeze({
546
+ list: bound(site, async (options?: unknown) => listRows(context, run, everything(), model.visible, options, model.visible)),
547
+ count: bound(site, async (options?: unknown) => countRows(context, run, everything(), model.visible, options)),
548
+ }),
549
+ )
550
+ }
551
+ }
552
+ return freeze(handle)
553
+ }
554
+
555
+ function unscopedTables(parts: DataLayerParts, site: DataSite, run: Runner): object | undefined {
556
+ const declared = ownKeys(site.unscoped)
557
+ if (declared.length === 0) {
558
+ return undefined
559
+ }
560
+ const handle: Record<string, unknown> = {}
561
+ for (let index = 0; index < declared.length; index++) {
562
+ const table = declared[index] ?? ''
563
+ const mode = site.unscoped[table]
564
+ if (table.startsWith(FRAMEWORK_PREFIX)) {
565
+ throw new QueryRefusedError('framework_table', `${table} is a framework table; unscoped reaches only the tables of src/db/tables.ts`)
566
+ }
567
+ if (modelOf(parts.models, table) === undefined) {
568
+ throw new QueryRefusedError('unknown_table', `unscoped declares ${table}, which is not a table of src/db/tables.ts`)
569
+ }
570
+ if (mode !== 'read' && mode !== 'write') {
571
+ throw new QueryRefusedError('undeclared_reach', `unscoped declares ${table} as ${String(mode)}; the modes are 'read' and 'write'`)
572
+ }
573
+ setOwn(handle, table, unscopedRows(parts.contextOf(table), site, run, mode))
574
+ }
575
+ return freeze(handle)
576
+ }
577
+
578
+ /** A table outside the site's scope, declared: its scope predicate is `true`, and in `'read'` it writes nothing. */
579
+ function unscopedRows(context: TableContext, site: DataSite, run: Runner, mode: UnscopedMode): object {
580
+ const model = context.model
581
+ const table = model.name
582
+ const visible = model.visible
583
+ const keyed = model.scope.mode === 'dimension' || model.scope.mode === 'child' || model.scope.mode === 'app'
584
+ const byKey = (id: unknown): OperationNode => {
585
+ if (!keyed) {
586
+ throw new QueryRefusedError('row_address', `${table} has no key; read it with list`)
587
+ }
588
+ if (typeof id !== 'string' || id.length === 0) {
589
+ throw new QueryRefusedError('row_address', `a ${table} row is addressed by its ${keyOf(model)}, a non-empty string`)
590
+ }
591
+ return equals(table, keyOf(model), id)
592
+ }
593
+ const write = <A extends unknown[], R>(operation: 'insert' | 'update' | 'remove', method: (...args: A) => Promise<R>) => {
594
+ if (mode === 'read') {
595
+ const verb = operation === 'remove' ? 'remove' : operation
596
+ return refused(site, 'undeclared_reach', `${table} is declared unscoped: 'read', so ${verb} needs unscoped: { ${table}: 'write' }`)
597
+ }
598
+ return writing(site, operation, method)
599
+ }
600
+ return freeze({
601
+ list: bound(site, async (options?: unknown) => listRows(context, run, everything(), visible, options, keyed ? [keyOf(model)] : visible)),
602
+ count: bound(site, async (options?: unknown) => countRows(context, run, everything(), visible, options)),
603
+ aggregate: bound(site, async <Row>(spec: AggregateSpec<Row>) => aggregateRows(context, run, everything(), context.shape, spec)),
604
+ get: bound(site, async (id: unknown) => {
605
+ const scope = everything()
606
+ const node = selectNode(rowState(table, [], [], 1), visible, scope, byKey(id), model.dates)
607
+ const rows = await (await run.read()).run(node, { root: 'SelectQueryNode', table, joins: {}, scope })
608
+ const [row] = answerDays(table, model.dates, rows)
609
+ if (row === undefined) {
610
+ throw new NotFoundError(`${table} not found`)
611
+ }
612
+ return row
613
+ }),
614
+ insert: write('insert', async (values: unknown) => insertUnscoped(context, run, site.principal, values)),
615
+ update: write('update', async (id: unknown, values: unknown, ...options: unknown[]) => {
616
+ const changes = readValues(values, model.update, 'update', model)
617
+ return updateRow(context, run, { scope: everything(), conditions: byKey(id) }, changes, versionArgument(context, options))
618
+ }),
619
+ remove: write('remove', async (id: unknown, ...options: unknown[]) => {
620
+ await removeRow(context, run, { scope: everything(), conditions: byKey(id) }, versionArgument(context, options))
621
+ }),
622
+ })
623
+ }
624
+
625
+ /**
626
+ * No capability stamps an unscoped row's containment, so the caller names every containment column; the key, the
627
+ * version and the creator stay the layer's.
628
+ */
629
+ async function insertUnscoped(context: TableContext, run: Runner, principal: string, values: unknown): Promise<unknown> {
630
+ const model = context.model
631
+ if (model.scope.mode !== 'dimension' && model.scope.mode !== 'child' && model.scope.mode !== 'app') {
632
+ throw new QueryRefusedError('read_only', `${model.name} is a ${model.scope.mode} table, which no handler writes`)
633
+ }
634
+ const chain: string[] = []
635
+ for (let index = 0; index < model.chain.length; index++) {
636
+ chain[chain.length] = model.chain[index]?.column ?? ''
637
+ }
638
+ const allowed = nameSet([...namesOf(model.insert), ...chain])
639
+ const row = readValues(values, allowed, 'insert', model)
640
+ for (let index = 0; index < chain.length; index++) {
641
+ const name = chain[index] ?? ''
642
+ const value = row.get(name)
643
+ if (typeof value !== 'string' || value.length === 0) {
644
+ throw new QueryRefusedError(
645
+ 'invalid_argument',
646
+ `an unscoped insert into ${model.name} names its containment: ${name} (containment columns: ${chain.join(', ')})`,
647
+ )
648
+ }
649
+ }
650
+ row.set(keyOf(model), Bun.randomUUIDv7())
651
+ if (model.version !== undefined) {
652
+ row.set(model.version, 1)
653
+ }
654
+ if (model.createdBy !== undefined) {
655
+ row.set(model.createdBy, principal)
656
+ }
657
+ return insertRow(context, run, row, undefined)
658
+ }
659
+
660
+ // ── Lists: keyset pages over the visible columns ──────────────────────────────────────────────────────────────────────
661
+
662
+ interface OrderColumn {
663
+ readonly column: string
664
+ readonly direction: 'asc' | 'desc'
665
+ }
666
+
667
+ /** The option object a list or a count takes, read through own data properties only; an unknown field is refused. */
668
+ function readOptions(options: unknown, allowed: readonly string[], what: string): Readonly<Record<string, unknown>> {
669
+ if (options === undefined) {
670
+ return {}
671
+ }
672
+ if (typeof options !== 'object' || options === null) {
673
+ throw new QueryRefusedError('invalid_argument', `${what} takes { ${allowed.join(', ')} }`)
674
+ }
675
+ const fields = nameSet(allowed)
676
+ const keys = ownNames(options)
677
+ const read: Record<string, unknown> = {}
678
+ for (let index = 0; index < keys.length; index++) {
679
+ const key = keys[index] ?? ''
680
+ const descriptor = ownDescriptor(options, key)
681
+ if (!inSet(fields, key)) {
682
+ throw new QueryRefusedError('invalid_argument', `${key} is not an option of ${what} (options: ${allowed.join(', ')})`)
683
+ }
684
+ if (descriptor === undefined || !hasOwn(descriptor, 'value')) {
685
+ throw new QueryRefusedError('invalid_argument', `${what}.${key} is an accessor; ${what} takes plain values`)
686
+ }
687
+ setOwn(read, key, descriptor.value)
688
+ }
689
+ return read
690
+ }
691
+
692
+ function conditionsOf(shape: TableShape, where: unknown): (Compare | Contains)[] {
693
+ if (where === undefined) {
694
+ return []
695
+ }
696
+ const conditions = parseList(where, (condition) => {
697
+ const parts = parseList(condition, (part) => part)
698
+ if (parts === undefined || parts.length !== 3) {
699
+ throw new QueryRefusedError('invalid_argument', 'where takes [column, operator, value] conditions, each a plain array')
700
+ }
701
+ return parseCondition(shape, parts[0], parts[1], parts[2])
702
+ })
703
+ if (conditions === undefined) {
704
+ throw new QueryRefusedError('invalid_argument', 'where takes a plain array of [column, operator, value] conditions')
705
+ }
706
+ return conditions
707
+ }
708
+
709
+ function orderOf(shape: TableShape, orderBy: unknown, tiebreak: readonly string[]): OrderColumn[] {
710
+ const terms = orderBy === undefined ? [] : parseList(orderBy, (term): OrderColumn => {
711
+ const parts = parseList(term, (part) => part)
712
+ const direction = parts?.[1]
713
+ if (parts === undefined || parts.length !== 2 || (direction !== 'asc' && direction !== 'desc')) {
714
+ throw new QueryRefusedError('list_page', "orderBy takes [column, 'asc' | 'desc'] terms, each a plain array")
715
+ }
716
+ return { column: columnOf(shape, parts[0]), direction }
717
+ })
718
+ if (terms === undefined) {
719
+ throw new QueryRefusedError('list_page', "orderBy takes a plain array of [column, 'asc' | 'desc'] terms")
720
+ }
721
+ const order = [...terms]
722
+ const named = nameSet(terms.map((term) => term.column))
723
+ for (let index = 0; index < tiebreak.length; index++) {
724
+ // The tiebreak is held to the capability's columns like the caller's terms: a page never orders by a hidden one.
725
+ const name = columnOf(shape, tiebreak[index])
726
+ if (!inSet(named, name)) {
727
+ order[order.length] = { column: name, direction: 'asc' }
728
+ }
729
+ }
730
+ return order
731
+ }
732
+
733
+ function limitOf(limit: unknown): number {
734
+ if (limit === undefined) {
735
+ return DEFAULT_LIST_LIMIT
736
+ }
737
+ if (typeof limit !== 'number' || !isSafeInteger(limit) || limit < 1 || limit > MAX_LIST_LIMIT) {
738
+ throw new QueryRefusedError('list_page', `limit must be an integer from 1 to ${MAX_LIST_LIMIT}`)
739
+ }
740
+ return limit
741
+ }
742
+
743
+ function rowState(table: string, conditions: readonly (Compare | Contains)[], order: readonly OrderColumn[], limit: number | undefined): SelectState {
744
+ return { origin: freeze({}), table, columns: undefined, conditions, orderBy: order, limit, offset: undefined, joins: [] }
745
+ }
746
+
747
+ /** An order column's value as the database prints it: exact, where a `Date` would drop microseconds. */
748
+ type CursorValue = string | null
749
+
750
+ /** A page's position: the text of its last row's order columns (`keyset.c<n>`), as opaque text. */
751
+ function encodeCursor(row: Readonly<Record<string, unknown>>, order: readonly OrderColumn[]): string {
752
+ const values: CursorValue[] = order.map(({ column: name }, index) => {
753
+ const value = hasOwn(row, cursorAlias(index)) ? row[cursorAlias(index)] : null
754
+ if (typeof value === 'string' || value === null) {
755
+ return value
756
+ }
757
+ throw new QueryRefusedError('list_page', `${name} came back as ${typeof value} text, which a list cannot page by`)
758
+ })
759
+ return Buffer.from(JSON.stringify(values)).toString('base64url')
760
+ }
761
+
762
+ /** A row as the caller sees it: the position columns stay the layer's. */
763
+ function withoutCursor(row: Readonly<Record<string, unknown>>, order: readonly OrderColumn[]): Readonly<Record<string, unknown>> {
764
+ const item: Record<string, unknown> = {}
765
+ const keys = ownKeys(row)
766
+ const cursor = nameSet(order.map((_term, index) => cursorAlias(index)))
767
+ for (let index = 0; index < keys.length; index++) {
768
+ const key = keys[index] ?? ''
769
+ if (!inSet(cursor, key)) {
770
+ setOwn(item, key, row[key])
771
+ }
772
+ }
773
+ return item
774
+ }
775
+
776
+ /** The cursor `after` names, parsed at the boundary: one plain value per order column. */
777
+ function decodeCursor(after: unknown, order: readonly OrderColumn[]): readonly CursorValue[] {
778
+ const refused = () => new QueryRefusedError('list_page', 'after takes the next cursor of a page of this same list')
779
+ if (typeof after !== 'string' || after.length === 0) {
780
+ throw refused()
781
+ }
782
+ let parsed: unknown
783
+ try {
784
+ parsed = JSON.parse(Buffer.from(after, 'base64url').toString('utf8'))
785
+ } catch {
786
+ throw refused()
787
+ }
788
+ const values = parseList(parsed, (value): CursorValue => {
789
+ if (typeof value === 'string' || value === null) {
790
+ return value
791
+ }
792
+ throw refused()
793
+ })
794
+ if (values === undefined || values.length !== order.length) {
795
+ throw refused()
796
+ }
797
+ return values
798
+ }
799
+
800
+ function compare(table: string, name: string, operator: '>' | '<' | '=', value: Primitive): OperationNode {
801
+ return BinaryOperationNode.create(column(table, name), OperatorNode.create(operator), ValueNode.create(value))
802
+ }
803
+
804
+ function isNull(table: string, name: string, negated: boolean): OperationNode {
805
+ return BinaryOperationNode.create(column(table, name), OperatorNode.create(negated ? 'is not' : 'is'), ValueNode.createImmediate(null))
806
+ }
807
+
808
+ /** Rows after `value` in one order column, where Postgres sorts nulls last ascending and first descending. */
809
+ function after(table: string, order: OrderColumn, value: CursorValue): OperationNode | undefined {
810
+ if (order.direction === 'asc') {
811
+ return value === null ? undefined : anyOf([compare(table, order.column, '>', value), isNull(table, order.column, false)])
812
+ }
813
+ return value === null ? isNull(table, order.column, true) : compare(table, order.column, '<', value)
814
+ }
815
+
816
+ /** `(c1 after v1) or (c1 = v1 and c2 after v2) or …`: every row the order puts after the cursor's. */
817
+ function keysetAfter(table: string, order: readonly OrderColumn[], values: readonly CursorValue[]): OperationNode {
818
+ const branches: OperationNode[] = []
819
+ for (let index = 0; index < order.length; index++) {
820
+ const term = order[index]
821
+ const position = term === undefined ? undefined : after(table, term, values[index] ?? null)
822
+ if (term === undefined || position === undefined) {
823
+ continue
824
+ }
825
+ const equal: OperationNode[] = []
826
+ for (let previous = 0; previous < index; previous++) {
827
+ const name = order[previous]?.column ?? ''
828
+ const value = values[previous] ?? null
829
+ equal[equal.length] = value === null ? isNull(table, name, false) : compare(table, name, '=', value)
830
+ }
831
+ branches[branches.length] = equal.length === 0 ? position : allOf([...equal, position])
832
+ }
833
+ return branches.length === 0 ? ValueNode.createImmediate(false) : anyOf(branches)
834
+ }
835
+
836
+ /** One keyset page: `limit` rows in the order asked, the tiebreak columns last, and the cursor of the next page. */
837
+ async function listRows(
838
+ context: TableContext,
839
+ run: Runner,
840
+ scope: OperationNode | undefined,
841
+ visible: readonly string[],
842
+ input: unknown,
843
+ tiebreak: readonly string[],
844
+ ): Promise<{ readonly items: Readonly<Record<string, unknown>>[]; readonly next: string | null }> {
845
+ const table = context.model.name
846
+ const options = readOptions(input, ['where', 'orderBy', 'limit', 'after'], 'list')
847
+ const shape = actionShape(context.model, visible)
848
+ const conditions = conditionsOf(shape, options['where'])
849
+ const order = orderOf(shape, options['orderBy'], tiebreak)
850
+ const limit = limitOf(options['limit'])
851
+ const position = options['after'] === undefined ? undefined : keysetAfter(table, order, decodeCursor(options['after'], order))
852
+ if (scope === undefined) {
853
+ return { items: [], next: null }
854
+ }
855
+ const node = withCursorColumns(
856
+ selectNode(rowState(table, conditions, order, limit + 1), visible, scope, position, context.model.dates),
857
+ table,
858
+ order.map((term) => term.column),
859
+ )
860
+ const rows = await (await run.read()).run<Readonly<Record<string, unknown>>>(node, {
861
+ root: 'SelectQueryNode',
862
+ table,
863
+ joins: {},
864
+ scope,
865
+ })
866
+ const page = answerDays(table, context.model.dates, rows.slice(0, limit))
867
+ const last = page[page.length - 1]
868
+ return {
869
+ items: page.map((row) => withoutCursor(row, order)),
870
+ next: rows.length > limit && last !== undefined ? encodeCursor(last, order) : null,
871
+ }
872
+ }
873
+
874
+ async function countRows(
875
+ context: TableContext,
876
+ run: Runner,
877
+ scope: OperationNode | undefined,
878
+ visible: readonly string[],
879
+ input: unknown,
880
+ ): Promise<number> {
881
+ const table = context.model.name
882
+ const options = readOptions(input, ['where'], 'count')
883
+ const conditions = conditionsOf(actionShape(context.model, visible), options['where'])
884
+ if (scope === undefined) {
885
+ return 0
886
+ }
887
+ const node = aggregateNode(table, { groupBy: [], count: true, sum: [], min: [], max: [] }, conditions, scope)
888
+ const [row] = await (await run.read()).run<Readonly<Record<string, unknown>>>(node, { root: 'SelectQueryNode', table, joins: {}, scope })
889
+ const count = row === undefined ? '0' : row['count']
890
+ return typeof count === 'string' || typeof count === 'number' ? Number(count) : 0
891
+ }