@fabrika/app 0.3.3 → 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/README.md +78 -90
- package/package.json +68 -6
- package/skill/SKILL.md +319 -0
- package/skill/references/app-setup.md +364 -0
- package/skill/references/dev.md +67 -0
- package/skill/references/endpoint.md +627 -0
- package/skill/references/errors.md +108 -0
- package/skill/references/files.md +335 -0
- package/skill/references/jobs.md +169 -0
- package/skill/references/public-intake.md +1495 -0
- package/skill/references/spa.md +741 -0
- package/skill/references/start.md +179 -0
- package/skill/references/table-and-migration.md +264 -0
- package/skill/references/tests.md +305 -0
- package/skill/references/webhooks-links-connectors.md +190 -0
- package/src/agent-skill.ts +77 -0
- package/src/build.ts +53 -0
- package/src/bun.ts +302 -3
- package/src/client/caller.ts +24 -0
- package/src/client/contract.ts +309 -0
- package/src/client/files.ts +135 -0
- package/src/client/index.ts +19 -0
- package/src/client/rpc.ts +47 -0
- package/src/client/share.ts +14 -0
- package/src/{client.ts → client/transport.ts} +58 -61
- package/src/cloudflare.ts +139 -3
- package/src/contract/artifact-schema.ts +317 -0
- package/src/contract/attachment.ts +74 -0
- package/src/contract/build.ts +1936 -0
- package/src/contract/compile.ts +143 -0
- package/src/contract/connect.ts +170 -0
- package/src/contract/context.ts +437 -0
- package/src/contract/declaration.ts +310 -0
- package/src/contract/defaults.ts +156 -0
- package/src/contract/define.ts +567 -0
- package/src/contract/file-schemas.ts +25 -0
- package/src/contract/group.ts +263 -0
- package/src/contract/handled.ts +50 -0
- package/src/contract/index.ts +34 -0
- package/src/contract/kit.ts +607 -0
- package/src/contract/platform.ts +113 -0
- package/src/contract/problems.ts +30 -0
- package/src/contract/schema-from-shape.ts +152 -0
- package/src/contract/schema-shape.ts +407 -0
- package/src/contract/snapshot-source.ts +168 -0
- package/src/contract/vocabulary.ts +50 -0
- package/src/disk-assets.ts +25 -0
- package/src/fetch.ts +10 -0
- package/src/files/archive.ts +292 -0
- package/src/files/clamscan.ts +253 -0
- package/src/files/collections.ts +669 -0
- package/src/files/handle.ts +77 -0
- package/src/files/scan.ts +308 -0
- package/src/files/sniff.ts +70 -0
- package/src/files/treatment.ts +48 -0
- package/src/from-row.ts +53 -0
- package/src/implement/anonymous.ts +102 -0
- package/src/implement/attachment.ts +143 -0
- package/src/implement/boot.ts +808 -0
- package/src/implement/bounded.ts +72 -0
- package/src/implement/browser-config.ts +54 -0
- package/src/implement/capability.ts +439 -0
- package/src/implement/challenge.ts +115 -0
- package/src/implement/connect.ts +386 -0
- package/src/implement/dispatch.ts +1085 -0
- package/src/implement/environment.ts +47 -0
- package/src/implement/failures.ts +116 -0
- package/src/implement/faults.ts +46 -0
- package/src/implement/form-admission.ts +120 -0
- package/src/implement/headers.ts +17 -0
- package/src/implement/implement.ts +81 -0
- package/src/implement/index.ts +60 -0
- package/src/implement/invocation.ts +346 -0
- package/src/implement/membership.ts +485 -0
- package/src/implement/principal.ts +99 -0
- package/src/implement/project.ts +76 -0
- package/src/implement/serve.ts +142 -0
- package/src/implement/types.ts +215 -0
- package/src/implement/webhook.ts +108 -0
- package/src/implement/workload.ts +160 -0
- package/src/implement/xlsx.ts +275 -0
- package/src/index.ts +12 -13
- package/src/jobs/report.ts +134 -0
- package/src/jobs/runner.ts +749 -0
- package/src/jobs/schedule.ts +129 -0
- package/src/jobs/types.ts +44 -0
- package/src/react/caller.tsx +60 -0
- package/src/react/errors.ts +82 -0
- package/src/react/hooks.ts +239 -0
- package/src/react/index.ts +31 -0
- package/src/react/provider.tsx +165 -0
- package/src/react/store.ts +215 -0
- package/src/react/types.ts +40 -0
- package/src/rpc/dispatcher.ts +4 -19
- package/src/service/define.ts +121 -0
- package/src/service/index.ts +21 -0
- package/src/service/serve.ts +99 -0
- package/src/testing/caller.ts +123 -0
- package/src/testing/dev.ts +73 -0
- package/src/testing/endpoint-access.ts +806 -0
- package/src/testing/index.ts +23 -0
- package/src/testing/isolation.ts +25 -0
- package/src/testing/kit.ts +552 -0
- package/src/testing/membership.ts +747 -0
- package/src/testing/policy-isolation.ts +827 -0
- package/src/testing/preload.ts +6 -0
- package/src/testing/proxy.ts +87 -0
- package/src/testing/runner.ts +61 -0
- package/src/testing/status.ts +19 -0
- package/src/undumpable.ts +42 -0
- package/src/app.ts +0 -76
- package/src/middleware.ts +0 -38
- package/src/router.ts +0 -149
- package/src/testing.ts +0 -119
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
// One invocation of a staged-kit site — an endpoint request, a webhook delivery, a job attempt — and the `ctx.db` its
|
|
2
|
+
// handler reaches data through. The invocation owns one transaction, opened by the first statement: the dispatcher
|
|
3
|
+
// commits it after the handler returned and its output passed validation, and rolls it back otherwise. Every handle
|
|
4
|
+
// is bound to the invocation: called from another request, another `boot`, or after it ended, it refuses.
|
|
5
|
+
|
|
6
|
+
import type { ContractArtifact } from '@fabrika/auth-core'
|
|
7
|
+
import {
|
|
8
|
+
type DataAccess,
|
|
9
|
+
type DataHandles,
|
|
10
|
+
type DataSite,
|
|
11
|
+
type InvocationTransaction,
|
|
12
|
+
type QueryRefusal,
|
|
13
|
+
QueryRefusedError,
|
|
14
|
+
type Session,
|
|
15
|
+
type SqlTag,
|
|
16
|
+
} from '@fabrika/db/framework'
|
|
17
|
+
import type { DataKind, UnscopedMode } from '@fabrika/db/tables'
|
|
18
|
+
import type { SqlMode } from '../contract/context'
|
|
19
|
+
import type { FileSite } from '../files/collections'
|
|
20
|
+
import { CapabilityRefusedError, isActiveRequest, type RequestFrame } from './capability'
|
|
21
|
+
import { ServerFault } from './faults'
|
|
22
|
+
|
|
23
|
+
/** A site's declared off-road reach, parsed at `boot`: what its handler's `ctx` gets beyond the paved road. */
|
|
24
|
+
export interface SiteDeclaration {
|
|
25
|
+
readonly sql: SqlMode | null
|
|
26
|
+
readonly unscoped: Readonly<Record<string, UnscopedMode>>
|
|
27
|
+
readonly connect: readonly string[]
|
|
28
|
+
readonly issues: string | null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type SiteFunction = (args: { readonly input?: unknown; readonly ctx: object }) => unknown
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A handler tree leaf of a staged-kit site, which `serve` builds from each handled endpoint, job and webhook: the
|
|
35
|
+
* handler and the site's `declared` object. `boot` builds the site's `ctx.db` (and the declared handles) from it.
|
|
36
|
+
*/
|
|
37
|
+
export interface SiteHandler {
|
|
38
|
+
readonly handler: SiteFunction
|
|
39
|
+
readonly declared: unknown
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const sites = new WeakSet<object>()
|
|
43
|
+
|
|
44
|
+
/** The leaf `serve` puts in the handler tree for one site. */
|
|
45
|
+
export function siteHandler(handler: SiteFunction, declared: unknown): SiteHandler {
|
|
46
|
+
const site = Object.freeze({ handler, declared })
|
|
47
|
+
sites.add(site)
|
|
48
|
+
return site
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Whether a handler tree node is a site leaf, rather than a group of handlers that happens to have the same fields. */
|
|
52
|
+
export function isSiteHandler(node: unknown): node is SiteHandler {
|
|
53
|
+
return typeof node === 'object' && node !== null && sites.has(node)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const DECLARED_FIELDS: readonly string[] = ['sql', 'unscoped', 'connect', 'issues']
|
|
57
|
+
const NO_DECLARATION: SiteDeclaration = Object.freeze({ sql: null, unscoped: Object.freeze({}), connect: Object.freeze([]), issues: null })
|
|
58
|
+
|
|
59
|
+
function isPlainObject(value: unknown): value is Readonly<Record<string, unknown>> {
|
|
60
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
const prototype: unknown = Object.getPrototypeOf(value)
|
|
64
|
+
return prototype === Object.prototype || prototype === null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function ownField(record: Readonly<Record<string, unknown>>, key: string): unknown {
|
|
68
|
+
return Object.hasOwn(record, key) ? record[key] : undefined
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The site's `declared` object, parsed at the boundary: the kit's types already refused what is wrong in it, and this
|
|
73
|
+
* refuses it again, because a widened or cast declaration would otherwise reach further than its site shows.
|
|
74
|
+
*/
|
|
75
|
+
export function parseDeclaration(where: string, declared: unknown, kind: DataKind, artifact: ContractArtifact, problems: string[]): SiteDeclaration {
|
|
76
|
+
if (declared === undefined) {
|
|
77
|
+
return NO_DECLARATION
|
|
78
|
+
}
|
|
79
|
+
if (!isPlainObject(declared)) {
|
|
80
|
+
problems.push(`${where}: its declaration is not a plain object`)
|
|
81
|
+
return NO_DECLARATION
|
|
82
|
+
}
|
|
83
|
+
for (const key of Object.keys(declared)) {
|
|
84
|
+
if (!DECLARED_FIELDS.includes(key)) {
|
|
85
|
+
problems.push(`${where}: '${key}' is no declaration (declarations: ${DECLARED_FIELDS.join(', ')})`)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const reads = kind === 'query'
|
|
89
|
+
const sql = ownField(declared, 'sql')
|
|
90
|
+
if (sql !== undefined && sql !== 'read' && (sql !== 'write' || reads)) {
|
|
91
|
+
problems.push(`${where}: sql is ${JSON.stringify(sql)}; ${reads ? "a query declares only sql: 'read'" : "sql is 'read' or 'write'"}`)
|
|
92
|
+
}
|
|
93
|
+
const unscoped: Record<string, UnscopedMode> = {}
|
|
94
|
+
const tables = ownField(declared, 'unscoped')
|
|
95
|
+
if (tables !== undefined && !isPlainObject(tables)) {
|
|
96
|
+
problems.push(`${where}: unscoped is not an object of tables`)
|
|
97
|
+
}
|
|
98
|
+
for (const [table, mode] of Object.entries(isPlainObject(tables) ? tables : {})) {
|
|
99
|
+
if (table.startsWith('fabrika_') || !Object.hasOwn(artifact.tables, table)) {
|
|
100
|
+
problems.push(`${where}: unscoped declares '${table}', which is not a table of src/db/tables.ts`)
|
|
101
|
+
} else if (mode === 'read' || (mode === 'write' && !reads)) {
|
|
102
|
+
unscoped[table] = mode
|
|
103
|
+
} else {
|
|
104
|
+
problems.push(
|
|
105
|
+
`${where}: unscoped declares '${table}' as ${JSON.stringify(mode)}; ${
|
|
106
|
+
reads ? "a query declares only 'read'" : "the modes are 'read' and 'write'"
|
|
107
|
+
}`,
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const connect = ownField(declared, 'connect')
|
|
112
|
+
const connectors = Array.isArray(connect) ? connect.filter((name): name is string => typeof name === 'string') : []
|
|
113
|
+
if (connect !== undefined && (!Array.isArray(connect) || connectors.length !== connect.length)) {
|
|
114
|
+
problems.push(`${where}: connect is not a list of connector names`)
|
|
115
|
+
}
|
|
116
|
+
for (const name of connectors) {
|
|
117
|
+
if (!Object.hasOwn(artifact.imports, name)) {
|
|
118
|
+
problems.push(`${where}: connect names '${name}', which the kit does not import`)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const issues = ownField(declared, 'issues')
|
|
122
|
+
if (issues !== undefined && typeof issues !== 'string') {
|
|
123
|
+
problems.push(`${where}: issues is not the name of a link mount`)
|
|
124
|
+
}
|
|
125
|
+
return Object.freeze({
|
|
126
|
+
sql: sql === 'read' || sql === 'write' ? sql : null,
|
|
127
|
+
unscoped: Object.freeze(unscoped),
|
|
128
|
+
connect: Object.freeze(connectors),
|
|
129
|
+
issues: typeof issues === 'string' ? issues : null,
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The declaration a site runs with: the one the approved contract holds (`ContractArtifact.declared`, which the
|
|
135
|
+
* contract's digest covers), by the site's endpoint id or `job:<name>`. A leaf whose own `declared` says otherwise is
|
|
136
|
+
* refused, so a declaration only the handler tree carries never widens a site past what was approved.
|
|
137
|
+
*/
|
|
138
|
+
export function approvedDeclaration(
|
|
139
|
+
where: string,
|
|
140
|
+
site: string,
|
|
141
|
+
leaf: unknown,
|
|
142
|
+
kind: DataKind,
|
|
143
|
+
artifact: ContractArtifact,
|
|
144
|
+
problems: string[],
|
|
145
|
+
): SiteDeclaration {
|
|
146
|
+
const before = problems.length
|
|
147
|
+
const declared = parseDeclaration(where, leaf, kind, artifact, problems)
|
|
148
|
+
const approved = approvedReach(site, artifact)
|
|
149
|
+
// A leaf the parse refused is reported once, for what is wrong in it.
|
|
150
|
+
if (problems.length === before && describeDeclaration(declared) !== describeDeclaration(approved)) {
|
|
151
|
+
problems.push(
|
|
152
|
+
`${where}: its handler declares ${describeDeclaration(declared)}, but the approved contract declares ${
|
|
153
|
+
describeDeclaration(approved)
|
|
154
|
+
}; declare the site's reach beside its declaration in the shell, which the contract's digest covers`,
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
return approved
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** What the approved artifact declares for `site` (an endpoint id or `job:<name>`); nothing when it holds none. */
|
|
161
|
+
export function approvedReach(site: string, artifact: ContractArtifact): SiteDeclaration {
|
|
162
|
+
const reach = artifact.declared === undefined || !Object.hasOwn(artifact.declared, site) ? undefined : artifact.declared[site]
|
|
163
|
+
return reach === undefined
|
|
164
|
+
? NO_DECLARATION
|
|
165
|
+
: Object.freeze({
|
|
166
|
+
sql: reach.sql,
|
|
167
|
+
unscoped: Object.freeze({ ...reach.unscoped }),
|
|
168
|
+
connect: Object.freeze([...reach.connect]),
|
|
169
|
+
issues: reach.issues,
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** A declaration as one comparable line: its fields in a fixed order, its tables and connectors sorted. */
|
|
174
|
+
function describeDeclaration(declaration: SiteDeclaration): string {
|
|
175
|
+
const unscoped = Object.entries(declaration.unscoped).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
176
|
+
return JSON.stringify({ sql: declaration.sql, unscoped, connect: [...declaration.connect].sort(), issues: declaration.issues })
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Refuses a handle used outside the work it was made for: another request, another `boot`, an ended one. `handle` names
|
|
181
|
+
* it in the refusal: a `ctx.db` handle, or `ctx.sql`.
|
|
182
|
+
*/
|
|
183
|
+
export function boundTo(frame: RequestFrame, ended: () => boolean, handle = 'a ctx.db handle'): () => void {
|
|
184
|
+
return () => {
|
|
185
|
+
if (frame.ended || ended()) {
|
|
186
|
+
throw new CapabilityRefusedError('ended', `${handle} of ${frame.id} was used after its invocation ended; never keep a handle past its handler`)
|
|
187
|
+
}
|
|
188
|
+
if (!isActiveRequest(frame)) {
|
|
189
|
+
throw new CapabilityRefusedError('foreign_work', `${handle} of ${frame.id} was used from other work; never share a handle`)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** One invocation's transaction, and the data handles of its site. */
|
|
195
|
+
export class Invocation {
|
|
196
|
+
readonly #frame: RequestFrame
|
|
197
|
+
readonly #transaction: InvocationTransaction
|
|
198
|
+
readonly #data: DataAccess
|
|
199
|
+
|
|
200
|
+
constructor(frame: RequestFrame, data: DataAccess, readOnly: boolean) {
|
|
201
|
+
this.#frame = frame
|
|
202
|
+
this.#data = data
|
|
203
|
+
this.#transaction = data.invocation(readOnly)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The handles of the site, all of them running in this invocation's transaction. */
|
|
207
|
+
handles(kind: DataKind, declaration: SiteDeclaration, principal: string): DataHandles {
|
|
208
|
+
const transaction = this.#transaction
|
|
209
|
+
const site: DataSite = {
|
|
210
|
+
kind,
|
|
211
|
+
unscoped: declaration.unscoped,
|
|
212
|
+
principal,
|
|
213
|
+
check: boundTo(this.#frame, () => transaction.ended),
|
|
214
|
+
session: () => transaction.session(),
|
|
215
|
+
}
|
|
216
|
+
return this.#data.handles(site)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** `ctx.sql` of a site that declares it: its statements run in this invocation's transaction, like every handle's. */
|
|
220
|
+
sql(mode: SqlMode): SqlTag {
|
|
221
|
+
const transaction = this.#transaction
|
|
222
|
+
return this.#data.sql({
|
|
223
|
+
mode,
|
|
224
|
+
check: boundTo(this.#frame, () => transaction.ended, 'ctx.sql'),
|
|
225
|
+
session: () => transaction.session(),
|
|
226
|
+
fail: () => transaction.fail(),
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Where `ctx.files` runs: this invocation's transaction, bound like every handle; a query's only reads. */
|
|
231
|
+
fileSite(where: string, kind: DataKind): Omit<FileSite, 'frame'> {
|
|
232
|
+
const transaction = this.#transaction
|
|
233
|
+
return {
|
|
234
|
+
where,
|
|
235
|
+
check: boundTo(this.#frame, () => transaction.ended, 'ctx.files'),
|
|
236
|
+
session: () => transaction.session(),
|
|
237
|
+
readOnly: kind === 'mutation' && !transaction.readOnly ? null : 'a query reads only; remove files in a mutation',
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Commits; a request the caller abandoned meanwhile rolls back instead. */
|
|
242
|
+
async commit(signal: AbortSignal | null): Promise<void> {
|
|
243
|
+
if (signal?.aborted === true) {
|
|
244
|
+
await this.#transaction.rollback()
|
|
245
|
+
throw new ServerFault(
|
|
246
|
+
'request_cancelled',
|
|
247
|
+
`request ${this.#frame.id} was cancelled or outlived its budget before its transaction committed; nothing it wrote is kept`,
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
await this.#transaction.commit()
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
rollback(): Promise<void> {
|
|
254
|
+
return this.#transaction.rollback()
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** What a request's access form handed the handler, by `ctx` name. */
|
|
259
|
+
export interface AccessCapabilities {
|
|
260
|
+
readonly target?: unknown
|
|
261
|
+
readonly within?: unknown
|
|
262
|
+
readonly created?: unknown
|
|
263
|
+
/** An `app` endpoint's capability, `ctx.app`. */
|
|
264
|
+
readonly app?: unknown
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** `ctx.db` of an endpoint, by its access form: the API matrix of `docs/reference/application-runtime.md`. */
|
|
268
|
+
export function endpointData(
|
|
269
|
+
handles: DataHandles,
|
|
270
|
+
form: 'on' | 'in' | 'over' | 'creates' | 'app' | 'member',
|
|
271
|
+
access: AccessCapabilities,
|
|
272
|
+
): object {
|
|
273
|
+
const shared = { reference: handles.reference, ...(handles.unscoped === undefined ? {} : { unscoped: handles.unscoped }) }
|
|
274
|
+
switch (form) {
|
|
275
|
+
case 'on':
|
|
276
|
+
return Object.freeze({ target: handles.target(access.target, 'on'), app: handles.app(access.target), ...shared })
|
|
277
|
+
case 'in':
|
|
278
|
+
return Object.freeze({ target: handles.target(access.target, 'in'), app: handles.app(access.target), ...shared })
|
|
279
|
+
case 'creates':
|
|
280
|
+
return Object.freeze({ created: handles.target(access.created, 'in'), ...shared })
|
|
281
|
+
case 'over':
|
|
282
|
+
return Object.freeze({ within: handles.within(access.within), app: handles.app(access.within), ...shared })
|
|
283
|
+
case 'app':
|
|
284
|
+
return Object.freeze({ app: handles.app(access.app), ...shared })
|
|
285
|
+
case 'member':
|
|
286
|
+
return Object.freeze(shared)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** `ctx.db` of a job or a webhook: each capability it minted or received binds the same handles an endpoint gets. */
|
|
291
|
+
export function siteData(handles: DataHandles): object {
|
|
292
|
+
return Object.freeze({
|
|
293
|
+
on: (ref: unknown) => handles.target(ref, 'on'),
|
|
294
|
+
in: (ref: unknown) => handles.target(ref, 'in'),
|
|
295
|
+
over: (within: unknown) => handles.within(within),
|
|
296
|
+
reference: handles.reference,
|
|
297
|
+
...(handles.unscoped === undefined ? {} : { unscoped: handles.unscoped }),
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** The handles of a job attempt, outside its fenced transaction (`'job'`, each read on its own) or inside it. */
|
|
302
|
+
export function jobHandles(
|
|
303
|
+
frame: RequestFrame,
|
|
304
|
+
data: DataAccess,
|
|
305
|
+
declaration: SiteDeclaration,
|
|
306
|
+
principal: string,
|
|
307
|
+
kind: 'job' | 'mutation',
|
|
308
|
+
session: () => Promise<Session>,
|
|
309
|
+
ended: () => boolean,
|
|
310
|
+
): DataHandles {
|
|
311
|
+
return data.handles({ kind, unscoped: declaration.unscoped, principal, check: boundTo(frame, ended), session })
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* A `ctx.sql` (`tx.sql`) that may not run here. The types already refuse it; at run time it exists only to refuse by
|
|
316
|
+
* name, so an untyped call reports what to change rather than a `TypeError`.
|
|
317
|
+
*/
|
|
318
|
+
function refusingSql(reason: QueryRefusal, message: string): SqlTag {
|
|
319
|
+
const refuse = (): never => {
|
|
320
|
+
throw new QueryRefusedError(reason, message)
|
|
321
|
+
}
|
|
322
|
+
return Object.freeze(Object.assign(() => refuse(), { scope: () => refuse() }))
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** `ctx.sql` (or `member`, a job's `tx.sql`) of a site that does not declare `sql`. */
|
|
326
|
+
export function undeclaredSql(site: string, kind: DataKind, member: 'ctx.sql' | 'tx.sql' = 'ctx.sql'): SqlTag {
|
|
327
|
+
const declaration = kind === 'query' ? "sql: 'read'" : "sql: 'read' (or 'write' to write)"
|
|
328
|
+
return refusingSql('undeclared_reach', `${site} does not declare sql, so it has no ${member}; add ${declaration} to it`)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** `ctx.sql` of a job that declares `sql`, outside `ctx.transaction`: its statements run only in the attempt's fence. */
|
|
332
|
+
export function jobSqlOutsideTransaction(job: string): SqlTag {
|
|
333
|
+
return refusingSql('sql_outside_transaction', `${job} runs ctx.sql inside ctx.transaction((tx) => …), as tx.sql`)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** `ctx.files` of a contract that declares no file collection: every name is refused. */
|
|
337
|
+
export function noFileCollections(where: string): (collection: string) => never {
|
|
338
|
+
return (collection) => {
|
|
339
|
+
throw new ServerFault('file_collection_unknown', `${where}: ctx.files('${String(collection)}') names a collection, and the contract declares none`)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** `tx.sql` of a job site that declares it: its statements run in the attempt's fenced transaction. */
|
|
344
|
+
export function jobSql(frame: RequestFrame, data: DataAccess, mode: SqlMode, session: Session, ended: () => boolean): SqlTag {
|
|
345
|
+
return data.sql({ mode, check: boundTo(frame, ended, 'tx.sql'), session: async () => session, fail: () => session.fail() })
|
|
346
|
+
}
|