@moku-labs/worker 0.8.1 → 0.9.1

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.
@@ -1608,6 +1608,76 @@ const runWranglerInherit = (args) => {
1608
1608
  });
1609
1609
  };
1610
1610
  //#endregion
1611
+ //#region src/plugins/deploy/seed.ts
1612
+ /**
1613
+ * @file deploy plugin — shared D1 seed helpers (resolve the target db, run a configured seed).
1614
+ *
1615
+ * Pure orchestration over an INJECTED wrangler runner, so the post-deploy REMOTE seed (api.ts) and
1616
+ * the dev-session LOCAL seed (dev/runner.ts) stay in lockstep — same file, same KV-reset semantics,
1617
+ * differing only in the `--remote` / `--local` scope. Migrations are NOT applied here: each caller
1618
+ * applies the schema first (the deploy's migration step / dev's local-migrate step), then seeds.
1619
+ * Node-only; never imported by the runtime Worker bundle.
1620
+ */
1621
+ /**
1622
+ * Resolve the single configured d1 database (or the one bound to `binding` when several exist) from
1623
+ * the d1 plugin's manifest. The shared resolver behind `seed()`, the post-deploy seed, and the dev
1624
+ * seed; throws a branded error when the choice is ambiguous (none/several, no binding) or unknown.
1625
+ *
1626
+ * @param ctx - The deploy plugin context.
1627
+ * @param binding - The d1 binding to target when more than one is configured; the sole one otherwise.
1628
+ * @returns The resolved d1 resource descriptor (its binding + optional migrations dir).
1629
+ * @throws {Error} When no single database resolves (none/several without a binding, or unknown binding).
1630
+ * @example
1631
+ * ```ts
1632
+ * const db = resolveD1(ctx, "DB");
1633
+ * ```
1634
+ */
1635
+ const resolveD1 = (ctx, binding) => {
1636
+ const databases = ctx.require(d1Plugin).deployManifest();
1637
+ const matched = binding === void 0 ? databases : databases.filter((db) => db.binding === binding);
1638
+ const target = matched.length === 1 ? matched[0] : void 0;
1639
+ if (target === void 0) throw new Error(binding === void 0 ? `[moku-worker] seed: ${String(databases.length)} d1 databases configured — pass { binding } to choose one.` : `[moku-worker] seed: no d1 database is bound to "${binding}".`);
1640
+ return target;
1641
+ };
1642
+ /**
1643
+ * Run a configured seed against one scope: execute the seed SQL against the d1 database, then delete
1644
+ * each configured cached KV key so the next read rebuilds it from the freshly-seeded rows. The
1645
+ * schema is assumed to exist (the caller applies migrations first), so this never migrates. The
1646
+ * wrangler runner is injected so the same orchestration serves the streamed deploy path and the
1647
+ * injectable dev path.
1648
+ *
1649
+ * @param ctx - The deploy plugin context.
1650
+ * @param run - The wrangler runner to execute each command through.
1651
+ * @param seed - The resolved seed config (SQL file, optional binding, KV keys to reset).
1652
+ * @param scope - The wrangler scope: `--remote` (deploy) or `--local` (dev).
1653
+ * @returns Resolves once the seed file has executed and every cached KV key is cleared.
1654
+ * @throws {Error} When no d1 database is configured, or the seed's binding cannot be resolved.
1655
+ * @example
1656
+ * ```ts
1657
+ * await runConfiguredSeed(ctx, runWranglerInherit, ctx.config.seed, "--remote");
1658
+ * ```
1659
+ */
1660
+ const runConfiguredSeed = async (ctx, run, seed, scope) => {
1661
+ if (!ctx.has("d1")) throw new Error("[moku-worker] seed: no d1 database is configured.");
1662
+ await run([
1663
+ "d1",
1664
+ "execute",
1665
+ resolveD1(ctx, seed.binding).binding,
1666
+ scope,
1667
+ "--file",
1668
+ seed.file
1669
+ ]);
1670
+ for (const entry of seed.resetKv ?? []) await run([
1671
+ "kv",
1672
+ "key",
1673
+ "delete",
1674
+ entry.key,
1675
+ "--binding",
1676
+ entry.binding,
1677
+ scope
1678
+ ]);
1679
+ };
1680
+ //#endregion
1611
1681
  //#region src/plugins/deploy/dev/build.ts
