@pithy-sh/core 0.9.0 → 0.10.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 (162) hide show
  1. package/dist/asyncapi/document.d.ts +46 -0
  2. package/dist/asyncapi/document.d.ts.map +1 -0
  3. package/dist/asyncapi/document.js +115 -0
  4. package/dist/capability/capability.d.ts +7 -0
  5. package/dist/capability/capability.d.ts.map +1 -1
  6. package/dist/capability/composition.d.ts.map +1 -1
  7. package/dist/capability/composition.js +5 -1
  8. package/dist/capability/settings.d.ts +10 -2
  9. package/dist/capability/settings.d.ts.map +1 -1
  10. package/dist/capability/settings.js +5 -3
  11. package/dist/cli/command.d.ts +364 -0
  12. package/dist/cli/command.d.ts.map +1 -0
  13. package/dist/cli/command.js +418 -0
  14. package/dist/cli/commandCatalog.generated.d.ts +2275 -0
  15. package/dist/cli/commandCatalog.generated.d.ts.map +1 -0
  16. package/dist/cli/commandCatalog.generated.js +2390 -0
  17. package/dist/cli/commandFacts.d.ts +164 -0
  18. package/dist/cli/commandFacts.d.ts.map +1 -0
  19. package/dist/cli/commandFacts.js +215 -0
  20. package/dist/controlPlane/error/errors.d.ts.map +1 -1
  21. package/dist/controlPlane/error/errors.js +4 -3
  22. package/dist/controlPlane/http/guard.d.ts.map +1 -1
  23. package/dist/controlPlane/http/guard.js +11 -1
  24. package/dist/controlPlane/http/routes.d.ts +1 -1
  25. package/dist/controlPlane/http/routes.d.ts.map +1 -1
  26. package/dist/controlPlane/http/routes.js +34 -7
  27. package/dist/createBackend.d.ts +7 -0
  28. package/dist/createBackend.d.ts.map +1 -1
  29. package/dist/createBackend.js +34 -4
  30. package/dist/entitlement/require.js +1 -1
  31. package/dist/error/cause.d.ts +16 -116
  32. package/dist/error/cause.d.ts.map +1 -1
  33. package/dist/error/cause.js +19 -153
  34. package/dist/error/http.d.ts +40 -0
  35. package/dist/error/http.d.ts.map +1 -1
  36. package/dist/error/payload.d.ts +80 -0
  37. package/dist/error/payload.d.ts.map +1 -1
  38. package/dist/error/payload.js +26 -0
  39. package/dist/error/pithyError.d.ts +44 -0
  40. package/dist/error/pithyError.d.ts.map +1 -1
  41. package/dist/error/pithyError.js +56 -1
  42. package/dist/error/unresolved.d.ts +265 -0
  43. package/dist/error/unresolved.d.ts.map +1 -0
  44. package/dist/error/unresolved.js +599 -0
  45. package/dist/error/wire.d.ts +20 -0
  46. package/dist/error/wire.d.ts.map +1 -0
  47. package/dist/error/wire.js +19 -0
  48. package/dist/http/oidcWebhook.d.ts +3 -3
  49. package/dist/http/oidcWebhook.js +3 -3
  50. package/dist/http/routeContract.js +1 -1
  51. package/dist/http/routeSpec.d.ts +66 -0
  52. package/dist/http/routeSpec.d.ts.map +1 -0
  53. package/dist/http/routeSpec.js +40 -0
  54. package/dist/http/sameOrigin.d.ts +1 -1
  55. package/dist/http/sameOrigin.js +1 -1
  56. package/dist/http/signedWebhook.d.ts +3 -3
  57. package/dist/http/signedWebhook.js +3 -3
  58. package/dist/http/socket.d.ts +52 -0
  59. package/dist/http/socket.d.ts.map +1 -0
  60. package/dist/http/socket.js +2 -0
  61. package/dist/http/subprotocol.d.ts +29 -0
  62. package/dist/http/subprotocol.d.ts.map +1 -0
  63. package/dist/http/subprotocol.js +37 -0
  64. package/dist/http/validation.d.ts +40 -8
  65. package/dist/http/validation.d.ts.map +1 -1
  66. package/dist/http/validation.js +25 -1
  67. package/dist/index.js +2 -2
  68. package/dist/logger/record.d.ts +23 -0
  69. package/dist/logger/record.d.ts.map +1 -1
  70. package/dist/migrations/bookkeeping.d.ts +21 -0
  71. package/dist/migrations/bookkeeping.d.ts.map +1 -1
  72. package/dist/migrations/bookkeeping.js +32 -3
  73. package/dist/migrations/groups.d.ts +140 -0
  74. package/dist/migrations/groups.d.ts.map +1 -0
  75. package/dist/migrations/groups.js +191 -0
  76. package/dist/migrations/migrateStep.d.ts +13 -0
  77. package/dist/migrations/migrateStep.d.ts.map +1 -0
  78. package/dist/migrations/migrateStep.js +20 -0
  79. package/dist/migrations/owner.d.ts +7 -0
  80. package/dist/migrations/owner.d.ts.map +1 -1
  81. package/dist/migrations/owner.js +11 -7
  82. package/dist/migrations/retained.js +2 -2
  83. package/dist/migrations/runner.d.ts +76 -5
  84. package/dist/migrations/runner.d.ts.map +1 -1
  85. package/dist/migrations/runner.js +130 -19
  86. package/dist/naming/domains.d.ts +17 -0
  87. package/dist/naming/domains.d.ts.map +1 -1
  88. package/dist/naming/domains.js +62 -4
  89. package/dist/naming/environment.d.ts.map +1 -1
  90. package/dist/naming/environment.js +2 -1
  91. package/dist/naming/packageName.d.ts +17 -0
  92. package/dist/naming/packageName.d.ts.map +1 -0
  93. package/dist/naming/packageName.js +22 -0
  94. package/dist/naming/provisionScope.d.ts.map +1 -1
  95. package/dist/naming/provisionScope.js +2 -1
  96. package/dist/openapi/document.d.ts +106 -0
  97. package/dist/openapi/document.d.ts.map +1 -0
  98. package/dist/openapi/document.js +314 -0
  99. package/dist/openapi/routeGroups.d.ts +8 -0
  100. package/dist/openapi/routeGroups.d.ts.map +1 -0
  101. package/dist/openapi/routeGroups.js +23 -0
  102. package/dist/text/comments.js +66 -4
  103. package/dist/text/literals.d.ts +43 -0
  104. package/dist/text/literals.d.ts.map +1 -0
  105. package/dist/text/literals.js +293 -0
  106. package/dist/version.generated.d.ts +1 -1
  107. package/dist/version.generated.d.ts.map +1 -1
  108. package/dist/version.generated.js +1 -1
  109. package/dist/workflow/dispatchRoute.d.ts.map +1 -1
  110. package/dist/workflow/dispatchRoute.js +12 -6
  111. package/dist/workflow/faults.d.ts.map +1 -1
  112. package/dist/workflow/faults.js +2 -3
  113. package/dist/workflow/hostEnv.d.ts +1 -0
  114. package/dist/workflow/hostEnv.d.ts.map +1 -1
  115. package/dist/workflow/hostEnv.js +4 -2
  116. package/dist/workflow/loopback.d.ts.map +1 -1
  117. package/dist/workflow/loopback.js +2 -1
  118. package/package.json +3 -3
  119. package/src/asyncapi/document.ts +164 -0
  120. package/src/capability/capability.ts +7 -0
  121. package/src/capability/composition.ts +2 -1
  122. package/src/capability/settings.ts +19 -2
  123. package/src/cli/command.ts +865 -0
  124. package/src/cli/commandCatalog.generated.ts +2533 -0
  125. package/src/cli/commandFacts.ts +219 -0
  126. package/src/controlPlane/error/errors.ts +10 -3
  127. package/src/controlPlane/http/guard.ts +14 -2
  128. package/src/controlPlane/http/routes.ts +57 -9
  129. package/src/createBackend.ts +35 -2
  130. package/src/entitlement/require.ts +1 -1
  131. package/src/error/cause.ts +18 -181
  132. package/src/error/payload.ts +59 -0
  133. package/src/error/pithyError.ts +76 -0
  134. package/src/error/unresolved.ts +853 -0
  135. package/src/error/wire.ts +40 -0
  136. package/src/http/oidcWebhook.ts +3 -3
  137. package/src/http/routeContract.ts +1 -1
  138. package/src/http/routeSpec.ts +81 -0
  139. package/src/http/sameOrigin.ts +1 -1
  140. package/src/http/signedWebhook.ts +3 -3
  141. package/src/http/socket.ts +55 -0
  142. package/src/http/subprotocol.ts +47 -0
  143. package/src/http/validation.ts +50 -8
  144. package/src/migrations/bookkeeping.ts +39 -2
  145. package/src/migrations/groups.ts +295 -0
  146. package/src/migrations/migrateStep.ts +19 -0
  147. package/src/migrations/owner.ts +10 -6
  148. package/src/migrations/retained.ts +2 -2
  149. package/src/migrations/runner.ts +214 -22
  150. package/src/naming/domains.ts +75 -16
  151. package/src/naming/environment.ts +2 -1
  152. package/src/naming/packageName.ts +20 -0
  153. package/src/naming/provisionScope.ts +2 -1
  154. package/src/openapi/document.ts +412 -0
  155. package/src/openapi/routeGroups.ts +25 -0
  156. package/src/text/comments.ts +56 -4
  157. package/src/text/literals.ts +294 -0
  158. package/src/version.generated.ts +1 -1
  159. package/src/workflow/dispatchRoute.ts +13 -6
  160. package/src/workflow/faults.ts +5 -3
  161. package/src/workflow/hostEnv.ts +9 -1
  162. package/src/workflow/loopback.ts +2 -1
