@pramen/server 0.0.9 → 0.0.10

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/src/worker.ts CHANGED
@@ -5,15 +5,18 @@
5
5
  // returned fetch with the matching DO class; a consumer just re-exports both.
6
6
 
7
7
  import { authorizeTenant, HmacStrategy, isAdmin, JwksStrategy, resolveIdentity, type VerifyStrategy } from "./auth";
8
- import { dispatch } from "./runtime/dispatch";
8
+ import { dispatch, tasksFacade, bindTasks } from "./runtime/dispatch";
9
+ import { ensureOutbox, drainOutbox, listTasks } from "./runtime/outbox";
9
10
  import { migrate } from "./runtime/migrate";
10
11
  import { compileAcl } from "./runtime/acl";
12
+ import { Db } from "./runtime/db";
11
13
  import { D1Driver, type Driver } from "./runtime/driver";
12
14
  import { toResponse } from "./runtime/errors";
13
15
  import { Kv } from "./runtime/kv";
14
16
  import { listDOs, partitionDoName } from "./runtime/registry";
15
17
  import { createFiles, handleFileRequest, R2Adapter } from "./runtime/storage";
16
18
  import type { Identity } from "./sdk/acl";
19
+ import type { HandlerContext } from "./sdk/handlers";
17
20
  import { DEFAULT_PARTITION } from "./sdk/schema";
18
21
  import type { PramenApp } from "./pramen";
19
22
 