1612
1682
  /**
1613
1683
  * @file deploy plugin — dev site-rebuild resolution.
@@ -1930,8 +2000,32 @@ const rebuild = async (ctx, deps, changedPaths, hooks) => {
1930
2000
  }
1931
2001
  };
1932
2002
  /**
1933
- * Run a long-lived dev session: cold build (local d1 migrate) spawn `wrangler dev`
1934
- * watch + rebuild on change teardown on signal.
2003
+ * Load the configured seed into the LOCAL D1 for a `dev --seed` session: execute the SQL file, then
2004
+ * clear the configured cached KV keys so the app rebuilds them from the freshly-seeded rows. The
2005
+ * schema already exists (the migrate step above runs first), so this never migrates — the local
2006
+ * analogue of the deploy's remote seed, over the same `pluginConfigs.deploy.seed` config.
2007
+ *
2008
+ * @param ctx - The deploy plugin context.
2009
+ * @param deps - The injected dev deps (for the wrangler runner).
2010
+ * @returns Resolves once the seed file has executed and every cached KV key is cleared.
2011
+ * @throws {Error} When `--seed` is set but no seed is configured under `pluginConfigs.deploy.seed`.
2012
+ * @example
2013
+ * ```ts
2014
+ * await seedLocal(ctx, realDevDeps());
2015
+ * ```
2016
+ */
2017
+ const seedLocal = async (ctx, deps) => {
2018
+ const config = ctx.config.seed;
2019
+ if (config === void 0) throw new Error("[moku-worker] dev({ seed: true }) but no seed is configured — set pluginConfigs.deploy.seed.");
2020
+ ctx.emit("dev:phase", {
2021
+ phase: "seed",
2022
+ detail: config.file
2023
+ });
2024
+ await runConfiguredSeed(ctx, deps.runWrangler, config, "--local");
2025
+ };
2026
+ /**
2027
+ * Run a long-lived dev session: cold build → (local d1 migrate) → (local seed) → spawn `wrangler
2028
+ * dev` → watch + rebuild on change → teardown on signal.
1935
2029
  *
1936
2030
  * @param ctx - The deploy plugin context (config + emit + require/has).
1937
2031
  * @param opts - Optional options.
@@ -1939,23 +2033,25 @@ const rebuild = async (ctx, deps, changedPaths, hooks) => {
1939
2033
  * @param opts.webBuild - Cold-build hook (also the per-change rebuild when `onChange` is omitted).
1940
2034
  * @param opts.onChange - Incremental per-change rebuild hook (e.g. `c => web.cli.update(c)`); when
1941
2035
  * set, each debounced change rebuilds only the changed paths instead of a full `webBuild()`.
2036
+ * @param opts.seed - Load the configured seed into the LOCAL D1 (+ reset its KV keys) before serving.
1942
2037
  * @param deps - Injected side effects (real ones from realDevDeps in production).
1943
2038
  * @returns Resolves when the session ends (SIGINT).
1944
2039
  * @example
1945
2040
  * ```ts
1946
- * await runDev(ctx, { port: 8787, webBuild: () => web.cli.build(), onChange: c => web.cli.update(c) }, realDevDeps());
2041
+ * await runDev(ctx, { port: 8787, seed: true, webBuild: () => web.cli.build() }, realDevDeps());
1947
2042
  * ```
1948
2043
  */
