@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
@@ -0,0 +1,295 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+ import type { Generated, Kysely } from "kysely";
6
+ import { sql } from "kysely";
7
+ import { z } from "zod";
8
+ import { chunkByBoundParameters, chunkRowsByBoundParameters } from "../data/boundParameters";
9
+ import { SQLiteDate } from "../data/codecs";
10
+ import { appliedMigrationChain, MIGRATION_GROUP_TABLE, migrationKysely } from "./bookkeeping";
11
+
12
+ /**
13
+ * **Which run applied each migration — the group a rollback names (#694).**
14
+ *
15
+ * `pithy migrate --rollback` used to step back one migration in every database the environment binds, and
16
+ * nothing recorded what a single run had applied. So reversing a deploy that moved three migrations was
17
+ * three invocations and the count came from outside the tool — a human's memory, or a CI log read under
18
+ * pressure. Every run belongs to a group now: the caller's own `--group <value>`, or a generated ISO-8601
19
+ * timestamp, recorded here against every migration the run applies, in every database it touches.
20
+ *
21
+ * **It cannot live in the ledger.** `pithy_migrations` is Kysely's own table, renamed off its default
22
+ * (`./bookkeeping`); Kysely creates it and its insert sets `name` and `timestamp` only. So the group sits
23
+ * in a table *beside* it, written by the runner from the `MigrationResult[]` Kysely hands back — the same
24
+ * placement, and the same reasoning, as `pithy_migrations_owner`: it is not a migration, so no capability
25
+ * ships it, `--rollback` cannot step it back, and `pithy seed --redo`'s full reset cannot wipe it. What a
26
+ * reversal removes is the reversed migrations' own rows, which is bookkeeping catching up with the ledger.
27
+ *
28
+ * **The rows say which run; the ledger says which order.** Every applied migration is recorded here as it
29
+ * applies and forgotten as it is reversed, so these rows are the membership. Where a group *sits* is a
30
+ * question about the ledger, though, because the ledger is what Kysely steps down from — so {@link
31
+ * readGroupPosition} reads both (`appliedMigrationChain`) and compares the group against the chain's tail.
32
+ * Reading the group table alone would answer from the order this table happened to be written in, and the
33
+ * two differ in exactly the cases that matter: a migration applied with no group row at all.
34
+ *
35
+ * **What it cannot see, it names.** A migration applied before this table existed carries no row, and so
36
+ * does one a `seed --redo` reset reapplied or one whose group write failed. Such a migration over a group
37
+ * buries it — reversing the group would reverse a migration nobody recorded — and the position says so by
38
+ * name rather than calling the group reversible. Reading is cheap and additive: one group row per applied
39
+ * migration, and the table is created on the first run that applies anything.
40
+ */
41
+
42
+ /** One applied migration and the run that applied it. The three columns are the whole table. */
43
+ export const MigrationGroupRecord = z
44
+ .object({
45
+ groupKey: z
46
+ .string()
47
+ .describe(
48
+ "The group this migration was applied under — the caller's `--group` value, or the generated ISO-8601 timestamp of the run. Not `group`: that is SQL's own word, and an operator reading this table by hand should not have to quote a column.",
49
+ ),
50
+ migration: z.string().describe("The composed migration name, exactly as the ledger records it."),
51
+ appliedAt: SQLiteDate.describe("When this migration was applied — the moment the run recorded it."),
52
+ })
53
+ .describe("One applied migration's membership of a migration group, recorded beside the ledger.");
54
+ export type MigrationGroupRecord = z.output<typeof MigrationGroupRecord>;
55
+
56
+ /**
57
+ * The group table as Kysely sees it: the schema's `z.input` (SQLite row) side — derived, never a
58
+ * hand-written row interface — plus the autoincrementing key that makes insertion order readable.
59
+ * `CamelCasePlugin` passes the already-snake_case table name through unchanged and maps every column.
60
+ */
61
+ interface GroupDatabase {
62
+ pithy_migrations_groups: z.input<typeof MigrationGroupRecord> & { id: Generated<number> };
63
+ }
64
+
65
+ /** One group as it stands in one database: what it applied here, and when it last did. */
66
+ export interface MigrationGroup {
67
+ /** The group's value — what `--rollback --group` takes back. */
68
+ group: string;
69
+ /**
70
+ * When this group last applied something here. A group extended by a retry carries the later moment,
71
+ * because that is the one that decides which group is newest.
72
+ */
73
+ appliedAt: Date;
74
+ /** The migrations this group applied in this database, in the order they were applied. */
75
+ migrations: string[];
76
+ }
77
+
78
+ /**
79
+ * Where a group sits in one database's chain — the question a rollback asks before it reverses anything.
80
+ *
81
+ * Three states, kept apart because they are three different answers. `absent` is *this database applied
82
+ * nothing under that group*, which for a group spanning two databases is ordinary and not a refusal.
83
+ * `buried` is the refusal: Kysely steps down from the tip, so reversing it would mean reversing the
84
+ * migrations over it, from groups nobody named.
85
+ */
86
+ export type GroupPosition =
87
+ | { state: "absent" }
88
+ | {
89
+ /** The group's migrations are the top of this database's chain. */
90
+ state: "top";
91
+ /** Them, in the order they will be reversed — newest first. */
92
+ migrations: string[];
93
+ }
94
+ | {
95
+ /** Something else sits over the group, or inside it. */
96
+ state: "buried";
97
+ /**
98
+ * The groups in the way, newest first: applied over its newest migration, or inside it by a run that
99
+ * came between two of its own. A group extended after another ran names that one.
100
+ */
101
+ above: string[];
102
+ /**
103
+ * The migrations in the way that no group claims, newest first — an older kit's, a reset's re-apply, or
104
+ * a run whose group write failed. They bury the group as surely as another group does, and nothing can
105
+ * be reversed by group while one of them is at the tip.
106
+ */
107
+ ungrouped: string[];
108
+ /**
109
+ * The group whose own migrations *are* the contiguous tip here, when there is one — the only group a
110
+ * refusal may name as reversible. Absent when the tip is interleaved or ungrouped, which is the state
111
+ * where naming anything would print a command that refuses in its turn.
112
+ */
113
+ top?: string;
114
+ };
115
+
116
+ /** What {@link recordMigrationGroup} writes: the group, what it applied, and when. */
117
+ export interface RecordGroupOptions {
118
+ /** The group every migration in this call was applied under. */
119
+ group: string;
120
+ /** The migrations applied, in application order. Empty writes nothing at all. */
121
+ migrations: readonly string[];
122
+ /** When they applied. Defaults to now; passed explicitly by tests and by a caller that stamps a run. */
123
+ at?: Date;
124
+ }
125
+
126
+ /**
127
+ * The group a run with no `--group` belongs to: an **ISO-8601 timestamp**, `2026-10-03T19:52:47.611Z`.
128
+ *
129
+ * A timestamp rather than a date, because one run is one group. Date-only would silently merge two
130
+ * unrelated runs on a busy day into one group, and reversing it would then undo more than the caller did.
131
+ * The precision costs nothing, it sorts lexicographically into chronological order, and it tells an
132
+ * operator *when* without their having named anything.
133
+ */
134
+ export function generatedMigrationGroup(now: Date = new Date()): string {
135
+ return now.toISOString();
136
+ }
137
+
138
+ /** Whether the group table has been created yet — a plain `sqlite_master` select, which D1 permits. */
139
+ async function groupTableExists(db: Kysely<GroupDatabase>): Promise<boolean> {
140
+ const { rows } = await sql<{
141
+ name: string;
142
+ }>`select name from sqlite_master where type = 'table' and name = ${MIGRATION_GROUP_TABLE}`.execute(db);
143
+ return rows.length > 0;
144
+ }
145
+
146
+ /** Every row, in the order it was written — which is the order the migrations applied. */
147
+ async function groupRows(db: Kysely<GroupDatabase>): Promise<MigrationGroupRecord[]> {
148
+ if (!(await groupTableExists(db))) return [];
149
+ const rows = await db
150
+ .selectFrom("pithy_migrations_groups")
151
+ .select(["groupKey", "migration", "appliedAt"])
152
+ .orderBy("id")
153
+ .execute();
154
+ return rows.map((row) => MigrationGroupRecord.parse(row));
155
+ }
156
+
157
+ /**
158
+ * How many parameters one group row binds — derived from the schema, so a fourth column re-derives the
159
+ * chunk size instead of silently taking the insert back over D1's cap.
160
+ */
161
+ const GROUP_ROW_COLUMNS = Object.keys(MigrationGroupRecord.shape).length;
162
+
163
+ /**
164
+ * Record a group against the migrations a run just applied, creating the table on demand. Idempotent in
165
+ * the only sense that matters: a migration applies once, so a name arrives here once — and a second call
166
+ * under the same group **extends** it, which is the retry case a release whose migrate half-failed needs.
167
+ */
168
+ export async function recordMigrationGroup(database: D1Database, options: RecordGroupOptions): Promise<void> {
169
+ if (options.migrations.length === 0) return;
170
+ const db = migrationKysely<GroupDatabase>(database);
171
+ await db.schema
172
+ .createTable(MIGRATION_GROUP_TABLE)
173
+ .ifNotExists()
174
+ .addColumn("id", "integer", (column) => column.primaryKey().autoIncrement())
175
+ .addColumn("groupKey", "text", (column) => column.notNull())
176
+ .addColumn("migration", "text", (column) => column.notNull())
177
+ .addColumn("appliedAt", "integer", (column) => column.notNull())
178
+ .execute();
179
+
180
+ const appliedAt = options.at ?? new Date();
181
+ const rows = options.migrations.map((migration) =>
182
+ MigrationGroupRecord.encode({ groupKey: options.group, migration, appliedAt }),
183
+ );
184
+ // One insert per chunk, because an insert binds one parameter per column per row and D1 takes 100 in a
185
+ // statement: one multi-row insert broke at 34 migrations, which is a chain a fresh environment applies in
186
+ // one `pithy provision` — and it broke *after* the schema was promoted, leaving the release ungrouped.
187
+ for (const chunk of chunkRowsByBoundParameters(rows, GROUP_ROW_COLUMNS)) {
188
+ await db.insertInto("pithy_migrations_groups").values(chunk).execute();
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Every group this database records, oldest first by when each last applied something — and so the last
194
+ * entry is the newest group, which is what a rollback with no `--group` names. Empty for a database
195
+ * nothing has grouped, and reading creates nothing.
196
+ */
197
+ export async function readMigrationGroups(database: D1Database): Promise<MigrationGroup[]> {
198
+ const rows = await groupRows(migrationKysely<GroupDatabase>(database));
199
+ const byGroup = new Map<string, MigrationGroup & { order: number }>();
200
+ for (const [index, row] of rows.entries()) {
201
+ const existing = byGroup.get(row.groupKey);
202
+ if (existing) {
203
+ existing.migrations.push(row.migration);
204
+ existing.appliedAt = row.appliedAt > existing.appliedAt ? row.appliedAt : existing.appliedAt;
205
+ existing.order = index;
206
+ continue;
207
+ }
208
+ byGroup.set(row.groupKey, {
209
+ group: row.groupKey,
210
+ appliedAt: row.appliedAt,
211
+ migrations: [row.migration],
212
+ order: index,
213
+ });
214
+ }
215
+ return [...byGroup.values()]
216
+ .sort((left, right) => left.order - right.order)
217
+ .map(({ group, appliedAt, migrations }) => ({ group, appliedAt, migrations }));
218
+ }
219
+
220
+ /**
221
+ * Where `group` sits in this database's chain — see {@link GroupPosition}.
222
+ *
223
+ * The test is *is this group the tail of the **ledger***, not *is it the newest by timestamp*: a group
224
+ * extended after another run has the later timestamp and still has that run's migration inside it, so
225
+ * reversing it would reverse the other group's work. Hence the two halves of one rule — nothing applied
226
+ * over the group's newest migration, and nothing applied inside it between two of its own.
227
+ *
228
+ * **Against the ledger, because the ledger is what comes down.** `migrateDown()` steps the ledger's tip,
229
+ * so a group whose rows are the tail of the *group table* is still buried when an ungrouped migration sits
230
+ * over it in the ledger — and that state is reachable from the ordinary dev loop, where `seed --redo`'s
231
+ * reset reapplies a pending migration under no group at all. Asked of the group table alone, this returned
232
+ * `top`, the `down` reversed a migration nobody asked about, and the refusal arrived after the damage.
233
+ */
234
+ export async function readGroupPosition(database: D1Database, group: string): Promise<GroupPosition> {
235
+ const db = migrationKysely<GroupDatabase>(database);
236
+ const groupOf = new Map((await groupRows(db)).map((row) => [row.migration, row.groupKey]));
237
+ const chain = await appliedMigrationChain(db);
238
+ const held = (name: string): boolean => groupOf.get(name) === group;
239
+ const first = chain.findIndex(held);
240
+ // Nothing of this group is *applied* here: a group narrowed away, already reversed, or recorded against
241
+ // rows the ledger no longer holds. Either way there is nothing here to step down.
242
+ if (first === -1) return { state: "absent" };
243
+ const last = chain.length - 1 - [...chain].reverse().findIndex(held);
244
+ const span = chain.slice(first, last + 1);
245
+ // Two different ways to be in the way, and the group is only on top when neither is: migrations applied
246
+ // *over* its newest, and migrations applied *inside* it by a run that came between two of its own.
247
+ const over = chain.slice(last + 1);
248
+ const inside = span.filter((name) => !held(name));
249
+ if (over.length === 0 && inside.length === 0) {
250
+ return { state: "top", migrations: span.filter(held).reverse() };
251
+ }
252
+ // Newest first, over before inside, because that is the order they would have to come down in.
253
+ const blocking = [...[...over].reverse(), ...[...inside].reverse()];
254
+ const above = [...new Set(blocking.map((name) => groupOf.get(name)).filter(isGroupKey))];
255
+ const ungrouped = blocking.filter((name) => !groupOf.has(name));
256
+ const top = contiguousTipGroup(chain, groupOf);
257
+ return { state: "buried", above, ungrouped, ...(top === undefined ? {} : { top }) };
258
+ }
259
+
260
+ /** A `groupOf` lookup that found a group, for narrowing the `above` list. */
261
+ function isGroupKey(key: string | undefined): key is string {
262
+ return key !== undefined;
263
+ }
264
+
265
+ /**
266
+ * The group whose own migrations are the contiguous tip of `chain`, if any.
267
+ *
268
+ * What it is for is the refusals: *reverse the group on top first* is only advice if such a group exists,
269
+ * and after a group was extended over another one none does — each is inside the other, so naming either
270
+ * prints a command that refuses in its turn and the operator is sent in a circle (#694). The candidate can
271
+ * only be the group holding the tip migration, so there is one to test, not a search.
272
+ */
273
+ function contiguousTipGroup(chain: readonly string[], groupOf: Map<string, string>): string | undefined {
274
+ const tip = chain.at(-1);
275
+ const candidate = tip === undefined ? undefined : groupOf.get(tip);
276
+ if (candidate === undefined) return undefined;
277
+ const held = chain.filter((name) => groupOf.get(name) === candidate).length;
278
+ const tail = chain.slice(chain.length - held);
279
+ return tail.every((name) => groupOf.get(name) === candidate) ? candidate : undefined;
280
+ }
281
+
282
+ /**
283
+ * Forget the group rows of migrations that are no longer applied — what a reversal writes once the `down`
284
+ * has run. By migration name alone, because the ledger's name is unique: one applied migration, one row.
285
+ * A database with no group table has nothing to forget.
286
+ */
287
+ export async function forgetMigrationGroups(database: D1Database, migrations: readonly string[]): Promise<void> {
288
+ if (migrations.length === 0) return;
289
+ const db = migrationKysely<GroupDatabase>(database);
290
+ if (!(await groupTableExists(db))) return;
291
+ // An `in (…)` list binds one parameter per value, so a long reversal is chunked under D1's cap too.
292
+ for (const chunk of chunkByBoundParameters(migrations, 0)) {
293
+ await db.deleteFrom("pithy_migrations_groups").where("migration", "in", chunk).execute();
294
+ }
295
+ }
@@ -0,0 +1,19 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { commandName, pithyCommand } from "../cli/command";
5
+ import { type AmbientEnv, compositionEnvironment } from "../env/ambient";
6
+
7
+ /**
8
+ * **The step that migrates the environment a Worker runs in**, as a sentence ends with it: `then ${step}.`
9
+ *
10
+ * `pithy migrate` defaults to `dev`, so a line that names it bare sends a staging Worker's operator to migrate
11
+ * their laptop (#711). A Worker knows its environment when the composition stamped one (`ENVIRONMENT`), and then
12
+ * the command names it; when it does not, the line says which flag to name rather than naming a command that
13
+ * would act on the wrong database.
14
+ */
15
+ export function migrateStep(env: AmbientEnv): string {
16
+ const environment = compositionEnvironment(env);
17
+ if (environment === undefined) return `migrate this environment with ${commandName("migrate")}, naming it with --env`;
18
+ return `run ${pithyCommand("migrate", { flags: { "--env": environment } })}`;
19
+ }
@@ -93,11 +93,15 @@ async function currentOwner(db: Kysely<OwnerDatabase>): Promise<MigrationOwner |
93
93
  return row ? MigrationOwner.parse(row) : undefined;
94
94
  }
95
95
 
96
- /** The refusal: both project names in the problem line, both remedies in the action line. */
97
- function foreignDatabase(owner: string, project: string, binding: string | undefined): ConflictError {
96
+ /**
97
+ * The refusal for a database another project owns: both project names in the problem line, both remedies in the
98
+ * action line. Exported so a reader that only checks the claim — `pithy migrate --dry-run`, which stamps nothing —
99
+ * refuses in the words the claim refuses with, rather than a copy of them (#697).
100
+ */
101
+ export function foreignOwnerRefusal(owner: string, project: string, binding: string | undefined): ConflictError {
98
102
  return new ConflictError({
99
103
  message: `${binding ?? "This database"} belongs to project "${owner}", not "${project}".`,
100
- action: `Bind ${project} to its own database, or set the project name back to ${owner}. Run pithy migrate again.`,
104
+ action: `Bind ${project} to its own database, or set the project name back to ${owner}. Then run this again.`,
101
105
  detail: `Recorded owner "${owner}" in ${MIGRATION_OWNER_TABLE}.`,
102
106
  });
103
107
  }
@@ -133,7 +137,7 @@ export async function claimMigrationOwnership(
133
137
  const db = migrationKysely<OwnerDatabase>(database);
134
138
  const existing = await currentOwner(db);
135
139
  if (existing) {
136
- if (existing.project !== project) throw foreignDatabase(existing.project, project, options.binding);
140
+ if (existing.project !== project) throw foreignOwnerRefusal(existing.project, project, options.binding);
137
141
  return { project, outcome: "confirmed", stampedAt: existing.stampedAt };
138
142
  }
139
143
 
@@ -157,10 +161,10 @@ export async function claimMigrationOwnership(
157
161
  if (!settled) {
158
162
  throw new ConflictError({
159
163
  message: `${options.binding ?? "This database"} could not be stamped as "${project}".`,
160
- action: "Check the database is writable. Run pithy migrate again.",
164
+ action: "Check the database is writable, then run this again.",
161
165
  detail: `No row in ${MIGRATION_OWNER_TABLE} after the claim insert.`,
162
166
  });
163
167
  }
164
- if (settled.project !== project) throw foreignDatabase(settled.project, project, options.binding);
168
+ if (settled.project !== project) throw foreignOwnerRefusal(settled.project, project, options.binding);
165
169
  return { project, outcome: "adopted", stampedAt: settled.stampedAt };
166
170
  }
@@ -12,8 +12,8 @@ import { declareRetainedDown, retainedTablesOf, retainedTablesOfDown } from "./r
12
12
  *
13
13
  * A migration's `down` is the tested inverse of its `up`, and for schema that is exactly right. For a
14
14
  * vault it is a `drop table` on the only copy of a credential Google issued. `0100_secrets_0001_init` is
15
- * the whole history of the secrets database, so one `pithy migrate --rollback` — which steps back one
16
- * migration in every composed database — emptied it in place, on staging, with nothing said first.
15
+ * the whole history of the secrets database, so one `pithy migrate --rollback` — which then stepped back
16
+ * one migration in every composed database — emptied it in place, on staging, with nothing said first.
17
17
  *
18
18
  * So a capability **declares** a table retained, beside the migrations that create it
19
19
  * (`DatabaseSpec.retained`), and the declaration is recorded on those migrations' own `down` functions