@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/store.ts
ADDED
|
@@ -0,0 +1,1693 @@
|
|
|
1
|
+
import { BinaryOperationNode, type OperationNode, OperatorNode, type ReturningNode, ValueNode } from 'kysely'
|
|
2
|
+
import {
|
|
3
|
+
freeze,
|
|
4
|
+
hasOwn,
|
|
5
|
+
inSet,
|
|
6
|
+
isArray,
|
|
7
|
+
isSafeInteger,
|
|
8
|
+
type NameSet,
|
|
9
|
+
nameSet,
|
|
10
|
+
namesOf,
|
|
11
|
+
OBJECT_PROTOTYPE,
|
|
12
|
+
ownDescriptor,
|
|
13
|
+
ownEntry,
|
|
14
|
+
ownKeys,
|
|
15
|
+
ownNames,
|
|
16
|
+
ownSymbols,
|
|
17
|
+
parseList,
|
|
18
|
+
prototypeOf,
|
|
19
|
+
setOwn,
|
|
20
|
+
} from './builtins'
|
|
21
|
+
import {
|
|
22
|
+
type AppGrant,
|
|
23
|
+
type CapabilityAuthority,
|
|
24
|
+
type CapabilityGrant,
|
|
25
|
+
describeGrant,
|
|
26
|
+
MAX_WITHIN_VALUES,
|
|
27
|
+
type Ref,
|
|
28
|
+
type RefGrant,
|
|
29
|
+
type Within,
|
|
30
|
+
type WithinGrant,
|
|
31
|
+
} from './capability'
|
|
32
|
+
import { type Database, InvocationTransaction, type Session, sessionOf } from './database'
|
|
33
|
+
import {
|
|
34
|
+
finishSelect,
|
|
35
|
+
isPrimitive,
|
|
36
|
+
type JoinShape,
|
|
37
|
+
type NoColumns,
|
|
38
|
+
type Primitive,
|
|
39
|
+
type ScopedSelect,
|
|
40
|
+
type SelectState,
|
|
41
|
+
startSelect,
|
|
42
|
+
type TableShape,
|
|
43
|
+
} from './description'
|
|
44
|
+
import { CapabilityError, ConflictError, NotFoundError, PolicyError, QueryRefusedError } from './errors'
|
|
45
|
+
import { fileTables } from './file-tables'
|
|
46
|
+
import { formAdmissions, type InboxQuota, takeFromBucket } from './form-admissions'
|
|
47
|
+
import { webhookTables } from './framework-tables'
|
|
48
|
+
import type { ForeignKeyPair, GuardContext, RootKind } from './guard'
|
|
49
|
+
import { fencedTransaction, jobLeases } from './job-leases'
|
|
50
|
+
import { linksPredicate, linkValues } from './links'
|
|
51
|
+
import {
|
|
52
|
+
acknowledgement,
|
|
53
|
+
acknowledgementNode,
|
|
54
|
+
type AggregateColumns,
|
|
55
|
+
aggregateNode,
|
|
56
|
+
allOf,
|
|
57
|
+
anyOf,
|
|
58
|
+
column,
|
|
59
|
+
deleteNode,
|
|
60
|
+
equals,
|
|
61
|
+
everything,
|
|
62
|
+
existsNode,
|
|
63
|
+
inList,
|
|
64
|
+
insertNode,
|
|
65
|
+
locateNode,
|
|
66
|
+
lockNode,
|
|
67
|
+
scopedWhere,
|
|
68
|
+
selectNode,
|
|
69
|
+
updateNode,
|
|
70
|
+
} from './nodes'
|
|
71
|
+
import { dataAccess } from './orm'
|
|
72
|
+
import {
|
|
73
|
+
type Acknowledgement,
|
|
74
|
+
type ActionOf,
|
|
75
|
+
type ChildTableName,
|
|
76
|
+
type Container,
|
|
77
|
+
type ContainerOf,
|
|
78
|
+
type DimensionOf,
|
|
79
|
+
type DimensionTableName,
|
|
80
|
+
type InsertValues,
|
|
81
|
+
type JoinableChildren,
|
|
82
|
+
type ListDimensionOf,
|
|
83
|
+
modelOf,
|
|
84
|
+
type MoveAcknowledgement,
|
|
85
|
+
namesAction,
|
|
86
|
+
type Operation,
|
|
87
|
+
type ParentOf,
|
|
88
|
+
type PolicyRow,
|
|
89
|
+
type TableModel,
|
|
90
|
+
type TableModels,
|
|
91
|
+
tableModels,
|
|
92
|
+
type Tables,
|
|
93
|
+
updateActions,
|
|
94
|
+
type UpdateValues,
|
|
95
|
+
type VersionArguments,
|
|
96
|
+
visibleFor,
|
|
97
|
+
} from './policy'
|
|
98
|
+
import { frameworkSideOf, type Locator, register } from './registry'
|
|
99
|
+
import type { ScopeTerms, ScopeTest, ScopeUse } from './sql'
|
|
100
|
+
import { type TablePolicies, tablePolicies } from './tables'
|
|
101
|
+
import { answerDays, dayInput, timestampInput } from './temporal'
|
|
102
|
+
|
|
103
|
+
export interface DataLayerRuntime {
|
|
104
|
+
readonly database: Database
|
|
105
|
+
readonly capabilities: CapabilityAuthority
|
|
106
|
+
/** Whether the request running now may only read (an export's); its every statement then runs `READ ONLY`. */
|
|
107
|
+
readonly readOnly?: () => boolean
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Empty until the framework connects the layer at boot; every table handle of one `scoped` call shares it. */
|
|
111
|
+
export interface Connection {
|
|
112
|
+
runtime: DataLayerRuntime | undefined
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface TableContext {
|
|
116
|
+
readonly model: TableModel
|
|
117
|
+
readonly parent: TableModel | undefined
|
|
118
|
+
/** The table that owns the dimension an insert or a move targets: the container's table, or a child's parent. */
|
|
119
|
+
readonly destination: TableModel | undefined
|
|
120
|
+
readonly shape: TableShape
|
|
121
|
+
readonly joins: Readonly<Record<string, readonly ForeignKeyPair[]>>
|
|
122
|
+
readonly connection: Connection
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function runtimeOf(connection: Connection): DataLayerRuntime {
|
|
126
|
+
const runtime = connection.runtime
|
|
127
|
+
if (runtime === undefined) {
|
|
128
|
+
throw new CapabilityError(
|
|
129
|
+
'layer_unbound',
|
|
130
|
+
'the data layer is not connected: the framework connects it at boot, so a store runs inside a handler, never while a module loads',
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
return runtime
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface DimensionScopeModel {
|
|
137
|
+
readonly dimension: string
|
|
138
|
+
readonly key: string
|
|
139
|
+
readonly container: Container | undefined
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function dimensionScope(model: TableModel): DimensionScopeModel {
|
|
143
|
+
if (model.scope.mode !== 'dimension') {
|
|
144
|
+
throw new CapabilityError('table_kind', `${model.name} is not a dimension table`)
|
|
145
|
+
}
|
|
146
|
+
return model.scope
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function childScope(context: TableContext): { readonly key: string; readonly column: string; readonly parent: DimensionScopeModel } {
|
|
150
|
+
const scope = context.model.scope
|
|
151
|
+
if (scope.mode !== 'child' || context.parent === undefined) {
|
|
152
|
+
throw new CapabilityError('table_kind', `${context.model.name} is not a child table`)
|
|
153
|
+
}
|
|
154
|
+
return { key: scope.key, column: scope.column, parent: dimensionScope(context.parent) }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The dimension that lists and inserts are scoped by: the container, or for a top-level table its own. */
|
|
158
|
+
export function listDimension(scope: DimensionScopeModel): string {
|
|
159
|
+
return scope.container?.dimension ?? scope.dimension
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function requiredAction(context: TableContext, operation: Operation): string {
|
|
163
|
+
const action = context.model.actions[operation]
|
|
164
|
+
if (action === undefined) {
|
|
165
|
+
throw new CapabilityError('undeclared_operation', `${context.model.name} allows no ${operation}; its policy declares no ${operation} action`)
|
|
166
|
+
}
|
|
167
|
+
return action
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function checkGrant(context: TableContext, grant: RefGrant | WithinGrant, dimension: string, operation: Operation): void {
|
|
171
|
+
const action = requiredAction(context, operation)
|
|
172
|
+
if (grant.dimension !== dimension) {
|
|
173
|
+
throw new CapabilityError(
|
|
174
|
+
'wrong_capability',
|
|
175
|
+
`${operation} on ${context.model.name} needs a ${grant.kind} of ${dimension}, got a ${grant.kind} of ${grant.dimension}`,
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
if (grant.action !== action) {
|
|
179
|
+
throw new CapabilityError('wrong_action', `${operation} on ${context.model.name} requires ${action}; the capability holds ${grant.action}`)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function refGrant(context: TableContext, capability: unknown, dimension: string, operation: Operation): RefGrant {
|
|
184
|
+
const grant = runtimeOf(context.connection).capabilities.resolve(capability)
|
|
185
|
+
if (grant.kind !== 'ref') {
|
|
186
|
+
throw new CapabilityError('wrong_capability', `${operation} on ${context.model.name} needs a Ref of ${dimension}, got ${describeGrant(grant)}`)
|
|
187
|
+
}
|
|
188
|
+
checkGrant(context, grant, dimension, operation)
|
|
189
|
+
return grant
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function withinGrant(context: TableContext, capability: unknown, dimension: string, operation: Operation): WithinGrant {
|
|
193
|
+
const grant = runtimeOf(context.connection).capabilities.resolve(capability)
|
|
194
|
+
if (grant.kind !== 'within') {
|
|
195
|
+
throw new CapabilityError('wrong_capability', `${operation} on ${context.model.name} needs a Within of ${dimension}, got ${describeGrant(grant)}`)
|
|
196
|
+
}
|
|
197
|
+
checkGrant(context, grant, dimension, operation)
|
|
198
|
+
return grant
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The chain links that lie above the `Within`'s own dimension: the only columns a container grant may filter on. */
|
|
202
|
+
function linksAbove(model: TableModel, dimension: string): readonly Container[] {
|
|
203
|
+
for (let index = 0; index < model.chain.length; index++) {
|
|
204
|
+
if (model.chain[index]?.dimension === dimension) {
|
|
205
|
+
const above: Container[] = []
|
|
206
|
+
for (let position = index + 1; position < model.chain.length; position++) {
|
|
207
|
+
const link = model.chain[position]
|
|
208
|
+
if (link !== undefined) {
|
|
209
|
+
above[above.length] = link
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return above
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return []
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* `column in (values) or <container column> in (container values) …`, one disjunct per container dimension, so
|
|
220
|
+
* the statement itself judges each row by the chain it carries now. Undefined for an empty `Within`: the caller
|
|
221
|
+
* answers `[]` without a query.
|
|
222
|
+
*/
|
|
223
|
+
function withinPredicate(model: TableModel, column: string, grant: WithinGrant): OperationNode | undefined {
|
|
224
|
+
if (grant.values === 'all') {
|
|
225
|
+
return everything()
|
|
226
|
+
}
|
|
227
|
+
const above = linksAbove(model, grant.dimension)
|
|
228
|
+
const predicates: OperationNode[] = []
|
|
229
|
+
let count = grant.values.length
|
|
230
|
+
if (grant.values.length > 0) {
|
|
231
|
+
predicates[predicates.length] = inList(model.name, column, grant.values)
|
|
232
|
+
}
|
|
233
|
+
for (let index = 0; index < grant.containers.length; index++) {
|
|
234
|
+
const container = grant.containers[index]
|
|
235
|
+
if (container === undefined) {
|
|
236
|
+
continue
|
|
237
|
+
}
|
|
238
|
+
let link: Container | undefined
|
|
239
|
+
for (let position = 0; position < above.length; position++) {
|
|
240
|
+
if (above[position]?.dimension === container.dimension) {
|
|
241
|
+
link = above[position]
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (link === undefined) {
|
|
245
|
+
throw new CapabilityError(
|
|
246
|
+
'unenforceable',
|
|
247
|
+
`the Within of ${grant.dimension} reaches through ${container.dimension}, which ${model.name} carries no column for above ${grant.dimension}; the layer cannot enforce it`,
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
count += container.values.length
|
|
251
|
+
if (container.values.length > 0) {
|
|
252
|
+
predicates[predicates.length] = inList(model.name, link.column, container.values)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (count > MAX_WITHIN_VALUES) {
|
|
256
|
+
throw new CapabilityError('within_size', `a Within carries at most ${MAX_WITHIN_VALUES} scope values; this one carries ${count}`)
|
|
257
|
+
}
|
|
258
|
+
return predicates.length === 0 ? undefined : anyOf(predicates)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The object a Ref of the model's own dimension names, with every link of its chain. */
|
|
262
|
+
export function selfLinks(model: TableModel): readonly Container[] {
|
|
263
|
+
const scope = dimensionScope(model)
|
|
264
|
+
return [{ dimension: scope.dimension, column: scope.key }, ...model.chain]
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** This object, over the whole chain the Ref carries. */
|
|
268
|
+
export function objectPredicate(context: TableContext, capability: unknown, operation: Operation): OperationNode {
|
|
269
|
+
const scope = dimensionScope(context.model)
|
|
270
|
+
const grant = refGrant(context, capability, scope.dimension, operation)
|
|
271
|
+
return linksPredicate(context.model.name, linkValues(context.model.name, selfLinks(context.model), grant))
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Rows of a dimension table within the scope values of its list dimension. */
|
|
275
|
+
export function dimensionListPredicate(context: TableContext, capability: unknown, operation: Operation): OperationNode | undefined {
|
|
276
|
+
const scope = dimensionScope(context.model)
|
|
277
|
+
const grant = withinGrant(context, capability, listDimension(scope), operation)
|
|
278
|
+
return withinPredicate(context.model, scope.container?.column ?? scope.key, grant)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Rows of a child table that belong to the parent object the Ref names, over the whole chain. */
|
|
282
|
+
export function parentPredicate(context: TableContext, grant: RefGrant): OperationNode {
|
|
283
|
+
return linksPredicate(context.model.name, linkValues(context.model.name, context.model.chain, grant))
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function childListPredicate(context: TableContext, capability: unknown, operation: Operation): OperationNode | undefined {
|
|
287
|
+
const scope = childScope(context)
|
|
288
|
+
const grant = runtimeOf(context.connection).capabilities.resolve(capability)
|
|
289
|
+
if (grant.kind === 'ref') {
|
|
290
|
+
checkGrant(context, grant, scope.parent.dimension, operation)
|
|
291
|
+
return parentPredicate(context, grant)
|
|
292
|
+
}
|
|
293
|
+
if (grant.kind === 'app') {
|
|
294
|
+
throw new CapabilityError('wrong_capability', `a list of ${context.model.name} by its parent needs a Ref or a Within, got an app capability`)
|
|
295
|
+
}
|
|
296
|
+
checkGrant(context, grant, listDimension(scope.parent), operation)
|
|
297
|
+
return withinPredicate(context.model, scope.parent.container?.column ?? scope.column, grant)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The column that places a row of `model` in `dimension`: the column of that link of its containment chain, or a
|
|
302
|
+
* top-level table's own key; `undefined` when no `Within` of the dimension reaches the table.
|
|
303
|
+
*/
|
|
304
|
+
export function reachColumn(model: TableModel, dimension: string): string | undefined {
|
|
305
|
+
if (model.scope.mode === 'dimension' && model.scope.container === undefined && model.scope.dimension === dimension) {
|
|
306
|
+
return model.scope.key
|
|
307
|
+
}
|
|
308
|
+
for (let index = 0; index < model.chain.length; index++) {
|
|
309
|
+
const link = model.chain[index]
|
|
310
|
+
if (link?.dimension === dimension) {
|
|
311
|
+
return link.column
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return undefined
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Rows of a table a `Within` of any dimension of its chain lists, by the table's read action: tasks under a `Within` of
|
|
319
|
+
* `org` through their stamped `org_id`, as under one of `project` through `project_id`.
|
|
320
|
+
*/
|
|
321
|
+
export function reachPredicate(context: TableContext, capability: unknown): OperationNode | undefined {
|
|
322
|
+
const grant = runtimeOf(context.connection).capabilities.resolve(capability)
|
|
323
|
+
const table = context.model.name
|
|
324
|
+
if (grant.kind !== 'within') {
|
|
325
|
+
throw new CapabilityError('wrong_capability', `a list of ${table} needs a Within, got ${describeGrant(grant)}`)
|
|
326
|
+
}
|
|
327
|
+
const action = requiredAction(context, 'read')
|
|
328
|
+
if (grant.action !== action) {
|
|
329
|
+
throw new CapabilityError('wrong_action', `read on ${table} requires ${action}; the capability holds ${grant.action}`)
|
|
330
|
+
}
|
|
331
|
+
const column = reachColumn(context.model, grant.dimension)
|
|
332
|
+
if (column === undefined) {
|
|
333
|
+
throw new CapabilityError(
|
|
334
|
+
'out_of_reach',
|
|
335
|
+
`a Within of ${grant.dimension} does not reach ${table}: its containment chain does not hold ${grant.dimension}`,
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
return withinPredicate(context.model, column, grant)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* A fragment on an UPDATE or DELETE target: the capability's action is that operation's, and an application table is
|
|
343
|
+
* written by an `app` capability only, a dimension or child table never by one.
|
|
344
|
+
*/
|
|
345
|
+
function checkWriteTarget(context: TableContext, grant: CapabilityGrant, use: 'update' | 'delete'): void {
|
|
346
|
+
const model = context.model
|
|
347
|
+
const statement = `${use === 'update' ? 'UPDATE' : 'DELETE'} of ${model.name}`
|
|
348
|
+
if (grant.kind === 'app' && model.scope.mode !== 'app') {
|
|
349
|
+
throw new CapabilityError('table_kind', `an app capability writes application tables only: ${statement} needs a Ref or a Within`)
|
|
350
|
+
}
|
|
351
|
+
if (grant.kind !== 'app' && model.scope.mode === 'app') {
|
|
352
|
+
throw new CapabilityError('table_kind', `a scoped site writes no application table: ${statement} needs the capability of an app endpoint`)
|
|
353
|
+
}
|
|
354
|
+
const action = model.actions[use]
|
|
355
|
+
if (action === undefined) {
|
|
356
|
+
throw new CapabilityError('undeclared_operation', `${statement}: its policy declares no ${use} action`)
|
|
357
|
+
}
|
|
358
|
+
if (grant.action !== action) {
|
|
359
|
+
throw new CapabilityError('wrong_action', `${statement} needs its ${use} action ${action}; the capability holds ${grant.action}`)
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* `ctx.sql.scope`'s predicate: what the handles of `capability` match in the table for `use`, as column tests. A
|
|
365
|
+
* fragment that reads (`'read'`) needs an action the handles read the table with: any action it names for a `Ref`,
|
|
366
|
+
* its read action for a `Within`, an action `ctx.db.app` lists it for with an `app` capability, and its read action
|
|
367
|
+
* for a scoped site's read of an application table. A fragment on the statement's UPDATE or DELETE target needs that
|
|
368
|
+
* operation's own action, and an `app` capability writes application tables only. A `Ref` matches its object's column
|
|
369
|
+
* and every link of the chain it carries; a `Within` matches like `ctx.db.within` (its values, each container grant on
|
|
370
|
+
* its own column, or every row for `all`); an `app` capability, and a scoped read of an application table, every row.
|
|
371
|
+
*/
|
|
372
|
+
export function scopeTerms(context: TableContext, capability: unknown, use: ScopeUse): ScopeTerms {
|
|
373
|
+
const grant = runtimeOf(context.connection).capabilities.resolve(capability)
|
|
374
|
+
const model = context.model
|
|
375
|
+
if (use !== 'read') {
|
|
376
|
+
checkWriteTarget(context, grant, use)
|
|
377
|
+
}
|
|
378
|
+
if (grant.kind === 'app') {
|
|
379
|
+
if (use === 'read') {
|
|
380
|
+
// The action is held as a global grant, so its scope is every row of a table it reaches.
|
|
381
|
+
checkAppReach(context, grant)
|
|
382
|
+
}
|
|
383
|
+
return 'all'
|
|
384
|
+
}
|
|
385
|
+
if (model.scope.mode === 'app') {
|
|
386
|
+
scopedApplicationRead(context, capability)
|
|
387
|
+
return 'all'
|
|
388
|
+
}
|
|
389
|
+
if (grant.kind === 'ref') {
|
|
390
|
+
const links = model.scope.mode === 'dimension' ? selfLinks(model) : model.chain
|
|
391
|
+
let start = -1
|
|
392
|
+
for (let index = 0; index < links.length && start === -1; index++) {
|
|
393
|
+
if (links[index]?.dimension === grant.dimension) start = index
|
|
394
|
+
}
|
|
395
|
+
if (start === -1) {
|
|
396
|
+
throw new CapabilityError(
|
|
397
|
+
'out_of_reach',
|
|
398
|
+
`a Ref of ${grant.dimension} does not reach ${model.name}: its containment chain does not hold ${grant.dimension}`,
|
|
399
|
+
)
|
|
400
|
+
}
|
|
401
|
+
if (use === 'read' && !namesAction(model, grant.action)) {
|
|
402
|
+
throw new CapabilityError('wrong_action', `${model.name} does not name the action ${grant.action}: a Ref reads it with an action it names`)
|
|
403
|
+
}
|
|
404
|
+
return [linkValues(model.name, links.slice(start), grant).map(({ link, value }) => ({ column: link.column, value }))]
|
|
405
|
+
}
|
|
406
|
+
const column = reachColumn(model, grant.dimension)
|
|
407
|
+
if (column === undefined) {
|
|
408
|
+
throw new CapabilityError(
|
|
409
|
+
'out_of_reach',
|
|
410
|
+
`a Within of ${grant.dimension} does not reach ${model.name}: its containment chain does not hold ${grant.dimension}`,
|
|
411
|
+
)
|
|
412
|
+
}
|
|
413
|
+
if (use === 'read' && grant.action !== model.actions.read) {
|
|
414
|
+
throw new CapabilityError(
|
|
415
|
+
'wrong_action',
|
|
416
|
+
`a Within of ${grant.action} does not read ${model.name}: it reads with its read action ${model.actions.read}`,
|
|
417
|
+
)
|
|
418
|
+
}
|
|
419
|
+
if (grant.values === 'all') {
|
|
420
|
+
return 'all'
|
|
421
|
+
}
|
|
422
|
+
const above = linksAbove(model, grant.dimension)
|
|
423
|
+
const groups: ScopeTest[][] = []
|
|
424
|
+
let count = grant.values.length
|
|
425
|
+
if (grant.values.length > 0) {
|
|
426
|
+
groups.push([{ column, value: grant.values }])
|
|
427
|
+
}
|
|
428
|
+
for (const container of grant.containers) {
|
|
429
|
+
const link = above.find((candidate) => candidate.dimension === container.dimension)
|
|
430
|
+
if (link === undefined) {
|
|
431
|
+
throw new CapabilityError(
|
|
432
|
+
'unenforceable',
|
|
433
|
+
`the Within of ${grant.dimension} reaches through ${container.dimension}, which ${model.name} carries no column for above ${grant.dimension}; the layer cannot enforce it`,
|
|
434
|
+
)
|
|
435
|
+
}
|
|
436
|
+
count += container.values.length
|
|
437
|
+
if (container.values.length > 0) {
|
|
438
|
+
groups.push([{ column: link.column, value: container.values }])
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if (count > MAX_WITHIN_VALUES) {
|
|
442
|
+
throw new CapabilityError('within_size', `a Within carries at most ${MAX_WITHIN_VALUES} scope values; this one carries ${count}`)
|
|
443
|
+
}
|
|
444
|
+
return groups
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* A `Ref` of `dimension` whose action updates columns of the table: its update action, or a column's own write action.
|
|
449
|
+
* Which columns it updates follows from the action (`updatableBy`).
|
|
450
|
+
*/
|
|
451
|
+
export function updatingRef(context: TableContext, capability: unknown, dimension: string): RefGrant {
|
|
452
|
+
const grant = runtimeOf(context.connection).capabilities.resolve(capability)
|
|
453
|
+
const table = context.model.name
|
|
454
|
+
if (grant.kind !== 'ref') {
|
|
455
|
+
throw new CapabilityError('wrong_capability', `update on ${table} needs a Ref of ${dimension}, got ${describeGrant(grant)}`)
|
|
456
|
+
}
|
|
457
|
+
if (grant.dimension !== dimension) {
|
|
458
|
+
throw new CapabilityError('wrong_capability', `update on ${table} needs a ref of ${dimension}, got a ref of ${grant.dimension}`)
|
|
459
|
+
}
|
|
460
|
+
const actions = updateActions(context.model)
|
|
461
|
+
if (actions.length === 0) {
|
|
462
|
+
throw new CapabilityError('undeclared_operation', `${table} allows no update; its policy declares no update action`)
|
|
463
|
+
}
|
|
464
|
+
for (let index = 0; index < actions.length; index++) {
|
|
465
|
+
if (actions[index] === grant.action) {
|
|
466
|
+
return grant
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
throw new CapabilityError('wrong_action', `update on ${table} requires ${actions.join(' or ')}; the capability holds ${grant.action}`)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* What an `app` capability reaches of a dimension or child table: its rows (`list`, a scope fragment) with the read
|
|
474
|
+
* action or an action of `appRead`; its counts (`count`, `aggregate`) with those or an action of `appCount`.
|
|
475
|
+
*/
|
|
476
|
+
export type AppReach = 'rows' | 'counts'
|
|
477
|
+
|
|
478
|
+
/** Refuses an `app` capability whose action does not reach `reach` of a dimension or child table. */
|
|
479
|
+
function checkRecordReach(context: TableContext, grant: AppGrant, reach: AppReach): void {
|
|
480
|
+
const model = context.model
|
|
481
|
+
const { read, appRead, appCount } = model.actions
|
|
482
|
+
if (grant.action === read || includesName(appRead, grant.action) || (reach === 'counts' && includesName(appCount, grant.action))) {
|
|
483
|
+
return
|
|
484
|
+
}
|
|
485
|
+
if (includesName(appCount, grant.action)) {
|
|
486
|
+
throw new CapabilityError(
|
|
487
|
+
'wrong_action',
|
|
488
|
+
`an app capability of ${grant.action} only counts ${model.name} (its appCount): it reads no row, so list, aggregate and a scope fragment need its read action ${read}${
|
|
489
|
+
appRead.length === 0 ? '' : ` or one of its appRead (${appRead.join(', ')})`
|
|
490
|
+
}`,
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
throw new CapabilityError(
|
|
494
|
+
'wrong_action',
|
|
495
|
+
`an app capability of ${grant.action} does not reach ${model.name}: ctx.db.app lists it for its read action ${read}${
|
|
496
|
+
appRead.length === 0 ? '' : ` or one of its appRead (${appRead.join(', ')})`
|
|
497
|
+
} only`,
|
|
498
|
+
)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function includesName(names: readonly string[], name: string): boolean {
|
|
502
|
+
for (let index = 0; index < names.length; index++) {
|
|
503
|
+
if (names[index] === name) {
|
|
504
|
+
return true
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return false
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Refuses a table `ctx.db.app` does not list for the capability (`applicationTables` in `orm.ts`): it lists an
|
|
512
|
+
* application table under any action the table names, and the rows of a dimension or child table under its read
|
|
513
|
+
* action or an action of its `appRead`.
|
|
514
|
+
*/
|
|
515
|
+
function checkAppReach(context: TableContext, grant: AppGrant): void {
|
|
516
|
+
const model = context.model
|
|
517
|
+
const mode = model.scope.mode
|
|
518
|
+
if (mode === 'app') {
|
|
519
|
+
if (!namesAction(model, grant.action)) {
|
|
520
|
+
throw new CapabilityError(
|
|
521
|
+
'wrong_action',
|
|
522
|
+
`an app capability of ${grant.action} does not reach ${model.name}: ${model.name} does not name the action`,
|
|
523
|
+
)
|
|
524
|
+
}
|
|
525
|
+
return
|
|
526
|
+
}
|
|
527
|
+
if (mode !== 'dimension' && mode !== 'child') {
|
|
528
|
+
throw new CapabilityError(
|
|
529
|
+
'table_kind',
|
|
530
|
+
`an app capability does not reach ${model.name}: ctx.db.app lists application, dimension and child tables only`,
|
|
531
|
+
)
|
|
532
|
+
}
|
|
533
|
+
checkRecordReach(context, grant, 'rows')
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Every row of a dimension or child table an `app` capability reads through `ctx.db.app`: `true`, for an action that
|
|
538
|
+
* reaches `reach` (`checkRecordReach`).
|
|
539
|
+
*/
|
|
540
|
+
export function appRecordPredicate(context: TableContext, capability: unknown, reach: AppReach): OperationNode {
|
|
541
|
+
const grant = runtimeOf(context.connection).capabilities.resolve(capability)
|
|
542
|
+
const model = context.model
|
|
543
|
+
if (grant.kind !== 'app') {
|
|
544
|
+
throw new CapabilityError(
|
|
545
|
+
'wrong_capability',
|
|
546
|
+
`a read of ${model.name} through ctx.db.app needs the capability of an app endpoint, got ${describeGrant(grant)}`,
|
|
547
|
+
)
|
|
548
|
+
}
|
|
549
|
+
if (model.scope.mode !== 'dimension' && model.scope.mode !== 'child') {
|
|
550
|
+
throw new CapabilityError('table_kind', `${model.name} is not a dimension or child table`)
|
|
551
|
+
}
|
|
552
|
+
checkRecordReach(context, grant, reach)
|
|
553
|
+
return everything()
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** An `app` capability addresses a row by its key, and writes, only in an application table. */
|
|
557
|
+
function checkApplicationTable(context: TableContext, operation: Operation): void {
|
|
558
|
+
if (context.model.scope.mode !== 'app') {
|
|
559
|
+
throw new CapabilityError(
|
|
560
|
+
'table_kind',
|
|
561
|
+
`${operation} of a ${context.model.name} row needs a Ref: an app capability addresses and writes application tables only`,
|
|
562
|
+
)
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* The capability of an `app` endpoint for `operation` on `table`: its action is that operation's. An application table
|
|
568
|
+
* is written with it, and a dimension or child table read with it.
|
|
569
|
+
*/
|
|
570
|
+
function appGrant(context: TableContext, capability: unknown, operation: Operation): AppGrant {
|
|
571
|
+
const grant = runtimeOf(context.connection).capabilities.resolve(capability)
|
|
572
|
+
const table = context.model.name
|
|
573
|
+
if (grant.kind !== 'app') {
|
|
574
|
+
throw new CapabilityError('wrong_capability', `${operation} on ${table} here needs the capability of an app endpoint, got ${describeGrant(grant)}`)
|
|
575
|
+
}
|
|
576
|
+
if (operation !== 'read') {
|
|
577
|
+
checkApplicationTable(context, operation)
|
|
578
|
+
}
|
|
579
|
+
checkAppReach(context, grant)
|
|
580
|
+
const action = requiredAction(context, operation)
|
|
581
|
+
if (grant.action !== action) {
|
|
582
|
+
throw new CapabilityError('wrong_action', `${operation} on ${table} requires ${action}; the capability holds ${grant.action}`)
|
|
583
|
+
}
|
|
584
|
+
return grant
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** Every row a capability of an `app` endpoint reads in `table`, by the table's read action: `true`. */
|
|
588
|
+
export function appListPredicate(context: TableContext, capability: unknown): OperationNode {
|
|
589
|
+
appGrant(context, capability, 'read')
|
|
590
|
+
return everything()
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Every row of an application table, for a `Ref` or a `Within` of its read action: what `ctx.db.app` of an `on`, `in`
|
|
595
|
+
* or `over` endpoint reads, read-only, as a reference table is read. The table's read action decides who reads it.
|
|
596
|
+
*/
|
|
597
|
+
export function scopedApplicationRead(context: TableContext, capability: unknown): OperationNode {
|
|
598
|
+
const grant = runtimeOf(context.connection).capabilities.resolve(capability)
|
|
599
|
+
const model = context.model
|
|
600
|
+
if (model.scope.mode !== 'app') {
|
|
601
|
+
throw new CapabilityError('table_kind', `${model.name} is not an application table: a scoped site reads it through its own handles`)
|
|
602
|
+
}
|
|
603
|
+
if (grant.kind === 'app') {
|
|
604
|
+
throw new CapabilityError('wrong_capability', `a read of ${model.name} from a scoped site needs a Ref or a Within, got ${describeGrant(grant)}`)
|
|
605
|
+
}
|
|
606
|
+
const read = requiredAction(context, 'read')
|
|
607
|
+
if (grant.action !== read) {
|
|
608
|
+
throw new CapabilityError(
|
|
609
|
+
'wrong_action',
|
|
610
|
+
`a scoped site reads ${model.name} with its read action ${read} only; the capability holds ${grant.action}`,
|
|
611
|
+
)
|
|
612
|
+
}
|
|
613
|
+
return everything()
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** One row of an application table by its key, for an `app` capability of `operation`'s action. */
|
|
617
|
+
export function appRowPredicate(context: TableContext, capability: unknown, key: unknown, operation: Operation): OperationNode {
|
|
618
|
+
checkApplicationTable(context, operation)
|
|
619
|
+
appGrant(context, capability, operation)
|
|
620
|
+
return appKeyPredicate(context.model, key)
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** The row of an application table `key` names. */
|
|
624
|
+
export function appKeyPredicate(model: TableModel, key: unknown): OperationNode {
|
|
625
|
+
if (typeof key !== 'string' || key.length === 0) {
|
|
626
|
+
throw new QueryRefusedError('row_address', `a ${model.name} row is addressed by its ${keyOf(model)}, a non-empty string`)
|
|
627
|
+
}
|
|
628
|
+
return equals(model.name, keyOf(model), key)
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* The capability of an `app` endpoint whose action updates columns of an application table: its update action, or a
|
|
633
|
+
* column's own write action. Which columns it updates follows from the action (`updatableBy`).
|
|
634
|
+
*/
|
|
635
|
+
export function updatingApp(context: TableContext, capability: unknown): AppGrant {
|
|
636
|
+
const grant = runtimeOf(context.connection).capabilities.resolve(capability)
|
|
637
|
+
const table = context.model.name
|
|
638
|
+
if (grant.kind !== 'app') {
|
|
639
|
+
throw new CapabilityError('wrong_capability', `update on ${table} needs the capability of an app endpoint, got ${describeGrant(grant)}`)
|
|
640
|
+
}
|
|
641
|
+
checkApplicationTable(context, 'update')
|
|
642
|
+
const actions = updateActions(context.model)
|
|
643
|
+
if (actions.length === 0) {
|
|
644
|
+
throw new CapabilityError('undeclared_operation', `${table} allows no update; its policy declares no update action`)
|
|
645
|
+
}
|
|
646
|
+
for (let index = 0; index < actions.length; index++) {
|
|
647
|
+
if (actions[index] === grant.action) {
|
|
648
|
+
return grant
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
throw new CapabilityError('wrong_action', `update on ${table} requires ${actions.join(' or ')}; the capability holds ${grant.action}`)
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
export function childObjectPredicate(context: TableContext, parent: unknown, key: unknown, operation: Operation): OperationNode {
|
|
655
|
+
const scope = childScope(context)
|
|
656
|
+
if (typeof key !== 'string' || key.length === 0) {
|
|
657
|
+
throw new QueryRefusedError('row_address', `a ${context.model.name} row is addressed by its parent Ref and its ${scope.key}`)
|
|
658
|
+
}
|
|
659
|
+
const grant = refGrant(context, parent, scope.parent.dimension, operation)
|
|
660
|
+
return allOf([equals(context.model.name, scope.key, key), parentPredicate(context, grant)])
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
export function guard(context: TableContext, root: RootKind, scope: OperationNode | undefined): GuardContext {
|
|
664
|
+
return { root, table: context.model.name, joins: root === 'SelectQueryNode' ? context.joins : {}, scope }
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Where a table operation's statements run: a store's own sessions, one transaction per mutation, or the mini-ORM's
|
|
669
|
+
* invocation transaction (`orm.ts`), where every statement of a handler runs in one.
|
|
670
|
+
*/
|
|
671
|
+
export interface Runner {
|
|
672
|
+
/** The session a read runs in. */
|
|
673
|
+
read(): Promise<Session>
|
|
674
|
+
/** Runs `work` in one transaction. */
|
|
675
|
+
write<T>(work: (transaction: Session) => Promise<T>): Promise<T>
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Every mutation of a store runs in its own transaction. In a job attempt that transaction is fenced by the attempt's
|
|
680
|
+
* lease (`fencedTransaction`): an attempt that lost it, or ran out of time, writes nothing.
|
|
681
|
+
*/
|
|
682
|
+
export function fencedWork<T>(runtime: DataLayerRuntime, work: (transaction: Session) => Promise<T>): Promise<T> {
|
|
683
|
+
const fence = runtime.capabilities.fence()
|
|
684
|
+
const session = sessionOf(runtime)
|
|
685
|
+
return fence === null ? session.transaction(work) : fencedTransaction(session, fence, work)
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function storeRunner(context: TableContext): Runner {
|
|
689
|
+
return {
|
|
690
|
+
read: async () => sessionOf(runtimeOf(context.connection)),
|
|
691
|
+
write: (work) => fencedWork(runtimeOf(context.connection), work),
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Reads insert or update data through its own property descriptors, so no getter runs and no inherited or
|
|
697
|
+
* non-enumerable key slips past. Every key is checked before any value is read; unknown keys are refused, not
|
|
698
|
+
* dropped, so an agent learns about them.
|
|
699
|
+
*/
|
|
700
|
+
export function readValues(values: unknown, allowed: NameSet, what: 'insert' | 'update', model: TableModel): Map<string, Primitive | null> {
|
|
701
|
+
const table = model.name
|
|
702
|
+
if (typeof values !== 'object' || values === null || isArray(values)) {
|
|
703
|
+
throw new QueryRefusedError('invalid_argument', `${what} takes an object of column values`)
|
|
704
|
+
}
|
|
705
|
+
const prototype: unknown = prototypeOf(values)
|
|
706
|
+
if (prototype !== OBJECT_PROTOTYPE && prototype !== null) {
|
|
707
|
+
throw new QueryRefusedError('invalid_argument', `${what} takes a plain object literal, not an instance of a class`)
|
|
708
|
+
}
|
|
709
|
+
if (ownSymbols(values).length > 0) {
|
|
710
|
+
throw new QueryRefusedError('invalid_argument', `${what} takes column names as keys, not symbols`)
|
|
711
|
+
}
|
|
712
|
+
const keys = ownNames(values)
|
|
713
|
+
const descriptors: PropertyDescriptor[] = []
|
|
714
|
+
for (let index = 0; index < keys.length; index++) {
|
|
715
|
+
const key = keys[index] ?? ''
|
|
716
|
+
const descriptor = ownDescriptor(values, key)
|
|
717
|
+
if (descriptor === undefined || !hasOwn(descriptor, 'value')) {
|
|
718
|
+
throw new QueryRefusedError('invalid_argument', `${table}.${key} is an accessor; ${what} takes plain values`)
|
|
719
|
+
}
|
|
720
|
+
if (!inSet(allowed, key)) {
|
|
721
|
+
const columns = namesOf(allowed).join(', ') || 'none'
|
|
722
|
+
throw new QueryRefusedError('unknown_column', `${key} is not an ${what} column of ${table} (${what} columns: ${columns})`)
|
|
723
|
+
}
|
|
724
|
+
descriptors[index] = descriptor
|
|
725
|
+
}
|
|
726
|
+
const copied = new Map<string, Primitive | null>()
|
|
727
|
+
for (let index = 0; index < keys.length; index++) {
|
|
728
|
+
const key = keys[index] ?? ''
|
|
729
|
+
const value: unknown = descriptors[index]?.value
|
|
730
|
+
if (value === undefined) {
|
|
731
|
+
continue
|
|
732
|
+
}
|
|
733
|
+
if (value !== null && !isPrimitive(value)) {
|
|
734
|
+
throw new QueryRefusedError('invalid_argument', `${table}.${key} takes a plain value (string, number, bigint, boolean, Date or null)`)
|
|
735
|
+
}
|
|
736
|
+
copied.set(key, typeof value === 'string' ? temporalText(model, key, value) : value)
|
|
737
|
+
}
|
|
738
|
+
return copied
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/** A day's or a timestamp's text, checked before it is bound; any other column's text as it is. */
|
|
742
|
+
function temporalText(model: TableModel, column: string, value: string): string {
|
|
743
|
+
if (inSet(model.dates, column)) {
|
|
744
|
+
return dayInput(value, `${model.name}.${column}`)
|
|
745
|
+
}
|
|
746
|
+
return inSet(model.timestamps, column) ? timestampInput(value, `${model.name}.${column}`) : value
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function stampInsert(
|
|
750
|
+
context: TableContext,
|
|
751
|
+
grant: { readonly principal: string },
|
|
752
|
+
values: Map<string, Primitive | null>,
|
|
753
|
+
key: string,
|
|
754
|
+
keyValue: string,
|
|
755
|
+
): void {
|
|
756
|
+
values.set(key, keyValue)
|
|
757
|
+
if (context.model.version !== undefined) {
|
|
758
|
+
values.set(context.model.version, 1)
|
|
759
|
+
}
|
|
760
|
+
if (context.model.createdBy !== undefined) {
|
|
761
|
+
values.set(context.model.createdBy, grant.principal)
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
export function versionArgument(context: TableContext, options: readonly unknown[]): OperationNode | undefined {
|
|
766
|
+
const version = context.model.version
|
|
767
|
+
if (version === undefined) {
|
|
768
|
+
return undefined
|
|
769
|
+
}
|
|
770
|
+
const first = options[0]
|
|
771
|
+
const expected: unknown = typeof first === 'object' && first !== null ? Reflect.get(first, 'expectVersion') : undefined
|
|
772
|
+
if (typeof expected !== 'number' || !isSafeInteger(expected)) {
|
|
773
|
+
throw new QueryRefusedError('version_required', `${context.model.name} has a version column; update and remove take { expectVersion: number }`)
|
|
774
|
+
}
|
|
775
|
+
return BinaryOperationNode.create(column(context.model.name, version), OperatorNode.create('='), ValueNode.create(expected))
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
async function selectRows<S>(
|
|
779
|
+
context: TableContext,
|
|
780
|
+
scope: OperationNode | undefined,
|
|
781
|
+
state: SelectState,
|
|
782
|
+
action: string,
|
|
783
|
+
visible: readonly string[] = context.model.visible,
|
|
784
|
+
): Promise<S[]> {
|
|
785
|
+
const run = storeRunner(context)
|
|
786
|
+
for (let index = 0; index < state.joins.length; index++) {
|
|
787
|
+
const join = state.joins[index]
|
|
788
|
+
if (join !== undefined && join.join.readAction !== action) {
|
|
789
|
+
throw new CapabilityError('wrong_action', `joining ${join.join.shape.table} requires ${join.join.readAction}; the capability holds ${action}`)
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
if (scope === undefined) {
|
|
793
|
+
return []
|
|
794
|
+
}
|
|
795
|
+
const node = selectNode(state, visible, scope, undefined, context.model.dates)
|
|
796
|
+
const rows = await (await run.read()).run<S>(node, guard(context, 'SelectQueryNode', scope))
|
|
797
|
+
return answerDays(context.model.name, joinedDays(context.model.dates, state), rows)
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** The `date` columns of a select's rows: the table's own, and each joined child's under its `<child>.<column>` alias. */
|
|
801
|
+
function joinedDays(own: NameSet, state: SelectState): NameSet {
|
|
802
|
+
if (state.joins.length === 0) {
|
|
803
|
+
return own
|
|
804
|
+
}
|
|
805
|
+
const names: string[] = []
|
|
806
|
+
const ownDates = namesOf(own)
|
|
807
|
+
for (let index = 0; index < ownDates.length; index++) {
|
|
808
|
+
names[names.length] = ownDates[index] ?? ''
|
|
809
|
+
}
|
|
810
|
+
for (let index = 0; index < state.joins.length; index++) {
|
|
811
|
+
const shape = state.joins[index]?.join.shape
|
|
812
|
+
const dates = shape === undefined ? [] : namesOf(shape.dates)
|
|
813
|
+
for (let position = 0; position < dates.length; position++) {
|
|
814
|
+
names[names.length] = `${shape?.table}.${dates[position]}`
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
return nameSet(names)
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** One row by its scope predicate, with `visible` columns (the table's read action's, unless a capability reads its own). */
|
|
821
|
+
export async function selectOne<O>(context: TableContext, run: Runner, scope: OperationNode, visible = context.model.visible): Promise<O> {
|
|
822
|
+
const start = startSelect(context.shape)
|
|
823
|
+
const state = finishSelect(start, start)
|
|
824
|
+
const node = selectNode(state, visible, scope, undefined, context.model.dates)
|
|
825
|
+
const rows = await (await run.read()).run<O>(node, guard(context, 'SelectQueryNode', scope))
|
|
826
|
+
const [row] = answerDays(context.model.name, context.model.dates, rows)
|
|
827
|
+
if (row === undefined) {
|
|
828
|
+
throw new NotFoundError(`${context.model.name} not found`)
|
|
829
|
+
}
|
|
830
|
+
return row
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
export function answer(context: TableContext): ReturningNode {
|
|
834
|
+
return acknowledgement(context.model.name, keyOf(context.model), context.model.version)
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/** Throws inside the mutation's transaction, so a table whose version is not an INTEGER keeps nothing written. */
|
|
838
|
+
function checkVersion(context: TableContext, row: unknown): void {
|
|
839
|
+
const version = context.model.version
|
|
840
|
+
if (version === undefined) {
|
|
841
|
+
return
|
|
842
|
+
}
|
|
843
|
+
const value: unknown = typeof row === 'object' && row !== null ? Reflect.get(row, 'version') : undefined
|
|
844
|
+
if (typeof value !== 'number' || !isSafeInteger(value)) {
|
|
845
|
+
throw new PolicyError(
|
|
846
|
+
`${context.model.name}.${version} came back as ${typeof value}; a version column must be INTEGER (int4), which Postgres returns as a number`,
|
|
847
|
+
)
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
export function keyOf(model: TableModel): string {
|
|
852
|
+
if (model.scope.mode !== 'dimension' && model.scope.mode !== 'child' && model.scope.mode !== 'app') {
|
|
853
|
+
throw new CapabilityError('undeclared_operation', `${model.name} is not written by handlers`)
|
|
854
|
+
}
|
|
855
|
+
return model.scope.key
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Locks the destination row `for share` and checks that it still lies where the Ref's chain says. Without it, a
|
|
860
|
+
* Ref minted before its object moved would insert into, or move rows into, the object's new container.
|
|
861
|
+
*/
|
|
862
|
+
async function lockDestination(transaction: Session, context: TableContext, grant: RefGrant): Promise<void> {
|
|
863
|
+
const owner = context.destination
|
|
864
|
+
if (owner === undefined) {
|
|
865
|
+
return
|
|
866
|
+
}
|
|
867
|
+
const scope = linksPredicate(owner.name, linkValues(owner.name, selfLinks(owner), grant))
|
|
868
|
+
const node = lockNode(owner.name, keyOf(owner), scope)
|
|
869
|
+
const rows = await transaction.run(node, { root: 'SelectQueryNode', table: owner.name, joins: {}, scope, lock: 'share' })
|
|
870
|
+
if (rows.length === 0) {
|
|
871
|
+
throw new NotFoundError(`${owner.name} ${grant.id} is not where its Ref says; obtain a fresh Ref`)
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
export async function insertRow<O>(
|
|
876
|
+
context: TableContext,
|
|
877
|
+
run: Runner,
|
|
878
|
+
values: ReadonlyMap<string, Primitive | null>,
|
|
879
|
+
destination: RefGrant | undefined,
|
|
880
|
+
): Promise<O> {
|
|
881
|
+
return run.write(async (transaction) => {
|
|
882
|
+
if (destination !== undefined) {
|
|
883
|
+
await lockDestination(transaction, context, destination)
|
|
884
|
+
}
|
|
885
|
+
const [row] = await transaction.run<O>(insertNode(context.model.name, values, answer(context)), guard(context, 'InsertQueryNode', undefined))
|
|
886
|
+
if (row === undefined) {
|
|
887
|
+
throw new QueryRefusedError('unexpected_row', `the insert into ${context.model.name} returned no row`)
|
|
888
|
+
}
|
|
889
|
+
checkVersion(context, row)
|
|
890
|
+
return row
|
|
891
|
+
})
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* The rows a write addresses: the scope predicate, and the caller's conditions beside it (an unscoped row's key, whose
|
|
896
|
+
* scope predicate is `true`).
|
|
897
|
+
*/
|
|
898
|
+
export interface Addressed {
|
|
899
|
+
readonly scope: OperationNode
|
|
900
|
+
readonly conditions?: OperationNode
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function addressed(scope: OperationNode): Addressed {
|
|
904
|
+
return { scope }
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function conditionsWith(target: Addressed, expectedVersion: OperationNode | undefined): OperationNode | undefined {
|
|
908
|
+
if (target.conditions === undefined) {
|
|
909
|
+
return expectedVersion
|
|
910
|
+
}
|
|
911
|
+
return expectedVersion === undefined ? target.conditions : allOf([target.conditions, expectedVersion])
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** A zero-row write under `expectVersion`: 409 when the row is still there, 404 when it is gone. */
|
|
915
|
+
async function missingOrChanged(
|
|
916
|
+
context: TableContext,
|
|
917
|
+
transaction: Session,
|
|
918
|
+
target: Addressed,
|
|
919
|
+
expectedVersion: OperationNode | undefined,
|
|
920
|
+
): Promise<never> {
|
|
921
|
+
const table = context.model.name
|
|
922
|
+
if (expectedVersion !== undefined) {
|
|
923
|
+
const node = existsNode(table, keyOf(context.model), target.scope, target.conditions)
|
|
924
|
+
const existing = await transaction.run(node, guard(context, 'SelectQueryNode', target.scope))
|
|
925
|
+
if (existing.length > 0) {
|
|
926
|
+
throw new ConflictError(`${table} was changed by someone else; read it again and retry`)
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
throw new NotFoundError(`${table} not found`)
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Zero changed rows → one follow-up read in the same transaction → 404 when the object is gone, else 409. An update
|
|
934
|
+
* that sets no column writes nothing, not even the version: it reads the acknowledgement under the same conditions.
|
|
935
|
+
*/
|
|
936
|
+
export async function updateRow<O>(
|
|
937
|
+
context: TableContext,
|
|
938
|
+
run: Runner,
|
|
939
|
+
target: Addressed,
|
|
940
|
+
changes: ReadonlyMap<string, Primitive | null>,
|
|
941
|
+
expectedVersion: OperationNode | undefined,
|
|
942
|
+
destination?: RefGrant,
|
|
943
|
+
): Promise<O> {
|
|
944
|
+
const { name: table, version } = context.model
|
|
945
|
+
const where = scopedWhere(conditionsWith(target, expectedVersion), target.scope)
|
|
946
|
+
const unchanged = changes.size === 0
|
|
947
|
+
const node = unchanged
|
|
948
|
+
? acknowledgementNode(table, keyOf(context.model), version, where)
|
|
949
|
+
: updateNode(table, changes, version, where, answer(context))
|
|
950
|
+
return run.write(async (transaction) => {
|
|
951
|
+
if (destination !== undefined) {
|
|
952
|
+
await lockDestination(transaction, context, destination)
|
|
953
|
+
}
|
|
954
|
+
const [row] = await transaction.run<O>(node, guard(context, unchanged ? 'SelectQueryNode' : 'UpdateQueryNode', target.scope))
|
|
955
|
+
if (row !== undefined) {
|
|
956
|
+
checkVersion(context, row)
|
|
957
|
+
return row
|
|
958
|
+
}
|
|
959
|
+
return missingOrChanged(context, transaction, target, expectedVersion)
|
|
960
|
+
})
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/** Like `updateRow`: under `expectVersion`, a row that changed is 409 and a missing one 404. */
|
|
964
|
+
export async function removeRow(context: TableContext, run: Runner, target: Addressed, expectedVersion?: OperationNode): Promise<void> {
|
|
965
|
+
const node = deleteNode(context.model.name, scopedWhere(conditionsWith(target, expectedVersion), target.scope), keyOf(context.model))
|
|
966
|
+
await run.write(async (transaction) => {
|
|
967
|
+
const rows = await transaction.run(node, guard(context, 'DeleteQueryNode', target.scope))
|
|
968
|
+
if (rows.length === 0) {
|
|
969
|
+
await missingOrChanged(context, transaction, target, expectedVersion)
|
|
970
|
+
}
|
|
971
|
+
})
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
type Columns<Row> = keyof Row & string
|
|
975
|
+
type NumericColumn<Row> = { [K in Columns<Row>]: Exclude<Row[K], null> extends number | bigint | string ? K : never }[Columns<Row>]
|
|
976
|
+
|
|
977
|
+
export interface AggregateSpec<Row> {
|
|
978
|
+
readonly groupBy?: readonly Columns<Row>[]
|
|
979
|
+
readonly count?: boolean
|
|
980
|
+
readonly sum?: readonly NumericColumn<Row>[]
|
|
981
|
+
readonly min?: readonly Columns<Row>[]
|
|
982
|
+
readonly max?: readonly Columns<Row>[]
|
|
983
|
+
readonly where?: (q: ScopedSelect<Row, Row, NoColumns>) => ScopedSelect<Row, unknown, NoColumns>
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
type Listed<S, K extends 'groupBy' | 'sum' | 'min' | 'max'> = S extends { readonly [P in K]: readonly (infer C extends string)[] } ? C : never
|
|
987
|
+
type Flat<T> = { [K in keyof T]: T[K] }
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* `count` is a string because Postgres returns a bigint. A sum is a string for integer and numeric columns and a
|
|
991
|
+
* number for floating-point ones; the column type alone cannot tell them apart.
|
|
992
|
+
*/
|
|
993
|
+
export type AggregateRow<Row, S> = Flat<
|
|
994
|
+
& Pick<Row, Listed<S, 'groupBy'> & keyof Row>
|
|
995
|
+
& (S extends { readonly count: true } ? { readonly count: string } : NoColumns)
|
|
996
|
+
& { readonly [K in Listed<S, 'sum'> as `sum_${K}`]: string | number | null }
|
|
997
|
+
& { readonly [K in Listed<S, 'min'> & keyof Row as `min_${K}`]: Row[K] | null }
|
|
998
|
+
& { readonly [K in Listed<S, 'max'> & keyof Row as `max_${K}`]: Row[K] | null }
|
|
999
|
+
>
|
|
1000
|
+
|
|
1001
|
+
/** The aggregate spec as it arrives at runtime, where the types may have been defeated. */
|
|
1002
|
+
interface AggregateInput {
|
|
1003
|
+
readonly groupBy?: unknown
|
|
1004
|
+
readonly count?: unknown
|
|
1005
|
+
readonly sum?: unknown
|
|
1006
|
+
readonly min?: unknown
|
|
1007
|
+
readonly max?: unknown
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function aggregateColumns(table: string, shape: TableShape, spec: AggregateInput): AggregateColumns {
|
|
1011
|
+
const list = (field: string, value: unknown): string[] => {
|
|
1012
|
+
if (value === undefined) {
|
|
1013
|
+
return []
|
|
1014
|
+
}
|
|
1015
|
+
const names = parseList(value, (name) => {
|
|
1016
|
+
if (!inSet(shape.visible, name)) {
|
|
1017
|
+
throw new QueryRefusedError('unknown_column', `${String(name)} is not a column of ${table} (columns: ${namesOf(shape.visible).join(', ')})`)
|
|
1018
|
+
}
|
|
1019
|
+
return name
|
|
1020
|
+
})
|
|
1021
|
+
if (names === undefined) {
|
|
1022
|
+
throw new QueryRefusedError('invalid_argument', `aggregate ${field} takes a plain array of column names`)
|
|
1023
|
+
}
|
|
1024
|
+
return names
|
|
1025
|
+
}
|
|
1026
|
+
const count = spec.count === true
|
|
1027
|
+
const columns = { groupBy: list('groupBy', spec.groupBy), count, sum: list('sum', spec.sum), min: list('min', spec.min), max: list('max', spec.max) }
|
|
1028
|
+
if (!count && columns.sum.length === 0 && columns.min.length === 0 && columns.max.length === 0) {
|
|
1029
|
+
throw new QueryRefusedError('invalid_argument', 'aggregate needs count, sum, min or max')
|
|
1030
|
+
}
|
|
1031
|
+
return columns
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function aggregateFilter<Row>(shape: TableShape, where: AggregateSpec<Row>['where']): SelectState | undefined {
|
|
1035
|
+
if (where === undefined) {
|
|
1036
|
+
return undefined
|
|
1037
|
+
}
|
|
1038
|
+
if (typeof where !== 'function') {
|
|
1039
|
+
throw new QueryRefusedError('invalid_argument', 'aggregate where takes a callback')
|
|
1040
|
+
}
|
|
1041
|
+
const start = startSelect<Row, NoColumns>(shape)
|
|
1042
|
+
const state = finishSelect(where(start), start)
|
|
1043
|
+
if (state.columns !== undefined || state.orderBy.length > 0 || state.limit !== undefined || state.offset !== undefined || state.joins.length > 0) {
|
|
1044
|
+
throw new QueryRefusedError('invalid_argument', 'aggregate where takes where conditions only')
|
|
1045
|
+
}
|
|
1046
|
+
return state
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
export async function aggregateRows<Row, O>(
|
|
1050
|
+
context: TableContext,
|
|
1051
|
+
run: Runner,
|
|
1052
|
+
scope: OperationNode | undefined,
|
|
1053
|
+
shape: TableShape,
|
|
1054
|
+
spec: AggregateSpec<Row>,
|
|
1055
|
+
): Promise<O[]> {
|
|
1056
|
+
if (typeof spec !== 'object' || spec === null) {
|
|
1057
|
+
throw new QueryRefusedError('invalid_argument', 'aggregate takes { groupBy, count, sum, min, max, where }')
|
|
1058
|
+
}
|
|
1059
|
+
const columns = aggregateColumns(context.model.name, shape, spec)
|
|
1060
|
+
const filter = aggregateFilter(shape, spec.where)
|
|
1061
|
+
if (scope === undefined) {
|
|
1062
|
+
return []
|
|
1063
|
+
}
|
|
1064
|
+
const node = aggregateNode(context.model.name, columns, filter?.conditions ?? [], scope, context.model.dates)
|
|
1065
|
+
const rows = await (await run.read()).run<O>(node, guard(context, 'SelectQueryNode', scope))
|
|
1066
|
+
return answerDays(context.model.name, aggregateDays(context.model.dates, columns), rows)
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/** The `date` columns of an aggregate's rows: a grouped one by its name, and its `min_` and `max_`. */
|
|
1070
|
+
function aggregateDays(dates: NameSet, columns: AggregateColumns): NameSet {
|
|
1071
|
+
const names: string[] = []
|
|
1072
|
+
const add = (prefix: string, list: readonly string[]) => {
|
|
1073
|
+
for (let index = 0; index < list.length; index++) {
|
|
1074
|
+
const name = list[index] ?? ''
|
|
1075
|
+
if (inSet(dates, name)) {
|
|
1076
|
+
names[names.length] = `${prefix}${name}`
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
add('', columns.groupBy)
|
|
1081
|
+
add('min_', columns.min)
|
|
1082
|
+
add('max_', columns.max)
|
|
1083
|
+
return nameSet(names)
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/** `DimensionTable.insert` over a capability nobody type-checked; the table probe of `@fabrika/db/testing` calls it too. */
|
|
1087
|
+
export function insertIntoDimension<O>(context: TableContext, run: Runner, target: unknown, values: unknown): Promise<O> {
|
|
1088
|
+
const scope = dimensionScope(context.model)
|
|
1089
|
+
const grant = refGrant(context, target, listDimension(scope), 'insert')
|
|
1090
|
+
const row = readValues(values, context.model.insert, 'insert', context.model)
|
|
1091
|
+
if (scope.container === undefined) {
|
|
1092
|
+
// The object does not exist yet; this only refuses a chain the table has no column for.
|
|
1093
|
+
linkValues(context.model.name, [{ dimension: scope.dimension, column: scope.key }], grant)
|
|
1094
|
+
stampInsert(context, grant, row, scope.key, grant.id)
|
|
1095
|
+
return insertRow(context, run, row, undefined)
|
|
1096
|
+
}
|
|
1097
|
+
stampInsert(context, grant, row, scope.key, Bun.randomUUIDv7())
|
|
1098
|
+
for (const { link, value } of linkValues(context.model.name, context.model.chain, grant)) {
|
|
1099
|
+
row.set(link.column, value)
|
|
1100
|
+
}
|
|
1101
|
+
return insertRow(context, run, row, grant)
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/** An application table's insert, for an `app` capability of its insert action: a new key, no containment. */
|
|
1105
|
+
export function insertIntoApplication<O>(context: TableContext, run: Runner, capability: unknown, values: unknown): Promise<O> {
|
|
1106
|
+
const grant = appGrant(context, capability, 'insert')
|
|
1107
|
+
const row = readValues(values, context.model.insert, 'insert', context.model)
|
|
1108
|
+
stampInsert(context, grant, row, keyOf(context.model), Bun.randomUUIDv7())
|
|
1109
|
+
return insertRow(context, run, row, undefined)
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/** `ChildTable.insert` over a capability nobody type-checked. */
|
|
1113
|
+
export function insertIntoChild<O>(context: TableContext, run: Runner, parent: unknown, values: unknown): Promise<O> {
|
|
1114
|
+
const scope = childScope(context)
|
|
1115
|
+
const grant = refGrant(context, parent, scope.parent.dimension, 'insert')
|
|
1116
|
+
const row = readValues(values, context.model.insert, 'insert', context.model)
|
|
1117
|
+
stampInsert(context, grant, row, scope.key, Bun.randomUUIDv7())
|
|
1118
|
+
for (const { link, value } of linkValues(context.model.name, context.model.chain, grant)) {
|
|
1119
|
+
row.set(link.column, value)
|
|
1120
|
+
}
|
|
1121
|
+
return insertRow(context, run, row, grant)
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
type ReadAction<P, T> = ActionOf<P, T, 'read'>
|
|
1125
|
+
type Row<DB, P, T> = PolicyRow<DB, P, T>
|
|
1126
|
+
type Joinable<DB, P, T> = JoinableChildren<DB, P, T>
|
|
1127
|
+
type ParentDimension<P, T> = DimensionOf<P, ParentOf<P, T>>
|
|
1128
|
+
|
|
1129
|
+
/** The table handle of a dimension table: every entry point takes a `Ref` or a `Within`. */
|
|
1130
|
+
export class DimensionTable<DB, P, T extends string> {
|
|
1131
|
+
readonly #context: TableContext
|
|
1132
|
+
readonly #types: { readonly db?: DB; readonly policies?: P; readonly table?: T } = {}
|
|
1133
|
+
|
|
1134
|
+
constructor(context: TableContext) {
|
|
1135
|
+
this.#context = context
|
|
1136
|
+
Object.freeze(this)
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
async select<S>(
|
|
1140
|
+
within: Within<ListDimensionOf<P, T>, ReadAction<P, T>>,
|
|
1141
|
+
build: (q: ScopedSelect<Row<DB, P, T>, Row<DB, P, T>, Joinable<DB, P, T>>) => ScopedSelect<Row<DB, P, T>, S, Joinable<DB, P, T>>,
|
|
1142
|
+
): Promise<S[]> {
|
|
1143
|
+
const context = this.#context
|
|
1144
|
+
const scope = dimensionListPredicate(context, within, 'read')
|
|
1145
|
+
const start = startSelect<Row<DB, P, T>, Joinable<DB, P, T>>(context.shape)
|
|
1146
|
+
const state = finishSelect(build(start), start)
|
|
1147
|
+
return selectRows<S>(context, scope, state, requiredAction(context, 'read'))
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
async get(ref: Ref<DimensionOf<P, T>, ReadAction<P, T>>): Promise<Row<DB, P, T>> {
|
|
1151
|
+
return selectOne(this.#context, storeRunner(this.#context), objectPredicate(this.#context, ref, 'read'))
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/**
|
|
1155
|
+
* Into a container (the key is a new UUIDv7, the chain comes from the container's Ref and is checked against
|
|
1156
|
+
* the database), or for a top-level table the object the `Ref` names. Answers an acknowledgement, not the row.
|
|
1157
|
+
*/
|
|
1158
|
+
async insert(target: Ref<ListDimensionOf<P, T>, ActionOf<P, T, 'insert'>>, values: InsertValues<DB, P, T>): Promise<Acknowledgement<P, T>> {
|
|
1159
|
+
return insertIntoDimension(this.#context, storeRunner(this.#context), target, values)
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
async update(
|
|
1163
|
+
ref: Ref<DimensionOf<P, T>, ActionOf<P, T, 'update'>>,
|
|
1164
|
+
values: UpdateValues<DB, P, T>,
|
|
1165
|
+
...options: VersionArguments<P, T>
|
|
1166
|
+
): Promise<Acknowledgement<P, T>> {
|
|
1167
|
+
const context = this.#context
|
|
1168
|
+
const scope = objectPredicate(context, ref, 'update')
|
|
1169
|
+
const changes = readValues(values, context.model.update, 'update', context.model)
|
|
1170
|
+
return updateRow(context, storeRunner(context), addressed(scope), changes, versionArgument(context, options))
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
async remove(ref: Ref<DimensionOf<P, T>, ActionOf<P, T, 'delete'>>): Promise<void> {
|
|
1174
|
+
const context = this.#context
|
|
1175
|
+
await removeRow(context, storeRunner(context), addressed(objectPredicate(context, ref, 'delete')))
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* Moves the object to another container: the delete action at the source, the insert action at the target,
|
|
1180
|
+
* whose chain is checked against the database. Every chain column changes; children that copy them follow
|
|
1181
|
+
* through `ON UPDATE CASCADE` on their composite foreign key. Answers the acknowledgement and the new chain.
|
|
1182
|
+
*/
|
|
1183
|
+
async move(
|
|
1184
|
+
ref: Ref<DimensionOf<P, T>, ActionOf<P, T, 'delete'>>,
|
|
1185
|
+
to: Ref<ContainerOf<P, T>, ActionOf<P, T, 'insert'>>,
|
|
1186
|
+
): Promise<MoveAcknowledgement<P, T>> {
|
|
1187
|
+
const context = this.#context
|
|
1188
|
+
const container = dimensionScope(context.model).container
|
|
1189
|
+
if (container === undefined) {
|
|
1190
|
+
throw new CapabilityError('table_kind', `${context.model.name} has no container to move between`)
|
|
1191
|
+
}
|
|
1192
|
+
const scope = objectPredicate(context, ref, 'delete')
|
|
1193
|
+
const target = refGrant(context, to, container.dimension, 'insert')
|
|
1194
|
+
const values = linkValues(context.model.name, context.model.chain, target)
|
|
1195
|
+
const changes = new Map<string, Primitive | null>(values.map(({ link, value }) => [link.column, value]))
|
|
1196
|
+
const acknowledged = await updateRow<Acknowledgement<P, T>>(context, storeRunner(context), addressed(scope), changes, undefined, target)
|
|
1197
|
+
return { ...acknowledged, chain: Object.fromEntries(values.map(({ link, value }) => [link.dimension, value])) }
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
async aggregate<const S extends AggregateSpec<Row<DB, P, T>>>(
|
|
1201
|
+
within: Within<ListDimensionOf<P, T>, ReadAction<P, T>>,
|
|
1202
|
+
spec: S,
|
|
1203
|
+
): Promise<AggregateRow<Row<DB, P, T>, S>[]> {
|
|
1204
|
+
return aggregateRows(this.#context, storeRunner(this.#context), dimensionListPredicate(this.#context, within, 'read'), this.#context.shape, spec)
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
Object.freeze(DimensionTable.prototype)
|
|
1208
|
+
|
|
1209
|
+
/** The scope a child list is reached through: one parent object, or the parent's list dimension. */
|
|
1210
|
+
export type ChildListScope<P, T> =
|
|
1211
|
+
| Ref<ParentDimension<P, T>, ReadAction<P, T>>
|
|
1212
|
+
| Within<ListDimensionOf<P, ParentOf<P, T>>, ReadAction<P, T>>
|
|
1213
|
+
|
|
1214
|
+
/** The table handle of a child table: one row is addressed by its parent's `Ref` and its own key. */
|
|
1215
|
+
export class ChildTable<DB, P, T extends string> {
|
|
1216
|
+
readonly #context: TableContext
|
|
1217
|
+
readonly #types: { readonly db?: DB; readonly policies?: P; readonly table?: T } = {}
|
|
1218
|
+
|
|
1219
|
+
constructor(context: TableContext) {
|
|
1220
|
+
this.#context = context
|
|
1221
|
+
Object.freeze(this)
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
async select<S>(
|
|
1225
|
+
scope: ChildListScope<P, T>,
|
|
1226
|
+
build: (q: ScopedSelect<Row<DB, P, T>, Row<DB, P, T>, NoColumns>) => ScopedSelect<Row<DB, P, T>, S, NoColumns>,
|
|
1227
|
+
): Promise<S[]> {
|
|
1228
|
+
const context = this.#context
|
|
1229
|
+
const predicate = childListPredicate(context, scope, 'read')
|
|
1230
|
+
const start = startSelect<Row<DB, P, T>, NoColumns>(context.shape)
|
|
1231
|
+
const state = finishSelect(build(start), start)
|
|
1232
|
+
return selectRows<S>(context, predicate, state, requiredAction(context, 'read'))
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
async get(parent: Ref<ParentDimension<P, T>, ReadAction<P, T>>, key: string): Promise<Row<DB, P, T>> {
|
|
1236
|
+
return selectOne(this.#context, storeRunner(this.#context), childObjectPredicate(this.#context, parent, key, 'read'))
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/** Under the parent the Ref names, whose chain is checked against the database. Answers an acknowledgement. */
|
|
1240
|
+
async insert(parent: Ref<ParentDimension<P, T>, ActionOf<P, T, 'insert'>>, values: InsertValues<DB, P, T>): Promise<Acknowledgement<P, T>> {
|
|
1241
|
+
return insertIntoChild(this.#context, storeRunner(this.#context), parent, values)
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
async update(
|
|
1245
|
+
parent: Ref<ParentDimension<P, T>, ActionOf<P, T, 'update'>>,
|
|
1246
|
+
key: string,
|
|
1247
|
+
values: UpdateValues<DB, P, T>,
|
|
1248
|
+
...options: VersionArguments<P, T>
|
|
1249
|
+
): Promise<Acknowledgement<P, T>> {
|
|
1250
|
+
const context = this.#context
|
|
1251
|
+
const scope = childObjectPredicate(context, parent, key, 'update')
|
|
1252
|
+
const changes = readValues(values, context.model.update, 'update', context.model)
|
|
1253
|
+
return updateRow(context, storeRunner(context), addressed(scope), changes, versionArgument(context, options))
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
async remove(parent: Ref<ParentDimension<P, T>, ActionOf<P, T, 'delete'>>, key: string): Promise<void> {
|
|
1257
|
+
const context = this.#context
|
|
1258
|
+
await removeRow(context, storeRunner(context), addressed(childObjectPredicate(context, parent, key, 'delete')))
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
async aggregate<const S extends AggregateSpec<Row<DB, P, T>>>(scope: ChildListScope<P, T>, spec: S): Promise<AggregateRow<Row<DB, P, T>, S>[]> {
|
|
1262
|
+
return aggregateRows(this.#context, storeRunner(this.#context), childListPredicate(this.#context, scope, 'read'), this.#context.shape, spec)
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
Object.freeze(ChildTable.prototype)
|
|
1266
|
+
|
|
1267
|
+
/**
|
|
1268
|
+
* One table's operations without the store types, for `@fabrika/db/testing` only: the generated isolation tests address
|
|
1269
|
+
* a table by name. Every runtime check of a store applies; a child row is addressed by its parent's capability and
|
|
1270
|
+
* `key`, a dimension row by its capability alone.
|
|
1271
|
+
*/
|
|
1272
|
+
export interface TableProbe {
|
|
1273
|
+
readonly mode: 'dimension' | 'child' | 'app'
|
|
1274
|
+
get(capability: unknown, key?: string): Promise<Readonly<Record<string, unknown>>>
|
|
1275
|
+
select(capability: unknown): Promise<readonly Readonly<Record<string, unknown>>[]>
|
|
1276
|
+
/** `aggregate(capability, { count: true })`; zero rows when the capability reaches none. */
|
|
1277
|
+
count(capability: unknown): Promise<string>
|
|
1278
|
+
/** `aggregate(capability, { groupBy: columns, count: true })`: the values of `columns` in each group, and its count. */
|
|
1279
|
+
group(capability: unknown, columns: readonly string[]): Promise<readonly Readonly<Record<string, unknown>>[]>
|
|
1280
|
+
insert(capability: unknown, values: unknown): Promise<unknown>
|
|
1281
|
+
update(capability: unknown, key: string | undefined, values: unknown, expectVersion?: number): Promise<unknown>
|
|
1282
|
+
remove(capability: unknown, key?: string): Promise<void>
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
function probeOf(context: TableContext): TableProbe {
|
|
1286
|
+
const child = context.model.scope.mode === 'child'
|
|
1287
|
+
const object = (capability: unknown, key: string | undefined, operation: Operation): OperationNode => {
|
|
1288
|
+
if (child) {
|
|
1289
|
+
return childObjectPredicate(context, capability, key, operation)
|
|
1290
|
+
}
|
|
1291
|
+
if (key !== undefined) {
|
|
1292
|
+
throw new QueryRefusedError('row_address', `a ${context.model.name} row is addressed by its Ref alone`)
|
|
1293
|
+
}
|
|
1294
|
+
return objectPredicate(context, capability, operation)
|
|
1295
|
+
}
|
|
1296
|
+
// An `app` capability reads every row through ctx.db.app: its rows with appRead, its counts with appCount too.
|
|
1297
|
+
const list = (capability: unknown, reach: AppReach): OperationNode | undefined => {
|
|
1298
|
+
if (runtimeOf(context.connection).capabilities.resolve(capability).kind === 'app') {
|
|
1299
|
+
return appRecordPredicate(context, capability, reach)
|
|
1300
|
+
}
|
|
1301
|
+
return child ? childListPredicate(context, capability, 'read') : dimensionListPredicate(context, capability, 'read')
|
|
1302
|
+
}
|
|
1303
|
+
// An `app` capability reads the columns of its own action, as ctx.db.app does; any other the read action's.
|
|
1304
|
+
const visible = (capability: unknown): readonly string[] => {
|
|
1305
|
+
const grant = runtimeOf(context.connection).capabilities.resolve(capability)
|
|
1306
|
+
return visibleFor(context.model, grant.kind === 'app' ? grant.action : requiredAction(context, 'read'))
|
|
1307
|
+
}
|
|
1308
|
+
const run = storeRunner(context)
|
|
1309
|
+
return freeze({
|
|
1310
|
+
mode: child ? 'child' : 'dimension',
|
|
1311
|
+
get: async (capability: unknown, key?: string) => selectOne<Readonly<Record<string, unknown>>>(context, run, object(capability, key, 'read')),
|
|
1312
|
+
select: async (capability: unknown) => {
|
|
1313
|
+
const start = startSelect<Readonly<Record<string, unknown>>, NoColumns>(context.shape)
|
|
1314
|
+
return selectRows<Readonly<Record<string, unknown>>>(
|
|
1315
|
+
context,
|
|
1316
|
+
list(capability, 'rows'),
|
|
1317
|
+
finishSelect(start, start),
|
|
1318
|
+
requiredAction(context, 'read'),
|
|
1319
|
+
visible(capability),
|
|
1320
|
+
)
|
|
1321
|
+
},
|
|
1322
|
+
group: async (capability: unknown, columns: readonly string[]) =>
|
|
1323
|
+
aggregateRows<Readonly<Record<string, unknown>>, Readonly<Record<string, unknown>>>(
|
|
1324
|
+
context,
|
|
1325
|
+
run,
|
|
1326
|
+
list(capability, 'rows'),
|
|
1327
|
+
actionShape(context.model, visible(capability)),
|
|
1328
|
+
{ groupBy: columns, count: true },
|
|
1329
|
+
),
|
|
1330
|
+
count: async (capability: unknown) => {
|
|
1331
|
+
const [row] = await aggregateRows<Readonly<Record<string, unknown>>, Readonly<Record<string, unknown>>>(
|
|
1332
|
+
context,
|
|
1333
|
+
run,
|
|
1334
|
+
list(capability, 'counts'),
|
|
1335
|
+
context.shape,
|
|
1336
|
+
{ count: true },
|
|
1337
|
+
)
|
|
1338
|
+
const count = row === undefined ? '0' : ownEntry(row, 'count')
|
|
1339
|
+
return typeof count === 'string' ? count : '0'
|
|
1340
|
+
},
|
|
1341
|
+
insert: async (capability: unknown, values: unknown) =>
|
|
1342
|
+
child ? insertIntoChild(context, run, capability, values) : insertIntoDimension(context, run, capability, values),
|
|
1343
|
+
update: async (capability: unknown, key: string | undefined, values: unknown, expectVersion?: number) => {
|
|
1344
|
+
const scope = object(capability, key, 'update')
|
|
1345
|
+
const changes = readValues(values, context.model.update, 'update', context.model)
|
|
1346
|
+
return updateRow(context, run, addressed(scope), changes, versionArgument(context, expectVersion === undefined ? [] : [{ expectVersion }]))
|
|
1347
|
+
},
|
|
1348
|
+
remove: async (capability: unknown, key?: string) => removeRow(context, run, addressed(object(capability, key, 'delete'))),
|
|
1349
|
+
})
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
/**
|
|
1353
|
+
* The probe of an application table: every operation takes an `app` capability and, but for an insert, a key; a read
|
|
1354
|
+
* also takes a scoped site's `Ref` or `Within` of the table's read action.
|
|
1355
|
+
*/
|
|
1356
|
+
function appProbeOf(context: TableContext): TableProbe {
|
|
1357
|
+
const run = storeRunner(context)
|
|
1358
|
+
// A scoped site reads an application table too: the probe answers both, as ctx.db.app does.
|
|
1359
|
+
const scoped = (capability: unknown): boolean => runtimeOf(context.connection).capabilities.resolve(capability).kind !== 'app'
|
|
1360
|
+
const every = (capability: unknown): OperationNode =>
|
|
1361
|
+
scoped(capability) ? scopedApplicationRead(context, capability) : appListPredicate(context, capability)
|
|
1362
|
+
const count = async (capability: unknown): Promise<string> => {
|
|
1363
|
+
const [row] = await aggregateRows<Readonly<Record<string, unknown>>, Readonly<Record<string, unknown>>>(
|
|
1364
|
+
context,
|
|
1365
|
+
run,
|
|
1366
|
+
every(capability),
|
|
1367
|
+
context.shape,
|
|
1368
|
+
{ count: true },
|
|
1369
|
+
)
|
|
1370
|
+
const value = row === undefined ? '0' : ownEntry(row, 'count')
|
|
1371
|
+
return typeof value === 'string' ? value : '0'
|
|
1372
|
+
}
|
|
1373
|
+
return freeze({
|
|
1374
|
+
mode: 'app',
|
|
1375
|
+
get: async (capability: unknown, key?: string) => {
|
|
1376
|
+
if (scoped(capability)) {
|
|
1377
|
+
scopedApplicationRead(context, capability)
|
|
1378
|
+
return selectOne<Readonly<Record<string, unknown>>>(context, run, appKeyPredicate(context.model, key))
|
|
1379
|
+
}
|
|
1380
|
+
return selectOne<Readonly<Record<string, unknown>>>(context, run, appRowPredicate(context, capability, key, 'read'))
|
|
1381
|
+
},
|
|
1382
|
+
select: async (capability: unknown) => {
|
|
1383
|
+
const start = startSelect<Readonly<Record<string, unknown>>, NoColumns>(context.shape)
|
|
1384
|
+
return selectRows<Readonly<Record<string, unknown>>>(
|
|
1385
|
+
context,
|
|
1386
|
+
every(capability),
|
|
1387
|
+
finishSelect(start, start),
|
|
1388
|
+
requiredAction(context, 'read'),
|
|
1389
|
+
)
|
|
1390
|
+
},
|
|
1391
|
+
count,
|
|
1392
|
+
group: async (capability: unknown, columns: readonly string[]) =>
|
|
1393
|
+
aggregateRows<Readonly<Record<string, unknown>>, Readonly<Record<string, unknown>>>(context, run, every(capability), context.shape, {
|
|
1394
|
+
groupBy: columns,
|
|
1395
|
+
count: true,
|
|
1396
|
+
}),
|
|
1397
|
+
insert: async (capability: unknown, values: unknown) => insertIntoApplication(context, run, capability, values),
|
|
1398
|
+
update: async (capability: unknown, key: string | undefined, values: unknown, expectVersion?: number) => {
|
|
1399
|
+
const scope = appRowPredicate(context, capability, key, 'update')
|
|
1400
|
+
const changes = readValues(values, context.model.update, 'update', context.model)
|
|
1401
|
+
return updateRow(context, run, addressed(scope), changes, versionArgument(context, expectVersion === undefined ? [] : [{ expectVersion }]))
|
|
1402
|
+
},
|
|
1403
|
+
remove: async (capability: unknown, key?: string) => removeRow(context, run, addressed(appRowPredicate(context, capability, key, 'delete'))),
|
|
1404
|
+
})
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
function destinationOf(models: TableModels, model: TableModel, parent: TableModel | undefined): TableModel | undefined {
|
|
1408
|
+
if (model.scope.mode === 'child') {
|
|
1409
|
+
return parent
|
|
1410
|
+
}
|
|
1411
|
+
const container = model.scope.mode === 'dimension' ? model.scope.container : undefined
|
|
1412
|
+
if (container === undefined) {
|
|
1413
|
+
return undefined
|
|
1414
|
+
}
|
|
1415
|
+
const names = ownKeys(models)
|
|
1416
|
+
for (let index = 0; index < names.length; index++) {
|
|
1417
|
+
const candidate = modelOf(models, names[index] ?? '')
|
|
1418
|
+
if (candidate?.scope.mode === 'dimension' && candidate.scope.dimension === container.dimension) {
|
|
1419
|
+
return candidate
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
return undefined
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
/** What a select or an aggregate of a capability that reads `visible` may name: those columns, and no join. */
|
|
1426
|
+
export function actionShape(model: TableModel, visible: readonly string[]): TableShape {
|
|
1427
|
+
return freeze({ table: model.name, visible: nameSet(visible), joinable: freeze({}), dates: model.dates })
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
function shapeOf(model: TableModel, joinable: Readonly<Record<string, JoinShape>>): TableShape {
|
|
1431
|
+
return freeze({ table: model.name, visible: nameSet(model.visible), joinable, dates: model.dates })
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
function joinShapes(models: TableModels, parent: TableModel): Readonly<Record<string, JoinShape>> {
|
|
1435
|
+
const joinable: Record<string, JoinShape> = {}
|
|
1436
|
+
if (parent.scope.mode !== 'dimension') {
|
|
1437
|
+
return freeze(joinable)
|
|
1438
|
+
}
|
|
1439
|
+
const names = ownKeys(models)
|
|
1440
|
+
for (let index = 0; index < names.length; index++) {
|
|
1441
|
+
const model = modelOf(models, names[index] ?? '')
|
|
1442
|
+
if (model?.scope.mode === 'child' && model.scope.parent === parent.name) {
|
|
1443
|
+
const on: ForeignKeyPair[] = [{ childColumn: model.scope.column, parentColumn: parent.scope.key }]
|
|
1444
|
+
for (let position = 0; position < model.scope.inherit.length; position++) {
|
|
1445
|
+
const name = model.scope.inherit[position] ?? ''
|
|
1446
|
+
on[on.length] = { childColumn: name, parentColumn: name }
|
|
1447
|
+
}
|
|
1448
|
+
setOwn(joinable, model.name, freeze({ shape: shapeOf(model, freeze({})), readAction: model.actions.read, on: freeze(on) }))
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
return freeze(joinable)
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
/** What `serve(contract, { database })` takes: the policies as data. `boot` binds only an object `scoped` created. */
|
|
1455
|
+
export interface DataLayer {
|
|
1456
|
+
/** Every table's policy as data, for the contract compiler. */
|
|
1457
|
+
readonly tables: TablePolicies
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
declare const layerTypes: unique symbol
|
|
1461
|
+
|
|
1462
|
+
/**
|
|
1463
|
+
* What `scoped` returns: the policies, typed by the tables they came from. It has no connection, no locator and no
|
|
1464
|
+
* handle of its own; `boot` binds it through `@fabrika/db/framework`, which only `@fabrika/app` imports, and a
|
|
1465
|
+
* handler reaches data through `ctx.db`. The brand is type-only: no value has the property.
|
|
1466
|
+
*/
|
|
1467
|
+
export interface ScopedDatabase<DB, P> extends DataLayer {
|
|
1468
|
+
readonly [layerTypes]?: { readonly db: DB; readonly policies: P }
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
/**
|
|
1472
|
+
* Binds the table policies to the data layer. An application calls it once, in `src/app.ts`, and passes the result to
|
|
1473
|
+
* `serve(contract, { database })`. Nothing reaches the database until `boot` binds that one object; another `scoped`
|
|
1474
|
+
* result never gets a connection.
|
|
1475
|
+
*/
|
|
1476
|
+
export function scoped<DB, P>(tables: Tables<DB, P>): ScopedDatabase<DB, P> {
|
|
1477
|
+
const models = tableModels(tables)
|
|
1478
|
+
const connection: Connection = { runtime: undefined }
|
|
1479
|
+
|
|
1480
|
+
function contextFor(table: string, mode: 'dimension' | 'child' | 'app'): TableContext {
|
|
1481
|
+
const model = typeof table === 'string' ? modelOf(models, table) : undefined
|
|
1482
|
+
if (model === undefined || model.scope.mode !== mode) {
|
|
1483
|
+
throw new QueryRefusedError('unknown_table', `${String(table)} is not a ${mode} table`)
|
|
1484
|
+
}
|
|
1485
|
+
return contextOf(model)
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
function contextOf(model: TableModel): TableContext {
|
|
1489
|
+
const parent = model.scope.mode === 'child' ? modelOf(models, model.scope.parent) : undefined
|
|
1490
|
+
const joinable = joinShapes(models, model)
|
|
1491
|
+
const joins: Record<string, readonly ForeignKeyPair[]> = {}
|
|
1492
|
+
const children = ownKeys(joinable)
|
|
1493
|
+
for (let index = 0; index < children.length; index++) {
|
|
1494
|
+
const child = children[index] ?? ''
|
|
1495
|
+
const join = ownEntry(joinable, child)
|
|
1496
|
+
if (join !== undefined) {
|
|
1497
|
+
setOwn(joins, child, join.on)
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
const shape = shapeOf(model, joinable)
|
|
1501
|
+
return freeze({ model, parent, destination: destinationOf(models, model, parent), shape, joins: freeze(joins), connection })
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
const database: ScopedDatabase<DB, P> = Object.freeze({ tables: tablePolicies(tables) })
|
|
1505
|
+
register(database, {
|
|
1506
|
+
tableContext: contextFor,
|
|
1507
|
+
probe(table: string): TableProbe {
|
|
1508
|
+
const mode = modelOf(models, table)?.scope.mode
|
|
1509
|
+
if (mode === 'app') {
|
|
1510
|
+
return appProbeOf(contextFor(table, mode))
|
|
1511
|
+
}
|
|
1512
|
+
if (mode !== 'dimension' && mode !== 'child') {
|
|
1513
|
+
throw new QueryRefusedError('unknown_table', `${String(table)} is not a dimension, child or application table; no store reaches it`)
|
|
1514
|
+
}
|
|
1515
|
+
return probeOf(contextFor(table, mode))
|
|
1516
|
+
},
|
|
1517
|
+
bind(runtime: DataLayerRuntime): Locator {
|
|
1518
|
+
if (connection.runtime !== undefined) {
|
|
1519
|
+
throw new CapabilityError('layer_unbound', 'the data layer is already connected; only the framework connects it, once, at boot')
|
|
1520
|
+
}
|
|
1521
|
+
const bound: DataLayerRuntime = freeze({ database: runtime.database, capabilities: runtime.capabilities, readOnly: runtime.readOnly })
|
|
1522
|
+
connection.runtime = bound
|
|
1523
|
+
return async (dimension, id) => locateObject(models, bound, dimension, id)
|
|
1524
|
+
},
|
|
1525
|
+
files: fileTables(models, () => runtimeOf(connection)),
|
|
1526
|
+
framework: freeze({
|
|
1527
|
+
inboxProblems: (form: string, fields: readonly string[]) => inboxProblems(models, form, fields),
|
|
1528
|
+
appendToInbox: (form: string, values: Readonly<Record<string, unknown>>, quota: InboxQuota) =>
|
|
1529
|
+
appendToInbox(models, runtimeOf(connection), form, values, quota),
|
|
1530
|
+
webhooks: webhookTables(() => runtimeOf(connection)),
|
|
1531
|
+
jobs: jobLeases(() => runtimeOf(connection)),
|
|
1532
|
+
forms: formAdmissions(() => runtimeOf(connection)),
|
|
1533
|
+
}),
|
|
1534
|
+
data: dataAccess({
|
|
1535
|
+
models,
|
|
1536
|
+
contextOf(table: string): TableContext {
|
|
1537
|
+
const model = modelOf(models, table)
|
|
1538
|
+
if (model === undefined) {
|
|
1539
|
+
throw new QueryRefusedError('unknown_table', `${String(table)} is not a table of src/db/tables.ts`)
|
|
1540
|
+
}
|
|
1541
|
+
return contextOf(model)
|
|
1542
|
+
},
|
|
1543
|
+
runtime: () => runtimeOf(connection),
|
|
1544
|
+
newSession: (runtime) => sessionOf(runtime),
|
|
1545
|
+
newInvocation: (runtime, readOnly) => new InvocationTransaction(runtime.database, readOnly || runtime.readOnly?.() === true),
|
|
1546
|
+
fenced: (runtime, work) => fencedWork(runtime, work),
|
|
1547
|
+
}),
|
|
1548
|
+
})
|
|
1549
|
+
return database
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
/** The context of `table` in the layer `scoped` created, for the framework's own store builders below. */
|
|
1553
|
+
function storeContext(database: DataLayer, table: string, mode: 'dimension' | 'child'): TableContext {
|
|
1554
|
+
const side = frameworkSideOf(database)
|
|
1555
|
+
if (side === undefined) {
|
|
1556
|
+
throw new TypeError('not a data layer scoped(tables) created')
|
|
1557
|
+
}
|
|
1558
|
+
return side.tableContext(table, mode)
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
/**
|
|
1562
|
+
* A store over one dimension table of the layer: the framework's own tests and kits build them, never an application,
|
|
1563
|
+
* whose handlers reach data through `ctx.db`. Exported from `@fabrika/db/testing` only.
|
|
1564
|
+
*/
|
|
1565
|
+
export function defineStore<DB, P, const T extends DimensionTableName<P>, S>(
|
|
1566
|
+
database: ScopedDatabase<DB, P>,
|
|
1567
|
+
table: T,
|
|
1568
|
+
build: (t: DimensionTable<DB, P, T>) => S,
|
|
1569
|
+
): S {
|
|
1570
|
+
return build(new DimensionTable(storeContext(database, table, 'dimension')))
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
/** A store over one child table of the layer, as `defineStore`; exported from `@fabrika/db/testing` only. */
|
|
1574
|
+
export function defineChildStore<DB, P, const T extends ChildTableName<P>, S>(
|
|
1575
|
+
database: ScopedDatabase<DB, P>,
|
|
1576
|
+
table: T,
|
|
1577
|
+
build: (t: ChildTable<DB, P, T>) => S,
|
|
1578
|
+
): S {
|
|
1579
|
+
return build(new ChildTable(storeContext(database, table, 'child')))
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
/** The inbox table of `form`: the one table whose scope is `{ inbox: form }`. */
|
|
1583
|
+
function inboxOf(models: TableModels, form: string): TableModel | undefined {
|
|
1584
|
+
const names = ownKeys(models)
|
|
1585
|
+
for (let index = 0; index < names.length; index++) {
|
|
1586
|
+
const model = modelOf(models, names[index] ?? '')
|
|
1587
|
+
if (model?.scope.mode === 'inbox' && model.scope.form === form) {
|
|
1588
|
+
return model
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
return undefined
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
function inboxProblems(models: TableModels, form: string, fields: readonly string[]): readonly string[] {
|
|
1595
|
+
const inbox = inboxOf(models, form)
|
|
1596
|
+
if (inbox === undefined) {
|
|
1597
|
+
return [`no table in src/db/tables.ts is the inbox of '${form}' (scope: { inbox: '${form}', read })`]
|
|
1598
|
+
}
|
|
1599
|
+
const problems: string[] = []
|
|
1600
|
+
for (let index = 0; index < fields.length; index++) {
|
|
1601
|
+
const field = fields[index] ?? ''
|
|
1602
|
+
if (!inSet(inbox.columns, field)) {
|
|
1603
|
+
problems[problems.length] = `the input field '${field}' of '${form}' is not a column of its inbox ${inbox.name}`
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
return problems
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
/**
|
|
1610
|
+
* One row of the form's declared fields, taken from the form's `quota` bucket in the same transaction: a row that
|
|
1611
|
+
* fails to insert gives its place back. `false`, with nothing written, when the bucket is full. An inbox answers
|
|
1612
|
+
* nothing, so the form reads nothing back.
|
|
1613
|
+
*/
|
|
1614
|
+
async function appendToInbox(
|
|
1615
|
+
models: TableModels,
|
|
1616
|
+
runtime: DataLayerRuntime,
|
|
1617
|
+
form: string,
|
|
1618
|
+
values: Readonly<Record<string, unknown>>,
|
|
1619
|
+
quota: InboxQuota,
|
|
1620
|
+
): Promise<boolean> {
|
|
1621
|
+
const inbox = inboxOf(models, form)
|
|
1622
|
+
if (inbox === undefined) {
|
|
1623
|
+
throw new QueryRefusedError('unknown_table', `no table is the inbox of ${form}`)
|
|
1624
|
+
}
|
|
1625
|
+
const row = readValues(values, inbox.columns, 'insert', inbox)
|
|
1626
|
+
return await sessionOf(runtime).transaction(async (transaction) => {
|
|
1627
|
+
if (!(await takeFromBucket(transaction, form, quota.bucket, quota.limit))) {
|
|
1628
|
+
return false
|
|
1629
|
+
}
|
|
1630
|
+
await transaction.run(insertNode(inbox.name, row, undefined), {
|
|
1631
|
+
root: 'InsertQueryNode',
|
|
1632
|
+
table: inbox.name,
|
|
1633
|
+
joins: {},
|
|
1634
|
+
scope: undefined,
|
|
1635
|
+
})
|
|
1636
|
+
return true
|
|
1637
|
+
})
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
/** The table that owns `dimension`: the one dimension table scoped by it (`defineTables` refuses a second). */
|
|
1641
|
+
export function ownerOf(models: TableModels, dimension: string): TableModel | undefined {
|
|
1642
|
+
const names = ownKeys(models)
|
|
1643
|
+
for (let index = 0; index < names.length; index++) {
|
|
1644
|
+
const model = modelOf(models, names[index] ?? '')
|
|
1645
|
+
if (model?.scope.mode === 'dimension' && model.scope.dimension === dimension) {
|
|
1646
|
+
return model
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
return undefined
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
/** The table that owns `dimension`, which the locator reads; a dimension no table owns is located by nothing. */
|
|
1653
|
+
function locatedOwner(models: TableModels, dimension: unknown): TableModel {
|
|
1654
|
+
const owner = typeof dimension === 'string' ? ownerOf(models, dimension) : undefined
|
|
1655
|
+
if (owner === undefined) {
|
|
1656
|
+
throw new QueryRefusedError('unknown_table', `no table owns the dimension ${String(dimension)}, so nothing locates it`)
|
|
1657
|
+
}
|
|
1658
|
+
return owner
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
/** One guarded select by key of the containment columns the policy declares; the generated locator of the dimension. */
|
|
1662
|
+
async function locateObject(
|
|
1663
|
+
models: TableModels,
|
|
1664
|
+
runtime: DataLayerRuntime,
|
|
1665
|
+
dimension: string,
|
|
1666
|
+
id: string,
|
|
1667
|
+
): Promise<ReadonlyMap<string, string> | null> {
|
|
1668
|
+
const owner = locatedOwner(models, dimension)
|
|
1669
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
1670
|
+
throw new QueryRefusedError('row_address', `a ${dimension} is located by its ${keyOf(owner)}, a non-empty string`)
|
|
1671
|
+
}
|
|
1672
|
+
const key = keyOf(owner)
|
|
1673
|
+
const scope = equals(owner.name, key, id)
|
|
1674
|
+
const columns: string[] = [key]
|
|
1675
|
+
for (let index = 0; index < owner.chain.length; index++) {
|
|
1676
|
+
columns[columns.length] = owner.chain[index]?.column ?? ''
|
|
1677
|
+
}
|
|
1678
|
+
const context: GuardContext = { root: 'SelectQueryNode', table: owner.name, joins: {}, scope }
|
|
1679
|
+
const [row] = await sessionOf(runtime).run<Readonly<Record<string, unknown>>>(locateNode(owner.name, columns, scope), context)
|
|
1680
|
+
if (row === undefined) {
|
|
1681
|
+
return null
|
|
1682
|
+
}
|
|
1683
|
+
const chain = new Map<string, string>()
|
|
1684
|
+
for (let index = 0; index < owner.chain.length; index++) {
|
|
1685
|
+
const link = owner.chain[index]
|
|
1686
|
+
const value = link === undefined ? undefined : ownEntry(row, link.column)
|
|
1687
|
+
if (link === undefined || typeof value !== 'string') {
|
|
1688
|
+
throw new PolicyError(`${owner.name}.${link?.column ?? ''} came back as ${typeof value}; a containment column holds its scope value as text`)
|
|
1689
|
+
}
|
|
1690
|
+
chain.set(link.dimension, value)
|
|
1691
|
+
}
|
|
1692
|
+
return chain
|
|
1693
|
+
}
|