1949
2044
  const runDev = async (ctx, opts, deps) => {
1950
2045
  const port = opts?.port ?? 8787;
1951
2046
  const webBuild = opts?.webBuild;
1952
2047
  const onChange = opts?.onChange;
2048
+ const seed = opts?.seed === true;
1953
2049
  ctx.emit("dev:phase", {
1954
2050
  phase: "build",
1955
2051
  detail: "site"
1956
2052
  });
1957
2053
  await deps.build(ctx, webBuild);
1958
- const migrationBindings = ctx.config.migrateLocal ? d1MigrationBindings(ctx) : [];
2054
+ const migrationBindings = ctx.config.migrateLocal || seed ? d1MigrationBindings(ctx) : [];
1959
2055
  if (migrationBindings.length > 0) {
1960
2056
  ctx.emit("dev:phase", {
1961
2057
  phase: "migrate",
@@ -1969,6 +2065,7 @@ const runDev = async (ctx, opts, deps) => {
1969
2065
  "--local"
1970
2066
  ]);
1971
2067
  }
2068
+ if (seed) await seedLocal(ctx, deps);
1972
2069
  ctx.emit("dev:phase", {
1973
2070
  phase: "serve",
1974
2071
  detail: `http://localhost:${String(port)}`
@@ -1993,12 +2090,12 @@ const runDev = async (ctx, opts, deps) => {
1993
2090
  //#endregion
1994
2091
  //#region src/plugins/deploy/infra/plan.ts
1995
2092
  /**
1996
- * Decide whether a single declared resource already exists in the account, recovering its id
1997
- * (kv/d1) when it does. Durable Objects are config-only — they ship with the Worker (`wrangler
1998
- * deploy` + the auto-derived DO migration create the namespace), never provisioned via the API — so
1999
- * they are treated as already EXISTING, and the plan never re-offers to "create" them each deploy.
2093
+ * Decide whether a single API-provisioned resource already exists in the account, recovering its id
2094
+ * (kv/d1) when it does. Durable Objects are NOT handled here — they ship with the Worker (`wrangler
2095
+ * deploy` + the auto-derived DO migration create the namespace), are never provisioned via the API,
2096
+ * and are partitioned into the plan's `ships` bucket by {@link planInfra} before this is ever called.
2000
2097
  *
2001
- * @param resource - The declared resource descriptor.
2098
+ * @param resource - The declared (provisionable) resource descriptor.
2002
2099
  * @param existing - The indexed set of resources already in the account.
2003
2100
  * @returns Whether it exists, plus the captured id for kv/d1.
2004
2101
  * @example
@@ -2024,7 +2121,6 @@ const checkExisting = (resource, existing) => {
2024
2121
  }
2025
2122
  case "r2": return { exists: existing.r2.has(resource.name) };
2026
2123
  case "queue": return { exists: existing.queue.has(resource.name) };
2027
- case "do": return { exists: true };
2028
2124
  }
2029
2125
  };
2030
2126
  /**
@@ -2033,7 +2129,7 @@ const checkExisting = (resource, existing) => {
2033
2129
  *
2034
2130
  * @param ctx - The deploy plugin context (env + emit).
2035
2131
  * @param manifest - The assembled (or caller-supplied) deploy manifest.
2036
- * @returns The infra plan: existing (with ids) vs missing resources.
2132
+ * @returns The infra plan: existing (with ids) vs missing vs ships-with-Worker (Durable Objects).
2037
2133
  * @throws {Error} When the token is absent/invalid or a Cloudflare listing fails.
2038
2134
  * @example
2039
2135
  * ```ts
@@ -2052,7 +2148,12 @@ const planInfra = async (ctx, manifest) => {
2052
2148
  const existing = await listExisting(token, account.id, kinds);
2053
2149
  const exists = [];
2054
2150
  const missing = [];
2151
+ const ships = [];
2055
2152
  for (const resource of manifest.resources) {
2153
+ if (resource.kind === "do") {
2154
+ ships.push(resource);
2155
+ continue;
2156
+ }
2056
2157
  const check = checkExisting(resource, existing);
2057
2158
  if (check.exists) exists.push(check.id === void 0 ? { resource } : {
2058
2159
  resource,
@@ -2063,13 +2164,15 @@ const planInfra = async (ctx, manifest) => {
2063
2164
  ctx.emit("provision:plan", {
2064
2165
  exists: exists.length,
2065
2166
  missing: missing.length,
2167
+ ships: ships.length,
2066
2168
  account: account.name
2067
2169
  });
2068
2170
  return {
2069
2171
  account: account.name,
2070
2172
  accountId: account.id,
2071
2173
  exists,
2072
- missing
2174
+ missing,
2175
+ ships
2073
2176
  };
2074
2177
  };
2075
2178
  //#endregion
@@ -2100,6 +2203,12 @@ const resourceName = (resource) => resource.kind === "do" ? resource.className :
2100
2203
  */
2101
2204
  const cell = (kind, name) => `${kind.padEnd(6)}${name}`;
2102
2205
  /**
2206
+ * Row tag for a Durable Object — it ships with the Worker (`wrangler deploy` creates the namespace),
2207
+ * so it is NEVER labelled `(exists)` (the planner never queried the account for it). Shared by the
2208
+ * plan and provision-result panels so the two always read the same.
2209
+ */
2210
+ const SHIPS_WITH_WORKER = "(ships with worker)";
2211
+ /**
2103
2212
  * ANSI SGR matcher — built from `String.fromCharCode(27)` (the ESC byte) so no control character
2104
2213
  * appears in a regex literal (which both linters reject).
2105
2214
  */
@@ -2160,10 +2269,11 @@ const wrapText = (text, width) => {
2160
2269
  /**
2161
2270
  * Render the infra preflight plan as a branded panel: a dim summary line (counts + account) then one
2162
2271
  * row per declared resource — a pink `+` for those to create, a dim `~ (exists)` for those already
2163
- * present. When nothing needs creating it still renders, so the user sees the full picture.
2272
+ * present, and a dim `~ (ships with worker)` for Durable Objects (created by `wrangler deploy`, never
2273
+ * pre-provisioned). When nothing needs creating it still renders, so the user sees the full picture.
2164
2274
  *
2165
2275
  * @param ui - The branded console to render through.
2166
- * @param plan - The infra plan (existing vs missing) from checkInfra()/planInfra().
2276
+ * @param plan - The infra plan (existing vs missing vs ships-with-Worker) from checkInfra()/planInfra().
2167
2277
  * @example
2168
2278
  * ```ts
2169
2279
  * renderPlan(ui, await planInfra(ctx, manifest));
@@ -2171,22 +2281,27 @@ const wrapText = (text, width) => {
2171
2281
  */
2172
2282
  const renderPlan = (ui, plan) => {
2173
2283
  const { palette } = ui;
2174
- const summary = palette.dim(`${String(plan.missing.length)} to create · ${String(plan.exists.length)} exist · ${plan.account}`);
2175
- const existsRows = plan.exists.map((ref) => `${palette.dim("~")} ${cell(ref.resource.kind, resourceName(ref.resource))} ${palette.dim("(exists)")}`);
2284
+ const counts = [`${String(plan.missing.length)} to create`, `${String(plan.exists.length)} exist`];
2285
+ if (plan.ships.length > 0) counts.push(`${String(plan.ships.length)} with worker`);
2286
+ const summary = palette.dim(`${counts.join(" · ")} · ${plan.account}`);
2176
2287
  const createRows = plan.missing.map((resource) => `${palette.pink("+")} ${cell(resource.kind, resourceName(resource))}`);
2288
+ const existsRows = plan.exists.map((ref) => `${palette.dim("~")} ${cell(ref.resource.kind, resourceName(ref.resource))} ${palette.dim("(exists)")}`);
2289
+ const shipsRows = plan.ships.map((resource) => `${palette.dim("~")} ${cell(resource.kind, resourceName(resource))} ${palette.dim(SHIPS_WITH_WORKER)}`);
2177
2290
  ui.heading("Infra plan");
2178
2291
  ui.box([
2179
2292
  summary,
2180
2293
  "",
2181
2294
  ...createRows,
2182
- ...existsRows
2295
+ ...existsRows,
2296
+ ...shipsRows
2183
2297
  ]);
2184
2298
  };
2185
2299
  /**
2186
2300
  * Render the provision result as a branded panel — a green `✓` per created resource, a dim `~` per
2187
- * skipped, a red `✗` per failure, then a summary line (failed count red when non-zero) — followed,
2188
- * when anything failed, by a detail block printing each failure's FULL reason (ANSI-stripped and
2189
- * word-wrapped) so it is actually readable instead of truncated inside the box.
2301
+ * skipped, a dim `~ (ships with worker)` per Durable Object, a red `✗` per failure, then a summary
2302
+ * line (failed count red when non-zero) — followed, when anything failed, by a detail block printing
2303
+ * each failure's FULL reason (ANSI-stripped and word-wrapped) so it is actually readable instead of
2304
+ * truncated inside the box.
2190
2305
  *
2191
2306
  * @param ui - The branded console to render through.
2192
2307
  * @param result - The provision result from provisionInfra()/the deploy pipeline.
@@ -2199,13 +2314,17 @@ const renderProvisionResult = (ui, result) => {
2199
2314
  const { palette } = ui;
2200
2315
  const createdRows = result.created.map((ref) => `${palette.green("✓")} ${cell(ref.resource.kind, resourceName(ref.resource))}`);
2201
2316
  const skippedRows = result.skipped.map((ref) => `${palette.dim("~")} ${cell(ref.resource.kind, resourceName(ref.resource))} ${palette.dim("(exists)")}`);
2317
+ const bundledRows = result.bundled.map((resource) => `${palette.dim("~")} ${cell(resource.kind, resourceName(resource))} ${palette.dim(SHIPS_WITH_WORKER)}`);
2202
2318
  const failedRows = result.failed.map((failure) => `${palette.red("✗")} ${cell(failure.resource.kind, resourceName(failure.resource))}`);
2203
2319
  const failedCount = result.failed.length > 0 ? palette.red(`${String(result.failed.length)} failed`) : "0 failed";
2204
- const summary = `${String(result.created.length)} created · ${String(result.skipped.length)} exist · ${failedCount}`;
2320
+ const counts = [`${String(result.created.length)} created`, `${String(result.skipped.length)} exist`];
2321
+ if (result.bundled.length > 0) counts.push(`${String(result.bundled.length)} with worker`);
2322
+ const summary = `${counts.join(" · ")} · ${failedCount}`;
2205
2323
  ui.heading("Provisioned");
2206
2324
  ui.box([
2207
2325
  ...createdRows,
2208
2326
  ...skippedRows,
2327
+ ...bundledRows,
2209
2328
  ...failedRows,
2210
2329
  "",
2211
2330
  summary
@@ -2248,16 +2367,19 @@ const formatDuration = (ms) => {
2248
2367
  * @param summary.stage - The target stage the worker deployed to.
2249
2368
  * @param summary.created - How many resources were created this run.
2250
2369
  * @param summary.exists - How many resources already existed (skipped).
2370
+ * @param summary.bundled - How many Durable Objects shipped with the Worker.
2251
2371
  * @param summary.failed - How many resources failed to provision.
2252
2372
  * @param summary.elapsedMs - The wall-clock deploy duration in milliseconds.
2253
2373
  * @example
2254
2374
  * ```ts
2255
- * renderDeploySummary(ui, { url, stage: "production", created: 0, exists: 5, failed: 0, elapsedMs: 4234 });
2375
+ * renderDeploySummary(ui, { url, stage: "production", created: 0, exists: 5, bundled: 1, failed: 0, elapsedMs: 4234 });
2256
2376
  * ```
2257
2377
  */
2258
2378
  const renderDeploySummary = (ui, summary) => {
2259
2379
  const { palette } = ui;
2260
- const tally = `${String(summary.exists)} exist · ${String(summary.created)} created`;
2380
+ const parts = [`${String(summary.exists)} exist`, `${String(summary.created)} created`];
2381
+ if (summary.bundled > 0) parts.push(`${String(summary.bundled)} with worker`);
2382
+ const tally = parts.join(" · ");
2261
2383
  const failedLabel = palette.red(`${String(summary.failed)} failed`);
2262
2384
  const resources = summary.failed > 0 ? `${tally} · ${failedLabel}` : tally;
2263
2385
  ui.heading("Deployed");
@@ -2889,32 +3011,25 @@ const assembleManifest = (ctx, stage) => {
2889
3011
  };
2890
3012
  };
2891
3013
  /**
2892
- * Act on an infra plan: skip the resources that already exist (reusing their ids), create the
2893
- * missing ones (capturing each new id), and announce each via provision:skip / :resource. Resilient
2894
- * — a single resource that fails to create is CAPTURED in `failed` (not thrown), so one bad resource
2895
- * (e.g. an invalid bucket name) never aborts the whole run and the caller can report a clear result.
3014
+ * Create the still-missing resources one at a time: provision each, fold its captured id (kv/d1) into
3015
+ * the shared `ids` map, and announce it via provision:resource. Resilient — a single failure is
3016
+ * CAPTURED (not thrown), so one bad resource never aborts the rest. Extracted from {@link applyPlan}
3017
+ * so that orchestrator stays flat (skip existing, skip DOs, create missing).
2896
3018
  *
2897
3019
  * @param ctx - The deploy plugin context.
2898
- * @param plan - The infra plan from planInfra (existing vs missing).
3020
+ * @param missing - The resources the plan flagged as not-yet-existing.
2899
3021
  * @param ci - Whether provisioning runs non-interactively (forwarded to each provider).
2900
- * @returns The provisioning result: created, skipped, failed, and the merged binding id map.
3022
+ * @param ids - The binding Cloudflare id map, mutated in place with each created kv/d1 id.
3023
+ * @returns The created refs and any captured per-resource failures.
2901
3024
  * @example
2902
3025
  * ```ts
2903
- * const { created, failed } = await applyPlan(ctx, plan, false);
3026
+ * const { created, failed } = await provisionMissing(ctx, plan.missing, false, ids);
2904
3027
  * ```
2905
3028
  */
2906
- const applyPlan = async (ctx, plan, ci) => {
2907
- const ids = {};
2908
- for (const ref of plan.exists) {
2909
- if (ref.id !== void 0 && (ref.resource.kind === "kv" || ref.resource.kind === "d1")) ids[ref.resource.binding] = ref.id;
2910
- ctx.emit("provision:skip", {
2911
- kind: ref.resource.kind,
2912
- name: resourceName(ref.resource)
2913
- });
2914
- }
3029
+ const provisionMissing = async (ctx, missing, ci, ids) => {
2915
3030
  const created = [];
2916
3031
  const failed = [];
2917
- for (const resource of plan.missing) try {
3032
+ for (const resource of missing) try {
2918
3033
  const { id } = await provisionResource(resource, ci);
2919
3034
  if (id !== void 0 && (resource.kind === "kv" || resource.kind === "d1")) ids[resource.binding] = id;
2920
3035
  created.push(id === void 0 ? { resource } : {
@@ -2931,9 +3046,45 @@ const applyPlan = async (ctx, plan, ci) => {
2931
3046
  error: error instanceof Error ? error.message : String(error)
2932
3047
  });
2933
3048
  }
3049
+ return {
3050
+ created,
3051
+ failed
3052
+ };
3053
+ };
3054
+ /**
3055
+ * Act on an infra plan: skip the resources that already exist (reusing their ids), skip the Durable
3056
+ * Objects that ship with the Worker, create the missing ones (capturing each new id), and announce
3057
+ * each via provision:skip / :resource. Resilient — a single resource that fails to create is CAPTURED
3058
+ * in `failed` (not thrown), so one bad resource (e.g. an invalid bucket name) never aborts the whole
3059
+ * run and the caller can report a clear result.
3060
+ *
3061
+ * @param ctx - The deploy plugin context.
3062
+ * @param plan - The infra plan from planInfra (existing vs missing vs ships-with-Worker).
3063
+ * @param ci - Whether provisioning runs non-interactively (forwarded to each provider).
3064
+ * @returns The provisioning result: created, skipped, bundled, failed, and the merged binding → id map.
3065
+ * @example
3066
+ * ```ts
3067
+ * const { created, failed } = await applyPlan(ctx, plan, false);
3068
+ * ```
3069
+ */
3070
+ const applyPlan = async (ctx, plan, ci) => {
3071
+ const ids = {};
3072
+ for (const ref of plan.exists) {
3073
+ if (ref.id !== void 0 && (ref.resource.kind === "kv" || ref.resource.kind === "d1")) ids[ref.resource.binding] = ref.id;
3074
+ ctx.emit("provision:skip", {
3075
+ kind: ref.resource.kind,
3076
+ name: resourceName(ref.resource)
3077
+ });
3078
+ }
3079
+ for (const resource of plan.ships) ctx.emit("provision:skip", {
3080
+ kind: resource.kind,
3081
+ name: resourceName(resource)
3082
+ });
3083
+ const { created, failed } = await provisionMissing(ctx, plan.missing, ci, ids);
2934
3084
  return {
2935
3085
  created,
2936
3086
  skipped: plan.exists,
3087
+ bundled: plan.ships,
2937
3088
  failed,
2938
3089
  ids
2939
3090
  };
@@ -2951,17 +3102,33 @@ const HINTS = {
2951
3102
  deploy: "wrangler deploy failed — review the output above, then retry."
2952
3103
  };
2953
3104
  /**
2954
- * Emit the terminal `aborted` phase the single exit every guided gate/retry funnels through when
2955
- * the user stops the deploy. Factored out so each abort path renders one consistent line.
3105
+ * Emit the terminal `aborted` phase AND build the matching {@link DeployReport} the single exit
3106
+ * every guided gate/retry funnels through when the user stops the deploy (or auth was never set up).
3107
+ * Centralizing it keeps every abort path emitting one consistent line and returning the same shaped
3108
+ * report: `status: "aborted"`, both post-steps `"skipped"`, no errors — so a calling script sees a
3109
+ * clean stop, never a half-filled success, and the remote-DB migration/seed are guaranteed unrun.
2956
3110
  *
2957
3111
  * @param ctx - The deploy plugin context.
2958
- * @returns Nothing.
3112
+ * @param stage - The resolved deploy stage (echoed into the report).
3113
+ * @param startedAt - The run's start timestamp, for the elapsed field.
3114
+ * @returns The aborted deploy report.
2959
3115
  * @example
2960
3116
  * ```ts
2961
- * if (declined) return emitAborted(ctx);
3117
+ * if (declined) return aborted(ctx, stage, startedAt);
2962
3118
  * ```
2963
3119
  */
2964
- const emitAborted = (ctx) => ctx.emit("deploy:phase", { phase: "aborted" });
3120
+ const aborted = (ctx, stage, startedAt) => {
3121
+ ctx.emit("deploy:phase", { phase: "aborted" });
3122
+ return {
3123
+ ok: false,
3124
+ status: "aborted",
3125
+ stage,
3126
+ migration: "skipped",
3127
+ seed: "skipped",
3128
+ elapsedMs: Date.now() - startedAt,
3129
+ errors: []
3130
+ };
3131
+ };
2965
3132
  /**
2966
3133
  * The full guided token setup shown after an auth failure on a TTY. Offers to walk the user through
2967
3134
  * it, and when accepted: prints WHERE to create the Cloudflare token (dashboard URL, which template,
@@ -3181,6 +3348,104 @@ const guidedDeployStep = async (ctx, manifest, stage, deps) => {
3181
3348
  return url;
3182
3349
  };
3183
3350
  /**
3351
+ * Apply pending D1 migrations to the REMOTE database for every configured d1 instance that ships a
3352
+ * migrations dir — the generic, deploy-owned analogue of `wrangler d1 migrations apply <binding>
3353
+ * --remote`. The wrangler config was written earlier in the pipeline, so each binding resolves. The
3354
+ * caller runs this only AFTER a successful deploy, so a deploy that never happened never migrates a
3355
+ * remote DB. Streams wrangler's output; throws on the first non-zero exit (the caller folds it into
3356
+ * the report).
3357
+ *
3358
+ * @param ctx - The deploy plugin context.
3359
+ * @returns Resolves once every configured database's remote migrations have been applied.
3360
+ * @example
3361
+ * ```ts
3362
+ * await applyRemoteMigrations(ctx);
3363
+ * ```
3364
+ */
3365
+ const applyRemoteMigrations = async (ctx) => {
3366
+ if (!ctx.has("d1")) return;
3367
+ for (const database of ctx.require(d1Plugin).deployManifest()) if (database.migrations !== void 0) await runWranglerInherit([
3368
+ "d1",
3369
+ "migrations",
3370
+ "apply",
3371
+ database.binding,
3372
+ "--remote"
3373
+ ]);
3374
+ };
3375
+ /**
3376
+ * Render a post-deploy step's failure as a branded line and capture its message into `errors` —
3377
+ * folding the failure into the report instead of throwing, so a deploy that already went live still
3378
+ * yields a complete, honest report when a later remote step (migration/seed) fails.
3379
+ *
3380
+ * @param ui - The branded console to render the error through.
3381
+ * @param errors - The accumulator the captured message is pushed into.
3382
+ * @param error - The thrown error (or value) to brand and capture.
3383
+ * @returns The captured (branded) message.
3384
+ * @example
3385
+ * ```ts
3386
+ * captureFailure(ui, errors, new Error("[moku-worker] seed failed"));
3387
+ * ```
3388
+ */
3389
+ const captureFailure = (ui, errors, error) => {
3390
+ const message = error instanceof Error ? error.message : String(error);
3391
+ ui.error(message);
3392
+ errors.push(message);
3393
+ return message;
3394
+ };
3395
+ /**
3396
+ * Run the post-deploy remote steps — REACHED ONLY ON A SUCCESSFUL DEPLOY (every gate in `run` returns
3397
+ * early before here), so a deploy that never happened never touches a remote DB. Applies remote D1
3398
+ * migrations (when requested), then loads the configured seed (when requested) — but skips the seed
3399
+ * if the migration it depends on failed. Each step's failure is RENDERED inline and CAPTURED in
3400
+ * `errors` (never thrown), so one failed step still yields a complete, honest report.
3401
+ *
3402
+ * @param ctx - The deploy plugin context.
3403
+ * @param want - Which post-steps the caller requested.
3404
+ * @param want.migration - Whether to apply pending remote D1 migrations.
3405
+ * @param want.seed - Whether to load the configured remote seed (and reset its KV keys).
3406
+ * @returns The migration + seed outcomes and any captured branded errors.
3407
+ * @example
3408
+ * ```ts
3409
+ * const post = await runPostDeploy(ctx, { migration: true, seed: true });
3410
+ * ```
3411
+ */
3412
+ const runPostDeploy = async (ctx, want) => {
3413
+ const ui = createBrandConsole();
3414
+ const errors = [];
3415
+ let migration = "skipped";
3416
+ if (want.migration) try {
3417
+ await applyRemoteMigrations(ctx);
3418
+ migration = "applied";
3419
+ ui.check(true, "migrated", "remote D1");
3420
+ } catch (error) {
3421
+ migration = "failed";
3422
+ captureFailure(ui, errors, error);
3423
+ }
3424
+ let seed = "skipped";
3425
+ if (want.seed && migration === "failed") {
3426
+ seed = "failed";
3427
+ captureFailure(ui, errors, /* @__PURE__ */ new Error("[moku-worker] seed skipped — the remote migration it depends on failed."));
3428
+ } else if (want.seed) {
3429
+ const config = ctx.config.seed;
3430
+ if (config === void 0) {
3431
+ seed = "failed";
3432
+ captureFailure(ui, errors, /* @__PURE__ */ new Error("[moku-worker] deploy({ seed: true }) but no seed is configured — set pluginConfigs.deploy.seed."));
3433
+ } else try {
3434
+ await runConfiguredSeed(ctx, runWranglerInherit, config, "--remote");
3435
+ seed = "applied";
3436
+ ui.check(true, "seeded", config.file);
3437
+ } catch (error) {
3438
+ seed = "failed";
3439
+ captureFailure(ui, errors, error);
3440
+ }
3441
+ }
3442
+ return {
3443
+ migration,
3444
+ seed,
3445
+ errors
3446
+ };
3447
+ };
3448
+ /**
3184
3449
  * Create the deploy api. Assembles the manifest from each resource plugin's own deployManifest(),
3185
3450
  * runs an infra preflight (check-before-create + id capture), generates config, uploads, and runs
3186
3451
  * `wrangler deploy`, emitting global deploy events along the way.
@@ -3196,13 +3461,19 @@ const guidedDeployStep = async (ctx, manifest, stage, deps) => {
3196
3461
  const createDeployApi = (ctx) => ({
3197
3462
  /**
3198
3463
  * Run the full deploy pipeline: detect → preflight (check-before-create) → provision (only the
3199
- * missing) → wrangler-config (with real ids) → upload → deploy. When opts.manifest is supplied
3200
- * it is used verbatim (universal path).
3464
+ * missing) → wrangler-config (with real ids) → upload → deploy, then ONLY on a successful
3465
+ * deploy the requested post-deploy remote steps (migration, seed). When opts.manifest is
3466
+ * supplied it is used verbatim (universal path).
3201
3467
  *
3202
3468
  * On a TTY the run is GUIDED end to end: each gate is confirmed, and every failure is recovered
3203
- * interactively rather than thrown — a missing/invalid token offers `auth setup`, and the build,
3204
- * infra, upload, and `wrangler deploy` steps offer a retry. In CI/pipes it fails fast (no prompt,
3205
- * the first error propagates to the branded CLI handler).
3469
+ * interactively rather than thrown — a missing/invalid token offers a `.env.local` scaffold, and
3470
+ * the build, infra, upload, and `wrangler deploy` steps offer a retry. In CI/pipes it fails fast
3471
+ * (no prompt, the first error propagates to the branded CLI handler).
3472
+ *
3473
+ * Resolves to a {@link DeployReport}. Every abort path (a declined gate, or auth never set up)
3474
+ * returns `status: "aborted"` BEFORE the post-deploy steps, so `migration`/`seed` run only when
3475
+ * the worker actually went live — a first `deploy --seed` with no token aborts cleanly instead of
3476
+ * falling through to a raw `wrangler … --remote` auth error.
3206
3477
  *
3207
3478
  * @param opts - Optional run options.
3208
3479
  * @param opts.ci - CI/automated mode: never prompts, auto-confirms every gate, fails fast. When
@@ -3211,11 +3482,15 @@ const createDeployApi = (ctx) => ({
3211
3482
  * @param opts.stage - Target stage; suffixes resource names (`production` = bare). Falls back to the app stage.
3212
3483
  * @param opts.webBuild - Build the web site first (e.g. `() => webApp.cli.build()`), before deploy.
3213
3484
  * @param opts.manifest - Caller-supplied manifest (bypasses deployManifest() assembly).
3214
- * @returns Resolves once the deploy completes.
3485
+ * @param opts.migration - After a successful deploy, apply pending remote D1 migrations for every
3486
+ * configured d1 instance that ships migrations. Skipped (not attempted) on an aborted deploy.
3487
+ * @param opts.seed - After a successful deploy (+ migration), load the seed configured under
3488
+ * `pluginConfigs.deploy.seed` into the remote D1 and reset its cached KV keys. Skipped on abort.
3489
+ * @returns The deploy report (status, url, resource tally, migration/seed outcome, errors).
3215
3490
  * @example
3216
3491
  * ```ts
3217
- * await api.run({ webBuild: () => web.cli.build() }); // guided on a TTY
3218
- * await api.run({ ci: true, manifest: { name: "w", compatibilityDate: "2026-06-17", resources: [] } });
3492
+ * const report = await api.run({ webBuild: () => web.cli.build(), migration: true, seed: true });
3493
+ * if (!report.ok) process.exitCode = 1; // aborted or a post-step failed
3219
3494
  * ```
3220
3495
  */
3221
3496
  async run(opts) {
@@ -3228,26 +3503,45 @@ const createDeployApi = (ctx) => ({
3228
3503
  };
3229
3504
  const startedAt = Date.now();
3230
3505
  ctx.emit("deploy:phase", { phase: "auth" });
3231
- if (!await guidedAuth(ctx, deps)) return emitAborted(ctx);
3232
- if (!await guidedWebBuild(ctx, opts?.webBuild ?? ctx.config.webBuild, deps)) return emitAborted(ctx);
3506
+ if (!await guidedAuth(ctx, deps)) return aborted(ctx, stage, startedAt);
3507
+ if (!await guidedWebBuild(ctx, opts?.webBuild ?? ctx.config.webBuild, deps)) return aborted(ctx, stage, startedAt);
3233
3508
  ctx.emit("deploy:phase", { phase: "detect" });
3234
3509
  const manifest = opts?.manifest ?? assembleManifest(ctx, stage);
3235
3510
  ctx.emit("deploy:phase", { phase: "provision" });
3236
3511
  const provisioned = await guidedProvision(ctx, manifest, ci, deps);
3237
- if (provisioned === ABORTED) return emitAborted(ctx);
3512
+ if (provisioned === ABORTED) return aborted(ctx, stage, startedAt);
3238
3513
  ctx.emit("deploy:phase", { phase: "wrangler-config" });
3239
3514
  await writeWranglerConfig(ctx.config.configFile, manifest, provisioned.ids, wranglerExtra(ctx.config));
3240
- if (!await guidedUpload(ctx, manifest, deps)) return emitAborted(ctx);
3515
+ if (!await guidedUpload(ctx, manifest, deps)) return aborted(ctx, stage, startedAt);
3241
3516
  const url = await guidedDeployStep(ctx, manifest, stage, deps);
3242
- if (url === void 0) return emitAborted(ctx);
3517
+ if (url === void 0) return aborted(ctx, stage, startedAt);
3518
+ const resources = {
3519
+ created: provisioned.created.length,
3520
+ exists: provisioned.skipped.length,
3521
+ bundled: provisioned.bundled.length,
3522
+ failed: provisioned.failed.length
3523
+ };
3243
3524
  renderDeploySummary(createBrandConsole(), {
3244
3525
  url,
3245
3526
  stage,
3246
- created: provisioned.created.length,
3247
- exists: provisioned.skipped.length,
3248
- failed: provisioned.failed.length,
3527
+ ...resources,
3249
3528
  elapsedMs: Date.now() - startedAt
3250
3529
  });
3530
+ const post = await runPostDeploy(ctx, {
3531
+ migration: opts?.migration === true,
3532
+ seed: opts?.seed === true
3533
+ });
3534
+ return {
3535
+ ok: post.errors.length === 0,
3536
+ status: post.errors.length === 0 ? "deployed" : "failed",
3537
+ stage,
3538
+ url,
3539
+ resources,
3540
+ migration: post.migration,
3541
+ seed: post.seed,
3542
+ elapsedMs: Date.now() - startedAt,
3543
+ errors: post.errors
3544
+ };
3251
3545
  },
3252
3546
  /**
3253
3547
  * Start a long-lived local dev session: cold-build the Moku site, spawn `wrangler dev
@@ -3260,10 +3554,12 @@ const createDeployApi = (ctx) => ({
3260
3554
  * @param opts.webBuild - Cold-build the web site (e.g. `() => webApp.cli.build()`); also the
3261
3555
  * per-change rebuild when `onChange` is omitted.
3262
3556
  * @param opts.onChange - Incremental per-change rebuild (e.g. `changes => webApp.cli.update(changes)`).
3557
+ * @param opts.seed - Load the configured seed (`pluginConfigs.deploy.seed`) into the LOCAL D1 and
3558
+ * reset its cached KV keys before serving — the local analogue of `deploy({ seed: true })`.
3263
3559
  * @returns Resolves when the dev session ends.
3264
3560
  * @example
3265
3561
  * ```ts
3266
- * await api.dev({ port: 8787, webBuild: () => web.cli.build(), onChange: c => web.cli.update(c) });
3562
+ * await api.dev({ port: 8787, seed: true, webBuild: () => web.cli.build(), onChange: c => web.cli.update(c) });
3267
3563
  * ```
3268
3564
  */
3269
3565
  async dev(opts) {
@@ -3293,11 +3589,7 @@ const createDeployApi = (ctx) => ({
3293
3589
  if (!ctx.has("d1")) throw new Error("[moku-worker] seed: no d1 database is configured.");
3294
3590
  const stage = opts?.stage ?? ctx.global.stage;
3295
3591
  await writeWranglerConfig(ctx.config.configFile, assembleManifest(ctx, stage), {}, wranglerExtra(ctx.config));
3296
- const databases = ctx.require(d1Plugin).deployManifest();
3297
- const wanted = opts?.binding;
3298
- const matched = wanted === void 0 ? databases : databases.filter((database) => database.binding === wanted);
3299
- const target = matched.length === 1 ? matched[0] : void 0;
3300
- if (target === void 0) throw new Error(wanted === void 0 ? `[moku-worker] seed: ${String(databases.length)} d1 databases configured — pass { binding } to choose one.` : `[moku-worker] seed: no d1 database is bound to "${wanted}".`);
3592
+ const target = resolveD1(ctx, opts?.binding);
3301
3593
  const scope = opts?.remote === true ? "--remote" : "--local";
3302
3594
  if (scope === "--local" && target.migrations !== void 0) await runWranglerInherit([
3303
3595
  "d1",
@@ -3520,10 +3812,12 @@ const createCliApi = (ctx) => ({
3520
3812
  * per-change rebuild when `onChange` is omitted.
3521
3813
  * @param opts.onChange - Incremental per-change rebuild (e.g. `changes => webApp.cli.update(changes)`),
3522
3814
  * so each change rebuilds only the changed paths instead of a full `webBuild()`.
3815
+ * @param opts.seed - Load the configured seed (`pluginConfigs.deploy.seed`) into the LOCAL D1 and
3816
+ * reset its cached KV keys before serving — the local analogue of `deploy({ seed: true })`.
3523
3817
  * @returns Resolves when the dev session ends.
3524
3818
  * @example
3525
3819
  * ```ts
3526
- * await api.dev({ port: 7878, webBuild: () => web.cli.build(), onChange: c => web.cli.update(c) });
3820
+ * await api.dev({ port: 7878, seed: true, webBuild: () => web.cli.build(), onChange: c => web.cli.update(c) });
3527
3821
  * ```
3528
3822
  */
3529
3823
  async dev(opts) {
@@ -3538,7 +3832,8 @@ const createCliApi = (ctx) => ({
3538
3832
  ...opts?.port === void 0 ? {} : { port: opts.port },
3539
3833
  ...stage === void 0 ? {} : { stage },
3540
3834
  ...opts?.webBuild ? { webBuild: opts.webBuild } : {},
3541
- ...opts?.onChange ? { onChange: opts.onChange } : {}
3835
+ ...opts?.onChange ? { onChange: opts.onChange } : {},
3836
+ ...opts?.seed ? { seed: opts.seed } : {}
3542
3837
  });
3543
3838
  ui.check(true, "dev session stopped cleanly");
3544
3839
  } catch (error) {
@@ -3547,32 +3842,48 @@ const createCliApi = (ctx) => ({
3547
3842
  }
3548
3843
  },
3549
3844
  /**
3550
- * One-command Cloudflare deploy; forwards opts verbatim to deploy.run. Guided/interactive by
3551
- * default; `{ ci: true }` runs the automated path (CI). A `webBuild` hook builds the web site
3552
- * first (before `wrangler deploy`). A failure renders a branded `✗` line + non-zero exit code
3553
- * (matching cli.auth/doctor), never a raw stack trace.
3845
+ * One-command Cloudflare deploy; forwards opts verbatim to deploy.run, then — only on a successful
3846
+ * deploy the requested post-deploy migration/seed. Guided/interactive by default; `{ ci: true }`
3847
+ * runs the automated path (CI). A `webBuild` hook builds the web site first (before `wrangler
3848
+ * deploy`). RETURNS the structured {@link DeployReport}; on a failure it also renders a branded `✗`
3849
+ * line + sets a non-zero exit code (matching cli.auth/doctor), never a raw stack trace.
3554
3850
  *
3555
3851
  * @param opts - Optional deploy options.
3556
3852
  * @param opts.ci - Automated mode: never prompts, auto-confirms. Omit/false → guided on a TTY.
3557
3853
  * @param opts.stage - Target stage (resource-name suffix); falls back to `--stage` then the app stage.
3558
3854
  * @param opts.webBuild - Build the web site first (e.g. `() => webApp.cli.build()`), before deploy.
3559
- * @returns Resolves once the deploy completes (or after a failure is rendered).
3855
+ * @param opts.migration - Apply pending remote D1 migrations after a successful deploy (skipped on abort).
3856
+ * @param opts.seed - Load the configured remote seed (`pluginConfigs.deploy.seed`) after a
3857
+ * successful deploy (+ migration); skipped on an aborted deploy.
3858
+ * @returns The deploy report (status, url, resource tally, migration/seed outcome, errors).
3560
3859
  * @example
3561
3860
  * ```ts
3562
- * await api.deploy({ webBuild: () => web.cli.build() }); // guided, app stage
3563
- * await api.deploy({ ci: true, webBuild: () => web.cli.build() }); // CI; `--stage dev` honored
3861
+ * const report = await api.deploy({ webBuild: () => web.cli.build(), migration: true, seed: true });
3862
+ * if (report.status === "aborted") return; // creds not set up yet nothing shipped
3564
3863
  * ```
3565
3864
  */
3566
3865
  async deploy(opts) {
3567
3866
  const stage = opts?.stage ?? parseStageArg(process.argv);
3568
3867
  try {
3569
- await ctx.require(deployPlugin).run({
3868
+ const report = await ctx.require(deployPlugin).run({
3570
3869
  ...opts,
3571
3870
  ...stage === void 0 ? {} : { stage }
3572
3871
  });
3872
+ if (report.status === "failed") process.exitCode = 1;
3873
+ return report;
3573
3874
  } catch (error) {
3574
- createBrandConsole().error(error instanceof Error ? error.message : String(error));
3875
+ const message = error instanceof Error ? error.message : String(error);
3876
+ createBrandConsole().error(message);
3575
3877
  process.exitCode = 1;
3878
+ return {
3879
+ ok: false,
3880
+ status: "failed",
3881
+ stage: stage ?? "production",
3882
+ migration: "skipped",
3883
+ seed: "skipped",
3884
+ elapsedMs: 0,
3885
+ errors: [message]
3886
+ };
3576
3887
  }
3577
3888
  },
3578
3889
  /**