@@ -4,10 +4,12 @@
4
4
  import type { D1Database } from "@cloudflare/workers-types";
5
5
  import { type Kysely, sql } from "kysely";
6
6
  import { type MigrationProvider, type MigrationResult, Migrator, NO_MIGRATIONS } from "kysely/migration";
7
+ import { pithyCommand } from "../cli/command";
7
8
  import { causeMessage } from "../error/cause";
8
- import { InternalError } from "../error/pithyError";
9
+ import { InternalError, ValidationError } from "../error/pithyError";
9
10
  import { batchedProvider } from "./batch";
10
- import { MIGRATION_LOCK_TABLE, MIGRATION_TABLE, migrationKysely } from "./bookkeeping";
11
+ import { appliedMigrationChain, MIGRATION_LOCK_TABLE, MIGRATION_TABLE, migrationKysely } from "./bookkeeping";
12
+ import { forgetMigrationGroups, generatedMigrationGroup, readGroupPosition, recordMigrationGroup } from "./groups";
11
13
  import { guardRetained, isDownRefusal, RetainedBudget } from "./retained";
12
14
 
13
15
  /**
@@ -33,6 +35,12 @@ import { guardRetained, isDownRefusal, RetainedBudget } from "./retained";
33
35
  * are argued.** The short version: a migration is now all-or-nothing where it used to be able to
34
36
  * half-apply, and nothing across a migration boundary changed, because the ledger names migrations
35
37
  * and a partial chain has to stay representable in it.
38
+ *
39
+ * **Every run belongs to a group, and a reversal names one — see `./groups` (#694).** A forward run
40
+ * records the group it applied each migration under, in a table beside the ledger;
41
+ * {@link reverseMigrationGroup} reverses one group's portion of one database, in reverse chain order and
42
+ * nothing outside it. {@link rollbackMigration} is still the single step underneath, for a caller holding
43
+ * one migration rather than a release — it is not what `pithy migrate --rollback` runs any more.
36
44
  */
