@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.
Files changed (234) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +72 -0
  3. package/scripts/templateManifest.ts +49 -0
  4. package/scripts/tsconfig.json +26 -0
  5. package/scripts/vendorTemplate.ts +84 -0
  6. package/scripts/verifyPack.ts +88 -0
  7. package/src/audit/cliAudit.ts +406 -0
  8. package/src/bin.ts +111 -0
  9. package/src/capabilities/add.ts +288 -0
  10. package/src/capabilities/addBootstrap.ts +275 -0
  11. package/src/capabilities/catalog.ts +175 -0
  12. package/src/capabilities/compose.ts +39 -0
  13. package/src/capabilities/configConstants.ts +74 -0
  14. package/src/capabilities/configImports.ts +397 -0
  15. package/src/capabilities/eject.ts +331 -0
  16. package/src/capabilities/emailProvisioner.ts +346 -0
  17. package/src/capabilities/entitlementGap.ts +70 -0
  18. package/src/capabilities/entryExports.ts +162 -0
  19. package/src/capabilities/flow.ts +550 -0
  20. package/src/capabilities/hostRegistry.ts +368 -0
  21. package/src/capabilities/loadFailure.ts +208 -0
  22. package/src/capabilities/manifests.ts +238 -0
  23. package/src/capabilities/mediaProvisioner.ts +471 -0
  24. package/src/capabilities/mintSecrets.ts +306 -0
  25. package/src/capabilities/paymentsProvisioner.ts +207 -0
  26. package/src/capabilities/prerequisites.ts +168 -0
  27. package/src/capabilities/r2Bucket.ts +113 -0
  28. package/src/capabilities/reconcile.ts +1483 -0
  29. package/src/capabilities/remove.ts +597 -0
  30. package/src/capabilities/requiredOptions.ts +92 -0
  31. package/src/capabilities/rotateSecrets.ts +305 -0
  32. package/src/capabilities/secrets.ts +178 -0
  33. package/src/capabilities/secretsDispatcher.ts +29 -0
  34. package/src/capabilities/secretsProvisioner.ts +389 -0
  35. package/src/capabilities/storageProvisioner.ts +414 -0
  36. package/src/capabilities/supportProvisioner.ts +515 -0
  37. package/src/capabilities/testersLoader.ts +52 -0
  38. package/src/capabilities/testersProvisioner.ts +236 -0
  39. package/src/capabilities/turnstileProvisioner.ts +347 -0
  40. package/src/capabilities/vectorProvisioner.ts +260 -0
  41. package/src/ci/fileModes.ts +223 -0
  42. package/src/ci/sourceFiles.ts +200 -0
  43. package/src/ci/workflowDrivers.ts +524 -0
  44. package/src/cloudflare/accountAnswer.ts +110 -0
  45. package/src/cloudflare/config.ts +685 -0
  46. package/src/cloudflare/storeId.ts +129 -0
  47. package/src/commands/add.ts +372 -0
  48. package/src/commands/alias.ts +205 -0
  49. package/src/commands/dashboard.ts +651 -0
  50. package/src/commands/deploy.ts +150 -0
  51. package/src/commands/dev.ts +37 -0
  52. package/src/commands/doctor.ts +2059 -0
  53. package/src/commands/email.ts +425 -0
  54. package/src/commands/env.ts +155 -0
  55. package/src/commands/feature.ts +359 -0
  56. package/src/commands/init.ts +538 -0
  57. package/src/commands/media.ts +303 -0
  58. package/src/commands/migrate.ts +129 -0
  59. package/src/commands/payments.ts +336 -0
  60. package/src/commands/provision.ts +368 -0
  61. package/src/commands/remove.ts +151 -0
  62. package/src/commands/secrets.ts +652 -0
  63. package/src/commands/seed.ts +229 -0
  64. package/src/commands/storage.ts +309 -0
  65. package/src/commands/support.ts +331 -0
  66. package/src/commands/testers.ts +1020 -0
  67. package/src/commands/token.ts +364 -0
  68. package/src/commands/turnstile.ts +271 -0
  69. package/src/commands/ui.ts +222 -0
  70. package/src/commands/upgrade.ts +517 -0
  71. package/src/commands/vector.ts +390 -0
  72. package/src/commands/worker.ts +295 -0
  73. package/src/dashboard/api.ts +323 -0
  74. package/src/dashboard/connect.ts +758 -0
  75. package/src/dashboard/contract.ts +289 -0
  76. package/src/dashboard/grant.ts +124 -0
  77. package/src/dashboard/registry.ts +519 -0
  78. package/src/dashboard/resolveTarget.ts +119 -0
  79. package/src/dev/delivery.ts +174 -0
  80. package/src/dev/devLogin.ts +155 -0
  81. package/src/dev/devLoginTargets.ts +91 -0
  82. package/src/dev/env.ts +206 -0
  83. package/src/dev/hostWorkers.ts +290 -0
  84. package/src/dev/keys.ts +111 -0
  85. package/src/dev/logging.ts +87 -0
  86. package/src/dev/openUrl.ts +75 -0
  87. package/src/dev/orchestrator.ts +1014 -0
  88. package/src/dev/ports.ts +220 -0
  89. package/src/dev/readyWatch.ts +142 -0
  90. package/src/dev/state.ts +90 -0
  91. package/src/devSecrets/bootstrapVars.ts +265 -0
  92. package/src/devSecrets/devVars.ts +240 -0
  93. package/src/devSecrets/edit.ts +256 -0
  94. package/src/devSecrets/file.ts +277 -0
  95. package/src/devSecrets/generate.ts +428 -0
  96. package/src/devSecrets/location.ts +80 -0
  97. package/src/devSecrets/mode.ts +71 -0
  98. package/src/devSecrets/records.ts +30 -0
  99. package/src/devSecrets/report.ts +99 -0
  100. package/src/devSecrets/seed.ts +344 -0
  101. package/src/devSecrets/store.ts +262 -0
  102. package/src/devSecrets/targets.ts +204 -0
  103. package/src/dispatch.ts +147 -0
  104. package/src/docs/catalog.ts +246 -0
  105. package/src/docs/writeCatalog.ts +45 -0
  106. package/src/doctor/cloudflare.ts +287 -0
  107. package/src/doctor/devPreferences.ts +155 -0
  108. package/src/doctor/devSecrets.ts +464 -0
  109. package/src/doctor/devVars.ts +414 -0
  110. package/src/doctor/devVarsLocal.ts +138 -0
  111. package/src/doctor/environments.ts +155 -0
  112. package/src/doctor/health.ts +354 -0
  113. package/src/doctor/localDelivery.ts +91 -0
  114. package/src/doctor/portsRegistry.ts +252 -0
  115. package/src/doctor/projectName.ts +584 -0
  116. package/src/doctor/secretBindings.ts +166 -0
  117. package/src/doctor/settings.ts +274 -0
  118. package/src/doctor/settingsSources.ts +202 -0
  119. package/src/doctor/workerName.ts +174 -0
  120. package/src/doctor/wranglerVars.ts +33 -0
  121. package/src/feature/bindings.ts +93 -0
  122. package/src/feature/create.ts +179 -0
  123. package/src/feature/destroy.ts +160 -0
  124. package/src/feature/devConfig.ts +201 -0
  125. package/src/feature/identity.ts +100 -0
  126. package/src/feature/manifest.ts +132 -0
  127. package/src/feature/ports.ts +615 -0
  128. package/src/feature/provision.ts +362 -0
  129. package/src/feature/sync.ts +148 -0
  130. package/src/feature/worktree.ts +282 -0
  131. package/src/help/groups.ts +47 -0
  132. package/src/help/rootUsage.ts +135 -0
  133. package/src/main.ts +73 -0
  134. package/src/migrations/ledger.ts +129 -0
  135. package/src/migrations/registry.ts +47 -0
  136. package/src/migrations/run.ts +1066 -0
  137. package/src/notifier/check.ts +129 -0
  138. package/src/notifier/installer.ts +48 -0
  139. package/src/notifier/notify.ts +152 -0
  140. package/src/notifier/state.ts +248 -0
  141. package/src/notifier/version.ts +59 -0
  142. package/src/platform/editor.ts +333 -0
  143. package/src/platform/rc.ts +118 -0
  144. package/src/platform/shell.ts +83 -0
  145. package/src/project/appBindings.ts +184 -0
  146. package/src/project/appWorkflows.ts +266 -0
  147. package/src/project/applyDomains.ts +166 -0
  148. package/src/project/askDomains.ts +220 -0
  149. package/src/project/atomic.ts +466 -0
  150. package/src/project/bindingEntries.ts +425 -0
  151. package/src/project/config.ts +701 -0
  152. package/src/project/dashboard.ts +118 -0
  153. package/src/project/deploy.ts +364 -0
  154. package/src/project/devVars.ts +113 -0
  155. package/src/project/domainPrompt.ts +191 -0
  156. package/src/project/domains.ts +386 -0
  157. package/src/project/envInventory.ts +356 -0
  158. package/src/project/environment.ts +125 -0
  159. package/src/project/extensions.ts +69 -0
  160. package/src/project/jsonc.ts +289 -0
  161. package/src/project/packageManager.ts +238 -0
  162. package/src/project/readOptionalFile.ts +342 -0
  163. package/src/project/rollback.ts +145 -0
  164. package/src/project/scaffold.ts +1088 -0
  165. package/src/project/templateFiles.ts +53 -0
  166. package/src/project/verifyDeploy.ts +230 -0
  167. package/src/project/versionMetadata.ts +77 -0
  168. package/src/project/workerAddress.ts +176 -0
  169. package/src/project/workerCommand.ts +564 -0
  170. package/src/project/workerIdentity.ts +50 -0
  171. package/src/project/workerManifest.ts +135 -0
  172. package/src/project/workerScaffold.ts +289 -0
  173. package/src/project/workerScope.ts +394 -0
  174. package/src/project/workers.ts +86 -0
  175. package/src/project/workflows.ts +281 -0
  176. package/src/project/wrangler.ts +168 -0
  177. package/src/provision/confirm.ts +86 -0
  178. package/src/provision/environment.ts +407 -0
  179. package/src/provision/featureConfig.ts +98 -0
  180. package/src/provision/mode.ts +62 -0
  181. package/src/provision/pendingSecrets.ts +96 -0
  182. package/src/provision/resources.ts +126 -0
  183. package/src/provision/secretBindings.ts +149 -0
  184. package/src/provision/store.ts +33 -0
  185. package/src/provision/unprovisioned.ts +114 -0
  186. package/src/provision/wranglerEnv.ts +220 -0
  187. package/src/rootFlags.ts +48 -0
  188. package/src/seed/drivers.ts +423 -0
  189. package/src/seed/media.ts +187 -0
  190. package/src/seed/plan.ts +137 -0
  191. package/src/seed/prepare.ts +224 -0
  192. package/src/seed/registry.ts +25 -0
  193. package/src/seed/run.ts +793 -0
  194. package/src/seed/safety.ts +206 -0
  195. package/src/terminal/logger.ts +42 -0
  196. package/src/terminal/output.ts +64 -0
  197. package/src/terminal/style.ts +132 -0
  198. package/src/test-utils/doctorHarness.ts +190 -0
  199. package/src/test-utils/migrateHarness.ts +126 -0
  200. package/src/test-utils/seedHarness.ts +173 -0
  201. package/src/test-utils/tempRepo.ts +45 -0
  202. package/src/tokens/config.ts +16 -0
  203. package/src/tokens/engine.ts +345 -0
  204. package/src/tokens/mintedTokens.ts +233 -0
  205. package/src/tokens/sinks.ts +84 -0
  206. package/src/ui/flow.ts +451 -0
  207. package/src/ui/react.ts +112 -0
  208. package/src/ui/routeAllowlist.ts +208 -0
  209. package/src/ui/scaffold.ts +113 -0
  210. package/src/ui/screenStyles.ts +127 -0
  211. package/src/ui/stubs.ts +135 -0
  212. package/src/ui/templates.ts +52 -0
  213. package/src/ui/wire.ts +311 -0
  214. package/src/ui/workerUi.ts +172 -0
  215. package/templates/starter/.dev.secrets.example.jsonc +43 -0
  216. package/templates/starter/.dev.vars.example +30 -0
  217. package/templates/starter/apps/api/package.json +22 -0
  218. package/templates/starter/apps/api/pithy.config.ts +65 -0
  219. package/templates/starter/apps/api/pithy.worker.jsonc +11 -0
  220. package/templates/starter/apps/api/src/bindings.workers.test.ts +18 -0
  221. package/templates/starter/apps/api/src/cloudflare-test.d.ts +11 -0
  222. package/templates/starter/apps/api/src/index.ts +8 -0
  223. package/templates/starter/apps/api/tsconfig.json +26 -0
  224. package/templates/starter/apps/api/wrangler.jsonc +68 -0
  225. package/templates/starter/biome.template.jsonc +75 -0
  226. package/templates/starter/gitignore +37 -0
  227. package/templates/starter/package.json +28 -0
  228. package/templates/starter/pithy.config.ts +67 -0
  229. package/templates/starter/plugins/no-console.grit +25 -0
  230. package/templates/starter/plugins/no-process-io.grit +25 -0
  231. package/templates/starter/tsconfig.json +14 -0
  232. package/templates/starter/tsconfig.tools.json +30 -0
  233. package/templates/starter/vitest.config.ts +124 -0
  234. 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
+ }