@pithy-sh/cli 0.1.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/LICENSE +21 -0
- package/package.json +72 -0
- package/scripts/templateManifest.ts +49 -0
- package/scripts/tsconfig.json +26 -0
- package/scripts/vendorTemplate.ts +84 -0
- package/scripts/verifyPack.ts +88 -0
- package/src/audit/cliAudit.ts +406 -0
- package/src/bin.ts +111 -0
- package/src/capabilities/add.ts +288 -0
- package/src/capabilities/addBootstrap.ts +275 -0
- package/src/capabilities/catalog.ts +175 -0
- package/src/capabilities/compose.ts +39 -0
- package/src/capabilities/configConstants.ts +74 -0
- package/src/capabilities/configImports.ts +397 -0
- package/src/capabilities/eject.ts +331 -0
- package/src/capabilities/emailProvisioner.ts +346 -0
- package/src/capabilities/entitlementGap.ts +70 -0
- package/src/capabilities/entryExports.ts +162 -0
- package/src/capabilities/flow.ts +550 -0
- package/src/capabilities/hostRegistry.ts +368 -0
- package/src/capabilities/loadFailure.ts +208 -0
- package/src/capabilities/manifests.ts +238 -0
- package/src/capabilities/mediaProvisioner.ts +471 -0
- package/src/capabilities/mintSecrets.ts +306 -0
- package/src/capabilities/paymentsProvisioner.ts +207 -0
- package/src/capabilities/prerequisites.ts +168 -0
- package/src/capabilities/r2Bucket.ts +113 -0
- package/src/capabilities/reconcile.ts +1483 -0
- package/src/capabilities/remove.ts +597 -0
- package/src/capabilities/requiredOptions.ts +92 -0
- package/src/capabilities/rotateSecrets.ts +305 -0
- package/src/capabilities/secrets.ts +178 -0
- package/src/capabilities/secretsDispatcher.ts +29 -0
- package/src/capabilities/secretsProvisioner.ts +389 -0
- package/src/capabilities/storageProvisioner.ts +414 -0
- package/src/capabilities/supportProvisioner.ts +515 -0
- package/src/capabilities/testersLoader.ts +52 -0
- package/src/capabilities/testersProvisioner.ts +236 -0
- package/src/capabilities/turnstileProvisioner.ts +347 -0
- package/src/capabilities/vectorProvisioner.ts +260 -0
- package/src/ci/fileModes.ts +223 -0
- package/src/ci/sourceFiles.ts +200 -0
- package/src/ci/workflowDrivers.ts +524 -0
- package/src/cloudflare/accountAnswer.ts +110 -0
- package/src/cloudflare/config.ts +685 -0
- package/src/cloudflare/storeId.ts +129 -0
- package/src/commands/add.ts +372 -0
- package/src/commands/alias.ts +205 -0
- package/src/commands/dashboard.ts +651 -0
- package/src/commands/deploy.ts +150 -0
- package/src/commands/dev.ts +37 -0
- package/src/commands/doctor.ts +2059 -0
- package/src/commands/email.ts +425 -0
- package/src/commands/env.ts +155 -0
- package/src/commands/feature.ts +359 -0
- package/src/commands/init.ts +538 -0
- package/src/commands/media.ts +303 -0
- package/src/commands/migrate.ts +129 -0
- package/src/commands/payments.ts +336 -0
- package/src/commands/provision.ts +368 -0
- package/src/commands/remove.ts +151 -0
- package/src/commands/secrets.ts +652 -0
- package/src/commands/seed.ts +229 -0
- package/src/commands/storage.ts +309 -0
- package/src/commands/support.ts +331 -0
- package/src/commands/testers.ts +1020 -0
- package/src/commands/token.ts +364 -0
- package/src/commands/turnstile.ts +271 -0
- package/src/commands/ui.ts +222 -0
- package/src/commands/upgrade.ts +517 -0
- package/src/commands/vector.ts +390 -0
- package/src/commands/worker.ts +295 -0
- package/src/dashboard/api.ts +323 -0
- package/src/dashboard/connect.ts +758 -0
- package/src/dashboard/contract.ts +289 -0
- package/src/dashboard/grant.ts +124 -0
- package/src/dashboard/registry.ts +519 -0
- package/src/dashboard/resolveTarget.ts +119 -0
- package/src/dev/delivery.ts +174 -0
- package/src/dev/devLogin.ts +155 -0
- package/src/dev/devLoginTargets.ts +91 -0
- package/src/dev/env.ts +206 -0
- package/src/dev/hostWorkers.ts +290 -0
- package/src/dev/keys.ts +111 -0
- package/src/dev/logging.ts +87 -0
- package/src/dev/openUrl.ts +75 -0
- package/src/dev/orchestrator.ts +1014 -0
- package/src/dev/ports.ts +220 -0
- package/src/dev/readyWatch.ts +142 -0
- package/src/dev/state.ts +90 -0
- package/src/devSecrets/bootstrapVars.ts +265 -0
- package/src/devSecrets/devVars.ts +240 -0
- package/src/devSecrets/edit.ts +256 -0
- package/src/devSecrets/file.ts +277 -0
- package/src/devSecrets/generate.ts +428 -0
- package/src/devSecrets/location.ts +80 -0
- package/src/devSecrets/mode.ts +71 -0
- package/src/devSecrets/records.ts +30 -0
- package/src/devSecrets/report.ts +99 -0
- package/src/devSecrets/seed.ts +344 -0
- package/src/devSecrets/store.ts +262 -0
- package/src/devSecrets/targets.ts +204 -0
- package/src/dispatch.ts +147 -0
- package/src/docs/catalog.ts +246 -0
- package/src/docs/writeCatalog.ts +45 -0
- package/src/doctor/cloudflare.ts +287 -0
- package/src/doctor/devPreferences.ts +155 -0
- package/src/doctor/devSecrets.ts +464 -0
- package/src/doctor/devVars.ts +414 -0
- package/src/doctor/devVarsLocal.ts +138 -0
- package/src/doctor/environments.ts +155 -0
- package/src/doctor/health.ts +354 -0
- package/src/doctor/localDelivery.ts +91 -0
- package/src/doctor/portsRegistry.ts +252 -0
- package/src/doctor/projectName.ts +584 -0
- package/src/doctor/secretBindings.ts +166 -0
- package/src/doctor/settings.ts +274 -0
- package/src/doctor/settingsSources.ts +202 -0
- package/src/doctor/workerName.ts +174 -0
- package/src/doctor/wranglerVars.ts +33 -0
- package/src/feature/bindings.ts +93 -0
- package/src/feature/create.ts +179 -0
- package/src/feature/destroy.ts +160 -0
- package/src/feature/devConfig.ts +201 -0
- package/src/feature/identity.ts +100 -0
- package/src/feature/manifest.ts +132 -0
- package/src/feature/ports.ts +615 -0
- package/src/feature/provision.ts +362 -0
- package/src/feature/sync.ts +148 -0
- package/src/feature/worktree.ts +282 -0
- package/src/help/groups.ts +47 -0
- package/src/help/rootUsage.ts +135 -0
- package/src/main.ts +73 -0
- package/src/migrations/ledger.ts +129 -0
- package/src/migrations/registry.ts +47 -0
- package/src/migrations/run.ts +1066 -0
- package/src/notifier/check.ts +129 -0
- package/src/notifier/installer.ts +48 -0
- package/src/notifier/notify.ts +152 -0
- package/src/notifier/state.ts +248 -0
- package/src/notifier/version.ts +59 -0
- package/src/platform/editor.ts +333 -0
- package/src/platform/rc.ts +118 -0
- package/src/platform/shell.ts +83 -0
- package/src/project/appBindings.ts +184 -0
- package/src/project/appWorkflows.ts +266 -0
- package/src/project/applyDomains.ts +166 -0
- package/src/project/askDomains.ts +220 -0
- package/src/project/atomic.ts +466 -0
- package/src/project/bindingEntries.ts +425 -0
- package/src/project/config.ts +701 -0
- package/src/project/dashboard.ts +118 -0
- package/src/project/deploy.ts +364 -0
- package/src/project/devVars.ts +113 -0
- package/src/project/domainPrompt.ts +191 -0
- package/src/project/domains.ts +386 -0
- package/src/project/envInventory.ts +356 -0
- package/src/project/environment.ts +125 -0
- package/src/project/extensions.ts +69 -0
- package/src/project/jsonc.ts +289 -0
- package/src/project/packageManager.ts +238 -0
- package/src/project/readOptionalFile.ts +342 -0
- package/src/project/rollback.ts +145 -0
- package/src/project/scaffold.ts +1088 -0
- package/src/project/templateFiles.ts +53 -0
- package/src/project/verifyDeploy.ts +230 -0
- package/src/project/versionMetadata.ts +77 -0
- package/src/project/workerAddress.ts +176 -0
- package/src/project/workerCommand.ts +564 -0
- package/src/project/workerIdentity.ts +50 -0
- package/src/project/workerManifest.ts +135 -0
- package/src/project/workerScaffold.ts +289 -0
- package/src/project/workerScope.ts +394 -0
- package/src/project/workers.ts +86 -0
- package/src/project/workflows.ts +281 -0
- package/src/project/wrangler.ts +168 -0
- package/src/provision/confirm.ts +86 -0
- package/src/provision/environment.ts +407 -0
- package/src/provision/featureConfig.ts +98 -0
- package/src/provision/mode.ts +62 -0
- package/src/provision/pendingSecrets.ts +96 -0
- package/src/provision/resources.ts +126 -0
- package/src/provision/secretBindings.ts +149 -0
- package/src/provision/store.ts +33 -0
- package/src/provision/unprovisioned.ts +114 -0
- package/src/provision/wranglerEnv.ts +220 -0
- package/src/rootFlags.ts +48 -0
- package/src/seed/drivers.ts +423 -0
- package/src/seed/media.ts +187 -0
- package/src/seed/plan.ts +137 -0
- package/src/seed/prepare.ts +224 -0
- package/src/seed/registry.ts +25 -0
- package/src/seed/run.ts +793 -0
- package/src/seed/safety.ts +206 -0
- package/src/terminal/logger.ts +42 -0
- package/src/terminal/output.ts +64 -0
- package/src/terminal/style.ts +132 -0
- package/src/test-utils/doctorHarness.ts +190 -0
- package/src/test-utils/migrateHarness.ts +126 -0
- package/src/test-utils/seedHarness.ts +173 -0
- package/src/test-utils/tempRepo.ts +45 -0
- package/src/tokens/config.ts +16 -0
- package/src/tokens/engine.ts +345 -0
- package/src/tokens/mintedTokens.ts +233 -0
- package/src/tokens/sinks.ts +84 -0
- package/src/ui/flow.ts +451 -0
- package/src/ui/react.ts +112 -0
- package/src/ui/routeAllowlist.ts +208 -0
- package/src/ui/scaffold.ts +113 -0
- package/src/ui/screenStyles.ts +127 -0
- package/src/ui/stubs.ts +135 -0
- package/src/ui/templates.ts +52 -0
- package/src/ui/wire.ts +311 -0
- package/src/ui/workerUi.ts +172 -0
- package/templates/starter/.dev.secrets.example.jsonc +43 -0
- package/templates/starter/.dev.vars.example +30 -0
- package/templates/starter/apps/api/package.json +22 -0
- package/templates/starter/apps/api/pithy.config.ts +65 -0
- package/templates/starter/apps/api/pithy.worker.jsonc +11 -0
- package/templates/starter/apps/api/src/bindings.workers.test.ts +18 -0
- package/templates/starter/apps/api/src/cloudflare-test.d.ts +11 -0
- package/templates/starter/apps/api/src/index.ts +8 -0
- package/templates/starter/apps/api/tsconfig.json +26 -0
- package/templates/starter/apps/api/wrangler.jsonc +68 -0
- package/templates/starter/biome.template.jsonc +75 -0
- package/templates/starter/gitignore +37 -0
- package/templates/starter/package.json +28 -0
- package/templates/starter/pithy.config.ts +67 -0
- package/templates/starter/plugins/no-console.grit +25 -0
- package/templates/starter/plugins/no-process-io.grit +25 -0
- package/templates/starter/tsconfig.json +14 -0
- package/templates/starter/tsconfig.tools.json +30 -0
- package/templates/starter/vitest.config.ts +124 -0
- package/templates/starter/vitest.workers.config.ts +26 -0
package/src/seed/run.ts
ADDED
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { KVNamespace } from "@cloudflare/workers-types";
|
|
5
|
+
import { uploadImageBytes, uploadStreamBytes } from "@pithy-sh/cloudflare/src/media/assetSeeder";
|
|
6
|
+
import type { AssetOwner } from "@pithy-sh/cloudflare/src/media/ownership";
|
|
7
|
+
import { composeDatabases, type MergedDatabases } from "@pithy-sh/core/src/data/databases";
|
|
8
|
+
import { createDatabase } from "@pithy-sh/core/src/data/db";
|
|
9
|
+
import { InternalError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
10
|
+
import { TypedKv } from "@pithy-sh/core/src/kv/kv";
|
|
11
|
+
import { composeKv, type MergedKvNamespaces } from "@pithy-sh/core/src/kv/namespaces";
|
|
12
|
+
import { LOCAL_ENVIRONMENT } from "@pithy-sh/core/src/naming/environment";
|
|
13
|
+
import type { ResolvedSeedSet } from "@pithy-sh/core/src/seed/compose";
|
|
14
|
+
import type { D1SeedGroup, KvSeedGroup, MediaSeedItem, R2SeedItem, SeedArtifact } from "@pithy-sh/core/src/seed/seed";
|
|
15
|
+
import { collectSeededRows, type SeededRows } from "@pithy-sh/core/src/seed/seededRows";
|
|
16
|
+
import { seedD1Group } from "@pithy-sh/core/src/seed/writeD1";
|
|
17
|
+
import { seedKvGroup } from "@pithy-sh/core/src/seed/writeKv";
|
|
18
|
+
import { aggregateSecretRegistries } from "@pithy-sh/secrets/src/sharedSecretsStore";
|
|
19
|
+
import type { ZodType } from "zod";
|
|
20
|
+
import type { CliAuditEmit } from "../audit/cliAudit";
|
|
21
|
+
import type { CloudflareAccountSelection } from "../cloudflare/config";
|
|
22
|
+
import { type DevConfig, devConfigPath, readDevConfig } from "../feature/devConfig";
|
|
23
|
+
import {
|
|
24
|
+
previewReset,
|
|
25
|
+
type ResetPreviewEntry,
|
|
26
|
+
resetProject,
|
|
27
|
+
resolveWorkerScopes,
|
|
28
|
+
type WorkerScope,
|
|
29
|
+
} from "../migrations/run";
|
|
30
|
+
import {
|
|
31
|
+
type ImagesFactory,
|
|
32
|
+
openSeedDriver,
|
|
33
|
+
type R2SeedTarget,
|
|
34
|
+
type RemoteD1Factory,
|
|
35
|
+
type RemoteKvFactory,
|
|
36
|
+
type RemoteR2Factory,
|
|
37
|
+
type ResolvedStoreIds,
|
|
38
|
+
resolveStoreIds,
|
|
39
|
+
type SeedDriver,
|
|
40
|
+
type StreamFactory,
|
|
41
|
+
} from "./drivers";
|
|
42
|
+
import { type MediaFs, type MediaUploader, type SeedMediaResult, seedMediaItem } from "./media";
|
|
43
|
+
import { buildDryRunPlan, type SeedPlanMediaAction, type SeedPlanSet } from "./plan";
|
|
44
|
+
import { devSecretReader, readDevPreferences, writeSeedArtifact } from "./prepare";
|
|
45
|
+
import { buildSeedPlan } from "./registry";
|
|
46
|
+
import { assertResetConfirmed, assertSeedConfirmed, assertSetAllowedForEnv } from "./safety";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The orchestrator behind `pithy seed`: compose every capability's seed sets for one environment, pass
|
|
50
|
+
* the layered env-safety gate, then write each set in order — D1 rows (encoded + validated through
|
|
51
|
+
* Kysely with `CamelCasePlugin`), KV entries (validated through the store's `TypedKv`), R2 objects, and
|
|
52
|
+
* media assets (uploaded, with the minted UUID recorded). Every write is idempotent and never
|
|
53
|
+
* destructive: D1 is `INSERT OR IGNORE`, KV `put` is by-key, `once` media skips a recorded upload.
|
|
54
|
+
*
|
|
55
|
+
* The structure mirrors `migrations/run.ts`: one plan, a driver that resolves each backend to a live
|
|
56
|
+
* handle (local Miniflare for `dev`, REST managers otherwise), and injectable factory seams so tests
|
|
57
|
+
* substitute in-memory D1/KV, a fake minter, and a fake filesystem for the network and the disk.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
/** Options for {@link seedProject}. */
|
|
61
|
+
export interface SeedProjectOptions {
|
|
62
|
+
/**
|
|
63
|
+
* The project root — the parent of `apps/`, the owner of the `.wrangler/state` stores every Worker's
|
|
64
|
+
* local backends live in, and the fallback base directory for a fixture's relative media paths.
|
|
65
|
+
*/
|
|
66
|
+
projectDir: string;
|
|
67
|
+
/**
|
|
68
|
+
* The project this run seeds — the root `pithy.config.ts` `name`, resolved by `requireProjectName` and
|
|
69
|
+
* never guessed (CLAUDE.md §Resource naming). Required, because a seed can mint Cloudflare Images and
|
|
70
|
+
* Stream assets, and those two stores are account-flat: an asset is keyed by a Cloudflare-minted id, so
|
|
71
|
+
* the ownership metadata this name goes into is the only thing that says which project created it.
|
|
72
|
+
* Neither store has a local emulation either, so even a `dev` seed writes into the store production
|
|
73
|
+
* shares. A run that cannot name its project must not upload.
|
|
74
|
+
*/
|
|
75
|
+
project: string;
|
|
76
|
+
/**
|
|
77
|
+
* The Cloudflare account this project belongs to, from `projectCloudflareAccount(projectDir)` — or
|
|
78
|
+
* `null` when the project names none.
|
|
79
|
+
*
|
|
80
|
+
* **This seam carried no account at all until #234**, which is why `seed/run.ts` could not pass one
|
|
81
|
+
* to `openSeedDriver` even after that parameter existed: there was nothing to pass. A non-`dev` seed
|
|
82
|
+
* writes rows into a real D1 and objects into a real R2, and Images/Stream have no local emulation at
|
|
83
|
+
* all, so even a `dev` seed with a media fixture reaches an account. Which account that is was, until
|
|
84
|
+
* now, whatever `<config>/cloudflare.json` happened to hold.
|
|
85
|
+
*/
|
|
86
|
+
account: CloudflareAccountSelection | null;
|
|
87
|
+
/** Narrow the fan-out to one Worker, by its name or its `apps/<dir>` basename. */
|
|
88
|
+
worker?: string;
|
|
89
|
+
/**
|
|
90
|
+
* The Workers to seed, already resolved. Skips `apps/` discovery — for a caller that resolved the set
|
|
91
|
+
* itself, and for tests, whose fixture Workers carry no importable `pithy.config.ts`.
|
|
92
|
+
*/
|
|
93
|
+
workers?: WorkerScope[];
|
|
94
|
+
/** Target environment. `dev` runs locally via Miniflare; other environments run over the REST managers. */
|
|
95
|
+
env: string;
|
|
96
|
+
/** Whether `example` sets are composed in (the project's `seed.includeExamples`; default off). */
|
|
97
|
+
includeExamples?: boolean;
|
|
98
|
+
/** Plan only: compute the write plan and read media sidecars, but perform no write and no upload. */
|
|
99
|
+
dryRun?: boolean;
|
|
100
|
+
/**
|
|
101
|
+
* The `--redo` flag: fully reset the schema (every migration's `down`, then every `up`) before
|
|
102
|
+
* seeding, instead of the ordinary non-destructive merge. **Destructive** — every row in every table
|
|
103
|
+
* the migration registry owns is gone, hand-inserted data included, not just what a fixture wrote.
|
|
104
|
+
* Because the schema comes back empty, the normal `INSERT OR IGNORE` / KV skip-if-exists writes just
|
|
105
|
+
* work afterward. Gated by the same escalating confirmation as a plain seed — never weaker.
|
|
106
|
+
*/
|
|
107
|
+
redo?: boolean;
|
|
108
|
+
/**
|
|
109
|
+
* The `--confirm-reset` phrase — the non-interactive unlock for a `--redo` schema reset on any non-`dev`
|
|
110
|
+
* environment. Must equal `resetConfirmPhrase(env)` exactly. Stricter than `--yes` on purpose: a reset
|
|
111
|
+
* destroys every row, so the flag that authorizes an additive seed must not also authorize a drop.
|
|
112
|
+
*/
|
|
113
|
+
confirmReset?: string;
|
|
114
|
+
/** Interactive confirm seam for the reset phrase (an `@clack/prompts` text prompt). */
|
|
115
|
+
promptReset?: () => Promise<string>;
|
|
116
|
+
/** Audit emitter, so a schema reset is always recorded. Defaults to recording nothing. */
|
|
117
|
+
audit?: CliAuditEmit;
|
|
118
|
+
/** The `--yes` flag — required for any non-`dev` environment (safety layer 2). */
|
|
119
|
+
yes?: boolean;
|
|
120
|
+
/** Non-interactive mode (set by `--json` or CI): no prompt is shown; production needs the confirm flag. */
|
|
121
|
+
json?: boolean;
|
|
122
|
+
/** The `--confirm-production` phrase — the non-interactive unlock for a production seed. */
|
|
123
|
+
confirmProduction?: string;
|
|
124
|
+
/**
|
|
125
|
+
* Environment names the project classifies as production beyond the built-in `production`/`prod`
|
|
126
|
+
* (from `pithy.config.ts` `seed.productionEnvironments`). An env named here requires the hard confirm
|
|
127
|
+
* phrase, not just `--yes`.
|
|
128
|
+
*/
|
|
129
|
+
productionEnvironments?: readonly string[];
|
|
130
|
+
/** Interactive confirm seam: prompt for the production phrase (an `@clack/prompts` text prompt). */
|
|
131
|
+
prompt?: () => Promise<string>;
|
|
132
|
+
/** Test seam: build the remote D1 for a binding instead of the default REST-backed client. */
|
|
133
|
+
remoteD1?: RemoteD1Factory;
|
|
134
|
+
/** Test seam: build the remote KV manager for a binding instead of the default REST-backed client. */
|
|
135
|
+
remoteKv?: RemoteKvFactory;
|
|
136
|
+
/** Test seam: build the remote R2 manager for a binding instead of the default REST-backed client. */
|
|
137
|
+
remoteR2?: RemoteR2Factory;
|
|
138
|
+
/** Test seam: build the (always-remote) Images manager instead of the default account client. */
|
|
139
|
+
images?: ImagesFactory;
|
|
140
|
+
/** Test seam: build the (always-remote) Stream manager instead of the default account client. */
|
|
141
|
+
stream?: StreamFactory;
|
|
142
|
+
/** Test seam: the filesystem media seeding reads bytes and writes the UUID sidecar through. */
|
|
143
|
+
mediaFs?: MediaFs;
|
|
144
|
+
/** Test seam: the byte uploader media seeding mints through, overriding the driver-built default. */
|
|
145
|
+
mediaUploader?: MediaUploader;
|
|
146
|
+
/**
|
|
147
|
+
* Seam: read the developer's machine-local preferences, handed to every prepared set. Defaults to
|
|
148
|
+
* `<pithy config dir>/<project>/dev.json` — `~/.config/pithy/<project>/dev.json` on POSIX. Read once per
|
|
149
|
+
* run, so one hand-edited file cannot be observed in two states by two Workers of the same fan-out.
|
|
150
|
+
*/
|
|
151
|
+
preferences?: () => Promise<unknown>;
|
|
152
|
+
/**
|
|
153
|
+
* Seam: resolve a secret for a prepared set. Defaults to the project's dev secrets file at
|
|
154
|
+
* `<config>/<project>/secrets.jsonc` — the one place a dev value is *stated*, whatever backend the
|
|
155
|
+
* registry gives it (#176). Passing this replaces the reader wholesale, so a test that supplies a
|
|
156
|
+
* value here proves nothing about where a real run finds one.
|
|
157
|
+
*/
|
|
158
|
+
secret?: (name: string) => Promise<string | undefined>;
|
|
159
|
+
/** Seam: write a prepared set's artifact. Defaults to the project's gitignored `logs/`. */
|
|
160
|
+
writeArtifact?: (artifact: SeedArtifact) => Promise<void>;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** One Worker's slice of a seed run: what its own capabilities' fixtures wrote, and what they didn't. */
|
|
164
|
+
export interface SeedWorkerReport {
|
|
165
|
+
/** The Worker's name. */
|
|
166
|
+
worker: string;
|
|
167
|
+
/** The per-set outcome, in run order. */
|
|
168
|
+
sets: SeedPlanSet[];
|
|
169
|
+
/** Namespaced keys of sets present but disallowed for `env` — surfaced, never silently dropped. */
|
|
170
|
+
skippedByEnv: string[];
|
|
171
|
+
/**
|
|
172
|
+
* Namespaced keys an earlier Worker in the fan-out already wrote **to the same store**. Workers share a
|
|
173
|
+
* resource by declaring the same binding, so the same capability composed into two Workers usually
|
|
174
|
+
* writes one fixture to one store — it runs once, and the Workers that skipped it say so rather than
|
|
175
|
+
* double-counting. A binding the two Workers point at different resources is not that: the fixture runs
|
|
176
|
+
* again there, and never appears here.
|
|
177
|
+
*/
|
|
178
|
+
shared: string[];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The structured outcome of a seed run — the `--json` payload and the source of the human summary. */
|
|
182
|
+
export interface SeedRunReport {
|
|
183
|
+
/** The command that produced the report. */
|
|
184
|
+
command: "seed";
|
|
185
|
+
/** The environment seeded. */
|
|
186
|
+
env: string;
|
|
187
|
+
/** Whether this was a dry run (nothing written). */
|
|
188
|
+
dryRun: boolean;
|
|
189
|
+
/** The per-Worker outcome, in fan-out order. */
|
|
190
|
+
workers: SeedWorkerReport[];
|
|
191
|
+
/**
|
|
192
|
+
* Present only when `--redo` was passed: the databases whose schema was (a real run) or would be (a
|
|
193
|
+
* dry run) fully reset — every migration rolled back, then reapplied — before seeding. One entry per
|
|
194
|
+
* physical database, so one two Workers share is listed once.
|
|
195
|
+
*/
|
|
196
|
+
reset?: ResetPreviewEntry[];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The slice of `CloudflareKVManager` the seed adapter bridges — a read (existence check) and a write. */
|
|
200
|
+
interface KvManagerSlice {
|
|
201
|
+
get(key: string): Promise<string | null>;
|
|
202
|
+
set(
|
|
203
|
+
key: string,
|
|
204
|
+
value: string,
|
|
205
|
+
options: { expirationTtl?: number; metadata?: Record<string, unknown> },
|
|
206
|
+
): Promise<void>;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Adapt a remote `CloudflareKVManager` to the `KVNamespace` surface `TypedKv` reads and writes through,
|
|
211
|
+
* so both local and remote KV seed through the **same** validated `TypedKv` path. Seeding puts, and
|
|
212
|
+
* reads once per entry for the non-destructive existence check, so `put` (→ `manager.set`) and `get`
|
|
213
|
+
* (→ `manager.get`) are bridged; a list/delete would throw, which never happens here.
|
|
214
|
+
*/
|
|
215
|
+
function kvNamespaceFromManager(manager: KvManagerSlice): KVNamespace {
|
|
216
|
+
return {
|
|
217
|
+
get: (key: string) => manager.get(key),
|
|
218
|
+
put: (key: string, value: string, options?: { expirationTtl?: number; metadata?: unknown }) =>
|
|
219
|
+
manager.set(key, value, {
|
|
220
|
+
...(options?.expirationTtl !== undefined ? { expirationTtl: options.expirationTtl } : {}),
|
|
221
|
+
...(options?.metadata !== undefined ? { metadata: options.metadata as Record<string, unknown> } : {}),
|
|
222
|
+
}),
|
|
223
|
+
} as unknown as KVNamespace;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Whether an R2 object already exists at `key` — used to keep the write non-destructive. Locally a
|
|
228
|
+
* `head` answers directly; remotely a presigned range GET does (200/206 exist, 404 absent). An
|
|
229
|
+
* ambiguous remote status surfaces as an error rather than risking an overwrite.
|
|
230
|
+
*/
|
|
231
|
+
async function r2ObjectExists(target: R2SeedTarget, key: string): Promise<boolean> {
|
|
232
|
+
if (target.kind === "local") {
|
|
233
|
+
return (await target.bucket.head(key)) !== null;
|
|
234
|
+
}
|
|
235
|
+
const url = await target.manager.createDownloadUrl(key);
|
|
236
|
+
const response = await fetch(url, { method: "GET", headers: { range: "bytes=0-0" } });
|
|
237
|
+
await response.body?.cancel().catch(() => {});
|
|
238
|
+
if (response.status === 404) return false;
|
|
239
|
+
if (response.ok || response.status === 206) return true;
|
|
240
|
+
throw new InternalError({
|
|
241
|
+
message: `Checking the R2 object "${key}" failed.`,
|
|
242
|
+
detail: `Presigned existence GET for "${key}" returned ${response.status}.`,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Write one R2 object to its resolved target: a local Miniflare bucket, or a remote presigned S3 PUT.
|
|
248
|
+
* Non-destructive: an object that already exists at the key is left untouched (R2's analog of D1's
|
|
249
|
+
* `INSERT OR IGNORE`), so a re-run never overwrites data that shares a key.
|
|
250
|
+
*/
|
|
251
|
+
async function writeR2Object(target: R2SeedTarget, item: R2SeedItem): Promise<void> {
|
|
252
|
+
if (await r2ObjectExists(target, item.key)) return;
|
|
253
|
+
|
|
254
|
+
if (target.kind === "local") {
|
|
255
|
+
await target.bucket.put(item.key, item.body, {
|
|
256
|
+
httpMetadata: { contentType: item.contentType },
|
|
257
|
+
...(item.metadata !== undefined ? { customMetadata: item.metadata } : {}),
|
|
258
|
+
});
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Remote R2 has no in-Worker binding: presign a one-hour PUT and upload the bytes. Custom metadata
|
|
263
|
+
// is not carried by the presigned S3 PUT (a v1 caveat) — the object's content type and bytes land.
|
|
264
|
+
const bytes = typeof item.body === "string" ? new TextEncoder().encode(item.body) : item.body;
|
|
265
|
+
const url = await target.manager.createUploadUrl(item.key, item.contentType, bytes.byteLength);
|
|
266
|
+
const response = await fetch(url, { method: "PUT", headers: { "content-type": item.contentType }, body: bytes });
|
|
267
|
+
if (!response.ok) {
|
|
268
|
+
throw new InternalError({
|
|
269
|
+
message: `Seeding R2 object "${item.key}" failed.`,
|
|
270
|
+
detail: `Presigned PUT for "${item.key}" returned ${response.status}.`,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The default media uploader: wrap the driver's Images/Stream managers with the seed byte helpers.
|
|
277
|
+
*
|
|
278
|
+
* `owner` is threaded through, not defaulted. Both helpers require it, and `withAssetOwnership` merges it
|
|
279
|
+
* **over** the fixture's own metadata, so a fixture can never claim another project's assets.
|
|
280
|
+
*/
|
|
281
|
+
function driverUploader(driver: SeedDriver, owner: AssetOwner): MediaUploader {
|
|
282
|
+
return {
|
|
283
|
+
images: (bytes, metadata) => uploadImageBytes(driver.images(), bytes, { owner, metadata }),
|
|
284
|
+
stream: async (bytes, metadata) => ({
|
|
285
|
+
id: (await uploadStreamBytes(driver.stream(), bytes, { owner, metadata })).uid,
|
|
286
|
+
}),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Resolve a database's binding and merged tables, failing with an actionable error if it is undeclared. */
|
|
291
|
+
function databaseGroup(
|
|
292
|
+
databases: MergedDatabases,
|
|
293
|
+
database: string,
|
|
294
|
+
): { binding: string; items: Record<string, ZodType> } {
|
|
295
|
+
const group = databases[database];
|
|
296
|
+
if (!group) {
|
|
297
|
+
throw new ValidationError({
|
|
298
|
+
message: `Seed targets database "${database}", which no capability declares.`,
|
|
299
|
+
action: `Declare "${database}" (binding and tables) in a capability, or fix the fixture's database name.`,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
return group;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Resolve a table's Zod schema from the merged databases, failing with an actionable error if absent. */
|
|
306
|
+
function tableSchema(items: Record<string, ZodType>, database: string, table: string): ZodType {
|
|
307
|
+
const schema = items[table];
|
|
308
|
+
if (!schema) {
|
|
309
|
+
throw new ValidationError({
|
|
310
|
+
message: `Seed targets table "${table}" in database "${database}", which no capability declares.`,
|
|
311
|
+
action: `Declare "${table}" in the "${database}" database, or fix the fixture's table name.`,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
return schema;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Write a media item's optional D1 asset row, injecting the minted UUID as the row's `id` when unset. */
|
|
318
|
+
async function writeMediaRecord(
|
|
319
|
+
driver: SeedDriver,
|
|
320
|
+
databases: MergedDatabases,
|
|
321
|
+
record: NonNullable<MediaSeedItem["record"]>,
|
|
322
|
+
id: string,
|
|
323
|
+
): Promise<void> {
|
|
324
|
+
const group = databaseGroup(databases, record.database);
|
|
325
|
+
const schema = tableSchema(group.items, record.database, record.table);
|
|
326
|
+
const db = createDatabase(driver.d1(group.binding), group.items);
|
|
327
|
+
// Asset tables key on the media UUID: fill in `id` when the fixture row leaves it out.
|
|
328
|
+
const row =
|
|
329
|
+
record.row !== null && typeof record.row === "object" && !("id" in record.row)
|
|
330
|
+
? { ...(record.row as Record<string, unknown>), id }
|
|
331
|
+
: record.row;
|
|
332
|
+
await seedD1Group(db, { database: record.database, table: record.table, rows: [row] }, schema);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Read every media item's action from its sidecar (no upload) — the dry-run plan's media state source. */
|
|
336
|
+
async function resolveMediaStates(
|
|
337
|
+
sets: readonly ResolvedSeedSet[],
|
|
338
|
+
env: string,
|
|
339
|
+
projectDir: string,
|
|
340
|
+
fs: MediaFs | undefined,
|
|
341
|
+
): Promise<Map<MediaSeedItem, { action: SeedPlanMediaAction; id?: string }>> {
|
|
342
|
+
const states = new Map<MediaSeedItem, { action: SeedPlanMediaAction; id?: string }>();
|
|
343
|
+
for (const resolved of sets) {
|
|
344
|
+
// Relative media paths resolve against the set's own module dir; the project root is the fallback.
|
|
345
|
+
const baseDir = resolved.set.baseDir ?? projectDir;
|
|
346
|
+
for (const item of resolved.set.media ?? []) {
|
|
347
|
+
const result = await seedMediaItem(item, { env, baseDir, fs, dryRun: true });
|
|
348
|
+
states.set(item, { action: result.action, ...(result.id !== undefined ? { id: result.id } : {}) });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return states;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Reject a media item the seeder cannot re-run idempotently: an `always` asset with a D1 `record`.
|
|
356
|
+
* `always` mints a fresh UUID every run, so recording it would inject a new primary key each time and
|
|
357
|
+
* grow the asset table without bound. A recorded asset must be `once` (stable, recorded UUID). Checked
|
|
358
|
+
* before any write so a bad fixture never lands a partial run.
|
|
359
|
+
*/
|
|
360
|
+
function assertMediaRecordsSupported(sets: readonly ResolvedSeedSet[]): void {
|
|
361
|
+
for (const resolved of sets) {
|
|
362
|
+
for (const item of resolved.set.media ?? []) {
|
|
363
|
+
if (item.mode === "always" && item.record) {
|
|
364
|
+
throw new ValidationError({
|
|
365
|
+
message: `Seed set "${resolved.set.name}" has an "always" media asset with a D1 record.`,
|
|
366
|
+
action: 'Use mode "once" for a recorded asset, or drop `record` from the "always" asset.',
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Convert a real media-seed result into its plan entry (store, mode, action, and the id when present). */
|
|
374
|
+
function mediaEntry(result: SeedMediaResult): SeedPlanSet["media"][number] {
|
|
375
|
+
return {
|
|
376
|
+
store: result.store,
|
|
377
|
+
mode: result.mode,
|
|
378
|
+
action: result.action,
|
|
379
|
+
...(result.id !== undefined ? { id: result.id } : {}),
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** One Worker's composed fixtures, after the fan-out has removed the sets an earlier Worker covers. */
|
|
384
|
+
interface ComposedWorker {
|
|
385
|
+
/** The Worker whose capabilities composed these sets. */
|
|
386
|
+
worker: WorkerScope;
|
|
387
|
+
/** The sets this Worker actually runs, in order. */
|
|
388
|
+
sets: ResolvedSeedSet[];
|
|
389
|
+
/** Namespaced keys of sets present but disallowed for the environment. */
|
|
390
|
+
skippedByEnv: string[];
|
|
391
|
+
/** Namespaced keys an earlier Worker in the fan-out already covers. */
|
|
392
|
+
shared: string[];
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* The stores one Worker's resolved set writes to, as a stable fingerprint: each D1 database, KV
|
|
397
|
+
* namespace, and R2 bucket the fixture touches, resolved through that Worker's own `wrangler.jsonc` to
|
|
398
|
+
* the identity the driver will persist under. Bindings are per-Worker, so the same fixture in two
|
|
399
|
+
* Workers can land in two different databases; the fingerprint is what tells those two writes apart.
|
|
400
|
+
* Images and Stream carry no store identity — they are account-global, so a media-only set fingerprints
|
|
401
|
+
* empty and is genuinely written once.
|
|
402
|
+
*/
|
|
403
|
+
function storeFingerprint(
|
|
404
|
+
resolved: ResolvedSeedSet,
|
|
405
|
+
ids: ResolvedStoreIds,
|
|
406
|
+
databases: () => MergedDatabases,
|
|
407
|
+
namespaces: () => MergedKvNamespaces,
|
|
408
|
+
): string {
|
|
409
|
+
const targets = new Set<string>();
|
|
410
|
+
const addD1 = (database: string): void => {
|
|
411
|
+
const binding = databases()[database]?.binding;
|
|
412
|
+
if (binding) targets.add(`d1:${ids.d1.get(binding) ?? binding}`);
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
for (const group of resolved.set.d1 ?? []) addD1(group.database);
|
|
416
|
+
for (const group of resolved.set.kv ?? []) {
|
|
417
|
+
const binding = namespaces()[group.namespace]?.binding;
|
|
418
|
+
if (binding) targets.add(`kv:${ids.kv.get(binding) ?? binding}`);
|
|
419
|
+
}
|
|
420
|
+
for (const item of resolved.set.r2 ?? []) targets.add(`r2:${ids.r2.get(item.binding) ?? item.binding}`);
|
|
421
|
+
for (const item of resolved.set.media ?? []) if (item.record) addD1(item.record.database);
|
|
422
|
+
|
|
423
|
+
return [...targets].sort().join("|");
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Compose every Worker's fixtures for one environment, deduped across the fan-out. A seed set's key is
|
|
428
|
+
* `NNNN_<capability>_<name>` — the same capability composed into two Workers yields the same key and the
|
|
429
|
+
* same rows. It does **not** guarantee the same destination: bindings are per-Worker, so two Workers can
|
|
430
|
+
* point one binding at two different databases (a wiring `migrate` supports and migrates separately).
|
|
431
|
+
* So a claim is (key, resolved stores), exactly as migrate groups by resolved database — the first
|
|
432
|
+
* Worker to claim one runs it and the rest record it as shared: one write per store, and a report that
|
|
433
|
+
* says so. Same key, different store, and the set runs again where it has not landed yet.
|
|
434
|
+
*/
|
|
435
|
+
async function composeFanOut(workers: WorkerScope[], env: string, includeExamples: boolean): Promise<ComposedWorker[]> {
|
|
436
|
+
const claimed = new Set<string>();
|
|
437
|
+
const composed: ComposedWorker[] = [];
|
|
438
|
+
for (const worker of workers) {
|
|
439
|
+
const { sets, skippedByEnv } = buildSeedPlan(worker.capabilities, { env, includeExamples });
|
|
440
|
+
const own: ResolvedSeedSet[] = [];
|
|
441
|
+
const shared: string[] = [];
|
|
442
|
+
|
|
443
|
+
// Resolved lazily: a Worker with no fixtures reads no config, and a fixture with no D1/KV target
|
|
444
|
+
// never composes the merged maps (a dry run over a half-wired project stays as forgiving as it was).
|
|
445
|
+
const ids = sets.length > 0 ? await resolveStoreIds({ workerDir: worker.dir, env }) : emptyStoreIds();
|
|
446
|
+
let databases: MergedDatabases | undefined;
|
|
447
|
+
let namespaces: MergedKvNamespaces | undefined;
|
|
448
|
+
|
|
449
|
+
for (const resolved of sets) {
|
|
450
|
+
const claim = `${resolved.key} ${storeFingerprint(
|
|
451
|
+
resolved,
|
|
452
|
+
ids,
|
|
453
|
+
() => (databases ??= composeDatabases(worker.capabilities)),
|
|
454
|
+
() => (namespaces ??= composeKv(worker.capabilities)),
|
|
455
|
+
)}`;
|
|
456
|
+
if (claimed.has(claim)) {
|
|
457
|
+
shared.push(resolved.key);
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
claimed.add(claim);
|
|
461
|
+
own.push(resolved);
|
|
462
|
+
}
|
|
463
|
+
composed.push({ worker, sets: own, skippedByEnv: [...skippedByEnv], shared });
|
|
464
|
+
}
|
|
465
|
+
return composed;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** The store map for a Worker with nothing to seed — no config read, every lookup a miss. */
|
|
469
|
+
function emptyStoreIds(): ResolvedStoreIds {
|
|
470
|
+
return { d1: new Map(), kv: new Map(), r2: new Map() };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Seed every Worker for one environment — compose, gate, and write. There is no root Worker: fixtures
|
|
475
|
+
* come from each Worker's own `pithy.config.ts` capabilities, so the run fans out over `apps/*` and
|
|
476
|
+
* reports per Worker. Sets are deduped across the fan-out ({@link composeFanOut}), and each Worker's
|
|
477
|
+
* backends resolve from its own `wrangler.jsonc` while the local Miniflare stores stay at the project
|
|
478
|
+
* root — two Workers that declare one binding share one store.
|
|
479
|
+
*
|
|
480
|
+
* A `dryRun` composes the plan and reads each media sidecar to report the action, but touches no backend
|
|
481
|
+
* and needs no credentials. A real run passes the escalating-confirmation gate, opens a driver per
|
|
482
|
+
* Worker, and writes every set in order: per set the D1 groups (validated on encode), KV groups
|
|
483
|
+
* (validated on put), R2 objects, and media assets (uploaded once or always, with the minted UUID
|
|
484
|
+
* recorded). The env allowlist is re-asserted per set at write time, so a set can never reach a
|
|
485
|
+
* disallowed environment.
|
|
486
|
+
*
|
|
487
|
+
* `redo` runs a full schema reset (every migration's `down`, then every `up` — {@link resetProject})
|
|
488
|
+
* ahead of the write loop, gated by the exact same confirmation as any other seed. It is not a
|
|
489
|
+
* per-fixture refresh: it destroys and recreates the whole schema the migration registry owns, so the
|
|
490
|
+
* ordinary non-destructive writes that follow simply land fresh — no row-identity logic needed.
|
|
491
|
+
*/
|
|
492
|
+
export async function seedProject(options: SeedProjectOptions): Promise<SeedRunReport> {
|
|
493
|
+
const workers = await resolveWorkerScopes({
|
|
494
|
+
projectDir: options.projectDir,
|
|
495
|
+
...(options.worker !== undefined ? { worker: options.worker } : {}),
|
|
496
|
+
...(options.workers !== undefined ? { workers: options.workers } : {}),
|
|
497
|
+
});
|
|
498
|
+
const composed = await composeFanOut(workers, options.env, options.includeExamples ?? false);
|
|
499
|
+
|
|
500
|
+
// Fail fast, before any write and across the whole fan-out, on a media fixture the seeder cannot re-run.
|
|
501
|
+
for (const entry of composed) assertMediaRecordsSupported(entry.sets);
|
|
502
|
+
|
|
503
|
+
// The reset preview and the reset itself both fan out over exactly the Workers this run targets.
|
|
504
|
+
// `project` rides along: a reset is the most destructive thing the CLI does, so it goes through the
|
|
505
|
+
// same ownership claim `pithy migrate` does — a database another project owns is refused by name,
|
|
506
|
+
// never rolled down. It is required here and non-empty by construction (`requireProjectName`), so it
|
|
507
|
+
// is spread unconditionally; `previewReset` reads no backend and ignores it.
|
|
508
|
+
const resetScope = {
|
|
509
|
+
projectDir: options.projectDir,
|
|
510
|
+
project: options.project,
|
|
511
|
+
account: options.account,
|
|
512
|
+
env: options.env,
|
|
513
|
+
...(options.worker !== undefined ? { worker: options.worker } : {}),
|
|
514
|
+
...(options.workers !== undefined ? { workers: options.workers } : {}),
|
|
515
|
+
...(options.remoteD1 ? { remoteD1: options.remoteD1 } : {}),
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
if (options.dryRun) {
|
|
519
|
+
const reports: SeedWorkerReport[] = [];
|
|
520
|
+
for (const entry of composed) {
|
|
521
|
+
const states = await resolveMediaStates(entry.sets, options.env, options.projectDir, options.mediaFs);
|
|
522
|
+
const plan = buildDryRunPlan(
|
|
523
|
+
options.env,
|
|
524
|
+
entry.sets,
|
|
525
|
+
(item) => states.get(item) ?? { action: item.mode === "always" ? "reupload" : "upload" },
|
|
526
|
+
);
|
|
527
|
+
reports.push({
|
|
528
|
+
worker: entry.worker.name,
|
|
529
|
+
sets: plan.sets,
|
|
530
|
+
skippedByEnv: entry.skippedByEnv,
|
|
531
|
+
shared: entry.shared,
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
const reset = options.redo ? await previewReset(resetScope) : undefined;
|
|
535
|
+
return {
|
|
536
|
+
command: "seed",
|
|
537
|
+
env: options.env,
|
|
538
|
+
dryRun: true,
|
|
539
|
+
workers: reports,
|
|
540
|
+
...(reset ? { reset } : {}),
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
await assertSeedConfirmed({
|
|
545
|
+
env: options.env,
|
|
546
|
+
yes: options.yes ?? false,
|
|
547
|
+
json: options.json ?? false,
|
|
548
|
+
confirmProduction: options.confirmProduction,
|
|
549
|
+
productionEnvironments: options.productionEnvironments,
|
|
550
|
+
prompt: options.prompt,
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
let reset: ResetPreviewEntry[] | undefined;
|
|
554
|
+
if (options.redo) {
|
|
555
|
+
// A reset destroys every row in every table the registry owns, so it carries its own, stricter gate:
|
|
556
|
+
// `--yes` authorizes an additive seed, never a drop (see `assertResetConfirmed`).
|
|
557
|
+
await assertResetConfirmed({
|
|
558
|
+
env: options.env,
|
|
559
|
+
json: options.json ?? false,
|
|
560
|
+
confirmReset: options.confirmReset,
|
|
561
|
+
prompt: options.promptReset,
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
// The numbers reported are the registry's static migration counts — identical before and after a
|
|
565
|
+
// successful reset, so one preview call serves both the report and (implicitly) the operation.
|
|
566
|
+
reset = await previewReset(resetScope);
|
|
567
|
+
|
|
568
|
+
const audit = options.audit ?? (async () => {});
|
|
569
|
+
// Recorded *after* the drop, truthfully: `outcome` reflects what actually happened, never intent.
|
|
570
|
+
// `critical` off dev — this is the most destructive thing the seeder can do.
|
|
571
|
+
const auditEvent = {
|
|
572
|
+
action: "seed/schema_reset" as const,
|
|
573
|
+
severity: options.env === "dev" ? ("warning" as const) : ("critical" as const),
|
|
574
|
+
resourceType: "schema" as const,
|
|
575
|
+
resourceId: options.env,
|
|
576
|
+
metadata: { databases: reset.map((entry) => entry.database) },
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
try {
|
|
580
|
+
await resetProject(resetScope);
|
|
581
|
+
} catch (error) {
|
|
582
|
+
await audit({ ...auditEvent, outcome: "failure" });
|
|
583
|
+
throw error;
|
|
584
|
+
}
|
|
585
|
+
await audit({ ...auditEvent, outcome: "success" });
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const prepareSeams = preparedRun(options, composed);
|
|
589
|
+
const reports: SeedWorkerReport[] = [];
|
|
590
|
+
for (const entry of composed) {
|
|
591
|
+
reports.push({
|
|
592
|
+
worker: entry.worker.name,
|
|
593
|
+
sets: entry.sets.length > 0 ? await writeWorker(entry, options, prepareSeams) : [],
|
|
594
|
+
skippedByEnv: entry.skippedByEnv,
|
|
595
|
+
shared: entry.shared,
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
return {
|
|
600
|
+
command: "seed",
|
|
601
|
+
env: options.env,
|
|
602
|
+
dryRun: false,
|
|
603
|
+
workers: reports,
|
|
604
|
+
...(reset ? { reset } : {}),
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** The machine-side seams a prepared set runs against, resolved once per run and shared by the fan-out. */
|
|
609
|
+
interface PreparedRun {
|
|
610
|
+
/** The developer's preferences, read lazily and at most once — no prepared set, no filesystem read. */
|
|
611
|
+
preferences: () => Promise<unknown>;
|
|
612
|
+
/**
|
|
613
|
+
* One Worker's pinned local origin, from this checkout's `.dev.config.json`. `null` off `dev`, and `null`
|
|
614
|
+
* for a Worker the allocation does not name. Per Worker, unlike everything else here, because an address
|
|
615
|
+
* is: two Workers of one fan-out bind two ports.
|
|
616
|
+
*/
|
|
617
|
+
origin: (worker: string) => Promise<string | null>;
|
|
618
|
+
/** Resolve a secret by name. */
|
|
619
|
+
secret: (name: string) => Promise<string | undefined>;
|
|
620
|
+
/** The rows this run declares, per table, across the whole fan-out. */
|
|
621
|
+
seeded: SeededRows;
|
|
622
|
+
/** Write one artifact. */
|
|
623
|
+
writeArtifact: (artifact: SeedArtifact) => Promise<void>;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** Bind the prepared-set seams to this run, defaulting each to the real machine. */
|
|
627
|
+
function preparedRun(options: SeedProjectOptions, composed: readonly ComposedWorker[]): PreparedRun {
|
|
628
|
+
const read = options.preferences ?? (() => readDevPreferences(options.project));
|
|
629
|
+
// Memoized, so two Workers in one fan-out cannot observe one hand-edited file in two states — and so a
|
|
630
|
+
// run with no prepared set never reads it at all.
|
|
631
|
+
let pending: Promise<unknown> | undefined;
|
|
632
|
+
// `.dev.config.json` is one file for the whole checkout, so it follows the same rule for the same reason.
|
|
633
|
+
// Lazy too: a run with no prepared set never opens it.
|
|
634
|
+
let dev: Promise<DevConfig | null> | undefined;
|
|
635
|
+
return {
|
|
636
|
+
preferences: () => (pending ??= read()),
|
|
637
|
+
// Read back, never recomposed. `buildDevConfig` mints `http://localhost:<port>` in exactly one place;
|
|
638
|
+
// composing it a second time here is a second rule, and two rules drift.
|
|
639
|
+
origin: async (worker) => {
|
|
640
|
+
// Only `dev` allocates a port. A deployed environment's address is declared, and `resolveWorkerAddress`
|
|
641
|
+
// is what answers it — inventing a localhost URL for staging would be a fixture pointing at nothing.
|
|
642
|
+
if (options.env !== LOCAL_ENVIRONMENT) return null;
|
|
643
|
+
// A corrupt or stale config answers `null` rather than failing the run, exactly as `scanPinnedBlocks`
|
|
644
|
+
// treats the same file. Seeding does not depend on this file: `@pithy-sh/auth`'s dev-session set ships
|
|
645
|
+
// by default, so almost every project has a prepared set, and a hand-edited trailing comma would
|
|
646
|
+
// otherwise abort `pithy seed` mid fan-out — after earlier Workers' rows had already landed — over a
|
|
647
|
+
// value most sets never read. Nothing is hidden by that: `pithy dev` genuinely cannot allocate ports
|
|
648
|
+
// without this file and still refuses loudly, and `null` is already the documented answer a set that
|
|
649
|
+
// needs an origin refuses on.
|
|
650
|
+
dev ??= readDevConfig(devConfigPath(options.projectDir)).catch(() => null);
|
|
651
|
+
const config = await dev;
|
|
652
|
+
// Keyed on the name `buildDevConfig` wrote and `pithy worker list` reads back.
|
|
653
|
+
return config?.workers[worker]?.origin ?? null;
|
|
654
|
+
},
|
|
655
|
+
// One inventory for the whole run, not one per Worker: a set deduped onto another Worker still writes
|
|
656
|
+
// its rows, so a prepared set must be able to see them wherever the fan-out put them.
|
|
657
|
+
seeded: collectSeededRows(composed.flatMap((entry) => entry.sets.map((resolved) => resolved.set))),
|
|
658
|
+
// The run's environment, not a claim about it: `devSecretReader` refuses to resolve anything unless
|
|
659
|
+
// this says `dev` (#159). The rule is inside the reader — this only tells it where the rows are going.
|
|
660
|
+
//
|
|
661
|
+
// The registry is the whole fan-out's, in one `aggregateSecretRegistries` call, because the seam is
|
|
662
|
+
// the run's and not one Worker's: a set deduped onto another Worker must resolve the same secret it
|
|
663
|
+
// would have resolved on its own. That is also the exact call each Worker makes at composition, so
|
|
664
|
+
// two Workers that declare one name incompatibly fail here saying so, rather than resolving to
|
|
665
|
+
// whichever Worker happened to be first — and the dev secrets file has one value per name anyway, so
|
|
666
|
+
// there is no answer to hand them.
|
|
667
|
+
secret:
|
|
668
|
+
options.secret ??
|
|
669
|
+
devSecretReader({
|
|
670
|
+
project: options.project,
|
|
671
|
+
env: options.env,
|
|
672
|
+
registry: aggregateSecretRegistries(composed.flatMap((entry) => entry.worker.capabilities)),
|
|
673
|
+
}),
|
|
674
|
+
writeArtifact:
|
|
675
|
+
options.writeArtifact ??
|
|
676
|
+
(async (artifact) => {
|
|
677
|
+
await writeSeedArtifact(options.projectDir, artifact);
|
|
678
|
+
}),
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Write one Worker's sets. The driver resolves that Worker's own bindings from its own `wrangler.jsonc`,
|
|
684
|
+
* while local persistence stays at the project root, so Workers sharing a binding write to one store.
|
|
685
|
+
* Opened only when the Worker has something to write — a Worker with no fixtures never builds a
|
|
686
|
+
* Miniflare instance and never reaches for credentials.
|
|
687
|
+
*/
|
|
688
|
+
async function writeWorker(
|
|
689
|
+
entry: ComposedWorker,
|
|
690
|
+
options: SeedProjectOptions,
|
|
691
|
+
prepared: PreparedRun,
|
|
692
|
+
): Promise<SeedPlanSet[]> {
|
|
693
|
+
const driver = await openSeedDriver({
|
|
694
|
+
workerDir: entry.worker.dir,
|
|
695
|
+
persistRoot: options.projectDir,
|
|
696
|
+
env: options.env,
|
|
697
|
+
account: options.account,
|
|
698
|
+
remoteD1: options.remoteD1,
|
|
699
|
+
remoteKv: options.remoteKv,
|
|
700
|
+
remoteR2: options.remoteR2,
|
|
701
|
+
images: options.images,
|
|
702
|
+
stream: options.stream,
|
|
703
|
+
});
|
|
704
|
+
try {
|
|
705
|
+
const databases = composeDatabases(entry.worker.capabilities);
|
|
706
|
+
const namespaces = composeKv(entry.worker.capabilities);
|
|
707
|
+
const uploader = options.mediaUploader ?? driverUploader(driver, { project: options.project, env: options.env });
|
|
708
|
+
const reportSets: SeedPlanSet[] = [];
|
|
709
|
+
|
|
710
|
+
for (const resolved of entry.sets) {
|
|
711
|
+
// Safety layer 1, re-asserted at the write site: a set can never land in a disallowed env.
|
|
712
|
+
assertSetAllowedForEnv(resolved.set, options.env);
|
|
713
|
+
const setReport: SeedPlanSet = { name: resolved.key, d1: [], kv: [], r2: [], media: [] };
|
|
714
|
+
|
|
715
|
+
// Late-bound fixtures, computed against this run before anything of this set is written. The groups
|
|
716
|
+
// it returns go through the identical validated path below — a prepared row is not a privileged row.
|
|
717
|
+
const preparation = resolved.set.prepare
|
|
718
|
+
? await resolved.set.prepare({
|
|
719
|
+
env: options.env,
|
|
720
|
+
project: options.project,
|
|
721
|
+
// The Worker this set is written through: its driver holds the rows and its bindings serve them,
|
|
722
|
+
// so its address is the only coherent one to hand a fixture. A set deduped across Workers that
|
|
723
|
+
// share a store is written once, and the writer is the honest answer — it is the Worker whose
|
|
724
|
+
// report lists the set, the others having recorded it in `shared`.
|
|
725
|
+
origin: await prepared.origin(entry.worker.name),
|
|
726
|
+
secret: prepared.secret,
|
|
727
|
+
preferences: await prepared.preferences(),
|
|
728
|
+
seeded: prepared.seeded,
|
|
729
|
+
})
|
|
730
|
+
: undefined;
|
|
731
|
+
const d1Groups: readonly D1SeedGroup[] = [...(resolved.set.d1 ?? []), ...(preparation?.d1 ?? [])];
|
|
732
|
+
const kvGroups: readonly KvSeedGroup[] = [...(resolved.set.kv ?? []), ...(preparation?.kv ?? [])];
|
|
733
|
+
|
|
734
|
+
for (const group of d1Groups) {
|
|
735
|
+
const dbGroup = databaseGroup(databases, group.database);
|
|
736
|
+
const schema = tableSchema(dbGroup.items, group.database, group.table);
|
|
737
|
+
const db = createDatabase(driver.d1(dbGroup.binding), dbGroup.items);
|
|
738
|
+
const result = await seedD1Group(db, group, schema);
|
|
739
|
+
setReport.d1.push({ database: group.database, table: result.table, rows: result.rows });
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
for (const group of kvGroups) {
|
|
743
|
+
const nsGroup = namespaces[group.namespace];
|
|
744
|
+
if (!nsGroup) {
|
|
745
|
+
throw new ValidationError({
|
|
746
|
+
message: `Seed targets KV namespace "${group.namespace}", which no capability declares.`,
|
|
747
|
+
action: `Declare "${group.namespace}" in a capability, or fix the fixture's namespace name.`,
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
const spec = nsGroup.items[group.store];
|
|
751
|
+
if (!spec) {
|
|
752
|
+
throw new ValidationError({
|
|
753
|
+
message: `Seed targets KV store "${group.store}" in "${group.namespace}", which no capability declares.`,
|
|
754
|
+
action: `Declare the "${group.store}" store, or fix the fixture's store name.`,
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
const target = driver.kv(nsGroup.binding);
|
|
758
|
+
const namespace = target.kind === "local" ? target.namespace : kvNamespaceFromManager(target.manager);
|
|
759
|
+
const store = new TypedKv(namespace, spec);
|
|
760
|
+
const result = await seedKvGroup(store, group, spec);
|
|
761
|
+
setReport.kv.push({ namespace: group.namespace, store: result.store, entries: result.entries });
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
for (const item of resolved.set.r2 ?? []) {
|
|
765
|
+
await writeR2Object(driver.r2(item.binding), item);
|
|
766
|
+
setReport.r2.push({ binding: item.binding, key: item.key });
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Relative media paths resolve against the set's own module dir; the project root is the fallback.
|
|
770
|
+
const mediaBaseDir = resolved.set.baseDir ?? options.projectDir;
|
|
771
|
+
for (const item of resolved.set.media ?? []) {
|
|
772
|
+
const result = await seedMediaItem(item, {
|
|
773
|
+
env: options.env,
|
|
774
|
+
baseDir: mediaBaseDir,
|
|
775
|
+
uploader,
|
|
776
|
+
fs: options.mediaFs,
|
|
777
|
+
});
|
|
778
|
+
if (item.record && result.id !== undefined) await writeMediaRecord(driver, databases, item.record, result.id);
|
|
779
|
+
setReport.media.push(mediaEntry(result));
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// Last, and only now: a run artifact describes rows that have landed. Written before the writes, a
|
|
783
|
+
// failed seed would leave behind a file promising a session that does not exist.
|
|
784
|
+
for (const artifact of preparation?.artifacts ?? []) await prepared.writeArtifact(artifact);
|
|
785
|
+
|
|
786
|
+
reportSets.push(setReport);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
return reportSets;
|
|
790
|
+
} finally {
|
|
791
|
+
await driver.dispose();
|
|
792
|
+
}
|
|
793
|
+
}
|