@voidbase-cloud/voidbase 0.2.2 → 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/CHANGELOG.md +54 -0
- package/README.md +16 -7
- package/bin/voidbase.ts +67 -6
- package/docs/adapter.md +280 -0
- package/docs/ci.md +195 -0
- package/docs/deploy.md +83 -5
- package/docs/releasing.md +39 -31
- package/hooks-plugin.ts +15 -4
- package/package.json +10 -4
- package/routes/api/[...path].ts +0 -5
- package/scripts/cf-builds.ts +228 -0
- package/scripts/ci-browser.sh +60 -0
- package/scripts/ci-cache.sh +40 -0
- package/scripts/ci-lib.sh +40 -0
- package/scripts/ci-oracles.sh +19 -0
- package/scripts/ci-plan.ts +270 -0
- package/scripts/ci-status.ts +126 -0
- package/scripts/ci-suites.sh +11 -1
- package/scripts/ci.sh +188 -0
- package/scripts/gh-release.ts +48 -0
- package/scripts/release.sh +104 -0
- package/scripts/seed-reference.sh +7 -2
- package/scripts/sync-app.ts +1 -0
- package/src/adapter/bundle.ts +130 -0
- package/src/adapter/codegen.ts +269 -0
- package/src/adapter/index.ts +6 -0
- package/src/adapter/plugin.ts +132 -0
- package/src/adapter/runtime.ts +325 -0
- package/src/adapter/scan.ts +277 -0
- package/src/cloud/rest.ts +10 -2
- package/src/env/define.ts +195 -0
- package/src/node/assets.ts +7 -1
- package/src/node/cloud-init.ts +14 -0
- package/src/node/deploy-cf.ts +124 -16
- package/src/node/secrets.ts +237 -0
- package/src/node/serve.ts +16 -2
- package/src/server/api.ts +7 -2
- package/src/server/app.ts +6 -1
- package/src/server/hooks/index.ts +27 -1
- package/src/server/hooks/migrations.ts +4 -1
- package/src/server/hooks/runtime.ts +10 -2
- package/src/server/jobs.ts +3 -1
- package/src/server/webauthn.ts +23 -6
- package/tsconfig.json +5 -0
- package/tsconfig.node.json +3 -1
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// Runs a Void app's server code inside voidbase, from a pb_hooks file.
|
|
2
|
+
//
|
|
3
|
+
// This module is bundled into `.voidbase/pb_hooks/void-app.js` together with the app's own routes, so it must not
|
|
4
|
+
// import anything from voidbase: a hook runs in a sandbox whose `require` reaches only its sibling hook files.
|
|
5
|
+
// PocketBase's API reaches it through `pb`, which reads the hook globals the generated `.pb.js` wrapper publishes
|
|
6
|
+
// before it requires this bundle. Its only imports are Void's own runtime, which is bundled along with it.
|
|
7
|
+
//
|
|
8
|
+
// Two things make Void code run unchanged:
|
|
9
|
+
// - routes register through `routerAdd`, the registry every pb_hooks route uses, and the RequestEvent it hands
|
|
10
|
+
// the handler carries `.c`, the real Hono context Void handlers expect;
|
|
11
|
+
// - every handler body runs inside `withRuntimeEnv`, Void's AsyncLocalStorage for bindings, so `void/db`,
|
|
12
|
+
// `void/storage`, `void/env` and `void/queues` resolve against voidbase's D1 and R2 with no shim.
|
|
13
|
+
import { withRuntimeEnv } from "void/_env";
|
|
14
|
+
import { convertReturnValue } from "void/response";
|
|
15
|
+
import type { Context } from "hono";
|
|
16
|
+
// types only: erased by the bundler, so this file keeps its promise of importing nothing from voidbase at runtime
|
|
17
|
+
import type { AppApi, RequestEvent } from "../server/hooks/runtime";
|
|
18
|
+
import type { CollectionRef, HookRecord } from "../server/hooks/record";
|
|
19
|
+
|
|
20
|
+
type Handler = (c: Context) => unknown;
|
|
21
|
+
export type Middleware = (c: Context, next: () => Promise<void>) => Promise<void>;
|
|
22
|
+
|
|
23
|
+
/** PocketBase's global request middleware: every request, before whatever answers it. */
|
|
24
|
+
export const REQUEST_HOOK = "routerUse";
|
|
25
|
+
/** The name of a PocketBase hook: `routerUse`, or one of its `on*` events. */
|
|
26
|
+
export type HookName = typeof REQUEST_HOOK | `on${string}`;
|
|
27
|
+
/** A `vb_hooks/` file's default export. */
|
|
28
|
+
export interface HookModule<E = HookEvent> { (e: E): unknown; hook: HookName; tags: string[] }
|
|
29
|
+
/** What a hook handler is given. Request hooks carry `.c`, the Hono context; every hook carries `.next()`. */
|
|
30
|
+
export type HookEvent = { next(): Promise<unknown> } & Record<string, unknown>;
|
|
31
|
+
type Bindings = Record<string, unknown>;
|
|
32
|
+
|
|
33
|
+
type ErrorClass = new (message?: string, data?: unknown) => Error;
|
|
34
|
+
/* eslint-disable @typescript-eslint/no-explicit-any -- PocketBase events are many shapes; the hook API is loose by nature */
|
|
35
|
+
type EventRegistrar = (fn: (e: any) => unknown, ...tags: string[]) => void;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* PocketBase's API, as the app's own code sees it. A route compiled into pb_hooks cannot import voidbase, so the
|
|
39
|
+
* generated hook publishes the hook globals and this reads them:
|
|
40
|
+
*
|
|
41
|
+
* import { defineHandler } from "void";
|
|
42
|
+
* import { pb, requireAuth } from "@voidbase-cloud/voidbase/adapter";
|
|
43
|
+
*
|
|
44
|
+
* export const GET = defineHandler(requireAuth("users"), async () => {
|
|
45
|
+
* return { posts: await pb.$app.findRecordsByFilter("posts", "published = true", "-created", 20, 0) };
|
|
46
|
+
* });
|
|
47
|
+
*
|
|
48
|
+
* It is filled in before any module body runs, so a plain module under `src/` can register PocketBase's *event*
|
|
49
|
+
* hooks at import time, exactly as a hook file does — as long as a route or middleware imports it:
|
|
50
|
+
*
|
|
51
|
+
* // src/server/audit.ts, imported by the routes that need it
|
|
52
|
+
* pb.onRecordAfterCreateSuccess((e) => { ... }, "posts");
|
|
53
|
+
*/
|
|
54
|
+
export type PocketBaseApi = {
|
|
55
|
+
/** the data API: findRecordById, findRecordsByFilter, save, delete, settings, ... */
|
|
56
|
+
$app: AppApi;
|
|
57
|
+
$apis: { requireAuth(...collections: string[]): unknown; requireSuperuserAuth(): unknown; requireGuestOnly(): unknown };
|
|
58
|
+
$os: { getenv(name: string): string };
|
|
59
|
+
/** the bindings of the request, cron tick or job running now */
|
|
60
|
+
$env(): Bindings;
|
|
61
|
+
/** voidbase's background queue */
|
|
62
|
+
$jobs: {
|
|
63
|
+
queueJob(job: { type: "queue"; queue: string; body: unknown }): Promise<unknown>;
|
|
64
|
+
onJob(type: "queue", fn: (env: Bindings, job: { queue: string; body: unknown }) => Promise<void>): void;
|
|
65
|
+
};
|
|
66
|
+
Record: new (collection: CollectionRef, data?: Record<string, unknown>) => HookRecord;
|
|
67
|
+
ApiError: ErrorClass;
|
|
68
|
+
BadRequestError: ErrorClass;
|
|
69
|
+
UnauthorizedError: ErrorClass;
|
|
70
|
+
ForbiddenError: ErrorClass;
|
|
71
|
+
NotFoundError: ErrorClass;
|
|
72
|
+
InternalServerError: ErrorClass;
|
|
73
|
+
ValidationError: ErrorClass;
|
|
74
|
+
routerAdd(method: string, path: string, handler: (e: RequestEvent) => unknown): void;
|
|
75
|
+
/** PocketBase's global middleware: every request, before the route that answers it */
|
|
76
|
+
routerUse(...middlewares: ((e: RequestEvent) => unknown)[]): void;
|
|
77
|
+
cronAdd(id: string, expr: string, fn: () => unknown): void;
|
|
78
|
+
} & { [K in `on${string}`]: EventRegistrar };
|
|
79
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
80
|
+
|
|
81
|
+
/** where the generated hook publishes the globals, before it requires this bundle */
|
|
82
|
+
const HANDOFF = "__voidbaseHooks";
|
|
83
|
+
const hookGlobals = () => (globalThis as Record<string, unknown>)[HANDOFF] as Record<string | symbol, unknown> | undefined;
|
|
84
|
+
|
|
85
|
+
// Registering something -- an event hook, a route, a cron -- is the one thing a module may do while it is being
|
|
86
|
+
// imported, and a module is imported in more places than the generated app: Void's build evaluates the same code
|
|
87
|
+
// to prerender the pages, with no PocketBase anywhere. So registrations made before the hook globals arrive are
|
|
88
|
+
// held and replayed once they do, and discarded with the process when they never do. Everything else -- $app and
|
|
89
|
+
// the rest of the data API -- has no answer outside the app and says so.
|
|
90
|
+
const REGISTRAR = /^(on[A-Z]|routerAdd$|routerUse$|cronAdd$)/;
|
|
91
|
+
const deferred: { prop: string; args: unknown[] }[] = [];
|
|
92
|
+
let replayed = false;
|
|
93
|
+
function replay(globals: Record<string | symbol, unknown>) {
|
|
94
|
+
if (replayed) return;
|
|
95
|
+
replayed = true;
|
|
96
|
+
for (const call of deferred.splice(0)) (globals[call.prop] as (...a: unknown[]) => unknown)(...call.args);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const pb: PocketBaseApi = new Proxy({} as PocketBaseApi, {
|
|
100
|
+
get(_t, prop) {
|
|
101
|
+
const globals = hookGlobals();
|
|
102
|
+
if (globals) { replay(globals); return globals[prop]; }
|
|
103
|
+
if (typeof prop === "string" && REGISTRAR.test(prop)) return (...args: unknown[]) => { deferred.push({ prop, args }); };
|
|
104
|
+
throw new Error(`voidbase: pb.${String(prop)} is only available inside the generated app, where the hook publishes PocketBase's API`);
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A `vb_hooks/` file: one PocketBase hook, registered once when the app mounts. The file names its hook here, so
|
|
110
|
+
* the build can read it without running anything:
|
|
111
|
+
*
|
|
112
|
+
* // vb_hooks/10.audit.ts
|
|
113
|
+
* import { defineHook } from "@voidbase-cloud/voidbase/adapter";
|
|
114
|
+
*
|
|
115
|
+
* export default defineHook("onRecordAfterCreateSuccess", async (e) => {
|
|
116
|
+
* await e.next();
|
|
117
|
+
* console.log("created", e.record);
|
|
118
|
+
* }, "posts");
|
|
119
|
+
*
|
|
120
|
+
* The trailing arguments are PocketBase's tags, the collections the hook is limited to. A middleware that runs on
|
|
121
|
+
* every request is Void's own thing and belongs in `middleware/`, written with `defineMiddleware`.
|
|
122
|
+
*/
|
|
123
|
+
export function defineHook<E = HookEvent>(hook: HookName, handler: (e: E) => unknown, ...tags: string[]): HookModule<E> {
|
|
124
|
+
return Object.assign(handler, { hook, tags }) as HookModule<E>;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* `vb_secrets/main.ts`: the secrets the app needs, named where the build can read them. Their values live in
|
|
129
|
+
* `vb_secrets/secrets.json` (git-ignored) on a dev machine and as the Worker's secrets once deployed; the app reads
|
|
130
|
+
* them like any binding (`c.env.SMTP_PASSWORD`, `pb.$os.getenv("SMTP_PASSWORD")`):
|
|
131
|
+
*
|
|
132
|
+
* export default defineSecrets({
|
|
133
|
+
* SMTP_PASSWORD: "the mail provider's SMTP password",
|
|
134
|
+
* });
|
|
135
|
+
*
|
|
136
|
+
* The result is the declaration itself, so `keyof typeof secrets` names them for the app's own typing.
|
|
137
|
+
*/
|
|
138
|
+
export function defineSecrets<const T extends Record<string, string | { description?: string }>>(secrets: T): T {
|
|
139
|
+
return secrets;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const AUTH = Symbol.for("voidbase.auth");
|
|
143
|
+
/** The authenticated record of this request, exactly as a PocketBase hook sees it (`e.auth`). */
|
|
144
|
+
export function authOf(c: Context): HookRecord | null {
|
|
145
|
+
return ((c as unknown as Record<symbol, HookRecord | null>)[AUTH]) ?? null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Void-shaped counterparts of $apis.requireAuth / requireSuperuserAuth, for `defineHandler(mw, handler)`. */
|
|
149
|
+
export function requireAuth(...collections: string[]): Middleware {
|
|
150
|
+
return async (c, next) => {
|
|
151
|
+
const auth = authOf(c);
|
|
152
|
+
if (!auth || (collections.length && !collections.includes(auth.collection().name))) throw new pb.UnauthorizedError("The request requires valid record authorization token.");
|
|
153
|
+
await next();
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
export function requireSuperuser(): Middleware {
|
|
157
|
+
return async (c, next) => {
|
|
158
|
+
const auth = authOf(c);
|
|
159
|
+
if (!auth) throw new pb.UnauthorizedError("The request requires valid record authorization token.");
|
|
160
|
+
if (!auth.isSuperuser()) throw new pb.ForbiddenError("The authorized record is not allowed to perform this action.");
|
|
161
|
+
await next();
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface MountedRoute {
|
|
166
|
+
url: string;
|
|
167
|
+
hookPath: string;
|
|
168
|
+
methods: string[];
|
|
169
|
+
params: string[];
|
|
170
|
+
splat?: string;
|
|
171
|
+
/** the route module: { GET, POST, ... } */
|
|
172
|
+
mod: Record<string, unknown>;
|
|
173
|
+
}
|
|
174
|
+
export interface MountedQueue {
|
|
175
|
+
name: string;
|
|
176
|
+
binding: string;
|
|
177
|
+
/** the queue module's default export, a `defineQueue` consumer */
|
|
178
|
+
consumer: (batch: QueueBatch, env: Bindings) => unknown;
|
|
179
|
+
}
|
|
180
|
+
export interface MountedCron {
|
|
181
|
+
/** the cron id, taken from the file name */
|
|
182
|
+
name: string;
|
|
183
|
+
/** the schedule the module exports as `cron` */
|
|
184
|
+
expr: string;
|
|
185
|
+
handler: (controller: { cron: string; scheduledTime: number }, env: Bindings) => unknown;
|
|
186
|
+
}
|
|
187
|
+
export interface QueueMessage { id: string; body: unknown; timestamp: Date; attempts: number; ack(): void; retry(): void }
|
|
188
|
+
export interface QueueBatch { queue: string; messages: QueueMessage[]; ackAll(): void; retryAll(): void }
|
|
189
|
+
|
|
190
|
+
export interface MountedHook {
|
|
191
|
+
/** the hook this file attaches to, read from its source at build time */
|
|
192
|
+
hook: HookName;
|
|
193
|
+
/** the file's default export */
|
|
194
|
+
handler: HookModule;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface MountSpec {
|
|
198
|
+
/** vb_hooks/, in file order: one PocketBase hook each, registered once */
|
|
199
|
+
hooks?: MountedHook[];
|
|
200
|
+
routes?: MountedRoute[];
|
|
201
|
+
/** middleware/, in file order: Void's own, registered through routerUse so it runs on every request */
|
|
202
|
+
middleware?: Middleware[];
|
|
203
|
+
crons?: MountedCron[];
|
|
204
|
+
queues?: MountedQueue[];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Producer bindings for the app's queues, overlaid on the real env so `void/queues` and `c.env.QUEUE_X` both work. */
|
|
208
|
+
function queueBindings(queues: MountedQueue[]): Bindings {
|
|
209
|
+
const out: Bindings = {};
|
|
210
|
+
for (const q of queues) {
|
|
211
|
+
out[q.binding] = {
|
|
212
|
+
send: (body: unknown) => pb.$jobs.queueJob({ type: "queue", queue: q.name, body }),
|
|
213
|
+
sendBatch: async (messages: Iterable<{ body: unknown }>) => { for (const m of messages) await pb.$jobs.queueJob({ type: "queue", queue: q.name, body: m.body }); },
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Pulls `:param` and `[...splat]` values out of the path the router matched. */
|
|
220
|
+
function paramsOf(route: MountedRoute, path: string): Record<string, string> {
|
|
221
|
+
const pattern = route.hookPath.split("/");
|
|
222
|
+
const actual = path.split("/");
|
|
223
|
+
const params: Record<string, string> = {};
|
|
224
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
225
|
+
const seg = pattern[i]!;
|
|
226
|
+
if (seg === "*") { if (route.splat) params[route.splat] = actual.slice(i).join("/"); break; }
|
|
227
|
+
if (seg.startsWith(":")) params[seg.slice(1)] = decodeURIComponent(actual[i] ?? "");
|
|
228
|
+
}
|
|
229
|
+
return params;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The Hono context a Void handler sees: real context, with the route's params and the queue bindings overlaid. */
|
|
233
|
+
function voidContext(c: Context, params: Record<string, string>, env: Bindings, auth: HookRecord | null): Context {
|
|
234
|
+
const bind = <T extends object>(target: T, prop: string | symbol) => {
|
|
235
|
+
const value = Reflect.get(target, prop, target);
|
|
236
|
+
return typeof value === "function" ? (value as (...a: unknown[]) => unknown).bind(target) : value;
|
|
237
|
+
};
|
|
238
|
+
const req = new Proxy(c.req, {
|
|
239
|
+
get(target, prop) {
|
|
240
|
+
if (prop === "param") return (name?: string) => (name === undefined ? { ...target.param(), ...params } : params[name] ?? target.param(name as never));
|
|
241
|
+
return bind(target, prop);
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
return new Proxy(c, {
|
|
245
|
+
get(target, prop) {
|
|
246
|
+
if (prop === "req") return req;
|
|
247
|
+
if (prop === "env") return env;
|
|
248
|
+
if (prop === AUTH) return auth;
|
|
249
|
+
return bind(target, prop);
|
|
250
|
+
},
|
|
251
|
+
set(target, prop, value) { return Reflect.set(target, prop, value, target); },
|
|
252
|
+
}) as Context;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** A handler's return value becomes a Response exactly as Void converts it. */
|
|
256
|
+
async function runHandler(c: Context, handler: Handler): Promise<Response> {
|
|
257
|
+
c.res = convertReturnValue(await handler(c));
|
|
258
|
+
return c.res;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function mountVoidApp(spec: MountSpec): void {
|
|
262
|
+
const { hooks = [], routes = [], middleware = [], crons = [], queues = [] } = spec;
|
|
263
|
+
|
|
264
|
+
const envFor = (base: Bindings): Bindings => (queues.length ? { ...base, ...queueBindings(queues) } : base);
|
|
265
|
+
|
|
266
|
+
// vb_hooks/ first, in file order: one PocketBase hook per file, registered once. An onBootstrap has to be in
|
|
267
|
+
// place before voidbase opens the database, and nothing else here depends on the order.
|
|
268
|
+
for (const h of hooks) {
|
|
269
|
+
const register = pb[h.hook] as (fn: (e: HookEvent) => unknown, ...tags: string[]) => void;
|
|
270
|
+
if (typeof register !== "function") throw new Error(`voidbase: "${h.hook}" is not one of PocketBase's hooks`);
|
|
271
|
+
register(h.handler, ...(h.handler.tags ?? []));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Void's middleware/ means every request, and PocketBase's routerUse is exactly that: in file order, before
|
|
275
|
+
// whatever answers, PocketBase's own endpoints and the admin panel included. So a middleware that throws takes
|
|
276
|
+
// the whole backend with it.
|
|
277
|
+
for (const voidMw of middleware) {
|
|
278
|
+
pb.routerUse(async (e: RequestEvent) => {
|
|
279
|
+
const c = e.c;
|
|
280
|
+
const env = envFor(c.env as unknown as Bindings);
|
|
281
|
+
const ctx = voidContext(c, {}, env, e.auth ?? null);
|
|
282
|
+
let called = false;
|
|
283
|
+
const res = await withRuntimeEnv(env, () => voidMw(ctx, async () => { called = true; await e.next(); }));
|
|
284
|
+
if (!called && res === undefined) await e.next(); // a middleware that returned without calling next() still passes through
|
|
285
|
+
return res;
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
for (const route of routes) {
|
|
290
|
+
for (const method of route.methods) {
|
|
291
|
+
const handler = route.mod[method] as Handler | undefined;
|
|
292
|
+
if (typeof handler !== "function") continue;
|
|
293
|
+
pb.routerAdd(method === "ALL" ? "ANY" : method, route.hookPath, async (e) => {
|
|
294
|
+
const c = e.c;
|
|
295
|
+
const env = envFor(c.env as unknown as Bindings);
|
|
296
|
+
const ctx = voidContext(c, paramsOf(route, new URL(c.req.url).pathname), env, e.auth ?? null);
|
|
297
|
+
return withRuntimeEnv(env, () => runHandler(ctx, handler));
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
for (const cron of crons) {
|
|
303
|
+
// cronAdd's callback takes no arguments: the bindings come from the hook store the cron runner opens
|
|
304
|
+
pb.cronAdd(cron.name, cron.expr, async () => {
|
|
305
|
+
const env = pb.$env();
|
|
306
|
+
const controller = { cron: cron.expr, scheduledTime: Date.now() };
|
|
307
|
+
await withRuntimeEnv(envFor(env), () => cron.handler(controller, env));
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (queues.length) {
|
|
312
|
+
const byName = new Map(queues.map((q) => [q.name, q]));
|
|
313
|
+
// One handler for every app queue: voidbase carries the message on its own jobs queue (or runs it inline when
|
|
314
|
+
// the deploy has none), so a Void consumer sees a one-message batch. A throw is the retry signal.
|
|
315
|
+
pb.$jobs.onJob("queue", async (env, job) => {
|
|
316
|
+
const q = byName.get(job.queue);
|
|
317
|
+
if (!q) throw new Error(`voidbase: no consumer for queue "${job.queue}"`);
|
|
318
|
+
let retry = false;
|
|
319
|
+
const message: QueueMessage = { id: crypto.randomUUID(), body: job.body, timestamp: new Date(), attempts: 1, ack: () => { retry = false; }, retry: () => { retry = true; } };
|
|
320
|
+
const batch: QueueBatch = { queue: q.name, messages: [message], ackAll: () => { retry = false; }, retryAll: () => { retry = true; } };
|
|
321
|
+
await withRuntimeEnv(envFor(env), () => q.consumer(batch, env));
|
|
322
|
+
if (retry) throw new Error(`voidbase: queue "${job.queue}" asked to retry`);
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// Reads a Void app's file conventions (void/docs/reference/structure.md) into a manifest. The scan is pure
|
|
2
|
+
// filesystem plus a TypeScript parse for the exported names, so it never imports the app's own code: the same
|
|
3
|
+
// function runs inside the Vite plugin, in `voidbase adapt` and in tests without booting the app.
|
|
4
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
5
|
+
import { basename, extname, join, relative, resolve } from "node:path";
|
|
6
|
+
import ts from "typescript";
|
|
7
|
+
import { EVENT_HOOKS } from "../../hooks-plugin";
|
|
8
|
+
import { DECLARATION_FILES, parseSecretsDeclaration, readSecretsValues, VALUES_FILE, type SecretsDeclaration } from "../node/secrets";
|
|
9
|
+
|
|
10
|
+
const CODE = new Set([".ts", ".tsx", ".mts", ".js", ".jsx", ".mjs"]);
|
|
11
|
+
export const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD", "ALL"] as const;
|
|
12
|
+
|
|
13
|
+
export interface VoidRoute {
|
|
14
|
+
/** source file, relative to the app root */
|
|
15
|
+
file: string;
|
|
16
|
+
/** URL the file maps to, in Void's own syntax: /api/users/:id, /files/* */
|
|
17
|
+
url: string;
|
|
18
|
+
/** the same path in the hook router's dialect (identical today; kept explicit so one can move) */
|
|
19
|
+
hookPath: string;
|
|
20
|
+
/** exported HTTP methods, uppercase */
|
|
21
|
+
methods: string[];
|
|
22
|
+
/** :param names in order, and the trailing [...name] when the route is a catch-all */
|
|
23
|
+
params: string[];
|
|
24
|
+
splat?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface VoidModule { file: string; name: string }
|
|
27
|
+
/** A `vb_hooks/` file: one PocketBase hook, registered once. `hook` is `routerUse` or an `on*` event name. */
|
|
28
|
+
export interface VoidHook extends VoidModule { hook: string }
|
|
29
|
+
export interface VoidQueue extends VoidModule {
|
|
30
|
+
/** Void derives the producer binding from the file name: queues/send-mail.ts -> QUEUE_SEND_MAIL */
|
|
31
|
+
binding: string;
|
|
32
|
+
}
|
|
33
|
+
export interface VoidMigration { file: string; name: string }
|
|
34
|
+
|
|
35
|
+
/** Where a Void app keeps what belongs to voidbase rather than to Void. All of it is optional. */
|
|
36
|
+
export interface VoidbaseExtras {
|
|
37
|
+
/** vb_migrations/: PocketBase JS migrations, copied in beside the ones generated from db/migrations */
|
|
38
|
+
migrationsDir?: string;
|
|
39
|
+
/** vb_secrets/: main.ts declares the configuration (defineSecrets), secrets.json (git-ignored) holds the local values */
|
|
40
|
+
secretsDir?: string;
|
|
41
|
+
}
|
|
42
|
+
/** The two directories this adapter adds to a Void app, both named for the voidbase thing they are, both sitting
|
|
43
|
+
* at the project root beside Void's own `db/`. Everything else is Void's and means what Void means by it:
|
|
44
|
+
* `routes/`, `middleware/`, `crons/` and `queues/` are the server code, compiled into the generated app's
|
|
45
|
+
* pb_hooks, and `src/` is library code they import. */
|
|
46
|
+
export const MIGRATIONS_DIR = "vb_migrations";
|
|
47
|
+
export const HOOKS_DIR = "vb_hooks";
|
|
48
|
+
export const SECRETS_DIR = "vb_secrets";
|
|
49
|
+
|
|
50
|
+
/** PocketBase's global request middleware: what Void's own `middleware/` becomes. */
|
|
51
|
+
export const REQUEST_HOOK = "routerUse";
|
|
52
|
+
/** every name a `vb_hooks/` file may attach itself to */
|
|
53
|
+
export const HOOK_NAMES: readonly string[] = [REQUEST_HOOK, ...EVENT_HOOKS];
|
|
54
|
+
/** the known hooks whose names are closest to `name`, for the build error */
|
|
55
|
+
function nearestHooks(name: string): string[] {
|
|
56
|
+
const needle = name.toLowerCase();
|
|
57
|
+
return HOOK_NAMES.filter((h) => { const k = h.toLowerCase(); return k.includes(needle) || needle.includes(k); }).slice(0, 4);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** paths voidbase serves itself; an app route under one of these never reaches the app (PocketBase answers first) */
|
|
61
|
+
export const RESERVED_PREFIXES = ["/api/backups", "/api/batch", "/api/collections", "/api/crons", "/api/files", "/api/health", "/api/logs", "/api/realtime", "/api/settings", "/api/webauthn", "/_/"];
|
|
62
|
+
|
|
63
|
+
export interface VoidManifest {
|
|
64
|
+
root: string;
|
|
65
|
+
/** static: nothing to run, the build is just files under pb_public. server: routes/middleware/crons/queues exist. */
|
|
66
|
+
mode: "static" | "server";
|
|
67
|
+
routes: VoidRoute[];
|
|
68
|
+
middleware: VoidModule[];
|
|
69
|
+
/** vb_hooks/: one PocketBase hook per file, registered once when the app mounts */
|
|
70
|
+
hooks: VoidHook[];
|
|
71
|
+
crons: VoidModule[];
|
|
72
|
+
queues: VoidQueue[];
|
|
73
|
+
migrations: VoidMigration[];
|
|
74
|
+
/** vb_secrets/main.ts: the configuration the app declares, names and tiers only (values are never part of the manifest) */
|
|
75
|
+
secrets: SecretsDeclaration | null;
|
|
76
|
+
/** the app's own voidbase side (vb_migrations/, vb_secrets/) */
|
|
77
|
+
extras: VoidbaseExtras;
|
|
78
|
+
/** directories the app has that this adapter cannot carry, with the reason */
|
|
79
|
+
unsupported: { what: string; why: string }[];
|
|
80
|
+
/** app routes shadowed by voidbase's own API */
|
|
81
|
+
collisions: string[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const isDir = (p: string) => existsSync(p) && statSync(p).isDirectory();
|
|
85
|
+
|
|
86
|
+
/** Every code file under dir, depth first, skipping `_`-prefixed files and directories (Void ignores those). */
|
|
87
|
+
function walk(dir: string, base = dir): string[] {
|
|
88
|
+
if (!isDir(dir)) return [];
|
|
89
|
+
return readdirSync(dir).sort().flatMap((entry) => {
|
|
90
|
+
if (entry.startsWith("_")) return [];
|
|
91
|
+
const full = join(dir, entry);
|
|
92
|
+
if (statSync(full).isDirectory()) return walk(full, base);
|
|
93
|
+
return CODE.has(extname(entry)) ? [relative(base, full)] : [];
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** `debug.dev.ts` builds in development only, `metrics.prod.ts` in production only; everything else in both. */
|
|
98
|
+
function envSuffix(file: string): "dev" | "prod" | null {
|
|
99
|
+
const stem = basename(file, extname(file));
|
|
100
|
+
if (stem.endsWith(".dev")) return "dev";
|
|
101
|
+
if (stem.endsWith(".prod")) return "prod";
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
/** the file name without its extension and without a `.dev` / `.prod` suffix (`[...path].ts` keeps its dots) */
|
|
105
|
+
function stemOf(file: string): string {
|
|
106
|
+
return basename(file, extname(file)).replace(/\.(dev|prod)$/, "");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The PocketBase hook a `vb_hooks/` file attaches to, read from the source without evaluating it:
|
|
111
|
+
*
|
|
112
|
+
* export default defineHook("onRecordCreate", handler, "posts")
|
|
113
|
+
*
|
|
114
|
+
* `export const hook = "onRecordCreate"` names it too, for a handler that comes from somewhere else. Returns null
|
|
115
|
+
* when the file names no hook, which is a build error: there is nowhere to register it.
|
|
116
|
+
*/
|
|
117
|
+
export function hookName(code: string, file = "m.ts"): string | null {
|
|
118
|
+
const sf = ts.createSourceFile(file, code, ts.ScriptTarget.Latest, true);
|
|
119
|
+
const literal = (n: ts.Node | undefined) => (n && ts.isStringLiteralLike(n) ? n.text : null);
|
|
120
|
+
let fromCall: string | null = null;
|
|
121
|
+
let fromConst: string | null = null;
|
|
122
|
+
|
|
123
|
+
const callName = (expr: ts.Expression): string | null => {
|
|
124
|
+
if (!ts.isCallExpression(expr)) return null;
|
|
125
|
+
const callee = ts.isPropertyAccessExpression(expr.expression) ? expr.expression.name.text : ts.isIdentifier(expr.expression) ? expr.expression.text : "";
|
|
126
|
+
return callee === "defineHook" ? literal(expr.arguments[0]) : null;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
for (const st of sf.statements) {
|
|
130
|
+
if (ts.isExportAssignment(st) && !st.isExportEquals) fromCall ??= callName(st.expression);
|
|
131
|
+
if (ts.isVariableStatement(st)) {
|
|
132
|
+
for (const d of st.declarationList.declarations) {
|
|
133
|
+
if (!ts.isIdentifier(d.name) || !d.initializer) continue;
|
|
134
|
+
if (d.name.text === "hook") fromConst ??= literal(d.initializer);
|
|
135
|
+
// `const mw = defineHook(...); export default mw;` is the same declaration, read through
|
|
136
|
+
fromCall ??= callName(d.initializer);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return fromCall ?? fromConst;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The names a module exports, without evaluating it (`export const GET`, `export { GET }`, `export default`). */
|
|
144
|
+
export function exportedNames(code: string, file = "m.ts"): Set<string> {
|
|
145
|
+
const sf = ts.createSourceFile(file, code, ts.ScriptTarget.Latest, true);
|
|
146
|
+
const names = new Set<string>();
|
|
147
|
+
const exported = (node: ts.Node) => !!ts.canHaveModifiers(node) && !!ts.getModifiers(node)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
|
|
148
|
+
for (const st of sf.statements) {
|
|
149
|
+
if (ts.isVariableStatement(st) && exported(st)) for (const d of st.declarationList.declarations) if (ts.isIdentifier(d.name)) names.add(d.name.text);
|
|
150
|
+
if ((ts.isFunctionDeclaration(st) || ts.isClassDeclaration(st)) && exported(st) && st.name) names.add(st.name.text);
|
|
151
|
+
if (ts.isExportAssignment(st)) names.add("default");
|
|
152
|
+
if (ts.isExportDeclaration(st) && st.exportClause && ts.isNamedExports(st.exportClause)) for (const el of st.exportClause.elements) names.add(el.name.text);
|
|
153
|
+
if ((ts.isFunctionDeclaration(st) || ts.isClassDeclaration(st)) && ts.canHaveModifiers(st) && ts.getModifiers(st)?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword)) names.add("default");
|
|
154
|
+
}
|
|
155
|
+
return names;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** routes/api/users/[id].ts -> { url: "/api/users/:id", params: ["id"] }; (group)/ is stripped, index is the directory. */
|
|
159
|
+
export function routeUrl(file: string): { url: string; params: string[]; splat?: string } {
|
|
160
|
+
const params: string[] = [];
|
|
161
|
+
let splat: string | undefined;
|
|
162
|
+
const stem = stemOf(file);
|
|
163
|
+
const segments = [...file.split(/[\\/]/).slice(0, -1), stem]
|
|
164
|
+
.filter((s) => s && !(s.startsWith("(") && s.endsWith(")")))
|
|
165
|
+
.filter((s, i, all) => !(s === "index" && i === all.length - 1));
|
|
166
|
+
const path = segments.map((seg) => {
|
|
167
|
+
const catchAll = /^\[\.\.\.(.+)\]$/.exec(seg);
|
|
168
|
+
if (catchAll) { splat = catchAll[1]!; return "*"; }
|
|
169
|
+
const param = /^\[(.+)\]$/.exec(seg);
|
|
170
|
+
if (param) { params.push(param[1]!); return `:${param[1]}`; }
|
|
171
|
+
return seg;
|
|
172
|
+
}).join("/");
|
|
173
|
+
return { url: "/" + path, params, splat };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface ScanOptions { root?: string; dev?: boolean }
|
|
177
|
+
|
|
178
|
+
export function scanVoidApp(opts: ScanOptions = {}): VoidManifest {
|
|
179
|
+
const root = resolve(opts.root ?? ".");
|
|
180
|
+
const dev = !!opts.dev;
|
|
181
|
+
const rel = (dir: string, file: string) => `${dir}/${file.split("\\").join("/")}`;
|
|
182
|
+
|
|
183
|
+
const routes: VoidRoute[] = [];
|
|
184
|
+
for (const file of walk(join(root, "routes"))) {
|
|
185
|
+
const suffix = envSuffix(file);
|
|
186
|
+
if (suffix && (suffix === "dev") !== dev) continue;
|
|
187
|
+
const methods = [...exportedNames(readFileSync(join(root, "routes", file), "utf8"), file)].filter((n) => (HTTP_METHODS as readonly string[]).includes(n));
|
|
188
|
+
if (!methods.length) continue; // a file in routes/ that exports no verb is not a route
|
|
189
|
+
const { url, params, splat } = routeUrl(file);
|
|
190
|
+
routes.push({ file: rel("routes", file), url, hookPath: url, methods, params, ...(splat ? { splat } : {}) });
|
|
191
|
+
}
|
|
192
|
+
// most specific first, so /api/users/me is registered before /api/users/:id and /files/*
|
|
193
|
+
routes.sort((a, b) => score(b.url) - score(a.url) || a.url.localeCompare(b.url));
|
|
194
|
+
|
|
195
|
+
const modules = (dir: string): VoidModule[] =>
|
|
196
|
+
walk(join(root, dir)).map((file) => ({ file: rel(dir, file), name: stemOf(file) }));
|
|
197
|
+
|
|
198
|
+
// Void's own middleware/: every request, in file order, which is PocketBase's routerUse. A file that turns out to
|
|
199
|
+
// be a PocketBase hook belongs next door, and says so rather than being called with the wrong arguments.
|
|
200
|
+
const middleware = modules("middleware").map((m) => {
|
|
201
|
+
const named = hookName(readFileSync(join(root, m.file), "utf8"), m.file);
|
|
202
|
+
if (named) throw new Error(`voidbase: ${m.file} is a PocketBase hook ("${named}"), not a Void middleware. Move it to ${HOOKS_DIR}/, where one file is one hook.\n middleware/ is Void's: defineMiddleware((c, next) => ...), every request, no hook to name.`);
|
|
203
|
+
return m;
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// vb_hooks/: one file, one PocketBase hook, registered once when the app mounts. Numeric prefixes order them,
|
|
207
|
+
// and walk() already sorts by name.
|
|
208
|
+
const hooks: VoidHook[] = modules(HOOKS_DIR).map((m) => {
|
|
209
|
+
const hook = hookName(readFileSync(join(root, m.file), "utf8"), m.file);
|
|
210
|
+
if (!hook) throw new Error(`voidbase: ${m.file} is not attached to a hook, so there is nowhere to register it. Name the hook it is:\n export default defineHook("onRecordCreate", handler, "posts")\n A plain Void middleware, running on every request, belongs in middleware/ instead.`);
|
|
211
|
+
if (!HOOK_NAMES.includes(hook)) {
|
|
212
|
+
const near = nearestHooks(hook);
|
|
213
|
+
throw new Error(`voidbase: ${m.file} names "${hook}", which is not one of PocketBase's hooks.${near.length ? ` Did you mean ${near.join(", ")}?` : ` Use "${REQUEST_HOOK}" for a request middleware, or one of PocketBase's on* event hooks.`}`);
|
|
214
|
+
}
|
|
215
|
+
return { ...m, hook };
|
|
216
|
+
});
|
|
217
|
+
const crons = modules("crons");
|
|
218
|
+
const queues: VoidQueue[] = modules("queues").map((q) => ({ ...q, binding: `QUEUE_${q.name.toUpperCase().replace(/[^A-Z0-9]/g, "_")}` }));
|
|
219
|
+
|
|
220
|
+
const migrationsDir = join(root, "db", "migrations");
|
|
221
|
+
const migrations: VoidMigration[] = isDir(migrationsDir)
|
|
222
|
+
? readdirSync(migrationsDir).sort().filter((f) => f.endsWith(".sql")).map((f) => ({ file: `db/migrations/${f}`, name: basename(f, ".sql") }))
|
|
223
|
+
: [];
|
|
224
|
+
|
|
225
|
+
// `output: "static"` prerenders every page to HTML at build time, which is exactly what pb_public wants; pages
|
|
226
|
+
// that still need rendering per request have no runtime here, so say so.
|
|
227
|
+
let voidOutput = "server";
|
|
228
|
+
try { voidOutput = (JSON.parse(readFileSync(join(root, "void.json"), "utf8")) as { output?: string }).output ?? "server"; } catch { /* no void.json */ }
|
|
229
|
+
const unsupported: { what: string; why: string }[] = [];
|
|
230
|
+
if (isDir(join(root, "pages")) && voidOutput !== "static") unsupported.push({ what: "pages/", why: 'server-rendered pages need Void\'s render pipeline; set "output": "static" in void.json to prerender them into pb_public' });
|
|
231
|
+
if (walk(join(root, "routes")).some((f) => f.endsWith(".ws.ts"))) unsupported.push({ what: "routes/**/*.ws.ts", why: "document WebSockets are Durable Objects; voidbase's realtime hub owns that binding" });
|
|
232
|
+
if (grepImports(root, "void/kv")) unsupported.push({ what: "void/kv", why: "voidbase binds D1 and R2 only; keep key-value data in a collection" });
|
|
233
|
+
if (grepImports(root, "void/isr")) unsupported.push({ what: "void/isr", why: "ISR caches through the Void platform's dispatch worker, which a voidbase app does not have" });
|
|
234
|
+
|
|
235
|
+
const extras: VoidbaseExtras = {
|
|
236
|
+
migrationsDir: isDir(join(root, MIGRATIONS_DIR)) ? MIGRATIONS_DIR : undefined,
|
|
237
|
+
secretsDir: isDir(join(root, SECRETS_DIR)) ? SECRETS_DIR : undefined,
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
// vb_secrets/: main.ts declares the configuration (`export default defineSecrets({ NAME: string().secret(), ... })`),
|
|
241
|
+
// read here without running it (names and tiers; the validators run where values are parsed); secrets.json
|
|
242
|
+
// beside it is the git-ignored local values file. Values that nothing declares are a build error: they would
|
|
243
|
+
// silently never reach the Worker.
|
|
244
|
+
let secrets: SecretsDeclaration | null = null;
|
|
245
|
+
if (extras.secretsDir) {
|
|
246
|
+
const decl = DECLARATION_FILES.map((f) => join(root, SECRETS_DIR, f)).find((f) => existsSync(f));
|
|
247
|
+
const values = readSecretsValues(join(root, SECRETS_DIR));
|
|
248
|
+
if (!decl && values && Object.keys(values).length) throw new Error(`voidbase: ${SECRETS_DIR}/${VALUES_FILE} holds ${Object.keys(values).join(", ")} but ${SECRETS_DIR}/main.ts does not exist to declare them:\n export default defineSecrets({ ${Object.keys(values).map((k) => `${k}: string().secret()`).join(", ")} })`);
|
|
249
|
+
if (decl) {
|
|
250
|
+
secrets = parseSecretsDeclaration(readFileSync(decl, "utf8"), relative(root, decl));
|
|
251
|
+
const undeclared = Object.keys(values ?? {}).filter((k) => !secrets!.names.includes(k));
|
|
252
|
+
if (undeclared.length) throw new Error(`voidbase: ${SECRETS_DIR}/${VALUES_FILE} holds ${undeclared.join(", ")}, which ${relative(root, decl)} does not declare. Add them to defineSecrets({...}) or remove them: an undeclared value never reaches the Worker.`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const collisions = routes.filter((r) => RESERVED_PREFIXES.some((p) => r.url === p || r.url.startsWith(p + "/"))).map((r) => r.url);
|
|
257
|
+
// "static" means nothing has to run: no Void server code and no voidbase extensions of the app's own
|
|
258
|
+
const mode = routes.length || middleware.length || hooks.length || crons.length || queues.length ? "server" : "static";
|
|
259
|
+
return { root, mode, routes, middleware, hooks, crons, queues, migrations, secrets, extras, unsupported, collisions };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** literal segments beat params beat wildcards, longer paths beat shorter (the hook router scores the same way) */
|
|
263
|
+
function score(path: string): number {
|
|
264
|
+
const segs = path.split("/").filter(Boolean);
|
|
265
|
+
return (path.includes("*") ? 0 : 1000) + segs.filter((s) => !s.startsWith(":") && s !== "*").length * 10 + segs.length;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** cheap source grep for a bare-specifier import, used only to report what the adapter cannot carry */
|
|
269
|
+
function grepImports(root: string, specifier: string): boolean {
|
|
270
|
+
const needle = new RegExp(`from\\s+["']${specifier.replace("/", "\\/")}["']`);
|
|
271
|
+
for (const dir of ["routes", "middleware", "crons", "queues", "src", "db"]) {
|
|
272
|
+
for (const file of walk(join(root, dir))) {
|
|
273
|
+
if (needle.test(readFileSync(join(root, dir, file), "utf8"))) return true;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return false;
|
|
277
|
+
}
|
package/src/cloud/rest.ts
CHANGED
|
@@ -258,8 +258,16 @@ export async function destroyInstance(cf: CfApi, o: { account: string; name: str
|
|
|
258
258
|
const log = o.log ?? (() => undefined); const res = instanceResources(o.name); const out: DestroyResult = { name: o.name, deleted: [], skipped: [], errors: [] };
|
|
259
259
|
const attempt = async (label: string, fn: () => Promise<boolean>) => { try { (await fn()) ? out.deleted.push(label) : out.skipped.push(label); log(`${label}: ${out.deleted.includes(label) ? "deleted" : "not found"}`); } catch (e) { out.errors.push(`${label}: ${e instanceof Error ? e.message : e}`); log(`${label}: ${e instanceof Error ? e.message : e}`); } };
|
|
260
260
|
await attempt(`custom domains of ${o.name}`, async () => (await detachCustomDomains(cf, o.account, o.name)).length > 0);
|
|
261
|
-
//
|
|
262
|
-
|
|
261
|
+
// Cloudflare refuses to delete a Worker that consumes a queue (10064) and a queue a Worker still binds (11005):
|
|
262
|
+
// the consumer goes first, then the script (so nothing keeps serving with bindings about to vanish), then the queue
|
|
263
|
+
await attempt(`queue consumer of ${o.name}`, async () => {
|
|
264
|
+
const q = await findQueue(cf, o.account, res.queue); if (!q) return false;
|
|
265
|
+
const consumers = await cf.json<{ consumer_id?: string; id?: string; script?: string; script_name?: string }[]>("GET", `/accounts/${o.account}/queues/${q.id}/consumers`);
|
|
266
|
+
let removed = false;
|
|
267
|
+
for (const c of consumers.result ?? []) { if ((c.script ?? c.script_name) !== o.name) continue; await cf.json("DELETE", `/accounts/${o.account}/queues/${q.id}/consumers/${c.consumer_id ?? c.id}`); removed = true; }
|
|
268
|
+
return removed;
|
|
269
|
+
});
|
|
270
|
+
await attempt(`worker ${o.name}`, async () => { const r = await cf.raw("DELETE", `/accounts/${o.account}/workers/scripts/${o.name}?force=true`); const body = await r.text(); if (r.status === 404) return false; if (!r.ok) throw new Error(`HTTP ${r.status} ${body.slice(0, 200)}`); return true; });
|
|
263
271
|
await attempt(`queue ${res.queue}`, async () => { const q = await findQueue(cf, o.account, res.queue); if (!q) return false; await cf.json("DELETE", `/accounts/${o.account}/queues/${q.id}`); return true; });
|
|
264
272
|
await attempt(`D1 ${res.db}`, async () => { const d = await findD1(cf, o.account, res.db); if (!d) return false; await cf.json("DELETE", `/accounts/${o.account}/d1/database/${d.uuid}`); return true; });
|
|
265
273
|
await attempt(`R2 ${res.bucket}`, async () => {
|