@fabrika/app 0.5.5 → 0.7.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 +9 -9
- package/skill/SKILL.md +29 -21
- package/skill/references/app-setup.md +54 -36
- package/skill/references/dev.md +5 -5
- package/skill/references/endpoint.md +152 -25
- package/skill/references/errors.md +26 -23
- package/skill/references/files.md +187 -137
- package/skill/references/jobs.md +82 -35
- package/skill/references/public-intake.md +387 -236
- package/skill/references/spa.md +5 -5
- package/skill/references/start.md +2 -1
- package/skill/references/table-and-migration.md +24 -2
- package/skill/references/tests.md +8 -5
- package/skill/references/webhooks-links-connectors.md +65 -12
- package/src/bun.ts +3 -2
- package/src/client/contract.ts +184 -74
- package/src/client/index.ts +2 -1
- package/src/client/transport.ts +7 -2
- package/src/cloudflare.ts +2 -0
- package/src/contract/artifact-schema.ts +110 -15
- package/src/contract/build.ts +1184 -613
- package/src/contract/compile.ts +44 -10
- package/src/contract/connect.ts +71 -5
- package/src/contract/context.ts +132 -23
- package/src/contract/declaration.ts +78 -63
- package/src/contract/defaults.ts +64 -58
- package/src/contract/define.ts +76 -124
- package/src/contract/file.ts +78 -0
- package/src/contract/group.ts +244 -37
- package/src/contract/index.ts +18 -0
- package/src/contract/kit.ts +206 -126
- package/src/contract/platform.ts +8 -9
- package/src/contract/redirect.ts +49 -0
- package/src/contract/schema-from-shape.ts +7 -0
- package/src/contract/schema-shape.ts +10 -0
- package/src/contract/snapshot-source.ts +5 -0
- package/src/contract/token.ts +89 -0
- package/src/contract/webhook-binding.ts +26 -0
- package/src/contract/webhook-scheme.ts +32 -0
- package/src/files/handle.ts +22 -0
- package/src/files/kinds.ts +454 -0
- package/src/files/scan.ts +15 -9
- package/src/files/streams.ts +111 -0
- package/src/honeypot.ts +7 -0
- package/src/implement/anonymous.ts +18 -42
- package/src/implement/boot.ts +177 -159
- package/src/implement/bounded.ts +23 -0
- package/src/implement/browser-config.ts +4 -4
- package/src/implement/capability.ts +78 -2
- package/src/implement/{form-admission.ts → client-address.ts} +3 -38
- package/src/implement/dispatch.ts +810 -409
- package/src/implement/egress.ts +84 -0
- package/src/implement/faults.ts +11 -1
- package/src/implement/index.ts +3 -2
- package/src/implement/invocation.ts +135 -35
- package/src/implement/params.ts +88 -0
- package/src/implement/principal.ts +7 -0
- package/src/implement/public.ts +142 -0
- package/src/implement/tokens.ts +219 -0
- package/src/implement/types.ts +24 -3
- package/src/implement/urls.ts +78 -0
- package/src/implement/verifiers.ts +299 -0
- package/src/implement/webhook.ts +136 -55
- package/src/implement/workload.ts +16 -14
- package/src/index.ts +3 -0
- package/src/jobs/mail.ts +98 -10
- package/src/jobs/runner.ts +103 -71
- package/src/jobs/scrub.ts +4 -1
- package/src/jobs/types.ts +19 -2
- package/src/react/hooks.ts +2 -2
- package/src/react/provider.tsx +2 -2
- package/src/testing/dev.ts +2 -2
- package/src/testing/endpoint-access.ts +167 -128
- package/src/testing/kit.ts +79 -24
- package/src/testing/policy-isolation.ts +55 -29
- package/src/testing/proxy.ts +6 -2
- package/src/testing/sample-file.ts +30 -0
- package/src/client/files.ts +0 -135
- package/src/contract/file-schemas.ts +0 -25
- package/src/files/collections.ts +0 -848
package/src/contract/compile.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// `buildArtifact`. A reader of source produces the same declaration and calls `buildArtifact` itself.
|
|
3
3
|
|
|
4
4
|
import type { ContractArtifact, SchemaShape, TablePolicyArtifact } from '@fabrika/auth-core'
|
|
5
|
-
import {
|
|
5
|
+
import { isTables, tablePolicies, type Tables } from '@fabrika/db/tables'
|
|
6
|
+
import { z } from 'zod/v4'
|
|
6
7
|
import { attachmentOf } from './attachment'
|
|
7
8
|
import { buildArtifact } from './build'
|
|
8
9
|
import {
|
|
@@ -13,24 +14,25 @@ import {
|
|
|
13
14
|
type MountDeclaration,
|
|
14
15
|
type ProcedureDeclaration,
|
|
15
16
|
type ProcedureTreeDeclaration,
|
|
17
|
+
type RedirectDeclaration,
|
|
16
18
|
SURFACE_SECTIONS,
|
|
17
19
|
type SurfaceSection,
|
|
18
20
|
} from './declaration'
|
|
19
21
|
import type { AnyContract, AnyMount, Schema, Surface } from './define'
|
|
20
22
|
import { Problems } from './problems'
|
|
23
|
+
import { redirectOf } from './redirect'
|
|
21
24
|
import { describeSchema } from './schema-shape'
|
|
22
25
|
|
|
23
26
|
/**
|
|
24
27
|
* The normalized artifact of a contract and its table policies: what `defineTables` returned (`src/db/tables.ts`),
|
|
25
|
-
* or the artifact form of each policy.
|
|
26
|
-
* against them; `boot` checks them against the data layer either way.
|
|
28
|
+
* or the artifact form of each policy.
|
|
27
29
|
*/
|
|
28
30
|
export function compileContract<DB, P>(
|
|
29
31
|
contract: AnyContract,
|
|
30
32
|
tables: Tables<DB, P> | Readonly<Record<string, TablePolicyArtifact>>,
|
|
31
33
|
): ContractArtifact {
|
|
32
34
|
const described = describeContract(contract)
|
|
33
|
-
return
|
|
35
|
+
return buildArtifact(described, isTables(tables) ? tablePolicies(tables) : tables)
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
/** The contract with its schemas read as shapes; throws `ContractError` for a schema outside the subset. */
|
|
@@ -46,13 +48,21 @@ export function describeContract(contract: AnyContract): ContractDeclaration<Sch
|
|
|
46
48
|
if (isMount(entry)) {
|
|
47
49
|
mounts[name] = reader.mount(name, entry)
|
|
48
50
|
} else {
|
|
49
|
-
problems.add(`mount '${name}'`, 'is not a mount (allowed: c.rpc, c.link, c.spa, c.health
|
|
51
|
+
problems.add(`mount '${name}'`, 'is not a mount (allowed: c.rpc, c.link, c.spa, c.health or app.webhook)')
|
|
50
52
|
}
|
|
51
53
|
}
|
|
52
54
|
const exports: Record<string, ExportDeclaration<SchemaShape>> = {}
|
|
53
55
|
for (const [name, declaration] of Object.entries(surface.exports ?? {})) {
|
|
54
56
|
exports[name] = { ...declaration, procedures: reader.tree(`export '${name}'`, declaration.procedures) }
|
|
55
57
|
}
|
|
58
|
+
const tokens = surface.tokens === undefined
|
|
59
|
+
? undefined
|
|
60
|
+
: Object.fromEntries(
|
|
61
|
+
Object.entries(surface.tokens).map(([purpose, declared]) => [purpose, {
|
|
62
|
+
claims: reader.schema(`token '${purpose}' claims`, declared.claims),
|
|
63
|
+
maxDays: declared.maxDays,
|
|
64
|
+
}]),
|
|
65
|
+
)
|
|
56
66
|
problems.throwIfAny()
|
|
57
67
|
return {
|
|
58
68
|
app: vocabulary.app,
|
|
@@ -65,6 +75,7 @@ export function describeContract(contract: AnyContract): ContractDeclaration<Sch
|
|
|
65
75
|
exports,
|
|
66
76
|
imports: surface.imports ?? {},
|
|
67
77
|
csp: surface.csp ?? {},
|
|
78
|
+
...(tokens === undefined ? {} : { tokens }),
|
|
68
79
|
...(vocabulary.locale === undefined ? {} : { locale: vocabulary.locale }),
|
|
69
80
|
...(vocabulary.displayName === undefined ? {} : { displayName: vocabulary.displayName }),
|
|
70
81
|
...(vocabulary.mail === undefined ? {} : { mail: vocabulary.mail }),
|
|
@@ -79,7 +90,7 @@ function isSection(name: string): name is SurfaceSection {
|
|
|
79
90
|
return SECTIONS.has(name)
|
|
80
91
|
}
|
|
81
92
|
|
|
82
|
-
const MOUNT_KINDS: ReadonlySet<string> = new Set(['rpc', 'link', 'spa', 'health', '
|
|
93
|
+
const MOUNT_KINDS: ReadonlySet<string> = new Set(['rpc', 'link', 'spa', 'health', 'webhook'])
|
|
83
94
|
|
|
84
95
|
function isMount(entry: Surface[string]): entry is AnyMount {
|
|
85
96
|
return typeof entry === 'object' && 'kind' in entry && typeof entry.kind === 'string' && MOUNT_KINDS.has(entry.kind)
|
|
@@ -99,8 +110,6 @@ class SchemaReader {
|
|
|
99
110
|
return { ...mount, procedures: this.tree(where, mount.procedures) }
|
|
100
111
|
case 'link':
|
|
101
112
|
return { ...mount, procedures: this.tree(where, mount.procedures) }
|
|
102
|
-
case 'form':
|
|
103
|
-
return { ...mount, input: this.schema(`${where} input`, mount.input) }
|
|
104
113
|
case 'webhook':
|
|
105
114
|
return { ...mount, input: this.schema(`${where} input`, mount.input) }
|
|
106
115
|
default:
|
|
@@ -118,6 +127,7 @@ class SchemaReader {
|
|
|
118
127
|
...node,
|
|
119
128
|
input: node.input === null ? null : this.schema(`${at} input`, node.input),
|
|
120
129
|
...this.output(`${at} output`, node.output),
|
|
130
|
+
...(node.http === undefined ? {} : { http: this.http(`${at} http.query`, node.http) }),
|
|
121
131
|
}
|
|
122
132
|
} else {
|
|
123
133
|
described[name] = this.tree(where, node, path)
|
|
@@ -126,8 +136,27 @@ class SchemaReader {
|
|
|
126
136
|
return described
|
|
127
137
|
}
|
|
128
138
|
|
|
129
|
-
/**
|
|
130
|
-
|
|
139
|
+
/** `http` as written, its `query` schema described; `buildArtifact` checks the rest. */
|
|
140
|
+
http(where: string, http: unknown): unknown {
|
|
141
|
+
if (typeof http !== 'object' || http === null) {
|
|
142
|
+
return http
|
|
143
|
+
}
|
|
144
|
+
const query: unknown = Reflect.get(http, 'query')
|
|
145
|
+
return isSchema(query) ? { ...http, query: this.schema(where, query) } : http
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* An `attachment()` output is described as the rows' array, its declaration carried beside it; a `redirect()` as its
|
|
150
|
+
* `{ to }` schema, its origins beside it.
|
|
151
|
+
*/
|
|
152
|
+
output(
|
|
153
|
+
where: string,
|
|
154
|
+
schema: Schema,
|
|
155
|
+
): { readonly output: SchemaShape; readonly attachment?: AttachmentDeclaration; readonly redirect?: RedirectDeclaration } {
|
|
156
|
+
const target = redirectOf(schema)
|
|
157
|
+
if (target !== null) {
|
|
158
|
+
return { output: this.schema(where, schema), redirect: { origins: [...target.origins] } }
|
|
159
|
+
}
|
|
131
160
|
const mark = attachmentOf(schema)
|
|
132
161
|
if (mark === null) {
|
|
133
162
|
return { output: this.schema(where, schema) }
|
|
@@ -141,3 +170,8 @@ class SchemaReader {
|
|
|
141
170
|
return describeSchema(schema, where, this.#problems) ?? { type: 'null' }
|
|
142
171
|
}
|
|
143
172
|
}
|
|
173
|
+
|
|
174
|
+
/** Whether `value` is a zod schema: what a contract writes where a schema stands. */
|
|
175
|
+
function isSchema(value: unknown): value is Schema {
|
|
176
|
+
return value instanceof z.ZodType
|
|
177
|
+
}
|
package/src/contract/connect.ts
CHANGED
|
@@ -3,13 +3,16 @@
|
|
|
3
3
|
// Browser-safe: the snapshot is imported by the contract, which the SPA imports.
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
|
+
canonicalAdmission,
|
|
6
7
|
canonicalJson,
|
|
8
|
+
canonicalOutput,
|
|
7
9
|
canonicalShape,
|
|
8
10
|
type ContractArtifact,
|
|
11
|
+
type EndpointRow,
|
|
9
12
|
type ExportDescription,
|
|
10
13
|
exportDigest,
|
|
11
14
|
type ExportProcedureDescription,
|
|
12
|
-
|
|
15
|
+
isSchemaOutput,
|
|
13
16
|
type SchemaShape,
|
|
14
17
|
} from '@fabrika/auth-core'
|
|
15
18
|
import { z } from 'zod/v4'
|
|
@@ -49,10 +52,8 @@ export function exportDescription(artifact: ContractArtifact, name: string): Exp
|
|
|
49
52
|
const prefix = `${exportBase(name, declaration.version)}.`
|
|
50
53
|
const procedures: Record<string, ExportProcedureDescription> = {}
|
|
51
54
|
for (const row of artifact.endpoints) {
|
|
52
|
-
// The build refuses an attachment on an export, so an export row's output is a schema.
|
|
53
|
-
if (
|
|
54
|
-
row.source.kind === 'export' && row.source.name === name && row.id.startsWith(prefix) && row.output !== null && !isAttachmentShape(row.output)
|
|
55
|
-
) {
|
|
55
|
+
// The build refuses an attachment and a redirect on an export, so an export row's output is a schema.
|
|
56
|
+
if (row.source.kind === 'export' && row.source.name === name && row.id.startsWith(prefix) && row.output !== null && isSchemaOutput(row.output)) {
|
|
56
57
|
procedures[row.id.slice(prefix.length)] = { input: row.input, output: row.output }
|
|
57
58
|
}
|
|
58
59
|
}
|
|
@@ -147,6 +148,71 @@ export function incompatibleChanges(before: ExportDescription, after: ExportDesc
|
|
|
147
148
|
return problems
|
|
148
149
|
}
|
|
149
150
|
|
|
151
|
+
/**
|
|
152
|
+
* What `after` breaks for the callers of each mount `before` promises `compatibility: 'backward'`: a procedure it
|
|
153
|
+
* removes, an input or a query that accepts less, another output, another HTTP shape (method, path, body codec), file
|
|
154
|
+
* answer or access (its kind, callers, requirement and cross-origin form), or a mount moved. The promise binds the
|
|
155
|
+
* publication that drops it too, so a breaking change is a new mount beside the old one. Empty when compatible.
|
|
156
|
+
*/
|
|
157
|
+
export function incompatibleMountChanges(before: ContractArtifact, after: ContractArtifact): string[] {
|
|
158
|
+
const problems: string[] = []
|
|
159
|
+
for (const [name, mount] of Object.entries(before.mounts)) {
|
|
160
|
+
if (mount.kind !== 'rpc' || mount.compatibility !== 'backward') {
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
const now = Object.hasOwn(after.mounts, name) ? after.mounts[name] : undefined
|
|
164
|
+
if (now?.kind !== 'rpc' || now.prefix !== mount.prefix) {
|
|
165
|
+
problems.push(`mount '${name}' promises backward compatibility, and this deploy removes or moves it`)
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
for (const row of before.endpoints.filter((endpoint) => isMountProcedure(endpoint, name))) {
|
|
169
|
+
const next = after.endpoints.find((candidate) => candidate.id === row.id)
|
|
170
|
+
if (next === undefined || !isMountProcedure(next, name)) {
|
|
171
|
+
problems.push(`removes '${row.id}'`)
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
problems.push(...procedureBreaks(row, next, before.procedures?.[row.id]?.query, after.procedures?.[row.id]?.query))
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return problems
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function isMountProcedure(row: EndpointRow, mount: string): boolean {
|
|
181
|
+
return row.source.kind === 'mount' && row.source.name === mount && (row.kind === 'query' || row.kind === 'mutation' || row.kind === 'public')
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function procedureBreaks(
|
|
185
|
+
before: EndpointRow,
|
|
186
|
+
after: EndpointRow,
|
|
187
|
+
queryBefore: SchemaShape | undefined,
|
|
188
|
+
queryAfter: SchemaShape | undefined,
|
|
189
|
+
): string[] {
|
|
190
|
+
const id = before.id
|
|
191
|
+
const breaks: string[] = []
|
|
192
|
+
if (!acceptsEveryInput(before.input, after.input) || !acceptsEveryInput(queryBefore ?? null, queryAfter ?? null)) {
|
|
193
|
+
breaks.push(`'${id}' accepts less input than before`)
|
|
194
|
+
}
|
|
195
|
+
if (
|
|
196
|
+
canonicalJson(before.output === null ? null : canonicalOutput(before.output))
|
|
197
|
+
!== canonicalJson(after.output === null ? null : canonicalOutput(after.output))
|
|
198
|
+
) {
|
|
199
|
+
breaks.push(`'${id}' answers another output than before`)
|
|
200
|
+
}
|
|
201
|
+
const admission = (row: EndpointRow) => (row.admission === null ? null : canonicalAdmission(row.admission).contentTypes)
|
|
202
|
+
if (before.method !== after.method || before.path !== after.path || canonicalJson(admission(before)) !== canonicalJson(admission(after))) {
|
|
203
|
+
breaks.push(`'${id}' changes its HTTP shape`)
|
|
204
|
+
}
|
|
205
|
+
if (before.response !== after.response) {
|
|
206
|
+
breaks.push(`'${id}' changes its file answer`)
|
|
207
|
+
}
|
|
208
|
+
const access = (row: EndpointRow) =>
|
|
209
|
+
canonicalJson({ kind: row.kind, callers: [...row.callers].sort(), requirement: row.requirement, crossOrigin: row.crossOrigin ?? null })
|
|
210
|
+
if (access(before) !== access(after)) {
|
|
211
|
+
breaks.push(`'${id}' changes its access`)
|
|
212
|
+
}
|
|
213
|
+
return breaks
|
|
214
|
+
}
|
|
215
|
+
|
|
150
216
|
function acceptsEveryInput(before: SchemaShape | null, after: SchemaShape | null): boolean {
|
|
151
217
|
if (before === null || after === null) {
|
|
152
218
|
return before === after
|
package/src/contract/context.ts
CHANGED
|
@@ -3,12 +3,15 @@
|
|
|
3
3
|
// so a group in any module types itself without importing `fabrika.contract.ts`.
|
|
4
4
|
|
|
5
5
|
import type { JobMailer, RpcCaller } from '@fabrika/auth-core'
|
|
6
|
+
import type { RowRef } from '@fabrika/db/capability'
|
|
6
7
|
import type {
|
|
7
8
|
AnyDbIndex,
|
|
8
9
|
ApplicationTables,
|
|
10
|
+
ClaimedRowHandle,
|
|
9
11
|
DataKind,
|
|
10
12
|
FragmentTable,
|
|
11
13
|
NoUnscoped,
|
|
14
|
+
PublicApplicationTables,
|
|
12
15
|
ReferenceTables,
|
|
13
16
|
ScopedApplicationTables,
|
|
14
17
|
ScopeTarget,
|
|
@@ -18,16 +21,18 @@ import type {
|
|
|
18
21
|
WithinTables,
|
|
19
22
|
} from '@fabrika/db/tables'
|
|
20
23
|
import type { z } from 'zod/v4'
|
|
21
|
-
import type {
|
|
24
|
+
import type { StoredFilesHandle, StoredFilesReadHandle } from '../files/handle'
|
|
22
25
|
import type { AppCapability, Ref, Within } from '../implement/capability'
|
|
23
26
|
import type { ApplicationDirectory, LinkIssuing, MemberDirectory, MemberLookup, MemberManagement } from '../implement/membership'
|
|
24
|
-
import type { AppPrincipal, PrincipalOf, ServicePrincipal } from '../implement/principal'
|
|
25
|
-
import type { ConnectCall, RequestContext } from '../implement/types'
|
|
27
|
+
import type { AppPrincipal, PrincipalOf, PublicPrincipal, ServicePrincipal } from '../implement/principal'
|
|
28
|
+
import type { CapabilityOf, ConnectCall, RequestContext } from '../implement/types'
|
|
26
29
|
import type { WebhookDelivery, WebhookPrincipal } from '../implement/webhook'
|
|
27
30
|
import type { AppMail, JobContext } from '../jobs/types'
|
|
28
31
|
import type { ConnectSnapshot } from './connect'
|
|
29
32
|
import type { MemberAccess, Schema } from './define'
|
|
30
33
|
import type { App } from './kit'
|
|
34
|
+
import type { TokenPurposes, TokenSigning } from './token'
|
|
35
|
+
import type { WebhookBindings } from './webhook-binding'
|
|
31
36
|
|
|
32
37
|
export type { Unavailable } from '@fabrika/db/tables'
|
|
33
38
|
|
|
@@ -55,6 +60,8 @@ export interface KitTypes {
|
|
|
55
60
|
/** The table names of `src/db/tables.ts`. */
|
|
56
61
|
readonly tables: string
|
|
57
62
|
readonly imports: KitImports
|
|
63
|
+
/** The token purposes of `defineApp`'s options, by purpose. */
|
|
64
|
+
readonly tokens: TokenPurposes
|
|
58
65
|
}
|
|
59
66
|
|
|
60
67
|
/**
|
|
@@ -134,17 +141,13 @@ type SqlContext<S, Ix extends AnyDbIndex> = {
|
|
|
134
141
|
: SqlTag<Ix, S & SqlMode>
|
|
135
142
|
}
|
|
136
143
|
|
|
137
|
-
/** `ctx.files
|
|
144
|
+
/** `ctx.files.put` and `.open` of the stored files: a put everywhere but in a query. */
|
|
138
145
|
type FilesContext<Ix, K extends DataKind | 'transaction'> = {
|
|
139
|
-
readonly files: Ix extends NoTablesIndex ? Unavailable<'this application declares no tables, so it
|
|
140
|
-
:
|
|
141
|
-
|
|
142
|
-
>
|
|
146
|
+
readonly files: Ix extends NoTablesIndex ? Unavailable<'this application declares no tables, so it stores no files'>
|
|
147
|
+
: K extends 'query' ? StoredFilesReadHandle
|
|
148
|
+
: StoredFilesHandle
|
|
143
149
|
}
|
|
144
150
|
|
|
145
|
-
type RemovesIn<K extends DataKind | 'transaction'> = K extends 'job' ? 'a job removes files inside ctx.transaction((tx) => …), as tx.files'
|
|
146
|
-
: 'a query reads only; remove files in a mutation'
|
|
147
|
-
|
|
148
151
|
type UnscopedData<Ix extends AnyDbIndex, U, K extends DataKind> = {
|
|
149
152
|
readonly unscoped: [U] extends [never] ? NoUnscoped : UnscopedTables<Ix, U, K>
|
|
150
153
|
}
|
|
@@ -162,6 +165,9 @@ type IsUnion<T, All = T> = T extends unknown ? ([All] extends [T] ? false : true
|
|
|
162
165
|
*/
|
|
163
166
|
export type CheckedAccess<Acc> = true extends IsUnion<Acc> ? InvalidAccess : Acc
|
|
164
167
|
|
|
168
|
+
/** Every action several capabilities name. */
|
|
169
|
+
type CapabilityActions<C> = { [N in keyof C]: C[N] extends { readonly action: infer A extends string } ? A : never }[keyof C]
|
|
170
|
+
|
|
165
171
|
/** The capability an access form hands, and what the other forms do not have. */
|
|
166
172
|
export type CapabilityContext<Acc> = Acc extends InvalidAccess ? {
|
|
167
173
|
readonly target: Unavailable<'the access form of this endpoint is refused where it is declared: fix it there'>
|
|
@@ -172,6 +178,11 @@ export type CapabilityContext<Acc> = Acc extends InvalidAccess ? {
|
|
|
172
178
|
readonly target: Unavailable<'a member endpoint holds no capability: declare access with on or in for ctx.target'>
|
|
173
179
|
readonly within: Unavailable<'a member endpoint holds no capability: declare access with over for ctx.within'>
|
|
174
180
|
}
|
|
181
|
+
: Acc extends { readonly capabilities: infer C } ? {
|
|
182
|
+
readonly capabilities: { readonly [N in keyof C]: CapabilityOf<C[N]> }
|
|
183
|
+
readonly target: Unavailable<'an endpoint of several capabilities holds each in ctx.capabilities.<name>'>
|
|
184
|
+
readonly within: Unavailable<'an endpoint of several capabilities holds each in ctx.capabilities.<name>'>
|
|
185
|
+
}
|
|
175
186
|
: Acc extends { readonly action: infer A extends string; readonly over: infer D extends string } ? {
|
|
176
187
|
readonly within: Within<D, A>
|
|
177
188
|
readonly target: Unavailable<'an over endpoint holds ctx.within, the scopes it may list in: declare access with on or in for ctx.target'>
|
|
@@ -203,7 +214,6 @@ type Reach<Ix extends AnyDbIndex, T extends string, Form extends 'target' | 'ove
|
|
|
203
214
|
/** Why the handle `Where` holds no table `T`: one line that names the table, the access form, and the form that reaches it. */
|
|
204
215
|
type Unreached<Ix extends AnyDbIndex, T extends string, Where extends string, Form extends 'target' | 'over'> = T extends Ix['references']
|
|
205
216
|
? Unavailable<`${T} is a reference table: read it through ctx.db.reference.${T}`>
|
|
206
|
-
: TableOf<Ix, T>['mode'] extends 'inbox' ? Unavailable<`${T} is a form inbox, which no ctx.db handle reads`>
|
|
207
217
|
: T extends Ix['applications']
|
|
208
218
|
? Unavailable<`${T} is an application table: read it through ctx.db.app.${T}, or declare access: { action, app: true }`>
|
|
209
219
|
: Unavailable<`${T} is not under ${Where} (${Lies<Ix, T>}): ${Reach<Ix, T, Form>}`>
|
|
@@ -223,7 +233,12 @@ type Target<Ix extends AnyDbIndex, D extends string, A extends string, K extends
|
|
|
223
233
|
'target'
|
|
224
234
|
>
|
|
225
235
|
|
|
226
|
-
type WithinData<Ix extends AnyDbIndex, D extends string, A extends string> = Reaching<
|
|
236
|
+
type WithinData<Ix extends AnyDbIndex, D extends string, A extends string, K extends DataKind> = Reaching<
|
|
237
|
+
Ix,
|
|
238
|
+
WithinTables<Ix, D, A, K>,
|
|
239
|
+
`over: '${D}'`,
|
|
240
|
+
'over'
|
|
241
|
+
>
|
|
227
242
|
|
|
228
243
|
type AppData<Ix extends AnyDbIndex, A extends string, K extends DataKind> = Reaching<Ix, ApplicationTables<Ix, A, K>, 'app: true', 'over'>
|
|
229
244
|
|
|
@@ -245,8 +260,15 @@ type AccessData<Ix extends AnyDbIndex, K extends DataKind, Acc> = Acc extends In
|
|
|
245
260
|
: Acc extends MemberAccess ? {
|
|
246
261
|
readonly target: Unavailable<'a member endpoint reaches no application data: declare access with on, in or over'>
|
|
247
262
|
}
|
|
263
|
+
: Acc extends { readonly capabilities: infer C } ?
|
|
264
|
+
& CapabilityData<Ix, K, CapabilityActions<C>>
|
|
265
|
+
& {
|
|
266
|
+
readonly target: Unavailable<'an endpoint of several capabilities binds each: ctx.db.on(ctx.capabilities.<name>), .in or .over'>
|
|
267
|
+
readonly within: Unavailable<'an endpoint of several capabilities binds each: ctx.db.on(ctx.capabilities.<name>), .in or .over'>
|
|
268
|
+
readonly app: Unavailable<'an endpoint of several capabilities binds each: ctx.db.on(ctx.capabilities.<name>), .in or .over'>
|
|
269
|
+
}
|
|
248
270
|
: Acc extends { readonly action: infer A extends string; readonly over: infer D extends string } ? {
|
|
249
|
-
readonly within: WithinData<Ix, D, A>
|
|
271
|
+
readonly within: WithinData<Ix, D, A, K>
|
|
250
272
|
readonly app: ScopedAppData<Ix, A>
|
|
251
273
|
readonly target: Unavailable<'an over endpoint lists through ctx.db.within: declare access with on or in for ctx.db.target'>
|
|
252
274
|
}
|
|
@@ -353,6 +375,63 @@ export type EndpointContext<
|
|
|
353
375
|
& { readonly db: DataOf<K['index'], EndpointData<K['index'], Kind, Acc, Of<D, 'unscoped'>>> }
|
|
354
376
|
& NoMail
|
|
355
377
|
|
|
378
|
+
type PublicOnly = 'a public procedure acts application-wide, as its own workload: ctx.app(<action>) and ctx.db.app(<action>)'
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* What the handler of a public procedure receives: anyone called it, and it runs as the endpoint's own workload, holding
|
|
382
|
+
* its declared actions `Acts` and nothing else. `ctx.app('<action>')` mints, and `ctx.db.app('<action>')` inserts and
|
|
383
|
+
* reads under it; no other reach.
|
|
384
|
+
*/
|
|
385
|
+
export type PublicContext<K extends KitTypes, Kind extends DataKind, Acts extends string> =
|
|
386
|
+
& RequestContext<PublicPrincipal>
|
|
387
|
+
& {
|
|
388
|
+
/** A capability of one of the procedure's declared actions, application-wide, bound to this request. */
|
|
389
|
+
app<const A extends Acts>(action: A): AppCapability<A>
|
|
390
|
+
readonly target: Unavailable<PublicOnly>
|
|
391
|
+
readonly within: Unavailable<PublicOnly>
|
|
392
|
+
readonly members: Unavailable<'a public procedure reaches no member directory'>
|
|
393
|
+
readonly links: Unavailable<'a public procedure issues no share link'>
|
|
394
|
+
readonly connect: Unavailable<'a public procedure calls no other application'>
|
|
395
|
+
/** The stored files: a public mutation puts one, and both open one this request holds. */
|
|
396
|
+
readonly files: Kind extends 'query' ? StoredFilesReadHandle : StoredFilesHandle
|
|
397
|
+
readonly sql: Unavailable<'a public procedure reaches its actions only: it declares no sql'>
|
|
398
|
+
}
|
|
399
|
+
& {
|
|
400
|
+
readonly db: DataOf<
|
|
401
|
+
K['index'],
|
|
402
|
+
{
|
|
403
|
+
app<const A extends Acts>(action: A): PublicApplicationTables<K['index'], A, Kind>
|
|
404
|
+
readonly target: Unavailable<PublicOnly>
|
|
405
|
+
readonly within: Unavailable<PublicOnly>
|
|
406
|
+
readonly unscoped: Unavailable<'a public procedure reaches its actions only: it declares no unscoped'>
|
|
407
|
+
}
|
|
408
|
+
>
|
|
409
|
+
}
|
|
410
|
+
& NoMail
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* `ctx.kick('<job>')` of a site that declares `kicks`: the job runs in the background after the site's transaction
|
|
414
|
+
* committed, once however often it is kicked, and not at all on a rollback.
|
|
415
|
+
*/
|
|
416
|
+
export type KickContext<Ks> = [Ks] extends [never]
|
|
417
|
+
? { readonly kick: Unavailable<"declare kicks: ['<cron job>'] on this site to kick a job after its commit"> }
|
|
418
|
+
: Ks extends readonly (infer N extends string)[] ? { kick(job: N): void }
|
|
419
|
+
: never
|
|
420
|
+
|
|
421
|
+
/** `ctx.tokens.sign('<purpose>', claims, { days })` of a site that declares `signs`: a token of one of those purposes. */
|
|
422
|
+
export type TokensContext<K extends KitTypes, Sg> = [Sg] extends [never]
|
|
423
|
+
? { readonly tokens: Unavailable<"declare signs: ['<token purpose>'] on this site to sign its tokens"> }
|
|
424
|
+
: Sg extends readonly (infer P extends string)[] ? { readonly tokens: TokenSigning<K['tokens'], P> }
|
|
425
|
+
: never
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* `ctx.webhooks('<webhook>')` of a mutation that declares `binds`: the webhook's bindings, on the value a capability of
|
|
429
|
+
* this request names, written in the mutation's transaction.
|
|
430
|
+
*/
|
|
431
|
+
export type WebhooksContext<Bs> = [Bs] extends [never]
|
|
432
|
+
? { readonly webhooks: Unavailable<"declare binds: '<webhook>' on this mutation to create and remove its bindings"> }
|
|
433
|
+
: { webhooks(name: Bs): WebhookBindings }
|
|
434
|
+
|
|
356
435
|
/** A link procedure acts on the one object its key is bound to. */
|
|
357
436
|
export type LinkContext<
|
|
358
437
|
K extends KitTypes,
|
|
@@ -384,8 +463,10 @@ export type ExportContext<K extends KitTypes, Acc, D extends Declared<'read', un
|
|
|
384
463
|
*/
|
|
385
464
|
export interface CapabilityData<Ix extends AnyDbIndex, Kind extends DataKind, Acts extends string> {
|
|
386
465
|
on<const D extends string, const A extends Acts>(ref: Ref<D, A>): Target<Ix, D, A, Kind, 'on'>
|
|
466
|
+
/** A row a claim leased (`row.ref`), finished inside `ctx.transaction`. */
|
|
467
|
+
on<const T extends TableName<Ix>, const A extends Acts>(ref: RowRef<T, A>): ClaimedRowHandle<T, Ix['tables'][T], A, Kind>
|
|
387
468
|
in<const D extends string, const A extends Acts>(ref: Ref<D, A>): Target<Ix, D, A, Kind, 'in'>
|
|
388
|
-
over<const D extends string, const A extends Acts>(within: Within<D, A>): WithinData<Ix, D, A>
|
|
469
|
+
over<const D extends string, const A extends Acts>(within: Within<D, A>): WithinData<Ix, D, A, Kind>
|
|
389
470
|
}
|
|
390
471
|
|
|
391
472
|
type SiteData<Ix extends AnyDbIndex, Kind extends DataKind, Acts extends string, U> =
|
|
@@ -393,15 +474,23 @@ type SiteData<Ix extends AnyDbIndex, Kind extends DataKind, Acts extends string,
|
|
|
393
474
|
& { readonly reference: ReferenceTables<Ix> }
|
|
394
475
|
& UnscopedData<Ix, U, Kind>
|
|
395
476
|
|
|
477
|
+
/**
|
|
478
|
+
* `ctx.db.app('<action>')` of a workload site that acts application-wide (a job): the application tables of one of its
|
|
479
|
+
* declared actions, over `ctx.app('<action>')`.
|
|
480
|
+
*/
|
|
481
|
+
export interface WorkloadAppData<Ix extends AnyDbIndex, Kind extends DataKind, Acts extends string> {
|
|
482
|
+
app<const A extends Acts>(action: A): AppData<Ix, A, Kind>
|
|
483
|
+
}
|
|
484
|
+
|
|
396
485
|
/** The fenced transaction of a job attempt: writes and `tx.sql` commit only while the attempt holds its lease. */
|
|
397
486
|
export type JobTransaction<K extends KitTypes, A extends string, D extends Declared<SqlMode, unknown, never, string, JobMailer>> =
|
|
398
|
-
& { readonly db: SiteData<K['index'], 'mutation', A, Of<D, 'unscoped'>> }
|
|
487
|
+
& { readonly db: SiteData<K['index'], 'mutation', A, Of<D, 'unscoped'>> & WorkloadAppData<K['index'], 'mutation', A> }
|
|
399
488
|
& SqlContext<Of<D, 'sql'>, K['index']>
|
|
400
489
|
& FilesContext<K['index'], 'transaction'>
|
|
401
490
|
& {
|
|
402
491
|
/**
|
|
403
492
|
* `ctx.ref`, located in this transaction: an object it wrote is found before the commit, so the same transaction
|
|
404
|
-
*
|
|
493
|
+
* reaches the record it just created.
|
|
405
494
|
*/
|
|
406
495
|
ref<const Act extends A, const Dim extends K['dimension']>(action: Act, dimension: Dim, id: string): Promise<Ref<Dim, Act>>
|
|
407
496
|
}
|
|
@@ -420,24 +509,44 @@ type JobMailContext<M extends JobMailer> = [M] extends [never]
|
|
|
420
509
|
: { readonly mail: AppMail }
|
|
421
510
|
|
|
422
511
|
/** A send is external I/O that never rolls back, so only a job sends, from the outbox row an endpoint wrote. */
|
|
423
|
-
type NoMail = { readonly mail: Unavailable<"an endpoint sends no mail: write an outbox row and kick a job that declares mailer (
|
|
512
|
+
type NoMail = { readonly mail: Unavailable<"an endpoint sends no mail: write an outbox row and kick a job that declares mailer (kicks: ['<job>'])"> }
|
|
424
513
|
|
|
425
514
|
/** What a cron job's handler receives. Its reads run outside the transaction; every write runs in `ctx.transaction`. */
|
|
426
515
|
export type JobKitContext<K extends KitTypes, A extends string, D extends Declared<SqlMode, unknown, never, string, JobMailer>> =
|
|
427
516
|
& JobContext<A, K['dimension']>
|
|
428
|
-
& { readonly db: SiteData<K['index'], 'job', A, Of<D, 'unscoped'>> }
|
|
517
|
+
& { readonly db: SiteData<K['index'], 'job', A, Of<D, 'unscoped'>> & WorkloadAppData<K['index'], 'job', A> }
|
|
429
518
|
& { readonly sql: Unavailable<'a job runs ctx.sql inside ctx.transaction((tx) => …), as tx.sql'> }
|
|
430
519
|
& FilesContext<K['index'], 'job'>
|
|
431
520
|
& JobLinksContext<K['dimension'], A, Of<D, 'issues'>>
|
|
432
521
|
& JobMailContext<Of<D, 'mailer'>>
|
|
433
522
|
& { transaction<R>(work: (tx: JobTransaction<K, A, D>) => Promise<R>): Promise<R> }
|
|
434
523
|
|
|
435
|
-
|
|
524
|
+
type BoundToValue = 'a webhook bound to a dimension acts on its bound value: use ctx.refs and ctx.db.on(ref)'
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* What a webhook delivery's handler receives: one transaction per delivery, idempotent by `ctx.delivery.key`. A webhook
|
|
528
|
+
* bound to a dimension acts on its bound value through `ctx.refs`; one bound to `'app'` acts application-wide, as its
|
|
529
|
+
* own workload: `ctx.app('<action>')` and `ctx.db.app('<action>')`.
|
|
530
|
+
*/
|
|
436
531
|
export type WebhookKitContext<K extends KitTypes, B extends string, Acts extends string, D extends Declared<SqlMode, unknown, never, never>> =
|
|
437
532
|
& RequestContext<WebhookPrincipal>
|
|
438
|
-
& { readonly
|
|
533
|
+
& { readonly delivery: WebhookDelivery }
|
|
534
|
+
& ([B] extends ['app'] ? {
|
|
535
|
+
/** A capability of one of the webhook's declared actions, application-wide, bound to this delivery. */
|
|
536
|
+
app<const A extends Acts>(action: A): AppCapability<A>
|
|
537
|
+
readonly refs: Unavailable<'a webhook bound to the application has no bound value: use ctx.app(<action>) and ctx.db.app(<action>)'>
|
|
538
|
+
}
|
|
539
|
+
: { readonly refs: { readonly [A in Acts]: Ref<B, A> }; readonly app: Unavailable<BoundToValue> })
|
|
439
540
|
& SqlContext<Of<D, 'sql'>, K['index']>
|
|
440
|
-
& {
|
|
541
|
+
& {
|
|
542
|
+
readonly db:
|
|
543
|
+
& SiteData<K['index'], 'mutation', Acts, Of<D, 'unscoped'>>
|
|
544
|
+
& ([B] extends ['app'] ? WorkloadAppData<K['index'], 'mutation', Acts> : { readonly app: Unavailable<BoundToValue> })
|
|
545
|
+
}
|
|
546
|
+
& {
|
|
547
|
+
/** The stored files: a delivery puts one, and opens one it holds. */
|
|
548
|
+
readonly files: StoredFilesHandle
|
|
549
|
+
}
|
|
441
550
|
& NoMail
|
|
442
551
|
|
|
443
552
|
type IndexOf<A> = A extends App<infer K> ? K['index'] : never
|
|
@@ -449,7 +558,7 @@ type IndexOf<A> = A extends App<infer K> ? K['index'] : never
|
|
|
449
558
|
*/
|
|
450
559
|
export type DbHandle<A, F extends TargetForm | 'over', D extends string, Act extends string, K extends DataKind = 'query'> = F extends TargetForm
|
|
451
560
|
? Target<IndexOf<A>, D, Act, K, F>
|
|
452
|
-
: WithinData<IndexOf<A>, D, Act>
|
|
561
|
+
: WithinData<IndexOf<A>, D, Act, K>
|
|
453
562
|
|
|
454
563
|
/**
|
|
455
564
|
* The type of `ctx.db.app` of an `app` endpoint, for a function of `src/<slice>/queries.ts` that takes it:
|