@@ -129,6 +132,7 @@ export function makeWorker(app: PramenApp) {
129
132
  const ensureD1Migrated = (driver: Driver, allowDestructive: boolean): Promise<void> => {
130
133
  if (!d1Ready) {
131
134
  d1Ready = migrate(driver, app.schema, { allowDestructive })
135
+ .then(() => ensureOutbox(driver)) // the deferred-tasks table also lives in D1
132
136
  .then(() => undefined)
133
137
  .catch((e) => {
134
138
  d1Ready = undefined;
@@ -138,6 +142,32 @@ export function makeWorker(app: PramenApp) {
138
142
  return d1Ready;
139
143
  };
140
144
 
145
+ // A privileged, system-scoped context for running task handlers on the D1 (Worker)
146
+ // path — mirrors the DO's taskCtx. No live socket, so no DO; drained by a Cron / the
147
+ // /admin/tasks/drain route, never a DO alarm.
148
+ const d1TaskCtx = (driver: Driver, env: Env): HandlerContext => {
149
+ const identity: Identity = { roles: ["admin"] };
150
+ const files = createFiles({ tenant: "main", secret: filesSecret(env), adapter: new R2Adapter(env.FILES) });
151
+ const db = new Db(driver, { acl: d1Acl, identity, system: true, schema: app.schema }, app.schema);
152
+ return { db, kv: new Kv(env.KV), files, env: env as unknown as Record<string, unknown>, identity, tasks: tasksFacade(driver) };
153
+ };
154
+
155
+ /** Drain the D1 outbox in the Worker (no DO/alarm on this path) — called by the
156
+ * /admin/tasks/drain route with `x-pramen-store: d1`, and by `scheduled()` (Cron). */
157
+ const drainD1 = async (env: Env): Promise<unknown> => {
158
+ if (!env.DB) throw new Error("D1 store is not configured");
159
+ const driver = new D1Driver(env.DB);
160
+ await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
161
+ return drainOutbox(driver, bindTasks(app.tasks, d1TaskCtx(driver, env)), Date.now());
162
+ };
163
+
164
+ const listD1Tasks = async (env: Env, status?: string, limit?: number): Promise<unknown> => {
165
+ if (!env.DB) throw new Error("D1 store is not configured");
166
+ const driver = new D1Driver(env.DB);
167
+ await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
168
+ return listTasks(driver, { status, limit });
169
+ };
170
+
141
171
  return {
142
172
  async fetch(request: Request, env: Env): Promise<Response> {
143
173
  const url = new URL(request.url);
@@ -239,6 +269,60 @@ export function makeWorker(app: PramenApp) {
239
269
  return withCors(res, cors);
240
270
  }
241
271
 
272
+ // --- admin: drain the deferred-task outbox now (the DO also self-drains via an
273
+ // alarm; this is the manual / Cron entry, and the ONLY drain for the D1 path).
274
+ // `x-pramen-store: d1` drains the D1 outbox in the Worker; else the tenant's DO. ---
275
+ if (url.pathname === "/admin/tasks/drain" && request.method === "POST") {
276
+ if (!isAdmin(identity)) return forbidden("tasks");
277
+ if (request.headers.get("x-pramen-store") === "d1") {
278
+ try {
279
+ return withCors(json({ ok: true, result: await drainD1(env) }), cors);
280
+ } catch (err) {
281
+ const { status, body } = toResponse(err);
282
+ return withCors(json(body, status), cors);
283
+ }
284
+ }
285
+ const body = (await request.json().catch(() => ({}))) as { tenant?: unknown; partition?: unknown };
286
+ const tenant = typeof body.tenant === "string" && body.tenant ? body.tenant : "main";
287
+ const partition = typeof body.partition === "string" && body.partition ? body.partition : DEFAULT_PARTITION;
288
+ const stub = partitionStubFor(env, tenant, partition);
289
+ const res = await stub.fetch(
290
+ new Request("https://do/__admin/tasks/drain", {
291
+ method: "POST",
292
+ headers: { "content-type": "application/json", "x-pramen-tenant": tenant, "x-pramen-partition": partition },
293
+ }),
294
+ );
295
+ return withCors(res, cors);
296
+ }
297
+
298
+ // --- admin: list outbox tasks (inspect dead-letters etc.). ?status=&limit=,
299
+ // ?tenant=&partition= (DO store) or x-pramen-store: d1 (Worker outbox). ---
300
+ if (url.pathname === "/admin/tasks/list") {
301
+ if (!isAdmin(identity)) return withCors(forbidden("tasks"), cors);
302
+ const status = url.searchParams.get("status") ?? undefined;
303
+ const limit = Number(url.searchParams.get("limit")) || undefined;
304
+ if (request.headers.get("x-pramen-store") === "d1") {
305
+ try {
306
+ return withCors(json({ ok: true, result: await listD1Tasks(env, status, limit) }), cors);
307
+ } catch (err) {
308
+ const { status: s, body } = toResponse(err);
309
+ return withCors(json(body, s), cors);
310
+ }
311
+ }
312
+ const tenant = url.searchParams.get("tenant") ?? "main";
313
+ const partition = url.searchParams.get("partition") || DEFAULT_PARTITION;
314
+ const q = new URLSearchParams();
315
+ if (status) q.set("status", status);
316
+ if (limit) q.set("limit", String(limit));
317
+ const stub = partitionStubFor(env, tenant, partition);
318
+ const res = await stub.fetch(
319
+ new Request(`https://do/__admin/tasks/list?${q}`, {
320
+ headers: { "x-pramen-tenant": tenant, "x-pramen-partition": partition },
321
+ }),
322
+ );
323
+ return withCors(res, cors);
324
+ }
325
+
242
326
  const isRpc = url.pathname.startsWith("/rpc/");
243
327
  const isLive = url.pathname === "/live";
244
328
 
@@ -303,5 +387,11 @@ export function makeWorker(app: PramenApp) {
303
387
  const res = await stub.fetch(new Request(req, { headers }));
304
388
  return isWs ? res : withCors(res, cors);
305
389
  },
390
+
391
+ // Cron Trigger entry: drains the D1 outbox (the DO path self-drains via an alarm,
392
+ // so it needs no cron). Wire a `[triggers] crons` in wrangler/oblaka to call this.
393
+ async scheduled(_event: unknown, env: Env): Promise<void> {
394
+ if (env.DB) await drainD1(env);
395
+ },
306
396
  };
307
397
  }