37
45
 
38
46
  /**
@@ -105,13 +113,63 @@ function guardOptions(
105
113
  return { binding: target?.binding ?? "this database", budget: consent?.budget ?? new RetainedBudget(undefined) };
106
114
  }
107
115
 
108
- /** Run every pending migration to latest. An empty provider resolves to `[]`. */
116
+ /**
117
+ * What a forward run records about itself (#694) — see `./groups`.
118
+ *
119
+ * Optional, and absent still records: a run with no group named is a group of its own, stamped with the
120
+ * moment it ran. There is always a group, so a rollback never has to fall back to counting migrations.
121
+ */
122
+ export interface RunGroupOptions {
123
+ /**
124
+ * The group every migration this run applies is recorded under. Absent, a generated ISO-8601 timestamp
125
+ * is used — per run, by construction. A caller spanning several databases passes **one** value to all
126
+ * of them, so one run is one group however many databases it touches.
127
+ */
128
+ group?: string;
129
+ }
130
+
131
+ /**
132
+ * Run every pending migration to latest, recording the run's group against each one. An empty provider
133
+ * resolves to `[]` and records nothing.
134
+ *
135
+ * **The group is written from what Kysely hands back, and before the failure is raised.** A run that
136
+ * half-applies is retried with the key it already has, so the migrations that stuck have to be in the
137
+ * group — otherwise the retry's group would sit on top of an ungrouped migration, and reversing the
138
+ * retry would leave half a release applied with nothing recording it.
139
+ */
109
140
  export async function runMigrations(
110
141
  database: D1Database,
111
142
  provider: MigrationProvider,
112
143
  target?: MigrationTarget,
144
+ options?: RunGroupOptions,
113
145
  ): Promise<MigrationResult[]> {
114
146
  const { error, results } = await migrator(database, provider, target, undefined).migrateToLatest();
147
+ const applied = (results ?? [])
148
+ .filter((result) => result.direction === "Up" && result.status === "Success")
149
+ .map((result) => result.migrationName);
150
+ try {
151
+ await recordMigrationGroup(database, {
152
+ group: options?.group ?? generatedMigrationGroup(),
153
+ migrations: applied,
154
+ });
155
+ } catch (cause) {
156
+ // A migration failure is the operator's first problem and wins the throw; `settle` raises it below.
157
+ // With the run otherwise clean, a ledger that records a migration no group claims is its own fault.
158
+ if (error === undefined) {
159
+ throw new InternalError(
160
+ {
161
+ message: `Applied ${applied.length === 1 ? "a migration" : `${applied.length} migrations`}${on(target)}, then couldn't record the group.`,
162
+ detail: `${where(target)} ${reasonOf(cause)}`,
163
+ // Never "run pithy migrate again to record the group": the second run has nothing pending, so it
164
+ // records nothing and prints `Nothing to migrate.` while the group stays lost. The honest fact is
165
+ // what these migrations can no longer be — the group they were applied under is gone (#694).
166
+ action:
167
+ "Check the database is writable. A second run records nothing for them, so they cannot be reversed by group.",
168
+ },
169
+ { cause },
170
+ );
171
+ }
172
+ }
115
173
  return settle("run", error, results, target);
116
174
  }
