@fabrika/db 0.0.0-stage → 0.4.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/package.json +69 -5
- package/src/builtins.ts +94 -0
- package/src/capability.ts +93 -0
- package/src/codegen.ts +302 -0
- package/src/database.ts +413 -0
- package/src/description.ts +309 -0
- package/src/errors.ts +124 -0
- package/src/file-table-sql.ts +133 -0
- package/src/file-tables.ts +577 -0
- package/src/form-admissions.ts +82 -0
- package/src/framework-tables.ts +147 -0
- package/src/framework.ts +89 -0
- package/src/guard.ts +439 -0
- package/src/identifier.ts +43 -0
- package/src/index.ts +129 -0
- package/src/job-leases.ts +373 -0
- package/src/links.ts +61 -0
- package/src/migrations.ts +97 -0
- package/src/nodes.ts +344 -0
- package/src/orm-types.ts +596 -0
- package/src/orm.ts +891 -0
- package/src/policy.ts +1194 -0
- package/src/production.ts +26 -0
- package/src/registry.ts +57 -0
- package/src/sql.ts +732 -0
- package/src/store.ts +1693 -0
- package/src/tables.ts +167 -0
- package/src/temporal.ts +91 -0
- package/src/testing.ts +695 -0
- package/src/vocabulary-tables.ts +206 -0
- package/README.md +0 -3
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
|
+
}
|