@pramen/server 0.0.59 → 0.0.60

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
@@ -9,6 +9,7 @@ import { dispatch, tasksFacade, bindTasks } from "./runtime/dispatch";
9
9
  import type { EnvBag } from "./sdk/handlers";
10
10
  import type { JsonValue } from "./sdk/infer";
11
11
  import { ensureOutbox, drainOutbox, listTasks } from "./runtime/outbox";
12
+ import { appliedMigrations, runDataMigrations } from "./runtime/data-migrations";
12
13
  import { createMail } from "./runtime/mail";
13
14
  import { createQueue, type QueueProducerBinding } from "./runtime/queue";
14
15
  import { dispatchQueueBatch, type QueueBatch, type QueueContext } from "./runtime/queue-consumer";
@@ -234,6 +235,43 @@ export function makeWorker(app: PramenApp) {
234
235
  }
235
236
  };
236
237
 
238
+ // The mirror of the DO's runDataMigrations() for the D1 store, run once per isolate after
239
+ // migration. It diverges in one deliberate way: it runs EVERY declared migration whatever
240
+ // partition it names, because D1 is ONE shared database with no partition split — every
241
+ // entity's table lives in it, so a migration declared for "audit" has real rows to touch
242
+ // here and would otherwise be permanently unrunnable on this store. Each is still recorded
243
+ // under its OWN declared partition key, so a ledger read is comparable across stores.
244
+ //
245
+ // Errors are NOT swallowed (unlike runBootstrapD1): the .catch in ensureD1Migrated clears
246
+ // `d1Ready`, so a failed migration fails this request and is retried on the next one —
247
+ // the same fail-closed contract as the DO path.
248
+ //
249
+ // ATOMICITY CAVEAT: D1's `transaction(fn)` is `fn()` (no interactive transactions), so
250
+ // here the claim and the work do NOT commit together. The runner claims the ledger row
251
+ // before running (which is what keeps two cold isolates from both applying the same
252
+ // backfill — `d1Ready` is per-isolate and there is no single writer) and releases it on a
253
+ // throw, so a failed migration leaves partial writes and re-runs. Write SQL that tolerates
254
+ // that (`WHERE col IS NULL`) when the D1 store is in play.
255
+ const runDataMigrationsD1 = async (driver: Driver): Promise<void> => {
256
+ const migrations = app.migrations;
257
+ if (!migrations?.length) return;
258
+ const db = new Db(driver, { acl: d1Acl, identity: { roles: ["admin"] }, system: true, schema: app.schema, suppressTriggers: true }, app.schema);
259
+ await runDataMigrations(driver, migrations, {
260
+ // No `partition` — see the "runs every declared migration" note above.
261
+ makeContext: (partition) => ({ db, driver, schema: app.schema, partition }),
262
+ });
263
+ };
264
+
265
+ /** Partitions an admin route may address: the schema's, the default (always addressable),
266
+ * and any a data migration declares. Computed once — the app is static. */
267
+ let knownPartitionsCache: Set<string> | undefined;
268
+ const knownPartitions = (): Set<string> => {
269
+ if (!knownPartitionsCache) {
270
+ knownPartitionsCache = new Set([DEFAULT_PARTITION, ...partitionsOf(app.schema), ...(app.migrations ?? []).map((m) => m.partition ?? DEFAULT_PARTITION)]);
271
+ }
272
+ return knownPartitionsCache;
273
+ };
274
+
237
275
  let d1Ready: Promise<void> | undefined;