117
175
 
@@ -153,9 +211,17 @@ export async function readMigrationLedger(database: D1Database, provider: Migrat
153
211
  }
154
212
 
155
213
  /**
156
- * Step this database's latest applied migration back — one step, per database. `pithy migrate --rollback`
157
- * calls it once for **every** database in its fan-out. Refused while a retained table here holds rows the
158
- * caller has not counted in `consent` (#588).
214
+ * Step this database's latest applied migration back — **one** step, in **one** database.
215
+ *
216
+ * It is no longer what `pithy migrate --rollback` runs: a rollback names a group and reverses that
217
+ * group's portion of each database ({@link reverseMigrationGroup}, #694). What this is, is the single
218
+ * step underneath — the primitive every capability's "its `down` is the inverse of its `up`" suite
219
+ * reverses one migration with, and the honest answer for a caller holding one migration rather than a
220
+ * release.
221
+ *
222
+ * It forgets the reversed migration's group row, so the group bookkeeping follows the ledger whichever
223
+ * way a migration came down. Refused while a retained table here holds rows the caller has not counted in
224
+ * `consent` (#588).
159
225
  */
160
226
  export async function rollbackMigration(
161
227
  database: D1Database,
@@ -164,9 +230,115 @@ export async function rollbackMigration(
164
230
  consent?: RetainedConsent,
165
231
  ): Promise<MigrationResult[]> {
166
232
  const { error, results } = await migrator(database, provider, target, consent).migrateDown();
233
+ await forgetReversed(database, results);
167
234
  return settle("rollback", error, results, target);
168
235
  }
169
236
 
237
+ /** What one database reverses of a group: the group, and its migrations newest first. */
238
+ export interface MigrationGroupStep {
239
+ /** The group being reversed — what the caller passed to `--group`. */
240
+ group: string;
241
+ /**
242
+ * This database's portion of it, in the order it comes down: newest first. The caller reads it from
243
+ * `readGroupPosition`, which is also what proves the group is the top of this database's chain. It says
244
+ * **whether** there is anything to reverse here; what comes down is read from the ledger again below, so
245
+ * a step that named a migration from the group underneath could not reverse it on the caller's word.
246
+ */
247
+ migrations: readonly string[];
248
+ }
249
+
250
+ /**
251
+ * **Reverse one group's portion of one database — the operation behind `pithy migrate --rollback
252
+ * --group` (#694).**
253
+ *
254
+ * Kysely steps down from the tip, so this is `migrateDown()` once per migration in the group, in reverse
255
+ * chain order, and **nothing outside it**. Both halves of that are read from the ledger here, not taken on
256
+ * the caller's word: the group has to be the ledger's own tail before the first `down`, and what each step
257
+ * brings down has to be one of the migrations that read said the group holds — anything else is an
258
+ * internal fault and throws rather than carrying on. **And a migration with no `down` stops the group**:
259
+ * Kysely reports one as `NotExecuted` and leaves it applied, so counting it as reversed is how a group
260
+ * reported itself fully rolled back with half of it still in the database. The caller's pre-flight is what
261
+ * makes a group reverse completely or not at all across databases; this is the floor under it.
262
+ *
263
+ * **One migrator for the whole group**, so the retained guard clears once and the caller's budget is spent
264
+ * once across every `down` it covers (#588). Each migration's group row is forgotten as it comes down —
265
+ * before the step's failure is raised — so a group reversal that dies partway can be re-run under the
266
+ * same group and reverses what is left.
267
+ */
268
+ export async function reverseMigrationGroup(
269
+ database: D1Database,
270
+ provider: MigrationProvider,
271
+ step: MigrationGroupStep,
272
+ target?: MigrationTarget,
273
+ consent?: RetainedConsent,
274
+ ): Promise<MigrationResult[]> {
275
+ if (step.migrations.length === 0) return [];
276
+ // Asked of the ledger before the first `down`, so the ordinary ways to get this wrong — another group
277
+ // applied over this one, or a migration applied under no group at all — are refused rather than
278
+ // diagnosed afterwards, from under a `down` that has already run.
279
+ const position = await readGroupPosition(database, step.group);
280
+ if (position.state !== "top") {
281
+ throw new InternalError({
282
+ message: `Group "${step.group}" is not the top of ${target?.binding ?? "this database"}'s chain.`,
283
+ detail: `${where(target)} ${position.state === "absent" ? "It records nothing under that group." : `Applied over it: ${overIt(position.above, position.ungrouped)}.`}`,
284
+ action: `Run ${pithyCommand("doctor")} to see where each database stands.`,
285
+ });
286
+ }
287
+ // What comes down is what the ledger says this group holds, never the caller's list: a step naming a
288
+ // migration the group does not hold would otherwise be reversed on the caller's word.
289
+ const remaining = new Set(position.migrations);
290
+ const runner = migrator(database, provider, target, consent);
291
+ const reversed: MigrationResult[] = [];
292
+ while (remaining.size > 0) {
293
+ const { error, results } = await runner.migrateDown();
294
+ await forgetReversed(database, results);
295
+ const settled = settle("rollback", error, results, target);
296
+ if (settled.length === 0) break;
297
+ for (const result of settled) {
298
+ // **A migration with no `down` is a refusal, not a reversal.** Kysely's `#migrateDown` runs the body
299
+ // and deletes the ledger row only `if (migration.down)`, so one without it comes back `NotExecuted`
300
+ // — no error, row intact — and `./batch` preserves that shape deliberately. Counted as reversed, the
301
+ // group reported itself fully rolled back while part of it was still applied and still carried its
302
+ // group rows; and when the `down`-less migration was the group's newest, the next pass got the same
303
+ // tip back and the throw below blamed the chain's order for a missing `down`. So it stops here, in
304
+ // the one wording that names the cause, before the rest of the group comes down around it.
305
+ if (result.status !== "Success") {
306
+ throw new ValidationError({
307
+ message: `Migration "${result.migrationName}" has no down, so group "${step.group}" cannot be reversed${on(target)}.`,
308
+ action: `Give ${result.migrationName} a down, then reverse the group again.`,
309
+ // What already came down stays down, and the operator needs it by name to know where they are —
310
+ // the same fact `settle` reports for a forward run that failed partway.
311
+ detail: [
312
+ where(target),
313
+ reversed.length === 0
314
+ ? "Nothing was reversed. A group reverses completely or not at all."
315
+ : `Reversed before the refusal: ${reversed.map((entry) => `"${entry.migrationName}"`).join(", ")}. The rest of the group is still applied.`,
316
+ ]
317
+ .filter(Boolean)
318
+ .join(" "),
319
+ });
320
+ }
321
+ if (!remaining.delete(result.migrationName)) {
322
+ throw new InternalError({
323
+ message: `Reversing group "${step.group}"${on(target)} would have reversed "${result.migrationName}", which is not in it.`,
324
+ detail: `${where(target)} The group was not the top of this database's chain. Kysely steps down from the tip, so only the newest group can be reversed.`,
325
+ action: `Run ${pithyCommand("doctor")} to see where each database stands.`,
326
+ });
327
+ }
328
+ reversed.push(result);
329
+ }
330
+ }
331
+ return reversed;
332
+ }
333
+
334
+ /** Forget the group rows of whatever actually came down, whether or not the step then failed. */
335
+ async function forgetReversed(database: D1Database, results: MigrationResult[] | undefined): Promise<void> {
336
+ const names = (results ?? [])
337
+ .filter((result) => result.direction === "Down" && result.status === "Success")
338
+ .map((result) => result.migrationName);
339
+ await forgetMigrationGroups(database, names);
340
+ }
341
+
170
342
  /**
171
343
  * Fully reset one database's schema: every applied migration's `down` runs, in one pass, in reverse
172
344
  * chronological order — Kysely's `NO_MIGRATIONS` target, not just the latest — then every migration's
@@ -175,6 +347,16 @@ export async function rollbackMigration(
175
347
  * skip-if-exists) simply work afterward — there is no per-row identity problem to solve. An empty
176
348
  * ledger rolls back nothing; an empty provider reapplies nothing. Refused, before the first `down`, while a
177
349
  * retained table here holds rows the caller has not counted in `consent` (#588).
350
+ *
351
+ * **It leaves the group bookkeeping exactly as it was (#694).** The table is not a migration, so the `down`
352
+ * pass cannot remove it, and rewriting the rows would restamp somebody's release as having happened during
353
+ * a `seed --redo`. Every group row still describes a migration this reset reapplied.
354
+ *
355
+ * **What it does not do is record a group for the rest.** A reset reapplies everything *declared*, which
356
+ * includes migrations that were pending when it started, and those come back applied under no group — this
357
+ * takes no group and invents none. Such a migration sits over the groups below it, so a `--rollback
358
+ * --group` of one of them is refused by name (`./groups`, `readGroupPosition`) rather than reversing a
359
+ * migration nobody recorded.
178
360
  */
179
361
  export async function resetMigrations(
180
362
  database: D1Database,
@@ -191,17 +373,12 @@ export async function resetMigrations(
191
373
  }
192
374
 
193
375
  /**
194
- * The applied migration names recorded in the ledger — empty when the ledger table doesn't exist yet.
195
- * Existence is checked against `sqlite_master` (a plain select D1 permits) rather than by catching the
196
- * read's error, so a genuine read failure surfaces instead of being silently treated as "none applied".
376
+ * The applied migration names recorded in the ledger — empty when the ledger table doesn't exist yet. The
377
+ * set half of `appliedMigrationChain`, which is the one read of the ledger: order matters to a group
378
+ * rollback and not at all to a membership test, and two reads of one table would drift.
197
379
  */
198
380
  async function appliedMigrationNames(db: Kysely<unknown>): Promise<Set<string>> {
199
- const present = await sql<{
200
- name: string;
201
- }>`select name from sqlite_master where type = 'table' and name = ${MIGRATION_TABLE}`.execute(db);
202
- if (present.rows.length === 0) return new Set();
203
- const { rows } = await sql<{ name: string }>`select name from ${sql.table(MIGRATION_TABLE)}`.execute(db);
204
- return new Set(rows.map((row) => row.name));
381
+ return new Set(await appliedMigrationChain(db));
205
382
  }
206
383
 
207
384
  /**
@@ -264,6 +441,9 @@ export async function dropMigrations(
264
441
  try {
265
442
  await down(db);
266
443
  await sql`delete from ${sql.table(MIGRATION_TABLE)} where name = ${name}`.execute(db);
444
+ // The group bookkeeping follows the ledger row it describes: a dropped migration is not applied,
445
+ // so nothing may go on recording which run applied it.
446
+ await forgetMigrationGroups(database, [name]);
267
447
  results.push({ migrationName: name, direction: "Down", status: "Success" });
268
448
  } catch (error) {
269
449
  // A refusal is not a failed drop: nothing is broken, and "fix the migration's down" would be a lie.
@@ -273,7 +453,7 @@ export async function dropMigrations(
273
453
  {
274
454
  message: `Couldn't drop "${name}"${on(target)}. ${reasonOf(error)}`,
275
455
  detail: `${where(target)}${dropped.length ? ` Dropped before the failure: ${dropped.join(", ")}.` : ""}`,
276
- action: "Fix the migration's down, or drop its table by hand. Run pithy remove --drop again.",
456
+ action: "Fix the migration's down, or drop its table by hand. Then run this again.",
277
457
  },
278
458
  { cause: error },
279
459
  );
@@ -287,27 +467,39 @@ const VOICE = {
287
467
  run: {
288
468
  failed: (key: string) => `Couldn't apply "${key}"`,
289
469
  fallback: "The migration run failed",
290
- action: "Fix the migration. Run pithy migrate again.",
470
+ action: "Fix the migration, then run this again.",
291
471
  },
292
472
  rollback: {
293
473
  failed: (key: string) => `Couldn't roll back "${key}"`,
294
474
  fallback: "The rollback failed",
295
- // Never "run --rollback again": a rollback steps back every database, so a second one after a partial
296
- // failure reverses a second migration in each database that already moved (#588).
297
- action: "Fix the migration's down. Run pithy doctor to see where each database stands.",
475
+ // Still not a bare "run --rollback again" — that command reverses nothing now, and before #694 it
476
+ // stepped a second migration back in every database that had already moved (#588). Re-running the
477
+ // *same group* is the safe retry: each migration's group row is forgotten as it comes down, so a
478
+ // second pass reverses what is left and nothing else. The group is on the command, not here.
479
+ action: `Fix the migration's down. Run ${pithyCommand("doctor")} to see where each database stands.`,
298
480
  },
299
481
  resetDown: {
300
482
  failed: (key: string) => `Couldn't roll back "${key}" during reset`,
301
483
  fallback: "The schema reset failed while rolling back",
302
- action: "Fix the migration. Run pithy seed --redo again.",
484
+ action: "Fix the migration, then run this again.",
303
485
  },
304
486
  resetUp: {
305
487
  failed: (key: string) => `Couldn't reapply "${key}" during reset`,
306
488
  fallback: "The schema reset failed while reapplying",
307
- action: "Fix the migration. Run pithy seed --redo again.",
489
+ action: "Fix the migration, then run this again.",
308
490
  },
309
491
  } as const;
310
492
 
493
+ /**
494
+ * What sits over a buried group, groups and ungrouped migrations alike, newest first.
495
+ *
496
+ * An ungrouped migration is named with its state, because the two are different problems: another group is
497
+ * something to reverse first, while a migration no group claims cannot be reversed by group at all.
498
+ */
499
+ function overIt(above: readonly string[], ungrouped: readonly string[]): string {
500
+ return [...above, ...ungrouped.map((name) => `${name} (ungrouped)`)].join(", ");
501
+ }
502
+
311
503
  /** ` on DB`, or nothing at all — every problem line here ends with this and then a period. */
312
504
  function on(target: MigrationTarget | undefined): string {
313
505
  return target ? ` on ${target.binding}` : "";
@@ -72,7 +72,73 @@ const PUNYCODE_LABEL = /(^|\.)xn--/;
72
72
  * them rather than a side effect of a parser version.
73
73
  */
74
74
  export function isPublicHostname(hostname: string): boolean {
75
- return hostname.length <= 253 && HOSTNAME_PATTERN.test(hostname) && !PUNYCODE_LABEL.test(hostname);
75
+ return publicHostnameProblem(hostname) === null;
76
+ }
77
+
78
+ /** The DNS limit on a whole name, and the only length any caller measures. */
79
+ const MAX_HOSTNAME_LENGTH = 253;
80
+
81
+ /**
82
+ * Why a string is not a hostname a Worker can answer on — the reason, not a boolean.
83
+ *
84
+ * **It exists so a refusal can say which rule it broke.** {@link isPublicHostname} is the answer when the
85
+ * caller only needs yes or no; `WorkerDomain` needs the reason, because the three are not interchangeable
86
+ * to the adopter reading the message. A name over the limit is a typo. A name with a scheme on it is a
87
+ * misunderstanding. An internationalized name is neither — it is a thing this kit cannot do, for a reason
88
+ * that is not this kit's, and a message about bare hostnames would send that adopter looking for a typo
89
+ * they do not have (#665).
90
+ *
91
+ * Ordered by how specific the answer is. The length check is first because a name that long is over the
92
+ * limit whatever else is wrong with it, and `punycode` last because it is only reachable for a string that
93
+ * is otherwise a well-formed hostname.
94
+ */
95
+ export function publicHostnameProblem(hostname: string): HostnameProblem | null {
96
+ if (hostname.length > MAX_HOSTNAME_LENGTH) return "too-long";
97
+ if (!HOSTNAME_PATTERN.test(hostname)) return "shape";
98
+ if (PUNYCODE_LABEL.test(hostname)) return "punycode";
99
+ return null;
100
+ }
101
+
102
+ /** The three ways a string fails {@link publicHostnameProblem}, each with its own sentence to say. */
103
+ export type HostnameProblem = "too-long" | "shape" | "punycode";
104
+
105
+ /**
106
+ * What an adopter is told, per reason, for a hostname this kit refuses.
107
+ *
108
+ * `shape` is the caller's to say, because what a bare hostname is differs between a `pattern` and a
109
+ * `zone` and each already had a sentence worth keeping. The other two are the same wherever they are hit.
110
+ *
111
+ * **The internationalized-domain sentence names Cloudflare**, and that is deliberate: the limit is not
112
+ * ours. `pithy-sh/pithy#691` tested it against a real zone — adding `tëst.pithy-sh.com` as a Worker
113
+ * Custom Domain returns *Internationalized Domain Names not supported* — so the kit refuses what the
114
+ * platform will not route rather than inventing a rule of its own. `docs/ACCEPTED-LIMITS.md` carries the
115
+ * evidence and the condition that would lift it.
116
+ */
117
+ const HOSTNAME_PROBLEM_MESSAGES: Readonly<Record<Exclude<HostnameProblem, "shape">, string>> = {
118
+ "too-long": `A hostname is at most ${MAX_HOSTNAME_LENGTH} characters — the DNS limit on a whole name.`,
119
+ punycode:
120
+ "Internationalized domains are not supported: Cloudflare Workers will not route one, so a Worker could never answer on it. Use an ASCII hostname.",
121
+ };
122
+
123
+ /**
124
+ * One `pattern` or `zone` field, held to the one hostname rule.
125
+ *
126
+ * **Through {@link publicHostnameProblem}, never `HOSTNAME_PATTERN` directly.** A configured domain was
127
+ * the third caller of that rule and the only one that did not go through the function — it handed the
128
+ * pattern to `.regex()`, so it took a punycode A-label and a 267-character name that `pithy seed --host`
129
+ * refused for the same string. Two answers to one question, forty lines apart (#665). A rule added to the
130
+ * function now reaches a configured domain without anyone remembering to.
131
+ */
132
+ function hostnameField(shapeMessage: string): z.ZodString {
133
+ return z.string().check((ctx) => {
134
+ const problem = publicHostnameProblem(ctx.value);
135
+ if (problem === null) return;
136
+ ctx.issues.push({
137
+ code: "custom",
138
+ input: ctx.value,
139
+ message: problem === "shape" ? shapeMessage : HOSTNAME_PROBLEM_MESSAGES[problem],
140
+ });
141
+ });
76
142
  }
77
143
 
78
144
  /** The environments a domain may be declared for — every managed one, never `dev`. */
@@ -81,21 +147,14 @@ export const DOMAIN_ENVIRONMENTS = ENVIRONMENTS.filter((environment) => environm
81
147
  /** One environment's public address for one Worker. */
82
148
  export const WorkerDomain = z
83
149
  .object({
84
- pattern: z
85
- .string()
86
- .regex(
87
- HOSTNAME_PATTERN,
88
- "A domain is a bare hostname — no scheme, no path, no port (e.g. `api.example.com`, not `https://api.example.com/`).",
89
- )
90
- .describe(
91
- "The hostname this Worker answers on in this environment, e.g. `api.example.com`. Written into `wrangler.jsonc` as a `routes` entry with `custom_domain: true`, and into `vars.BASE_URL` as `https://<pattern>`. A bare hostname rather than a URL, because that is what wrangler's route matcher takes — a scheme here would be silently wrong.",
92
- ),
93
- zone: z
94
- .string()
95
- .regex(HOSTNAME_PATTERN, "A zone is the registrable domain on your Cloudflare account, e.g. `example.com`.")
96
- .describe(
97
- "The Cloudflare zone `pattern` sits under — the registrable domain as it appears on your account, e.g. `example.com` for `api.example.com`. Cloudflare needs it to attach a custom domain, and it is not always derivable from the hostname: a zone can be a subdomain, and a public-suffix guess would be wrong for exactly the adopters who are hardest to debug.",
98
- ),
150
+ pattern: hostnameField(
151
+ "A domain is a bare hostname — no scheme, no path, no port (e.g. `api.example.com`, not `https://api.example.com/`).",
152
+ ).describe(
153
+ "The hostname this Worker answers on in this environment, e.g. `api.example.com`. Written into `wrangler.jsonc` as a `routes` entry with `custom_domain: true`, and into `vars.BASE_URL` as `https://<pattern>`. A bare hostname rather than a URL, because that is what wrangler's route matcher takes — a scheme here would be silently wrong.",
154
+ ),
155
+ zone: hostnameField("A zone is the registrable domain on your Cloudflare account, e.g. `example.com`.").describe(
156
+ "The Cloudflare zone `pattern` sits under — the registrable domain as it appears on your account, e.g. `example.com` for `api.example.com`. Cloudflare needs it to attach a custom domain, and it is not always derivable from the hostname: a zone can be a subdomain, and a public-suffix guess would be wrong for exactly the adopters who are hardest to debug.",
157
+ ),
99
158
  })
100
159
  .describe("Where one Worker answers in one environment: the hostname, and the Cloudflare zone it sits under.")
101
160
  .check((ctx) => {
@@ -2,6 +2,7 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
4
  import { z } from "zod";
5
+ import { commandName } from "../cli/command";
5
6
  import { PithyError, ValidationError } from "../error/pithyError";
6
7
  import { NAME_SEGMENT } from "./segment";
7
8
 
@@ -144,7 +145,7 @@ export function assertValidEnvironment(name: string): void {
144
145
  throw new ValidationError({
145
146
  message: `"${name}" starts the way a feature's name does.`,
146
147
  action: "Start the environment with something other than f and a number.",
147
- detail: `A feature takes the environment's slot with f<issue>, so <project>-${name}-<worker> can be a feature's Worker name exactly, and \`pithy feature destroy\` deletes that Worker.`,
148
+ detail: `A feature takes the environment's slot with f<issue>, so <project>-${name}-<worker> can be a feature's Worker name exactly, and \`${commandName("feature destroy")}\` deletes that Worker.`,
148
149
  });
149
150
  }
150
151
  if (name.length > MAX_ENVIRONMENT_NAME && NAME_SEGMENT.test(name)) {
@@ -0,0 +1,20 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * The package a bare specifier names: `@pithy-sh/payments` from `@pithy-sh/payments/src/capability`, and
6
+ * `stripe` from `stripe/lib/x` — **scope-aware, and the only copy in the kit (#709)**.
7
+ *
8
+ * There were five. Three took the first segment or two depending on a leading `@`; two took the first two
9
+ * segments whatever they were, which is right for every `@pithy-sh/*` name they were written against and
10
+ * wrong for the first unscoped dependency anyone passed them. A rule that small drifts precisely because
11
+ * nobody thinks it needs a home, so it has one. That it is the only copy is a gate, not a claim: `packages/cli/src/ci/packageOfCopies.test.ts` fails on any source that writes the rule out by hand, because the first migration left three behind and said it had not.
12
+ *
13
+ * Total, and deliberately unopinionated about what it is handed: a caller with a relative path, a `#`
14
+ * import or a URL gets the first segment back. Deciding whether a specifier is a package at all is the
15
+ * unresolved-import classifier's job (`error/unresolved.ts`), not this function's.
16
+ */
17
+ export function packageOf(specifier: string): string {
18
+ const parts = specifier.split("/");
19
+ return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : (parts[0] ?? specifier);
20
+ }
@@ -1,6 +1,7 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
+ import { commandName } from "../cli/command";
4
5
  import { InternalError, ValidationError } from "../error/pithyError";
5
6
  import type { WorkflowHostNameParts } from "../workflow/naming";
6
7
  import { assertValidEnvironment, FEATURE_ENVIRONMENT, GLOBAL_SCOPE, isFeatureMarker } from "./environment";
@@ -336,7 +337,7 @@ export function environmentScope(project: string, environment: string): Provisio
336
337
  throw new ValidationError({
337
338
  message: `The ${environment} Worker "${name}" is named the way a feature's Worker is.`,
338
339
  action: `Rename it in env.${environment}.name, or the Worker's top-level name, so it does not follow ${kebab(project)}- with f and a number.`,
339
- detail: `${declared === undefined ? "Composed by wrangler from the deploy name" : `Declared in env.${environment}.name`}. A feature's Worker is <project>-f<issue>-<slug>-<app>, and \`pithy feature destroy\` deletes it by that exact name.`,
340
+ detail: `${declared === undefined ? "Composed by wrangler from the deploy name" : `Declared in env.${environment}.name`}. A feature's Worker is <project>-f<issue>-<slug>-<app>, and \`${commandName("feature destroy")}\` deletes it by that exact name.`,
340
341
  });
341
342
  }
342
343
  return name;