@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
|
@@ -0,0 +1,1066 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
7
|
+
import { CloudflareClients } from "@pithy-sh/cloudflare/src/client/clients";
|
|
8
|
+
import type { Capability } from "@pithy-sh/core/src/capability/capability";
|
|
9
|
+
import { composeDatabases } from "@pithy-sh/core/src/data/databases";
|
|
10
|
+
import { InternalError, NotFoundError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
11
|
+
import { claimMigrationOwnership } from "@pithy-sh/core/src/migrations/owner";
|
|
12
|
+
import { createMigrationRegistry, type NamespacedMigrations } from "@pithy-sh/core/src/migrations/registry";
|
|
13
|
+
import {
|
|
14
|
+
dropMigrations,
|
|
15
|
+
type MigrationLedger,
|
|
16
|
+
type MigrationTarget,
|
|
17
|
+
readMigrationLedger,
|
|
18
|
+
resetMigrations,
|
|
19
|
+
rollbackMigration,
|
|
20
|
+
runMigrations,
|
|
21
|
+
} from "@pithy-sh/core/src/migrations/runner";
|
|
22
|
+
import { partialWriteReport } from "@pithy-sh/secrets/src/cli/partialWrite";
|
|
23
|
+
import { parse } from "comment-json";
|
|
24
|
+
import type { Migration, MigrationProvider, MigrationResult } from "kysely/migration";
|
|
25
|
+
import { Miniflare } from "miniflare";
|
|
26
|
+
import { z } from "zod";
|
|
27
|
+
import { type CloudflareAccountSelection, cloudflareEnv } from "../cloudflare/config";
|
|
28
|
+
import { resolveWorkers } from "../project/workerScope";
|
|
29
|
+
import { wranglerConfigPath } from "../provision/featureConfig";
|
|
30
|
+
import { assertLedgerDeclared, UndeclaredMigration } from "./ledger";
|
|
31
|
+
import { collectMigrationSets } from "./registry";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `pithy migrate` fans out over Workers. There is no root Worker: every Worker lives in `apps/<name>/`
|
|
35
|
+
* with its own `pithy.config.ts` (its capabilities) and its own `wrangler.jsonc` (its bindings), so a
|
|
36
|
+
* migration run is one pass per Worker over that Worker's own registry.
|
|
37
|
+
*
|
|
38
|
+
* Two things follow from Workers sharing resources **by binding name**:
|
|
39
|
+
*
|
|
40
|
+
* - **Bindings are per-Worker, local state is per-project.** Each Worker's D1 bindings come from its own
|
|
41
|
+
* `wrangler.jsonc`, but the local Miniflare store stays at `<projectRoot>/.wrangler/state` — the one
|
|
42
|
+
* `wrangler dev` uses. Giving each Worker its own persistence directory would silently hand two Workers
|
|
43
|
+
* that both declare `DB` two different local databases.
|
|
44
|
+
* - **A shared database migrates once.** Workers whose entries resolve to the same D1 are grouped, their
|
|
45
|
+
* migration sets merged into one ordered provider, and that provider runs a single time. Each result is
|
|
46
|
+
* then credited back to the Worker whose capability declared it, so the report and `--json` stay truthful.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build the remote D1 for a binding — the REST-backed `D1Database` `pithy migrate --env staging`
|
|
51
|
+
* runs against. Injectable so tests substitute an in-memory D1 for the network client (issue #24's
|
|
52
|
+
* `CloudflareD1Manager` is tested separately).
|
|
53
|
+
*/
|
|
54
|
+
export type RemoteD1Factory = (args: { binding: string; databaseId: string }) => D1Database;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* One Worker in a fan-out: its name, its directory (where its `wrangler.jsonc` lives), and the
|
|
58
|
+
* capabilities its own `pithy.config.ts` composes. `ResolvedWorker` satisfies this structurally, so a
|
|
59
|
+
* caller that already resolved the set passes it straight through.
|
|
60
|
+
*/
|
|
61
|
+
export interface WorkerScope {
|
|
62
|
+
/** The Worker's name — its `wrangler.jsonc` name, else its `apps/<dir>` basename. */
|
|
63
|
+
name: string;
|
|
64
|
+
/** The Worker's directory: its `wrangler.jsonc` supplies the D1 bindings and their ids. */
|
|
65
|
+
dir: string;
|
|
66
|
+
/** The capabilities that Worker composes, libraries first and its app last. */
|
|
67
|
+
capabilities: Capability[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The fan-out every project-scoped migration entry point shares — which project root, which
|
|
72
|
+
* environment, which Workers. It carries no project **name**, because it is also what the read-only
|
|
73
|
+
* entry points take: {@link readProjectLedger} and {@link previewReset} inspect a database without
|
|
74
|
+
* writing to it, so they have no claim to make and `pithy doctor` may run them on a nameless project.
|
|
75
|
+
*/
|
|
76
|
+
export interface MigrationFanOutOptions {
|
|
77
|
+
/**
|
|
78
|
+
* The project root — the parent of `apps/`, and the owner of the `.wrangler/state` store every
|
|
79
|
+
* Worker's local D1 lives in.
|
|
80
|
+
*/
|
|
81
|
+
projectDir: string;
|
|
82
|
+
/**
|
|
83
|
+
* The Cloudflare account this project belongs to. A remote migration alters a real schema, so the
|
|
84
|
+
* wrong account's credentials would run it against another company's database (#206).
|
|
85
|
+
*
|
|
86
|
+
* **Required (#234).** `null` is the answer for a project that names no account, and it has to be
|
|
87
|
+
* written down: `account?:` let seven callers — `capabilities/flow.ts`, `capabilities/reconcile.ts`,
|
|
88
|
+
* `capabilities/remove.ts`, `feature/provision.ts`, `feature/create.ts`, `commands/feature.ts` and
|
|
89
|
+
* `seed/run.ts` — reach a live D1 without ever saying whose it was, and none of those omissions was
|
|
90
|
+
* visible in a diff. A `dev` run never touches the network, so `null` there costs nothing; a
|
|
91
|
+
* `--env staging` run is the one this exists for.
|
|
92
|
+
*/
|
|
93
|
+
account: CloudflareAccountSelection | null;
|
|
94
|
+
/** Target environment. `dev` runs locally via Miniflare; staging/prod run over the D1 REST API. */
|
|
95
|
+
env: string;
|
|
96
|
+
/** Narrow the fan-out to one Worker, by its name or its `apps/<dir>` basename. */
|
|
97
|
+
worker?: string;
|
|
98
|
+
/** Test seam: build the remote D1 for a binding instead of the default REST-backed client. */
|
|
99
|
+
remoteD1?: RemoteD1Factory;
|
|
100
|
+
/**
|
|
101
|
+
* The Workers to run against, already resolved. Skips `apps/` discovery for the run's **scope** — for a
|
|
102
|
+
* caller that resolved the set itself (`pithy add` hands over the one Worker it just wired), and for
|
|
103
|
+
* tests, whose fixture Workers carry no importable `pithy.config.ts`. The project is still discovered
|
|
104
|
+
* behind it, best effort, so a database one of these Workers shares with another migrates as a whole;
|
|
105
|
+
* what is reported and applied stays exactly this set.
|
|
106
|
+
*/
|
|
107
|
+
workers?: WorkerScope[];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The options for a run that **writes**: the fan-out, plus the project every database it touches is claimed for. */
|
|
111
|
+
export interface MigrateProjectOptions extends MigrationFanOutOptions {
|
|
112
|
+
/**
|
|
113
|
+
* The project this run belongs to — the root `pithy.config.ts` `name` (`requireProjectName`, never
|
|
114
|
+
* `resolveProjectName`). Every database the run touches is stamped with it on first use and checked
|
|
115
|
+
* against it afterwards, so a database another project owns is refused by name instead of silently
|
|
116
|
+
* merging two schemas (`claimMigrationOwnership`).
|
|
117
|
+
*
|
|
118
|
+
* **Required, on every entry point that can change a database.** It was optional once, and the result
|
|
119
|
+
* was a guard `pithy migrate` honored while `pithy add`, `pithy remove`, `pithy upgrade --migrate`,
|
|
120
|
+
* and the three `pithy feature` paths quietly wrote unstamped — a database no project owns is one any
|
|
121
|
+
* project may later claim. A caller that cannot resolve a stable name has no business writing here.
|
|
122
|
+
*/
|
|
123
|
+
project: string;
|
|
124
|
+
/** Step the latest applied migration back instead of running forward. */
|
|
125
|
+
rollback?: boolean;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A reset never rolls back — it rolls *everything* back, then reapplies everything. */
|
|
129
|
+
export type ResetProjectOptions = Omit<MigrateProjectOptions, "rollback">;
|
|
130
|
+
|
|
131
|
+
/** One database's migration run: which database, its binding, and what Kysely did. */
|
|
132
|
+
export interface DatabaseRun {
|
|
133
|
+
/** The database name (a capability's `databases` key). */
|
|
134
|
+
database: string;
|
|
135
|
+
/** The D1 binding it resolves to in this Worker's `wrangler.jsonc`. */
|
|
136
|
+
binding: string;
|
|
137
|
+
/**
|
|
138
|
+
* The migrations this Worker's capabilities contributed, and how each fared. A shared database always
|
|
139
|
+
* migrates as a whole — a run narrowed to one Worker still applies what the Workers it shares with
|
|
140
|
+
* contributed — but every result is credited to the Worker that declared it, so a row never claims a
|
|
141
|
+
* migration its own capabilities do not own.
|
|
142
|
+
*/
|
|
143
|
+
results: MigrationResult[];
|
|
144
|
+
/**
|
|
145
|
+
* The other Workers bound to this same physical D1. Present only when a database is shared: the
|
|
146
|
+
* merged registry ran once for all of them, and each Worker is credited with its own migrations.
|
|
147
|
+
*/
|
|
148
|
+
sharedWith?: string[];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** One Worker's slice of a fan-out run: the Worker, and each database its registry touched. */
|
|
152
|
+
export interface WorkerMigrationRun {
|
|
153
|
+
/** The Worker's name. */
|
|
154
|
+
worker: string;
|
|
155
|
+
/** Its databases, in registry order. Empty when the Worker composes no migrations. */
|
|
156
|
+
databases: DatabaseRun[];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** One Worker's claim on one database: the sets it contributes, and the binding it declares. */
|
|
160
|
+
interface WorkerPlanEntry {
|
|
161
|
+
/** The Worker's name. */
|
|
162
|
+
worker: string;
|
|
163
|
+
/** The database name this Worker knows it by. */
|
|
164
|
+
database: string;
|
|
165
|
+
/** The D1 binding in this Worker's `wrangler.jsonc`. */
|
|
166
|
+
binding: string;
|
|
167
|
+
/** This Worker's migration sets for that database. */
|
|
168
|
+
sets: NamespacedMigrations[];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* One physical D1 and everything that migrates into it — the unit a run actually executes.
|
|
173
|
+
*
|
|
174
|
+
* Exported for {@link ./ledger}, which compares each group's provider against the ledger of the database
|
|
175
|
+
* this same run is about to write to. A type-only import there, so the two files do not form a cycle.
|
|
176
|
+
*/
|
|
177
|
+
export interface DatabaseGroup {
|
|
178
|
+
/** The resolved D1 identity every entry shares: locally the binding/name fallback, remotely the `database_id`. */
|
|
179
|
+
id: string;
|
|
180
|
+
/** The first entry's database name — the group's label in the report. */
|
|
181
|
+
database: string;
|
|
182
|
+
/** The first entry's binding — the group's label, and what the remote factory is told. */
|
|
183
|
+
binding: string;
|
|
184
|
+
/** The remote `database_id`, when the target env's stanza declares one. */
|
|
185
|
+
databaseId?: string;
|
|
186
|
+
/** Every Worker claim on this database, in Worker order. */
|
|
187
|
+
entries: WorkerPlanEntry[];
|
|
188
|
+
/** The merged, ordered provider — every contributing Worker's sets in one registry. */
|
|
189
|
+
provider: MigrationProvider;
|
|
190
|
+
/** Composed migration name → the Worker credited with it. */
|
|
191
|
+
owners: Map<string, string>;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** A resolved set of D1s to migrate — the only thing that differs between local and remote — plus teardown. */
|
|
195
|
+
export interface MigrationDriver {
|
|
196
|
+
/** The D1 for a group; every group is resolved before the run loop reads it. */
|
|
197
|
+
database(group: DatabaseGroup): D1Database;
|
|
198
|
+
/** Release the driver's resources (Miniflare instance locally; a no-op remotely). */
|
|
199
|
+
dispose(): Promise<void>;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* One D1 binding entry in wrangler.jsonc — the fields migrate reads to resolve a database's id.
|
|
204
|
+
* `database_name` is not among them: wrangler ignores it when binding a local D1 (see {@link idsFor}),
|
|
205
|
+
* so migrate must ignore it too. Leaving it off the type makes reading it a compile error.
|
|
206
|
+
*/
|
|
207
|
+
interface D1Binding {
|
|
208
|
+
binding: string;
|
|
209
|
+
database_id?: string;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The wrangler.jsonc slice migrate reads: the local (top-level) bindings and each env's bindings. */
|
|
213
|
+
interface WranglerD1Config {
|
|
214
|
+
d1_databases?: D1Binding[];
|
|
215
|
+
env?: Record<string, { d1_databases?: D1Binding[] } | undefined>;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Read and parse the config that describes this Worker in this environment. A missing file yields an
|
|
220
|
+
* empty config.
|
|
221
|
+
*
|
|
222
|
+
* Through {@link wranglerConfigPath}, which is what makes a feature environment read the generated
|
|
223
|
+
* config under `.wrangler/` rather than the tracked one (#242) — provisioning writes a feature's ids
|
|
224
|
+
* there, so reading `wrangler.jsonc` here would find no `env.feature` stanza and refuse the migrate.
|
|
225
|
+
*/
|
|
226
|
+
async function readWranglerConfig(workerDir: string, env: string): Promise<WranglerD1Config> {
|
|
227
|
+
try {
|
|
228
|
+
const raw = await readFile(wranglerConfigPath(workerDir, env), "utf8");
|
|
229
|
+
return parse(raw) as unknown as WranglerD1Config;
|
|
230
|
+
} catch {
|
|
231
|
+
return {};
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Map binding → id from a set of D1 entries. Locally the id is `database_id` else the binding — the
|
|
237
|
+
* exact chain wrangler's `d1DatabaseEntry({ binding, database_id, preview_database_id })` uses to key
|
|
238
|
+
* the Miniflare store (`getRemoteId(preview_database_id ?? database_id) ?? binding`). **`database_name`
|
|
239
|
+
* is deliberately absent**: wrangler does not read it for the local binding at all, so folding it in
|
|
240
|
+
* would migrate a store `pithy dev` never opens — and `pithy add` writes exactly that stanza
|
|
241
|
+
* (`database_name`, no id), so every request would fail `D1_ERROR: no such table`. Remotely (`idOnly`)
|
|
242
|
+
* only a real `database_id` counts, so a binding with none is caught as an actionable error, not run
|
|
243
|
+
* against the wrong database.
|
|
244
|
+
*/
|
|
245
|
+
function idsFor(entries: D1Binding[] | undefined, idOnly = false): Map<string, string> {
|
|
246
|
+
const ids = new Map<string, string>();
|
|
247
|
+
for (const entry of entries ?? []) {
|
|
248
|
+
const value = idOnly ? entry.database_id : (entry.database_id ?? entry.binding);
|
|
249
|
+
if (value) ids.set(entry.binding, value);
|
|
250
|
+
}
|
|
251
|
+
return ids;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* One Worker's claims: every database it has migrations for, resolved to the binding its own
|
|
256
|
+
* capabilities declare. Rejects two databases sharing one binding within a Worker (they would migrate
|
|
257
|
+
* against a single physical D1 store, colliding on data and migration bookkeeping) — that is a wiring
|
|
258
|
+
* mistake, unlike two *Workers* sharing a binding, which is how they share a resource.
|
|
259
|
+
*/
|
|
260
|
+
function buildPlan(worker: WorkerScope): WorkerPlanEntry[] {
|
|
261
|
+
const databases = composeDatabases(worker.capabilities);
|
|
262
|
+
const byDatabase = new Map<string, NamespacedMigrations[]>();
|
|
263
|
+
for (const set of collectMigrationSets(worker.capabilities)) {
|
|
264
|
+
const existing = byDatabase.get(set.database);
|
|
265
|
+
if (existing) existing.push(set);
|
|
266
|
+
else byDatabase.set(set.database, [set]);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const plan: WorkerPlanEntry[] = [];
|
|
270
|
+
const bindingOwner = new Map<string, string>();
|
|
271
|
+
for (const [database, sets] of byDatabase) {
|
|
272
|
+
const group = databases[database];
|
|
273
|
+
if (!group) {
|
|
274
|
+
throw new InternalError({
|
|
275
|
+
message: `Database "${database}" has migrations but no capability declares it.`,
|
|
276
|
+
action: "Declare the database (binding and tables) in a capability. Run pithy migrate again.",
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
const owner = bindingOwner.get(group.binding);
|
|
280
|
+
if (owner) {
|
|
281
|
+
throw new InternalError({
|
|
282
|
+
message: `Databases "${owner}" and "${database}" are both bound to "${group.binding}".`,
|
|
283
|
+
action: "Give each database its own binding. Run pithy migrate again.",
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
bindingOwner.set(group.binding, database);
|
|
287
|
+
plan.push({ worker: worker.name, database, binding: group.binding, sets });
|
|
288
|
+
}
|
|
289
|
+
return plan;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Whether two entries under one key are the same migration — identity, not resemblance. A capability
|
|
294
|
+
* ships its migrations as module-level objects (`multiplayer_0001_results`), so composing one capability
|
|
295
|
+
* into two Workers hands both the *same* object even when the capability itself is built per Worker by a
|
|
296
|
+
* factory; a spread that rebuilds the record still carries the same `up`/`down`. Two capabilities that
|
|
297
|
+
* merely agree on a key name do not, which is the whole point: `0001_init` is the canonical first key, so
|
|
298
|
+
* a name-only test calls two unrelated migrations identical and silently drops one.
|
|
299
|
+
*/
|
|
300
|
+
function sameMigration(a: Migration, b: Migration): boolean {
|
|
301
|
+
return a === b || (a.up === b.up && a.down === b.down);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Whether two sets carry the same migrations — the test for "the same capability, composed twice".
|
|
306
|
+
* Same order, same keys, and the same migration behind every key: anything else is two different sets
|
|
307
|
+
* wearing one namespace, which the caller rejects rather than silently dropping one.
|
|
308
|
+
*/
|
|
309
|
+
function sameMigrations(a: NamespacedMigrations, b: NamespacedMigrations): boolean {
|
|
310
|
+
if (a.order !== b.order) return false;
|
|
311
|
+
const left = Object.keys(a.migrations).sort();
|
|
312
|
+
const right = Object.keys(b.migrations).sort();
|
|
313
|
+
if (left.length !== right.length) return false;
|
|
314
|
+
return left.every((key, index) => {
|
|
315
|
+
if (key !== right[index]) return false;
|
|
316
|
+
const one = a.migrations[key];
|
|
317
|
+
const two = b.migrations[key];
|
|
318
|
+
return one !== undefined && two !== undefined && sameMigration(one, two);
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Merge every contributing Worker's sets for one physical D1 into a single ordered provider, and record
|
|
324
|
+
* which Worker each composed migration name belongs to.
|
|
325
|
+
*
|
|
326
|
+
* A namespace claimed by two Workers is the *same capability* composed into both — dedupe it, so a
|
|
327
|
+
* shared database migrates once. A namespace claimed twice with **different** migrations is two
|
|
328
|
+
* different capabilities wearing one name: their composed keys would collide in the ledger, so that
|
|
329
|
+
* fails loudly rather than letting one silently shadow the other.
|
|
330
|
+
*/
|
|
331
|
+
async function mergeGroup(
|
|
332
|
+
database: string,
|
|
333
|
+
binding: string,
|
|
334
|
+
entries: WorkerPlanEntry[],
|
|
335
|
+
): Promise<{ provider: MigrationProvider; owners: Map<string, string> }> {
|
|
336
|
+
const kept = new Map<string, { set: NamespacedMigrations; worker: string }>();
|
|
337
|
+
for (const entry of entries) {
|
|
338
|
+
for (const set of entry.sets) {
|
|
339
|
+
const prior = kept.get(set.namespace);
|
|
340
|
+
if (!prior) {
|
|
341
|
+
kept.set(set.namespace, { set, worker: entry.worker });
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
if (sameMigrations(prior.set, set)) continue;
|
|
345
|
+
throw new InternalError({
|
|
346
|
+
message: `Workers "${prior.worker}" and "${entry.worker}" both migrate "${binding}" under the "${set.namespace}" namespace, with different migrations.`,
|
|
347
|
+
action: "Rename one capability, or bind the workers to different databases. Run pithy migrate again.",
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const sets = [...kept.values()].map(({ set }) => ({ ...set, database }));
|
|
353
|
+
const provider = createMigrationRegistry(sets)[database] ?? { getMigrations: async () => ({}) };
|
|
354
|
+
|
|
355
|
+
// Credit each composed name to its Worker by composing that one set through the same registry, so the
|
|
356
|
+
// key format stays core's single definition rather than a copy that can drift.
|
|
357
|
+
const owners = new Map<string, string>();
|
|
358
|
+
for (const { set, worker } of kept.values()) {
|
|
359
|
+
const single = createMigrationRegistry([{ ...set, database }])[database];
|
|
360
|
+
for (const name of Object.keys((await single?.getMigrations()) ?? {})) owners.set(name, worker);
|
|
361
|
+
}
|
|
362
|
+
return { provider, owners };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Group every Worker's claims by the physical D1 they resolve to, and merge each group's registries.
|
|
367
|
+
* Grouping is by resolved id — locally `database_id` else the binding (wrangler's own chain, see
|
|
368
|
+
* {@link idsFor}), remotely the env stanza's `database_id` — falling back to the binding name when no id
|
|
369
|
+
* is declared, since Workers share a resource precisely by declaring the same binding.
|
|
370
|
+
*
|
|
371
|
+
* `workers` is always the **complete** set, never the run's narrowed scope: a database is migrated as a
|
|
372
|
+
* whole, so every Worker bound to it must be in its group even when only one of them is being reported
|
|
373
|
+
* (see {@link scopedGroups}). `scope` names the Workers the run actually targets — a Worker outside it
|
|
374
|
+
* that has no stanza for a non-`dev` env has never migrated there, so it is skipped rather than failing
|
|
375
|
+
* a run that never asked about it.
|
|
376
|
+
*/
|
|
377
|
+
async function buildGroups(workers: WorkerScope[], env: string, scope: Set<string>): Promise<DatabaseGroup[]> {
|
|
378
|
+
const ordered: { id: string; database: string; binding: string; databaseId?: string; entries: WorkerPlanEntry[] }[] =
|
|
379
|
+
[];
|
|
380
|
+
const byId = new Map<string, (typeof ordered)[number]>();
|
|
381
|
+
|
|
382
|
+
for (const worker of workers) {
|
|
383
|
+
const plan = buildPlan(worker);
|
|
384
|
+
if (plan.length === 0) continue;
|
|
385
|
+
const config = await readWranglerConfig(worker.dir, env);
|
|
386
|
+
if (env !== "dev" && !config.env?.[env]) {
|
|
387
|
+
if (!scope.has(worker.name)) continue;
|
|
388
|
+
throw new ValidationError({
|
|
389
|
+
message: `${worker.name}: wrangler.jsonc has no env.${env} stanza.`,
|
|
390
|
+
action: `Add the ${env} environment to ${worker.name}'s wrangler.jsonc with its D1 bindings. Run pithy migrate --env ${env} again.`,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
const local = idsFor(config.d1_databases);
|
|
394
|
+
const remote = idsFor(config.env?.[env]?.d1_databases, true);
|
|
395
|
+
|
|
396
|
+
for (const entry of plan) {
|
|
397
|
+
const databaseId = remote.get(entry.binding);
|
|
398
|
+
const id = env === "dev" ? (local.get(entry.binding) ?? entry.binding) : (databaseId ?? entry.binding);
|
|
399
|
+
const existing = byId.get(id);
|
|
400
|
+
if (existing) {
|
|
401
|
+
existing.entries.push(entry);
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
const group = {
|
|
405
|
+
id,
|
|
406
|
+
database: entry.database,
|
|
407
|
+
binding: entry.binding,
|
|
408
|
+
...(databaseId !== undefined ? { databaseId } : {}),
|
|
409
|
+
entries: [entry],
|
|
410
|
+
};
|
|
411
|
+
byId.set(id, group);
|
|
412
|
+
ordered.push(group);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const groups: DatabaseGroup[] = [];
|
|
417
|
+
for (const group of ordered) {
|
|
418
|
+
const { provider, owners } = await mergeGroup(group.database, group.binding, group.entries);
|
|
419
|
+
groups.push({ ...group, provider, owners });
|
|
420
|
+
}
|
|
421
|
+
return groups;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* The local driver: the same D1 store `wrangler dev` uses, under `<projectRoot>/.wrangler/state`, via
|
|
426
|
+
* Miniflare. Persistence is deliberately project-scoped, not Worker-scoped — two Workers that declare
|
|
427
|
+
* the same binding are meant to share one local database. Each group gets a synthetic Miniflare binding
|
|
428
|
+
* (groups are keyed by id, and two Workers can name one id under one binding), so the persistence key
|
|
429
|
+
* is the resolved id and nothing else. Miniflare needs a script even though no request runs — migrations
|
|
430
|
+
* go through the D1 bindings alone.
|
|
431
|
+
*/
|
|
432
|
+
async function localDriver(persistRoot: string, groups: DatabaseGroup[]): Promise<MigrationDriver> {
|
|
433
|
+
const bindings: Record<string, string> = {};
|
|
434
|
+
groups.forEach((group, index) => {
|
|
435
|
+
bindings[`d1_${index}`] = group.id;
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
const miniflare = new Miniflare({
|
|
439
|
+
modules: true,
|
|
440
|
+
script: "export default {};",
|
|
441
|
+
d1Databases: bindings,
|
|
442
|
+
d1Persist: join(persistRoot, ".wrangler", "state", "v3", "d1"),
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
const cache = new Map<string, D1Database>();
|
|
446
|
+
for (const [index, group] of groups.entries()) {
|
|
447
|
+
cache.set(group.id, (await miniflare.getD1Database(`d1_${index}`)) as unknown as D1Database);
|
|
448
|
+
}
|
|
449
|
+
return { database: (group) => databaseOf(cache, group), dispose: () => miniflare.dispose() };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** The D1 for a group, or an internal error — the driver populates every group up front. */
|
|
453
|
+
function databaseOf(cache: Map<string, D1Database>, group: DatabaseGroup): D1Database {
|
|
454
|
+
const database = cache.get(group.id);
|
|
455
|
+
if (!database) throw new InternalError({ detail: `No D1 resolved for binding "${group.binding}".` });
|
|
456
|
+
return database;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Build the default REST-backed D1 factory: read CF creds from the environment (the project root's
|
|
461
|
+
* `.dev.vars` — one shared file for the whole repo — then `process.env`) and back each binding with a
|
|
462
|
+
* `@pithy-sh/cloudflare` D1 client (a shared client memoizes managers by database id). Only ever called
|
|
463
|
+
* for the real REST path; an injected `override` replaces it wholesale.
|
|
464
|
+
*/
|
|
465
|
+
function defaultRemoteD1(env: string, account: CloudflareAccountSelection | null): RemoteD1Factory {
|
|
466
|
+
const vars = cloudflareEnv({ account });
|
|
467
|
+
const accountId = vars.CLOUDFLARE_ACCOUNT_ID ?? "";
|
|
468
|
+
// The active CF token: the bootstrap token locally, or the least-privilege `ci-system` token in CI
|
|
469
|
+
// (an operator mints it with `pithy token mint ci-system` and sets it as CI's CLOUDFLARE_API_TOKEN).
|
|
470
|
+
const apiToken = vars.CLOUDFLARE_API_TOKEN ?? "";
|
|
471
|
+
if (!accountId || !apiToken) {
|
|
472
|
+
throw new ValidationError({
|
|
473
|
+
message: "Cloudflare credentials are missing.",
|
|
474
|
+
action: `Set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN to migrate --env ${env}.`,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
const clients = new CloudflareClients({ accountId, apiToken });
|
|
478
|
+
return ({ databaseId }) => clients.d1(databaseId) as unknown as D1Database;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* The remote driver: the same migration registries, executed over the D1 REST API through
|
|
483
|
+
* `@pithy-sh/cloudflare`. Each group's remote id comes from the owning Worker's `wrangler.jsonc` env
|
|
484
|
+
* block. The REST-backed D1 is the *only* thing that differs from local; the registries, ordering, and
|
|
485
|
+
* per-database runs are shared. An injected `override` — a test's in-memory D1 — replaces the REST
|
|
486
|
+
* client wholesale, so credentials are demanded only for the real path (mirrors the seed driver, which
|
|
487
|
+
* is likewise credential-lazy): substituting the network client must never require ambient CF creds.
|
|
488
|
+
*/
|
|
489
|
+
function remoteDriver(
|
|
490
|
+
env: string,
|
|
491
|
+
groups: DatabaseGroup[],
|
|
492
|
+
account: CloudflareAccountSelection | null,
|
|
493
|
+
override?: RemoteD1Factory,
|
|
494
|
+
): MigrationDriver {
|
|
495
|
+
// Build the REST-backed D1 per binding; the default reaches for creds, an override bypasses them.
|
|
496
|
+
const resolveD1: RemoteD1Factory = override ?? defaultRemoteD1(env, account);
|
|
497
|
+
|
|
498
|
+
const cache = new Map<string, D1Database>();
|
|
499
|
+
for (const group of groups) {
|
|
500
|
+
if (!group.databaseId) {
|
|
501
|
+
throw new ValidationError({
|
|
502
|
+
message: `wrangler.jsonc env.${env} has no database_id for the "${group.binding}" binding.`,
|
|
503
|
+
action: `Provision the ${env} D1 and set its id on ${group.binding}. Run pithy migrate --env ${env} again.`,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
cache.set(group.id, resolveD1({ binding: group.binding, databaseId: group.databaseId }));
|
|
507
|
+
}
|
|
508
|
+
return { database: (group) => databaseOf(cache, group), dispose: async () => {} };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Everything a run needs once the Workers are resolved: where state lives, and how D1 is reached. */
|
|
512
|
+
interface RunContext {
|
|
513
|
+
/** The Workers in the fan-out, in report order — the run's scope, already narrowed by `--worker`. */
|
|
514
|
+
workers: WorkerScope[];
|
|
515
|
+
/**
|
|
516
|
+
* Every Worker a database in scope may be shared with — the set each group's merged provider is built
|
|
517
|
+
* from. It is the whole project, not the narrowed scope: the ledger of a shared D1 records both
|
|
518
|
+
* Workers' migrations, so a provider covering only one of them reads as corrupted state to Kysely.
|
|
519
|
+
*/
|
|
520
|
+
groupWorkers: WorkerScope[];
|
|
521
|
+
/** The project root whose `.wrangler/state` holds the local D1 every Worker shares. */
|
|
522
|
+
persistRoot: string;
|
|
523
|
+
/**
|
|
524
|
+
* The Cloudflare account this project belongs to, carried from the run's options. A remote migration
|
|
525
|
+
* alters a real schema, so the wrong account's credentials would run it against another company's
|
|
526
|
+
* database (#206).
|
|
527
|
+
*/
|
|
528
|
+
account: CloudflareAccountSelection | null;
|
|
529
|
+
/** Target environment. */
|
|
530
|
+
env: string;
|
|
531
|
+
/** The owning project every touched database is stamped with and checked against. Optional. */
|
|
532
|
+
project?: string;
|
|
533
|
+
/** Test seam for the remote D1 client. */
|
|
534
|
+
remoteD1?: RemoteD1Factory;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Pick the driver for the target environment: Miniflare for `dev`, the D1 REST API otherwise. */
|
|
538
|
+
function driverFor(context: RunContext, groups: DatabaseGroup[]): Promise<MigrationDriver> {
|
|
539
|
+
return context.env === "dev"
|
|
540
|
+
? localDriver(context.persistRoot, groups)
|
|
541
|
+
: Promise.resolve(remoteDriver(context.env, groups, context.account, context.remoteD1));
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* The fan-out set: the caller's pre-resolved Workers, else discovery over `apps/`, narrowed by `worker`.
|
|
546
|
+
* Exported because `pithy seed` fans out over exactly the same set, under exactly the same rules — one
|
|
547
|
+
* definition of "which Workers does this command act on" for both data-plane commands.
|
|
548
|
+
*/
|
|
549
|
+
export async function resolveWorkerScopes(options: {
|
|
550
|
+
/** The project root — the parent of `apps/`. */
|
|
551
|
+
projectDir: string;
|
|
552
|
+
/** Narrow to one Worker, by its name or its `apps/<dir>` basename. */
|
|
553
|
+
worker?: string;
|
|
554
|
+
/** Pre-resolved Workers, skipping `apps/` discovery. */
|
|
555
|
+
workers?: WorkerScope[];
|
|
556
|
+
}): Promise<WorkerScope[]> {
|
|
557
|
+
if (!options.workers) {
|
|
558
|
+
return resolveWorkers({
|
|
559
|
+
projectDir: options.projectDir,
|
|
560
|
+
...(options.worker !== undefined ? { worker: options.worker } : {}),
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
if (options.worker === undefined) return options.workers;
|
|
564
|
+
const found = options.workers.filter(
|
|
565
|
+
(candidate) => candidate.name === options.worker || candidate.dir.endsWith(`/${options.worker}`),
|
|
566
|
+
);
|
|
567
|
+
if (found.length === 0) {
|
|
568
|
+
throw new NotFoundError({
|
|
569
|
+
message: `No worker named "${options.worker}".`,
|
|
570
|
+
action: `Run pithy worker list to see this project's workers. Known: ${options.workers.map((w) => w.name).join(", ")}.`,
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
return found;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** An empty report row per Worker, in fan-out order — a Worker with no migrations still appears. */
|
|
577
|
+
function emptyReport(workers: WorkerScope[]): WorkerMigrationRun[] {
|
|
578
|
+
return workers.map((worker) => ({ worker: worker.name, databases: [] }));
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Fold one group's run into the per-Worker report: each result goes to the Worker whose capability
|
|
583
|
+
* declared it, and a database shared by several Workers names the others on every row.
|
|
584
|
+
*/
|
|
585
|
+
function record(report: WorkerMigrationRun[], group: DatabaseGroup, results: MigrationResult[]): void {
|
|
586
|
+
for (const entry of group.entries) {
|
|
587
|
+
const row = report.find((candidate) => candidate.worker === entry.worker);
|
|
588
|
+
if (!row) continue;
|
|
589
|
+
const others = group.entries.filter((other) => other.worker !== entry.worker).map((other) => other.worker);
|
|
590
|
+
row.databases.push({
|
|
591
|
+
database: entry.database,
|
|
592
|
+
binding: entry.binding,
|
|
593
|
+
results: results.filter((result) => group.owners.get(result.migrationName) === entry.worker),
|
|
594
|
+
...(others.length > 0 ? { sharedWith: others } : {}),
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* The groups a run touches: every database at least one in-scope Worker claims, each carrying **every**
|
|
601
|
+
* Worker bound to it. Narrowing (`--worker`, and every `pithy add`/`remove`/`upgrade --migrate`, which
|
|
602
|
+
* always scope to one Worker) narrows what is reported and which databases are visited — never the
|
|
603
|
+
* registry a visited database runs. A shared D1's ledger holds both Workers' migrations, so a provider
|
|
604
|
+
* missing one of them is corrupted state to Kysely and the run aborts.
|
|
605
|
+
*/
|
|
606
|
+
async function scopedGroups(context: RunContext): Promise<DatabaseGroup[]> {
|
|
607
|
+
const scope = new Set(context.workers.map((worker) => worker.name));
|
|
608
|
+
const groups = await buildGroups(context.groupWorkers, context.env, scope);
|
|
609
|
+
return groups.filter((group) => group.entries.some((entry) => scope.has(entry.worker)));
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Claim every database in the run for this project **before any of them is written to**. The stamp
|
|
614
|
+
* lives in the migration bookkeeping, so the first run adopts a database and every later one checks
|
|
615
|
+
* it; a database another project owns throws, naming both. Doing the whole pass up front is the point
|
|
616
|
+
* — a foreign database in the set aborts the run rather than being discovered halfway through, with
|
|
617
|
+
* some of the project's databases already moved.
|
|
618
|
+
*
|
|
619
|
+
* **This is the single choke point, and it refuses rather than shrugs.** Every entry point that can
|
|
620
|
+
* change a database — forward, rollback, reset, capability drop — runs through {@link runGroups} and so
|
|
621
|
+
* through here, which is why the check lives at this one line instead of at each of the eight callers.
|
|
622
|
+
* It used to return quietly on a missing project, and that is precisely how the guard shipped honored
|
|
623
|
+
* by two commands and ignored by six. A nameless run is now impossible to *reach* the write with.
|
|
624
|
+
*/
|
|
625
|
+
async function claimGroups(context: RunContext, driver: MigrationDriver, groups: DatabaseGroup[]): Promise<void> {
|
|
626
|
+
const project = context.project;
|
|
627
|
+
if (project === undefined) {
|
|
628
|
+
throw new ValidationError({
|
|
629
|
+
message: "A migration run needs a project name.",
|
|
630
|
+
action:
|
|
631
|
+
"Set `name` in pithy.config.ts. It stamps each database as this project's, so another project's database is refused instead of silently merged.",
|
|
632
|
+
detail: `No project on a run over ${groups.map((group) => group.binding).join(", ") || "no databases"}.`,
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
for (const group of groups) {
|
|
636
|
+
await claimMigrationOwnership(driver.database(group), { project, binding: group.binding });
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* One pass over every database in a run: what to do to each, and whether the provider doing it speaks
|
|
642
|
+
* for the whole ledger.
|
|
643
|
+
*/
|
|
644
|
+
interface MigrationPass {
|
|
645
|
+
/** The work itself. The target rides along so a failure names the database it failed on (#282). */
|
|
646
|
+
execute: (database: D1Database, provider: MigrationProvider, target: MigrationTarget) => Promise<MigrationResult[]>;
|
|
647
|
+
/**
|
|
648
|
+
* Whether the provider carries every migration the ledger could hold.
|
|
649
|
+
*
|
|
650
|
+
* True for migrate, rollback and reset, which run the whole composed registry — so a ledger row the
|
|
651
|
+
* provider does not carry is a migration this project no longer declares, and the run is refused
|
|
652
|
+
* before it writes. False for `pithy remove --drop`, whose provider is *deliberately* one capability's
|
|
653
|
+
* migrations against a database full of other capabilities' rows: to it every other row is
|
|
654
|
+
* undeclared, and a check here would refuse the command it exists to serve.
|
|
655
|
+
*/
|
|
656
|
+
spansLedger: boolean;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* What a fan-out changed before it stopped, in the three states a database in scope can be left in
|
|
661
|
+
* (#380).
|
|
662
|
+
*
|
|
663
|
+
* A run visits one database at a time and each one is a **write**. The pass on the third throws, the
|
|
664
|
+
* first two have already moved their schema, and the return value that would have named them never
|
|
665
|
+
* happens — so the operator is told a migration failed and nothing at all about which databases are now
|
|
666
|
+
* ahead of the others. That is the record you need most when a run dies partway, and it was the one the
|
|
667
|
+
* throw took with it.
|
|
668
|
+
*
|
|
669
|
+
* The three fields are three different facts and they share no entry, which is the point: `migrated` is
|
|
670
|
+
* what definitely ran, `failed` is the one database whose pass threw, and `unreached` is every database
|
|
671
|
+
* the run never opened. An empty `unreached` means the failure was on the last database — never "no
|
|
672
|
+
* scan". Absent and unknown do not collapse into one another here.
|
|
673
|
+
*
|
|
674
|
+
* **Nothing derived from the failure is in it.** The report names databases and bindings; what a
|
|
675
|
+
* migration throws names a statement, a table, or an id, and that stays on the error the operator is
|
|
676
|
+
* already reading.
|
|
677
|
+
*/
|
|
678
|
+
export interface MigrationProgress {
|
|
679
|
+
/**
|
|
680
|
+
* Per-Worker rows for every database whose pass **completed**, in fan-out order — exactly the report a
|
|
681
|
+
* finished run returns, truncated at the failure. A Worker whose databases were all unreached appears
|
|
682
|
+
* with an empty `databases`, as it does in a clean run.
|
|
683
|
+
*/
|
|
684
|
+
migrated: WorkerMigrationRun[];
|
|
685
|
+
/** The database the run died on. Its schema is in whatever state the failed pass left it. */
|
|
686
|
+
failed: MigrationTarget;
|
|
687
|
+
/** Every database in scope this run never opened, in fan-out order. Empty means the failure was the last one. */
|
|
688
|
+
unreached: MigrationTarget[];
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** A carried value arrives as `unknown`; this is the narrowing, never a cast. */
|
|
692
|
+
function isMigrationProgress(value: unknown): value is MigrationProgress {
|
|
693
|
+
if (typeof value !== "object" || value === null) return false;
|
|
694
|
+
const candidate = value as Partial<MigrationProgress>;
|
|
695
|
+
if (!Array.isArray(candidate.migrated) || !Array.isArray(candidate.unreached)) return false;
|
|
696
|
+
return typeof candidate.failed === "object" && candidate.failed !== null;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* **Where the record of a partial run rides out of a failure (#380).**
|
|
701
|
+
*
|
|
702
|
+
* The mechanism is `partialWriteReport`'s, the same one `mintDeclaredSecrets` carries its minted
|
|
703
|
+
* secrets on (#324) and `dispatchSecretWrite` its reached environments (#325). Carried, never replaced:
|
|
704
|
+
* the failure an operator reads is the failure that happened, and what the run wrote is what makes the
|
|
705
|
+
* remedy in it safe to perform.
|
|
706
|
+
*/
|
|
707
|
+
const progressReport = partialWriteReport<MigrationProgress>("pithy.cli.migrationProgress", isMigrationProgress);
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* What a failed fan-out ({@link migrateProject}, {@link resetProject}, a capability drop) changed before
|
|
711
|
+
* it failed, or `undefined` for a throw from anywhere else — which is the honest answer when the run
|
|
712
|
+
* never reached a database at all.
|
|
713
|
+
*/
|
|
714
|
+
export function migratedBeforeFailure(error: unknown): MigrationProgress | undefined {
|
|
715
|
+
return progressReport.read(error);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Open the driver, run the pass per group, fold the results into a per-Worker report, and tear down.
|
|
720
|
+
*
|
|
721
|
+
* **The run still stops at the first database that fails, and it still throws.** Every entry point that
|
|
722
|
+
* writes a schema comes through here, and a pass that failed for a reason belonging to the whole run —
|
|
723
|
+
* a revoked token, an account that is not this project's — would otherwise carry on applying migrations
|
|
724
|
+
* to the databases behind it. What changed is that the record survives the throw: {@link
|
|
725
|
+
* migratedBeforeFailure} reads back which databases moved, which one died, and which were never opened
|
|
726
|
+
* (#380).
|
|
727
|
+
*
|
|
728
|
+
* **The three steps above the loop are deliberately not guarded.** `scopedGroups`, `claimGroups` and
|
|
729
|
+
* `assertLedgerDeclared` decide *what* the run is over and whether it may write at all — the loop's
|
|
730
|
+
* preconditions, not contributors to it — so their failure is not one database missing, it is there
|
|
731
|
+
* being no run. `claimGroups` in particular is the choke point that refuses another project's database,
|
|
732
|
+
* and a guard around it would be a guard around the refusal.
|
|
733
|
+
*/
|
|
734
|
+
async function runGroups(context: RunContext, pass: MigrationPass): Promise<WorkerMigrationRun[]> {
|
|
735
|
+
const report = emptyReport(context.workers);
|
|
736
|
+
const groups = await scopedGroups(context);
|
|
737
|
+
if (groups.length === 0) return report;
|
|
738
|
+
|
|
739
|
+
const driver = await driverFor(context, groups);
|
|
740
|
+
try {
|
|
741
|
+
await claimGroups(context, driver, groups);
|
|
742
|
+
if (pass.spansLedger) await assertLedgerDeclared({ env: context.env, driver, groups });
|
|
743
|
+
for (const [index, group] of groups.entries()) {
|
|
744
|
+
const target = { binding: group.binding, database: group.database };
|
|
745
|
+
let results: MigrationResult[];
|
|
746
|
+
// `try`/`catch` rather than `.catch()`: a pass that throws before it returns a promise — a driver
|
|
747
|
+
// handing back a database that is not there, a provider that will not build — is not a rejected
|
|
748
|
+
// promise, and a `.catch()` would not see it (#371).
|
|
749
|
+
try {
|
|
750
|
+
results = await pass.execute(driver.database(group), group.provider, target);
|
|
751
|
+
} catch (error) {
|
|
752
|
+
// The guard takes no binding. The two names are what an operator acts on; what a migration
|
|
753
|
+
// throws is already on the error being rethrown, untouched.
|
|
754
|
+
throw progressReport.carry(error, {
|
|
755
|
+
migrated: report,
|
|
756
|
+
failed: target,
|
|
757
|
+
unreached: groups.slice(index + 1).map((rest) => ({ binding: rest.binding, database: rest.database })),
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
record(report, group, results);
|
|
761
|
+
}
|
|
762
|
+
return report;
|
|
763
|
+
} finally {
|
|
764
|
+
await driver.dispose();
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Every Worker a database in scope could be shared with — the set each group's provider is merged from.
|
|
770
|
+
*
|
|
771
|
+
* With nothing pre-resolved that is plain `apps/` discovery, narrowed later, never here. A caller can
|
|
772
|
+
* also hand over an **already narrowed** set: `pithy add`/`remove`/`upgrade --migrate` pass the single
|
|
773
|
+
* Worker they just wired. A database that Worker shares still migrates as a whole, so the rest of the
|
|
774
|
+
* project is discovered alongside it. Best effort by design — a project with nothing importable (a test
|
|
775
|
+
* fixture, an uninstalled checkout) contributes no neighbors and the caller's set stands alone, exactly
|
|
776
|
+
* as it did before.
|
|
777
|
+
*/
|
|
778
|
+
async function projectWorkers(options: MigrationFanOutOptions): Promise<WorkerScope[]> {
|
|
779
|
+
if (!options.workers) return resolveWorkerScopes({ projectDir: options.projectDir });
|
|
780
|
+
|
|
781
|
+
const discovered = await resolveWorkerScopes({ projectDir: options.projectDir }).catch(() => []);
|
|
782
|
+
const workers = [...options.workers];
|
|
783
|
+
for (const found of discovered) {
|
|
784
|
+
const known = workers.some((candidate) => resolve(candidate.dir) === resolve(found.dir));
|
|
785
|
+
if (!known) workers.push(found);
|
|
786
|
+
}
|
|
787
|
+
return workers;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Turn the public fan-out options into a run context: the whole project (what each database's registry is
|
|
792
|
+
* built from) and the run's own scope (what is reported, and which databases are visited). The scope is
|
|
793
|
+
* always the caller's set narrowed by `--worker`, so an unknown name still fails naming the same Workers.
|
|
794
|
+
*/
|
|
795
|
+
async function contextFor(options: MigrationFanOutOptions & { project?: string }): Promise<RunContext> {
|
|
796
|
+
const groupWorkers = await projectWorkers(options);
|
|
797
|
+
return {
|
|
798
|
+
workers: await resolveWorkerScopes({
|
|
799
|
+
projectDir: options.projectDir,
|
|
800
|
+
workers: options.workers ?? groupWorkers,
|
|
801
|
+
...(options.worker !== undefined ? { worker: options.worker } : {}),
|
|
802
|
+
}),
|
|
803
|
+
groupWorkers,
|
|
804
|
+
persistRoot: options.projectDir,
|
|
805
|
+
account: options.account,
|
|
806
|
+
env: options.env,
|
|
807
|
+
...(options.project !== undefined ? { project: options.project } : {}),
|
|
808
|
+
...(options.remoteD1 ? { remoteD1: options.remoteD1 } : {}),
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Run (or roll back) every Worker's migration registry — the logic behind `pithy migrate`. Each Worker
|
|
814
|
+
* contributes its own capabilities; Workers bound to the same physical D1 merge into one run so a shared
|
|
815
|
+
* database migrates once. Locally that D1 is a Miniflare store under the project root's `.wrangler/state`
|
|
816
|
+
* (shared with `wrangler dev`); for staging/prod it is the remote database over the D1 REST API.
|
|
817
|
+
* The registries, ordering, and per-database runs are identical — only the driver differs.
|
|
818
|
+
*/
|
|
819
|
+
export function migrateProject(options: MigrateProjectOptions): Promise<WorkerMigrationRun[]> {
|
|
820
|
+
return contextFor(options).then((context) =>
|
|
821
|
+
runGroups(context, {
|
|
822
|
+
spansLedger: true,
|
|
823
|
+
execute: (database, provider, target) =>
|
|
824
|
+
options.rollback ? rollbackMigration(database, provider, target) : runMigrations(database, provider, target),
|
|
825
|
+
}),
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Fully reset every migrated database's schema — the seam behind `pithy seed --redo`'s destructive
|
|
831
|
+
* rebuild. Runs the **same fan-out, grouping, and driver** as {@link migrateProject} but calls
|
|
832
|
+
* `resetMigrations` per database instead of running or rolling back: every applied migration's `down`
|
|
833
|
+
* runs (all of them, not just the latest), then every migration's `up` reapplies from empty. **This
|
|
834
|
+
* destroys every row in every table the registry owns — hand-inserted data included — by design**, not
|
|
835
|
+
* just the rows a seed fixture wrote. Callers gate this behind the same escalating confirmation a
|
|
836
|
+
* destructive seed needs before calling it.
|
|
837
|
+
*/
|
|
838
|
+
export function resetProject(options: ResetProjectOptions): Promise<WorkerMigrationRun[]> {
|
|
839
|
+
return contextFor(options).then((context) => runGroups(context, { spansLedger: true, execute: resetMigrations }));
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/** One database's place in a {@link resetProject} run: which database, its binding, and how many migrations it carries. */
|
|
843
|
+
export interface ResetPreviewEntry {
|
|
844
|
+
/** The database being reset (matches a capability's `databases` key). */
|
|
845
|
+
database: string;
|
|
846
|
+
/** The D1 binding the database resolves to. */
|
|
847
|
+
binding: string;
|
|
848
|
+
/** The number of migrations the merged registry carries for this database — how many roll back, then reapply. */
|
|
849
|
+
migrations: number;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Preview a {@link resetProject}: which databases are in scope and how many migrations each carries.
|
|
854
|
+
* Computed from the composed registries and each Worker's `wrangler.jsonc` alone — no backend access, no
|
|
855
|
+
* credentials, nothing read or written — so it is safe to call for `pithy seed --redo --dry-run` (and to
|
|
856
|
+
* compute the same numbers a real reset will produce, for the run report). One entry per physical
|
|
857
|
+
* database, so a database two Workers share is previewed once, with their merged migration count.
|
|
858
|
+
*/
|
|
859
|
+
export async function previewReset(options: MigrationFanOutOptions): Promise<ResetPreviewEntry[]> {
|
|
860
|
+
const groups = await scopedGroups(await contextFor(options));
|
|
861
|
+
const preview: ResetPreviewEntry[] = [];
|
|
862
|
+
for (const group of groups) {
|
|
863
|
+
const migrations = await group.provider.getMigrations();
|
|
864
|
+
preview.push({ database: group.database, binding: group.binding, migrations: Object.keys(migrations).length });
|
|
865
|
+
}
|
|
866
|
+
return preview;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/** One database in scope whose ledger could not be read at all. Named, because the fix is on that one. */
|
|
870
|
+
export const UnreadableLedger = z
|
|
871
|
+
.object({
|
|
872
|
+
database: z.string().describe("The database name — a capability's `databases` key."),
|
|
873
|
+
binding: z
|
|
874
|
+
.string()
|
|
875
|
+
.describe("The D1 binding it resolves to, as wrangler.jsonc declares it — the name an adopter recognizes."),
|
|
876
|
+
})
|
|
877
|
+
.describe(
|
|
878
|
+
"One database whose ledger could not be read on this pass. It carries no reason: what the read threw is throw-site context about somebody's database, and the actionable fact is which database went unread.",
|
|
879
|
+
);
|
|
880
|
+
export type UnreadableLedger = z.infer<typeof UnreadableLedger>;
|
|
881
|
+
|
|
882
|
+
/** The two halves of the comparison, over the databases that answered. */
|
|
883
|
+
export const LedgerCounts = z
|
|
884
|
+
.object({
|
|
885
|
+
pending: z.number().int().nonnegative().describe("How many declared migrations have not run yet."),
|
|
886
|
+
undeclared: z
|
|
887
|
+
.array(UndeclaredMigration)
|
|
888
|
+
.describe(
|
|
889
|
+
"Every applied migration this project no longer declares, in group order. Non-empty means `migrate` refuses — see {@link ./ledger} — so a reporter that shows the count and hides this is the #282 bug.",
|
|
890
|
+
),
|
|
891
|
+
})
|
|
892
|
+
.describe("What the databases that answered have applied, against what this project declares.");
|
|
893
|
+
export type LedgerCounts = z.infer<typeof LedgerCounts>;
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* What one environment's databases have applied, against what this project declares — **and whether
|
|
897
|
+
* every one of them answered** (#371).
|
|
898
|
+
*
|
|
899
|
+
* A project migrates several databases and the counts are a sum over them, so one unreachable D1 used to
|
|
900
|
+
* throw out of the loop and lose the ledger for every other database with it. Guarding the loop is only
|
|
901
|
+
* half the fix: a sum computed over four databases out of five is not the same number as a sum over five,
|
|
902
|
+
* and nothing in `{ pending, undeclared }` could say so.
|
|
903
|
+
*
|
|
904
|
+
* So the scalars live **behind the discriminant**, on #350's rule. `read` carries them flat; `partial`
|
|
905
|
+
* nests them under `counted` beside the databases that went unread, which is what stops
|
|
906
|
+
* `if (ledger.state !== "unavailable") use(ledger.pending)` from compiling and quietly reporting a short
|
|
907
|
+
* sum. `unavailable` carries no number at all.
|
|
908
|
+
*
|
|
909
|
+
* **`readProjectLedger` never returns `unavailable`.** It throws instead, because enumerating the
|
|
910
|
+
* databases is not a contributor to this aggregate — it is the aggregate's precondition, and there is no
|
|
911
|
+
* partial answer to give when nothing could be enumerated. The state exists for the caller that catches
|
|
912
|
+
* that throw and still has siblings of its own to report ({@link ../capabilities/reconcile}).
|
|
913
|
+
*/
|
|
914
|
+
export const ProjectLedger = z
|
|
915
|
+
.discriminatedUnion("state", [
|
|
916
|
+
z
|
|
917
|
+
.object({
|
|
918
|
+
state: z.literal("read").describe("Every database in scope answered."),
|
|
919
|
+
pending: LedgerCounts.shape.pending.describe("How many declared migrations have not run yet, across them all."),
|
|
920
|
+
undeclared: LedgerCounts.shape.undeclared.describe(
|
|
921
|
+
"Every applied migration this project no longer declares, in group order.",
|
|
922
|
+
),
|
|
923
|
+
})
|
|
924
|
+
.describe("The whole comparison, over every database in scope."),
|
|
925
|
+
z
|
|
926
|
+
.object({
|
|
927
|
+
state: z.literal("partial").describe("Some databases answered and at least one could not be read."),
|
|
928
|
+
counted: LedgerCounts.describe(
|
|
929
|
+
"The comparison over the databases that answered — a sum with a known hole in it, which is why it is not spelled the way `read` spells it.",
|
|
930
|
+
),
|
|
931
|
+
unreadable: z
|
|
932
|
+
.array(UnreadableLedger)
|
|
933
|
+
.min(1)
|
|
934
|
+
.describe("Every database whose ledger could not be read. Non-empty, or this would be `read`."),
|
|
935
|
+
})
|
|
936
|
+
.describe("A comparison over some of the databases, naming the ones it could not include."),
|
|
937
|
+
z
|
|
938
|
+
.object({
|
|
939
|
+
state: z.literal("unavailable").describe("The ledger could not be read at all."),
|
|
940
|
+
})
|
|
941
|
+
.describe(
|
|
942
|
+
"No comparison was made. Deliberately empty: nothing derived from the failure travels, and there is no number here to mistake for a zero.",
|
|
943
|
+
),
|
|
944
|
+
])
|
|
945
|
+
.describe(
|
|
946
|
+
"What one environment's databases have applied against what this project declares, and whether every database answered. The counts sit behind the discriminant, so a short sum cannot be read as a whole one.",
|
|
947
|
+
);
|
|
948
|
+
export type ProjectLedger = z.infer<typeof ProjectLedger>;
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Read every Worker's databases for an environment against what the project declares — read-only,
|
|
952
|
+
* applying nothing. It runs the same fan-out, grouping, and driver as {@link migrateProject}, so it
|
|
953
|
+
* reports against the same D1s (local under the project root's `.wrangler/state`, or the remote
|
|
954
|
+
* databases over REST) migrate would touch, and reads a database two Workers share once. The seam behind
|
|
955
|
+
* `pithy doctor`'s migrations line and `pithy deploy`'s warn-only "schema is behind" check.
|
|
956
|
+
*
|
|
957
|
+
* **Both directions, because the count alone was the fault.** This replaced `countPendingMigrations`,
|
|
958
|
+
* whose callers could only ever learn that a declared migration had not run — never that the ledger held
|
|
959
|
+
* one the project had dropped, which is the state that stops migrate dead (#282). Returning one object
|
|
960
|
+
* is what keeps a caller from re-acquiring half the question.
|
|
961
|
+
*
|
|
962
|
+
* **One database at a time, because the answer is a sum (#371).** A project migrates several databases,
|
|
963
|
+
* and one of them being unreachable — a revoked token, a deleted D1, a `wrangler.jsonc` naming an id that
|
|
964
|
+
* is gone — used to throw out of this loop and lose the ledger for every other database in the project.
|
|
965
|
+
* Each group is read under its own guard now, and a group that will not read is named on `unreadable`
|
|
966
|
+
* rather than absorbed into a smaller `pending`.
|
|
967
|
+
*
|
|
968
|
+
* **Enumerating the databases is not one of the contributors.** `contextFor`, `scopedGroups` and
|
|
969
|
+
* `driverFor` run before the loop and still throw: they decide *what* the aggregate is over, so their
|
|
970
|
+
* failure is not one contributor missing, it is there being nothing to aggregate. A caller that wants to
|
|
971
|
+
* survive that catches it — {@link ../capabilities/reconcile} does, into `unavailable`.
|
|
972
|
+
*
|
|
973
|
+
* **The guard takes no binding.** What a D1 read throws names a database id, a token, or a query, and none
|
|
974
|
+
* of that is anybody's business but the adopter's — so nothing derived from it is kept, which is a
|
|
975
|
+
* property of the code rather than a promise about it (#350).
|
|
976
|
+
*/
|
|
977
|
+
export async function readProjectLedger(options: MigrationFanOutOptions): Promise<ProjectLedger> {
|
|
978
|
+
const context = await contextFor(options);
|
|
979
|
+
const groups = await scopedGroups(context);
|
|
980
|
+
if (groups.length === 0) return { state: "read", pending: 0, undeclared: [] };
|
|
981
|
+
|
|
982
|
+
const driver = await driverFor(context, groups);
|
|
983
|
+
try {
|
|
984
|
+
let pending = 0;
|
|
985
|
+
const undeclared: UndeclaredMigration[] = [];
|
|
986
|
+
const unreadable: UnreadableLedger[] = [];
|
|
987
|
+
for (const group of groups) {
|
|
988
|
+
let read: MigrationLedger;
|
|
989
|
+
try {
|
|
990
|
+
read = await readMigrationLedger(driver.database(group), group.provider);
|
|
991
|
+
} catch {
|
|
992
|
+
unreadable.push({ database: group.database, binding: group.binding });
|
|
993
|
+
continue;
|
|
994
|
+
}
|
|
995
|
+
pending += read.pending.length;
|
|
996
|
+
for (const name of read.undeclared) {
|
|
997
|
+
undeclared.push({ database: group.database, binding: group.binding, name });
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
return unreadable.length === 0
|
|
1001
|
+
? { state: "read", pending, undeclared }
|
|
1002
|
+
: { state: "partial", counted: { pending, undeclared }, unreadable };
|
|
1003
|
+
} finally {
|
|
1004
|
+
await driver.dispose();
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/** Options for {@link dropCapabilityTables}: the capability, the Worker it is wired into, and the env. */
|
|
1009
|
+
export interface DropCapabilityOptions {
|
|
1010
|
+
/** The capability being removed, whose migrations to reverse. */
|
|
1011
|
+
capability: Capability;
|
|
1012
|
+
/** The Worker's directory — its `wrangler.jsonc` supplies the D1 bindings and their ids. */
|
|
1013
|
+
workerDir: string;
|
|
1014
|
+
/** The project root whose `.wrangler/state` holds the local D1 every Worker shares. */
|
|
1015
|
+
persistRoot: string;
|
|
1016
|
+
/**
|
|
1017
|
+
* The Cloudflare account this project belongs to. A remote drop reverses migrations against a real
|
|
1018
|
+
* database, so it must be the account the project claims and no other (#206). Required (#234): this
|
|
1019
|
+
* is the most destructive thing `pithy remove` can do, and `defaultRemoveSteps` named no account for
|
|
1020
|
+
* as long as the field was optional.
|
|
1021
|
+
*/
|
|
1022
|
+
account: CloudflareAccountSelection | null;
|
|
1023
|
+
/** Target environment. `dev` runs locally via Miniflare; staging/prod over the D1 REST API. */
|
|
1024
|
+
env: string;
|
|
1025
|
+
/**
|
|
1026
|
+
* The project this drop belongs to — the root `pithy.config.ts` `name` (`requireProjectName`).
|
|
1027
|
+
* Checked against the database's recorded owner before a single `down` runs, so a project never
|
|
1028
|
+
* reverses another project's migrations. Required: this is the most destructive thing `pithy remove`
|
|
1029
|
+
* can do, and it ran unchecked for as long as the field was optional.
|
|
1030
|
+
*/
|
|
1031
|
+
project: string;
|
|
1032
|
+
/** Test seam: build the remote D1 for a binding instead of the default REST-backed client. */
|
|
1033
|
+
remoteD1?: RemoteD1Factory;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* Drop a single capability's tables for an environment — the seam behind `pithy remove --drop`. It runs
|
|
1038
|
+
* the **same grouping and driver** as {@link migrateProject} but over just the removed capability, in
|
|
1039
|
+
* just the Worker it is wired into, so only that capability's migrations are reversed (via
|
|
1040
|
+
* {@link dropMigrations}); every other capability's tables and ledger rows are untouched — including
|
|
1041
|
+
* those of another Worker sharing the same physical D1. Runs before the capability is
|
|
1042
|
+
* unwired/uninstalled, while its `down` code is still present.
|
|
1043
|
+
*/
|
|
1044
|
+
export async function dropCapabilityTables(options: DropCapabilityOptions): Promise<DatabaseRun[]> {
|
|
1045
|
+
const worker: WorkerScope = {
|
|
1046
|
+
name: options.capability.name,
|
|
1047
|
+
dir: options.workerDir,
|
|
1048
|
+
capabilities: [options.capability],
|
|
1049
|
+
};
|
|
1050
|
+
const context: RunContext = {
|
|
1051
|
+
workers: [worker],
|
|
1052
|
+
// Deliberately just this capability: `dropMigrations` reverses the provider's own migrations
|
|
1053
|
+
// directly, never through Kysely's stepwise `Migrator`, so a partial registry is the point — every
|
|
1054
|
+
// other capability's tables and ledger rows, on this D1 or a Worker sharing it, stay untouched.
|
|
1055
|
+
groupWorkers: [worker],
|
|
1056
|
+
persistRoot: options.persistRoot,
|
|
1057
|
+
account: options.account,
|
|
1058
|
+
env: options.env,
|
|
1059
|
+
project: options.project,
|
|
1060
|
+
...(options.remoteD1 ? { remoteD1: options.remoteD1 } : {}),
|
|
1061
|
+
};
|
|
1062
|
+
// `spansLedger: false`: this provider is one capability's migrations, and every other capability's row
|
|
1063
|
+
// in the same database is undeclared to it. See {@link MigrationPass}.
|
|
1064
|
+
const [run] = await runGroups(context, { spansLedger: false, execute: dropMigrations });
|
|
1065
|
+
return run?.databases ?? [];
|
|
1066
|
+
}
|