238
276
  /** Run one handler against the D1 store, in the Worker. The request path and the
239
277
  * PRIVILEGED path (routes, which have no ctx.db) both come through here, so the two
@@ -293,6 +331,7 @@ export function makeWorker(app: PramenApp) {
293
331
  const ensureD1Migrated = (driver: Driver, allowDestructive: boolean): Promise<void> => {
294
332
  if (!d1Ready) {
295
333
  d1Ready = migrate(driver, app.schema, { allowDestructive })
334
+ .then(() => runDataMigrationsD1(driver)) // imperative, recorded backfills (fail closed)
296
335
  .then(() => ensureOutbox(driver)) // the deferred-tasks table also lives in D1
297
336
  .then(() => runBootstrapD1(driver)) // converge code-defined reference data
298
337
  .then(() => undefined)
@@ -354,6 +393,26 @@ export function makeWorker(app: PramenApp) {
354
393
  return listTasks(driver, { status, limit });
355
394
  };
356
395
 
396
+ /** Read the data-migration ledger straight from D1 in the Worker (there is no DO on this
397
+ * path), shaped exactly like the DO's /__migrations answer so the CLI can't tell them
398
+ * apart. The D1 ledger holds every partition's rows; filter to the one asked for.
399
+ *
400
+ * Strictly READ-ONLY: deliberately NOT ensureD1Migrated / ensureMigrationsTable. Booting
401
+ * the store here would apply every pending backfill as a side effect of asking about them,
402
+ * so `migrations status` could never report PENDING — which is the only question the
403
+ * command exists to answer. An absent ledger table is simply "none applied". */
404
+ const listD1Migrations = async (env: Env, tenant: string, partition: string): Promise<unknown> => {
405
+ if (!env.DB) throw new BadRequest("D1 store is not configured");
406
+ // The same COMMINGLING GUARD as every other D1 entry point: one shared database with no
407
+ // tenant column, so reporting it as some specific tenant's ledger requires the opt-in.
408
+ if (tenant !== "main" && env.PRAMEN_D1_ALLOW_MULTITENANT !== "true") {
409
+ throw new Forbidden(`D1 store for tenant '${tenant}' (shared D1 has no tenant isolation — set PRAMEN_D1_ALLOW_MULTITENANT=true to allow)`);
410
+ }
411
+ const driver = new D1Driver(env.DB, { start: "first-primary" });
412
+ const rows = await appliedMigrations(driver, partition).catch(() => []);
413
+ return { partition, applied: rows.map((r) => ({ id: r.id, appliedAt: r.appliedAt })) };
414
+ };
415
+
357
416
  return {
358
417
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
359
418
  const url = new URL(request.url);
@@ -482,6 +541,35 @@ export function makeWorker(app: PramenApp) {
482
541
  return withCors(res, cors);
483
542
  }
484
543
 
544
+ // --- admin: which data migrations a tenant has applied. The fleet-wide "is it safe to
545
+ // prune this id?" signal — a cold tenant is unmigrated until touched, so nothing else
546
+ // can answer it. `x-pramen-store: d1` reads the Worker's shared ledger instead. ---
547
+ if (url.pathname === "/admin/migrations") {
548
+ if (!isAdmin(identity)) return withCors(forbidden("migrations"), cors);
549
+ const partition = url.searchParams.get("partition") || DEFAULT_PARTITION;
550
+ const tenant = url.searchParams.get("tenant") ?? "main";
551
+ // `partition` is caller-supplied and reaches partitionStubFor, which INSTANTIATES a
552
+ // DO — the same reason /live validates it: without this an admin typo mints a junk DO
553
+ // and a permanent registry key for a partition nothing lives in. A migration may
554
+ // declare a partition of its own, so accept those too.
555
+ if (!knownPartitions().has(partition)) return withCors(badRequest(`unknown partition '${partition}'`), cors);
556
+ try {
557
+ if (request.headers.get("x-pramen-store") === "d1") {
558
+ return withCors(json({ ok: true, result: await listD1Migrations(env, tenant, partition) }), cors);
559
+ }
560
+ const stub = partitionStubFor(env, tenant, partition);
561
+ const res = await stub.fetch(
562
+ new Request("https://do/__migrations", { headers: { "x-pramen-tenant": tenant, "x-pramen-partition": partition } }),
563
+ );
564
+ return withCors(res, cors);
565
+ } catch (err) {
566
+ // The DO branch throws a plain Error on a D1-ONLY deployment (no PRAMEN binding).
567
+ // Uncaught that is an opaque 500; surfaced, it's the actionable "pin the D1 store".
568
+ const { status, body } = toResponse(err);
569
+ return withCors(json(body, status), cors);
570
+ }
571
+ }
572
+
485
573
  // --- admin: generic data ops over a tenant's tables (browse/edit any row).
486
574
  // Body: { tenant, table, op: list|get|create|update|delete|count, ... }. Runs
487
575
  // in the DO under SYSTEM scope (ACL bypassed) — gated to admins here. ---
@@ -564,7 +652,7 @@ export function makeWorker(app: PramenApp) {
564
652
  "Header X-Pramen-Tenant selects the store (default: main). " +
565
653
  "Admin (optional partition selects the partition DO, default: " + DEFAULT_PARTITION + "): " +
566
654
  "GET /tenants, POST /admin/recover {tenant,timestamp,partition?}, GET /admin/schema?tenant=&partition=, " +
567
- "POST /admin/data {tenant,table,op,partition?}.\n",
655
+ "GET /admin/migrations?tenant=&partition=, POST /admin/data {tenant,table,op,partition?}.\n",
568
656
  { headers: { "content-type": "text/plain" } },
569
657
  );
570
658
  }