@fabrika/app 0.8.2 → 0.9.0-cloudflare.2
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 +40 -24
- package/skill/SKILL.md +29 -3
- package/skill/references/app-setup.md +29 -11
- package/skill/references/dev.md +2 -1
- package/skill/references/endpoint.md +12 -3
- package/skill/references/errors.md +39 -35
- package/skill/references/files.md +4 -2
- package/skill/references/jobs.md +9 -0
- package/skill/references/table-and-migration.md +19 -2
- package/skill/references/tests.md +33 -1
- package/skill/references/webhooks-links-connectors.md +1 -1
- package/src/build-recipe.ts +198 -0
- package/src/build.ts +60 -4
- package/src/bun.ts +33 -8
- package/src/cloudflare/application.ts +352 -0
- package/src/cloudflare/bindings.ts +159 -0
- package/src/cloudflare/names.ts +16 -0
- package/src/cloudflare/ops.ts +246 -0
- package/src/cloudflare/r2-files.ts +172 -0
- package/src/cloudflare.ts +99 -146
- package/src/contract/app-id.ts +5 -0
- package/src/contract/artifact-schema.ts +8 -1
- package/src/contract/build.ts +68 -11
- package/src/contract/compile.ts +1 -0
- package/src/contract/context.ts +9 -0
- package/src/contract/declaration.ts +2 -0
- package/src/contract/define.ts +28 -2
- package/src/contract/group.ts +10 -1
- package/src/contract/kit.ts +6 -2
- package/src/contract/platform.ts +3 -2
- package/src/contract/vocabulary.ts +26 -8
- package/src/dev/changes.ts +35 -0
- package/src/dev/contract-digest.ts +17 -0
- package/src/dev/index.ts +11 -0
- package/src/dev/launcher.ts +713 -0
- package/src/dev/output.ts +72 -0
- package/src/dev/wire.ts +136 -0
- package/src/disk-assets.ts +8 -3
- package/src/files/archive.ts +2 -2
- package/src/files/job-names.ts +12 -0
- package/src/files/kinds.ts +19 -7
- package/src/files/scan.ts +3 -4
- package/src/files/streams.ts +3 -1
- package/src/implement/anonymous.ts +11 -1
- package/src/implement/boot.ts +70 -7
- package/src/implement/bounded.ts +21 -2
- package/src/implement/capability.ts +18 -3
- package/src/implement/dispatch.ts +251 -147
- package/src/implement/egress.ts +341 -8
- package/src/implement/failures.ts +17 -1
- package/src/implement/faults.ts +1 -0
- package/src/implement/index.ts +1 -0
- package/src/implement/invocation.ts +18 -9
- package/src/implement/principal.ts +25 -2
- package/src/implement/terminals.ts +93 -0
- package/src/implement/workload.ts +24 -13
- package/src/jobs/report.ts +5 -1
- package/src/jobs/runner.ts +40 -19
- package/src/jobs/schedule.ts +7 -1
- package/src/jobs/scrub.ts +14 -1
- package/src/spa-fallback.ts +12 -0
- package/src/testing/endpoint-access.ts +307 -152
- package/src/testing/isolation.ts +8 -1
- package/src/testing/kit.ts +20 -2
- package/src/testing/proxy.ts +5 -5
- package/types/src/agent-skill.d.ts +29 -0
- package/types/src/build-recipe.d.ts +70 -0
- package/types/src/build.d.ts +29 -0
- package/types/src/bun.d.ts +66 -0
- package/types/src/client/caller.d.ts +19 -0
- package/types/src/client/contract.d.ts +131 -0
- package/types/src/client/error-reporting.d.ts +66 -0
- package/types/src/client/index.d.ts +4 -0
- package/types/src/client/rpc.d.ts +14 -0
- package/types/src/client/share.d.ts +2 -0
- package/types/src/client/transport.d.ts +42 -0
- package/types/src/cloudflare/application.d.ts +42 -0
- package/types/src/cloudflare/bindings.d.ts +33 -0
- package/types/src/cloudflare/names.d.ts +11 -0
- package/types/src/cloudflare/ops.d.ts +46 -0
- package/types/src/cloudflare/r2-files.d.ts +9 -0
- package/types/src/cloudflare.d.ts +42 -0
- package/types/src/contract/app-id.d.ts +2 -0
- package/types/src/contract/artifact-schema.d.ts +8 -0
- package/types/src/contract/attachment.d.ts +41 -0
- package/types/src/contract/build.d.ts +12 -0
- package/types/src/contract/compile.d.ts +11 -0
- package/types/src/contract/connect.d.ts +51 -0
- package/types/src/contract/context.d.ts +510 -0
- package/types/src/contract/declaration.d.ts +307 -0
- package/types/src/contract/defaults.d.ts +208 -0
- package/types/src/contract/define.d.ts +376 -0
- package/types/src/contract/file.d.ts +44 -0
- package/types/src/contract/group.d.ts +282 -0
- package/types/src/contract/handled.d.ts +18 -0
- package/types/src/contract/index.d.ts +19 -0
- package/types/src/contract/kit.d.ts +211 -0
- package/types/src/contract/platform.d.ts +9 -0
- package/types/src/contract/problems.d.ts +14 -0
- package/types/src/contract/redirect.d.ts +25 -0
- package/types/src/contract/schema-from-shape.d.ts +10 -0
- package/types/src/contract/schema-shape.d.ts +5 -0
- package/types/src/contract/snapshot-source.d.ts +15 -0
- package/types/src/contract/token.d.ts +58 -0
- package/types/src/contract/vocabulary.d.ts +32 -0
- package/types/src/contract/webhook-binding.d.ts +24 -0
- package/types/src/contract/webhook-scheme.d.ts +23 -0
- package/types/src/dev/changes.d.ts +7 -0
- package/types/src/dev/contract-digest.d.ts +1 -0
- package/types/src/dev/index.d.ts +2 -0
- package/types/src/dev/launcher.d.ts +32 -0
- package/types/src/dev/output.d.ts +28 -0
- package/types/src/dev/wire.d.ts +126 -0
- package/types/src/disk-assets.d.ts +6 -0
- package/types/src/errors.d.ts +46 -0
- package/types/src/fetch.d.ts +7 -0
- package/types/src/files/archive.d.ts +26 -0
- package/types/src/files/clamscan.d.ts +32 -0
- package/types/src/files/handle.d.ts +106 -0
- package/types/src/files/job-names.d.ts +9 -0
- package/types/src/files/kinds.d.ts +101 -0
- package/types/src/files/scan.d.ts +78 -0
- package/types/src/files/sniff.d.ts +7 -0
- package/types/src/files/streams.d.ts +17 -0
- package/types/src/files/treatment.d.ts +18 -0
- package/types/src/from-row.d.ts +20 -0
- package/types/src/honeypot.d.ts +2 -0
- package/types/src/implement/anonymous.d.ts +28 -0
- package/types/src/implement/attachment.d.ts +14 -0
- package/types/src/implement/boot.d.ts +120 -0
- package/types/src/implement/bounded.d.ts +32 -0
- package/types/src/implement/browser-config.d.ts +19 -0
- package/types/src/implement/capability.d.ts +144 -0
- package/types/src/implement/challenge.d.ts +75 -0
- package/types/src/implement/client-address.d.ts +11 -0
- package/types/src/implement/connect.d.ts +67 -0
- package/types/src/implement/crashes.d.ts +31 -0
- package/types/src/implement/dispatch.d.ts +221 -0
- package/types/src/implement/egress.d.ts +49 -0
- package/types/src/implement/environment.d.ts +14 -0
- package/types/src/implement/failures.d.ts +39 -0
- package/types/src/implement/faults.d.ts +45 -0
- package/types/src/implement/headers.d.ts +7 -0
- package/types/src/implement/implement.d.ts +58 -0
- package/types/src/implement/index.d.ts +25 -0
- package/types/src/implement/invocation.d.ts +125 -0
- package/types/src/implement/membership.d.ts +201 -0
- package/types/src/implement/multipart.d.ts +36 -0
- package/types/src/implement/params.d.ts +7 -0
- package/types/src/implement/principal.d.ts +57 -0
- package/types/src/implement/project.d.ts +11 -0
- package/types/src/implement/public.d.ts +61 -0
- package/types/src/implement/serve.d.ts +25 -0
- package/types/src/implement/terminals.d.ts +33 -0
- package/types/src/implement/tokens.d.ts +49 -0
- package/types/src/implement/types.d.ts +243 -0
- package/types/src/implement/urls.d.ts +15 -0
- package/types/src/implement/verifiers.d.ts +19 -0
- package/types/src/implement/webhook.d.ts +55 -0
- package/types/src/implement/workload.d.ts +56 -0
- package/types/src/implement/xlsx.d.ts +6 -0
- package/types/src/index.d.ts +12 -0
- package/types/src/jobs/exception.d.ts +40 -0
- package/types/src/jobs/mail.d.ts +67 -0
- package/types/src/jobs/report.d.ts +56 -0
- package/types/src/jobs/runner.d.ts +154 -0
- package/types/src/jobs/schedule.d.ts +17 -0
- package/types/src/jobs/scrub.d.ts +12 -0
- package/types/src/jobs/types.d.ts +98 -0
- package/types/src/react/caller.d.ts +30 -0
- package/types/src/react/errors.d.ts +40 -0
- package/types/src/react/hooks.d.ts +110 -0
- package/types/src/react/index.d.ts +5 -0
- package/types/src/react/provider.d.ts +58 -0
- package/types/src/react/store.d.ts +68 -0
- package/types/src/react/testing.d.ts +28 -0
- package/types/src/react/types.d.ts +33 -0
- package/types/src/rpc/builder.d.ts +52 -0
- package/types/src/rpc/contract.d.ts +23 -0
- package/types/src/rpc/dispatcher.d.ts +11 -0
- package/types/src/rpc/types.d.ts +62 -0
- package/types/src/service/define.d.ts +42 -0
- package/types/src/service/index.d.ts +3 -0
- package/types/src/service/serve.d.ts +28 -0
- package/types/src/spa-fallback.d.ts +2 -0
- package/types/src/standard-schema.d.ts +55 -0
- package/types/src/testing/caller.d.ts +95 -0
- package/types/src/testing/challenge.d.ts +11 -0
- package/types/src/testing/dev.d.ts +97 -0
- package/types/src/testing/endpoint-access.d.ts +131 -0
- package/types/src/testing/index.d.ts +12 -0
- package/types/src/testing/isolation.d.ts +3 -0
- package/types/src/testing/kit.d.ts +187 -0
- package/types/src/testing/membership.d.ts +132 -0
- package/types/src/testing/policy-isolation.d.ts +113 -0
- package/types/src/testing/preload.d.ts +1 -0
- package/types/src/testing/proxy.d.ts +40 -0
- package/types/src/testing/runner.d.ts +8 -0
- package/types/src/testing/sample-file.d.ts +5 -0
- package/types/src/testing/status.d.ts +4 -0
- package/types/src/undumpable.d.ts +5 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// The object's side of Control's ops channel (ADR-0069 §1, §2): it authenticates the caller before it reads anything
|
|
2
|
+
// the request names, answers every refusal with `opsRefusal()`, and serves the view from its storage alone, so it
|
|
3
|
+
// works while the application cannot boot. Framework code only: an ops request never reaches the contract.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type ApplicationObjects,
|
|
7
|
+
ENDPOINT_HEADER,
|
|
8
|
+
FABRIKA_APPLICATION_OBJECT,
|
|
9
|
+
FABRIKA_APPLICATION_OBJECTS,
|
|
10
|
+
FABRIKA_OPS_KEY_DIGESTS,
|
|
11
|
+
formatEndpointHeader,
|
|
12
|
+
OPS_METHOD,
|
|
13
|
+
OPS_ROW_COUNT_CAP,
|
|
14
|
+
type OpsBootFailureContent,
|
|
15
|
+
type OpsContentView,
|
|
16
|
+
opsKeyDigest,
|
|
17
|
+
type OpsOperation,
|
|
18
|
+
opsRefusal,
|
|
19
|
+
type OpsView,
|
|
20
|
+
parseApplicationObjects,
|
|
21
|
+
parseOpsContentView,
|
|
22
|
+
parseOpsInspectRequest,
|
|
23
|
+
parseOpsKeyDigests,
|
|
24
|
+
parseOpsMigrateRequest,
|
|
25
|
+
parseOpsOperation,
|
|
26
|
+
parseOpsStateRequest,
|
|
27
|
+
parseOpsView,
|
|
28
|
+
} from '@fabrika/auth-core'
|
|
29
|
+
import { FILES_TABLE, JOB_LEASES_TABLE, WEBHOOK_BINDINGS_TABLE, WEBHOOK_DELIVERIES_TABLE } from '@fabrika/db/migrations'
|
|
30
|
+
import type { SqliteCursor, SqliteValue } from '@fabrika/db/sqlite'
|
|
31
|
+
import { opsProxyRow } from '@fabrika/proxy-core/manifest'
|
|
32
|
+
import { timingSafeEqual } from 'node:crypto'
|
|
33
|
+
import appPackage from '../../package.json' with { type: 'json' }
|
|
34
|
+
import { jsonResponse } from '../errors.js'
|
|
35
|
+
|
|
36
|
+
/** What the view reads of the object's storage; workerd's `DurableObjectStorage` has both optional members. */
|
|
37
|
+
export interface OpsStorage {
|
|
38
|
+
readonly sql: {
|
|
39
|
+
exec(query: string, ...bindings: SqliteValue[]): SqliteCursor
|
|
40
|
+
/** The bun:sqlite test double has no size. */
|
|
41
|
+
readonly databaseSize?: number
|
|
42
|
+
}
|
|
43
|
+
/** The bun:sqlite test double has no alarm. */
|
|
44
|
+
getAlarm?(): Promise<number | null>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const OPS_ROW = opsProxyRow()
|
|
48
|
+
|
|
49
|
+
/** `X-Fabrika-Endpoint` of the composed ops row, the only value an ops request may carry (ADR-0022 rule 3). */
|
|
50
|
+
export const OPS_ENDPOINT = formatEndpointHeader(OPS_ROW.id, OPS_ROW.digest)
|
|
51
|
+
|
|
52
|
+
/** A view with no data but one table, through which `admittedTableName` asks the wire's own parser about a name. */
|
|
53
|
+
const ONE_TABLE_VIEW = {
|
|
54
|
+
release: '0.0.0',
|
|
55
|
+
maintenance: { mode: 'open', generation: null, published: false },
|
|
56
|
+
migrations: [],
|
|
57
|
+
scheduler: { alarm: null },
|
|
58
|
+
jobs: [],
|
|
59
|
+
files: [],
|
|
60
|
+
webhooks: [],
|
|
61
|
+
bootFailure: null,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The migration ledger `@fabrika/db/sqlite-migrate` keeps in the object's SQLite. */
|
|
65
|
+
const MIGRATION_LEDGER = 'fabrika_migrations'
|
|
66
|
+
|
|
67
|
+
/** What the platform wrote for the ops channel: the key digests and the objects a request may name. */
|
|
68
|
+
export interface OpsAccess {
|
|
69
|
+
readonly digests: readonly Uint8Array[]
|
|
70
|
+
readonly objects: ApplicationObjects
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The environment's ops access, or `null` (an older deploy, or a malformed value), which refuses every ops request. */
|
|
74
|
+
export function opsAccess(env: Readonly<Record<string, unknown>>): OpsAccess | null {
|
|
75
|
+
const digests = parseOpsKeyDigests(stringOf(env[FABRIKA_OPS_KEY_DIGESTS]) ?? '')
|
|
76
|
+
const objects = parseApplicationObjects(stringOf(env[FABRIKA_APPLICATION_OBJECT]), stringOf(env[FABRIKA_APPLICATION_OBJECTS]))
|
|
77
|
+
if (digests === null || objects === null) {
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
return { digests: digests.map((digest) => new TextEncoder().encode(digest)), objects }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** What an ops request reads of the object it reached. */
|
|
84
|
+
export interface OpsTarget {
|
|
85
|
+
readonly storage: OpsStorage
|
|
86
|
+
/**
|
|
87
|
+
* The instance's last failed boot, the application's error text included. Asked only after authentication: it
|
|
88
|
+
* waits for a boot under way and never starts one.
|
|
89
|
+
*/
|
|
90
|
+
bootFailure(): Promise<OpsBootFailureContent | null>
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Answers an ops request the Worker forwarded with the proxy secret, which the object checked before. Authentication
|
|
95
|
+
* runs first; an unknown operation, a malformed body and an object name the list lacks are refused after it.
|
|
96
|
+
*/
|
|
97
|
+
export async function serveOps(request: Request, access: OpsAccess | null, target: OpsTarget): Promise<Response> {
|
|
98
|
+
if (access === null || !opsAuthenticated(request, access)) {
|
|
99
|
+
return opsRefusal()
|
|
100
|
+
}
|
|
101
|
+
const operation = parseOpsOperation(new URL(request.url).pathname)
|
|
102
|
+
if (operation === null) {
|
|
103
|
+
return opsRefusal()
|
|
104
|
+
}
|
|
105
|
+
const body = await jsonBody(request)
|
|
106
|
+
if (operation === 'inspect') {
|
|
107
|
+
const inspect = parseOpsInspectRequest(body)
|
|
108
|
+
if (inspect === null || !access.objects.objects.includes(inspect.object)) {
|
|
109
|
+
return opsRefusal()
|
|
110
|
+
}
|
|
111
|
+
return jsonResponse(await inspectView(target, inspect.content), 200)
|
|
112
|
+
}
|
|
113
|
+
const object = objectNamed(operation, body)
|
|
114
|
+
if (object === null || !access.objects.objects.includes(object)) {
|
|
115
|
+
return opsRefusal()
|
|
116
|
+
}
|
|
117
|
+
// `state` and `migrate` belong to the maintenance barrier (work unit 3.3b).
|
|
118
|
+
return jsonResponse({ refused: 'not_implemented' }, 501)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The object an ops request names, read with its operation's strict parser; `null` for a body that does not parse. */
|
|
122
|
+
export function objectNamed(operation: OpsOperation, body: unknown): string | null {
|
|
123
|
+
switch (operation) {
|
|
124
|
+
case 'inspect':
|
|
125
|
+
return parseOpsInspectRequest(body)?.object ?? null
|
|
126
|
+
case 'state':
|
|
127
|
+
return parseOpsStateRequest(body)?.object ?? null
|
|
128
|
+
case 'migrate':
|
|
129
|
+
return parseOpsMigrateRequest(body)?.object ?? null
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The request's body as JSON, or `undefined` when it is none: every parser refuses `undefined`. */
|
|
134
|
+
export async function jsonBody(request: { text(): Promise<string> }): Promise<unknown> {
|
|
135
|
+
try {
|
|
136
|
+
return JSON.parse(await request.text())
|
|
137
|
+
} catch {
|
|
138
|
+
return undefined
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Control's credentials on an ops request: the row's method, its endpoint header, and a key whose digest is one the
|
|
144
|
+
* platform wrote, compared in constant time. The Worker and the object both run it; it reads nothing the body names.
|
|
145
|
+
*/
|
|
146
|
+
export function opsAuthenticated(request: Request, access: OpsAccess): boolean {
|
|
147
|
+
if (request.method !== OPS_METHOD || request.headers.get(ENDPOINT_HEADER) !== OPS_ENDPOINT) {
|
|
148
|
+
return false
|
|
149
|
+
}
|
|
150
|
+
const authorization = request.headers.get('Authorization') ?? ''
|
|
151
|
+
const key = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : ''
|
|
152
|
+
if (key === '') {
|
|
153
|
+
return false
|
|
154
|
+
}
|
|
155
|
+
const presented = new TextEncoder().encode(opsKeyDigest(key))
|
|
156
|
+
let matched = false
|
|
157
|
+
for (const digest of access.digests) {
|
|
158
|
+
// Every digest is compared, so the answer's timing does not say which one matched.
|
|
159
|
+
if (timingSafeEqual(presented, digest)) {
|
|
160
|
+
matched = true
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return matched
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The view of the object's storage, read and never written. A table the framework has not created yet (a boot that
|
|
168
|
+
* failed before its migrations) reads as empty. What the storage holds is parsed by the wire's own parser.
|
|
169
|
+
*/
|
|
170
|
+
async function inspectView(target: OpsTarget, content: boolean): Promise<OpsView | OpsContentView> {
|
|
171
|
+
const { storage } = target
|
|
172
|
+
const bootFailure = await target.bootFailure()
|
|
173
|
+
const tables = tableNames(storage)
|
|
174
|
+
const has = (name: string) => tables.includes(name)
|
|
175
|
+
const alarm = storage.getAlarm === undefined ? missing('getAlarm') : await storage.getAlarm()
|
|
176
|
+
const view = {
|
|
177
|
+
release: appPackage.version,
|
|
178
|
+
maintenance: { mode: 'open', generation: null, published: false },
|
|
179
|
+
database: {
|
|
180
|
+
sizeBytes: storage.sql.databaseSize ?? missing('sql.databaseSize'),
|
|
181
|
+
tables: tables.filter(admittedTableName).map((name) => ({ name, rows: cappedCount(storage, name) })),
|
|
182
|
+
},
|
|
183
|
+
migrations: has(MIGRATION_LEDGER)
|
|
184
|
+
? rows(storage, `select bundle, name, checksum, applied_at as appliedAt from ${MIGRATION_LEDGER} order by bundle, name`)
|
|
185
|
+
: [],
|
|
186
|
+
scheduler: { alarm: alarm === null ? null : new Date(alarm).toISOString() },
|
|
187
|
+
jobs: has(JOB_LEASES_TABLE)
|
|
188
|
+
? rows(
|
|
189
|
+
storage,
|
|
190
|
+
`select job, last_status as lastStatus, last_started_at as lastStartedAt, last_finished_at as lastFinishedAt,
|
|
191
|
+
last_duration_ms as lastDurationMs from ${JOB_LEASES_TABLE} order by job`,
|
|
192
|
+
)
|
|
193
|
+
: [],
|
|
194
|
+
files: has(FILES_TABLE)
|
|
195
|
+
? rows(storage, `select kind, status, count(*) as count from ${FILES_TABLE} group by kind, status order by kind, status`)
|
|
196
|
+
: [],
|
|
197
|
+
webhooks: has(WEBHOOK_BINDINGS_TABLE) && has(WEBHOOK_DELIVERIES_TABLE)
|
|
198
|
+
? rows(
|
|
199
|
+
storage,
|
|
200
|
+
`select b.webhook as webhook, case when d.lease_until is null then 'completed' else 'claimed' end as state, count(*) as count
|
|
201
|
+
from ${WEBHOOK_DELIVERIES_TABLE} d join ${WEBHOOK_BINDINGS_TABLE} b on b.id = d.binding_id
|
|
202
|
+
group by 1, 2 order by 1, 2`,
|
|
203
|
+
)
|
|
204
|
+
: [],
|
|
205
|
+
bootFailure: content || bootFailure === null ? bootFailure : { at: bootFailure.at },
|
|
206
|
+
}
|
|
207
|
+
const parsed = content ? parseOpsContentView(view) : parseOpsView(view)
|
|
208
|
+
if (parsed === null) {
|
|
209
|
+
throw new Error("the object's ops view does not parse as the wire's view")
|
|
210
|
+
}
|
|
211
|
+
return parsed
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Cloudflare's own `_cf_*` tables and SQLite's `sqlite_*` are no table of the application or the framework. */
|
|
215
|
+
function tableNames(storage: OpsStorage): string[] {
|
|
216
|
+
return storage.sql.exec(
|
|
217
|
+
`select name from sqlite_schema where type = 'table' and name not like '\\_cf\\_%' escape '\\' and name not like 'sqlite\\_%' escape '\\'
|
|
218
|
+
order by name`,
|
|
219
|
+
).toArray().flatMap((row) => typeof row['name'] === 'string' ? [row['name']] : [])
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Whether the view admits `name`. A table SQLite allows but the wire does not (`"MixedCase"`, from a raw migration) is
|
|
224
|
+
* left out, so one such table never takes the whole view down with it.
|
|
225
|
+
*/
|
|
226
|
+
function admittedTableName(name: string): boolean {
|
|
227
|
+
return parseOpsView({ ...ONE_TABLE_VIEW, database: { sizeBytes: 0, tables: [{ name, rows: 0 }] } }) !== null
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** At most `OPS_ROW_COUNT_CAP`: a full count bills every row read. */
|
|
231
|
+
function cappedCount(storage: OpsStorage, table: string): unknown {
|
|
232
|
+
const quoted = `"${table.replaceAll('"', '""')}"`
|
|
233
|
+
return storage.sql.exec(`select count(*) as count from (select 1 from ${quoted} limit ${OPS_ROW_COUNT_CAP})`).toArray()[0]?.['count']
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function rows(storage: OpsStorage, query: string): Record<string, SqliteValue>[] {
|
|
237
|
+
return storage.sql.exec(query).toArray()
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function missing(member: string): never {
|
|
241
|
+
throw new Error(`the object's storage has no ${member}; a Durable Object's storage has it`)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function stringOf(value: unknown): string | undefined {
|
|
245
|
+
return typeof value === 'string' ? value : undefined
|
|
246
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// The stored files of an application environment in its R2 bucket (`FABRIKA_FILES`, ADR-0065 §6): the
|
|
2
|
+
// `FileObjectStore` port, as the S3 store serves it on Zerops. A version is the object's etag.
|
|
3
|
+
|
|
4
|
+
import { type FileObjectStore, ObjectExistsError, type ObjectMetadata, ObjectTooLargeError, type StoredObject } from '@fabrika/platform'
|
|
5
|
+
import { method } from './bindings.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* R2 stores a stream only when its length is known up front, which an upload's is not. A body up to this size is
|
|
9
|
+
* buffered and written in one conditional put; a longer one goes up in parts of this size, so an object holds at most
|
|
10
|
+
* one part in memory. R2 takes parts of at least 5 MiB, all but the last of one size.
|
|
11
|
+
*/
|
|
12
|
+
export const R2_PART_BYTES = 8 * 1024 * 1024
|
|
13
|
+
|
|
14
|
+
interface R2Info {
|
|
15
|
+
readonly size: number
|
|
16
|
+
readonly etag: string
|
|
17
|
+
readonly contentType: string | null
|
|
18
|
+
readonly body: ReadableStream<Uint8Array> | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** An R2 answer's object, or `null` for anything else. */
|
|
22
|
+
function infoOf(value: unknown): R2Info | null {
|
|
23
|
+
if (typeof value !== 'object' || value === null) {
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
const size: unknown = Reflect.get(value, 'size')
|
|
27
|
+
const etag: unknown = Reflect.get(value, 'etag')
|
|
28
|
+
if (typeof size !== 'number' || !Number.isSafeInteger(size) || typeof etag !== 'string' || etag === '') {
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
const metadata: unknown = Reflect.get(value, 'httpMetadata')
|
|
32
|
+
const type: unknown = typeof metadata === 'object' && metadata !== null ? Reflect.get(metadata, 'contentType') : undefined
|
|
33
|
+
const body: unknown = 'body' in value ? Reflect.get(value, 'body') : null
|
|
34
|
+
return { size, etag, contentType: typeof type === 'string' && type !== '' ? type : null, body: body instanceof ReadableStream ? body : null }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function required(binding: unknown, name: string): (...args: unknown[]) => unknown {
|
|
38
|
+
const found = method(binding, name)
|
|
39
|
+
if (found === null) {
|
|
40
|
+
throw new Error(`the FABRIKA_FILES binding has no ${name}; it is an R2 bucket`)
|
|
41
|
+
}
|
|
42
|
+
return found
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function joined(chunks: readonly Uint8Array[], length: number): Uint8Array {
|
|
46
|
+
const bytes = new Uint8Array(length)
|
|
47
|
+
let offset = 0
|
|
48
|
+
for (const chunk of chunks) {
|
|
49
|
+
bytes.set(chunk, offset)
|
|
50
|
+
offset += chunk.byteLength
|
|
51
|
+
}
|
|
52
|
+
return bytes
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface MultipartUpload {
|
|
56
|
+
uploadPart(bytes: Uint8Array): Promise<void>
|
|
57
|
+
complete(): Promise<void>
|
|
58
|
+
abort(): Promise<void>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function multipartUpload(bucket: unknown, key: string, contentType: string): Promise<MultipartUpload> {
|
|
62
|
+
const upload: unknown = await required(bucket, 'createMultipartUpload')(key, { httpMetadata: { contentType } })
|
|
63
|
+
const parts: { readonly partNumber: number; readonly etag: string }[] = []
|
|
64
|
+
return {
|
|
65
|
+
async uploadPart(bytes) {
|
|
66
|
+
const partNumber = parts.length + 1
|
|
67
|
+
const uploaded: unknown = await required(upload, 'uploadPart')(partNumber, bytes)
|
|
68
|
+
const etag: unknown = typeof uploaded === 'object' && uploaded !== null ? Reflect.get(uploaded, 'etag') : undefined
|
|
69
|
+
if (typeof etag !== 'string') {
|
|
70
|
+
throw new Error('R2 answered a part upload without its etag')
|
|
71
|
+
}
|
|
72
|
+
parts.push({ partNumber, etag })
|
|
73
|
+
},
|
|
74
|
+
async complete() {
|
|
75
|
+
await required(upload, 'complete')(parts)
|
|
76
|
+
},
|
|
77
|
+
async abort() {
|
|
78
|
+
await required(upload, 'abort')()
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** `bucket` (an R2 binding) as the files' object store, or `undefined` when the environment binds none. */
|
|
84
|
+
export function r2FileObjectStore(bucket: unknown): FileObjectStore | undefined {
|
|
85
|
+
if (!['head', 'get', 'put', 'delete'].every((name) => method(bucket, name) !== null)) {
|
|
86
|
+
return undefined
|
|
87
|
+
}
|
|
88
|
+
const head = required(bucket, 'head')
|
|
89
|
+
const get = required(bucket, 'get')
|
|
90
|
+
const put = required(bucket, 'put')
|
|
91
|
+
const remove = required(bucket, 'delete')
|
|
92
|
+
|
|
93
|
+
const stored = (answer: unknown): StoredObject | null => {
|
|
94
|
+
const info = infoOf(answer)
|
|
95
|
+
// An object without a body is R2's answer to a failed condition: the object was replaced.
|
|
96
|
+
if (info === null || info.body === null) {
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
return { size: info.size, contentType: info.contentType, version: info.etag, body: info.body }
|
|
100
|
+
}
|
|
101
|
+
const stopped = (signal: AbortSignal | undefined): void => {
|
|
102
|
+
if (signal?.aborted === true) {
|
|
103
|
+
throw new Error('the object read was stopped')
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
async write(key, body, options): Promise<ObjectMetadata> {
|
|
109
|
+
// A file's key is a fresh UUID behind a unique row; the conditional put refuses a reused key atomically, and
|
|
110
|
+
// this check refuses one before a multipart upload, which takes no condition.
|
|
111
|
+
if ((await head(key)) !== null) {
|
|
112
|
+
await body.cancel()
|
|
113
|
+
throw new ObjectExistsError()
|
|
114
|
+
}
|
|
115
|
+
const httpMetadata = { contentType: options.contentType }
|
|
116
|
+
const reader = body.getReader()
|
|
117
|
+
const pending: Uint8Array[] = []
|
|
118
|
+
let buffered = 0
|
|
119
|
+
let size = 0
|
|
120
|
+
let upload: MultipartUpload | null = null
|
|
121
|
+
try {
|
|
122
|
+
for (;;) {
|
|
123
|
+
const { done, value } = await reader.read()
|
|
124
|
+
if (done) {
|
|
125
|
+
break
|
|
126
|
+
}
|
|
127
|
+
size += value.byteLength
|
|
128
|
+
if (size > options.maxBytes) {
|
|
129
|
+
await reader.cancel()
|
|
130
|
+
throw new ObjectTooLargeError(options.maxBytes)
|
|
131
|
+
}
|
|
132
|
+
pending.push(value)
|
|
133
|
+
buffered += value.byteLength
|
|
134
|
+
while (buffered >= R2_PART_BYTES) {
|
|
135
|
+
const all = joined(pending, buffered)
|
|
136
|
+
pending.length = 0
|
|
137
|
+
pending.push(all.subarray(R2_PART_BYTES))
|
|
138
|
+
buffered -= R2_PART_BYTES
|
|
139
|
+
upload ??= await multipartUpload(bucket, key, options.contentType)
|
|
140
|
+
await upload.uploadPart(all.subarray(0, R2_PART_BYTES))
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const rest = joined(pending, buffered)
|
|
144
|
+
if (upload === null) {
|
|
145
|
+
if ((await put(key, rest, { httpMetadata, onlyIf: { etagDoesNotMatch: '*' } })) === null) {
|
|
146
|
+
throw new ObjectExistsError()
|
|
147
|
+
}
|
|
148
|
+
} else {
|
|
149
|
+
if (rest.byteLength > 0) {
|
|
150
|
+
await upload.uploadPart(rest)
|
|
151
|
+
}
|
|
152
|
+
await upload.complete()
|
|
153
|
+
}
|
|
154
|
+
} catch (error) {
|
|
155
|
+
await upload?.abort().catch(() => undefined)
|
|
156
|
+
throw error
|
|
157
|
+
}
|
|
158
|
+
return { size, contentType: options.contentType }
|
|
159
|
+
},
|
|
160
|
+
async read(key, signal) {
|
|
161
|
+
stopped(signal)
|
|
162
|
+
return stored(await get(key))
|
|
163
|
+
},
|
|
164
|
+
async readVersion(key, version, signal) {
|
|
165
|
+
stopped(signal)
|
|
166
|
+
return stored(await get(key, { onlyIf: { etagMatches: version } }))
|
|
167
|
+
},
|
|
168
|
+
async delete(key) {
|
|
169
|
+
await remove(key)
|
|
170
|
+
},
|
|
171
|
+
}
|
|
172
|
+
}
|