@pithy-sh/core 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/capability/capability.d.ts.map +1 -1
- package/dist/capability/capability.js +2 -0
- package/dist/capability/settings.d.ts +15 -2
- package/dist/capability/settings.d.ts.map +1 -1
- package/dist/data/databases.d.ts +11 -0
- package/dist/data/databases.d.ts.map +1 -1
- package/dist/migrations/registry.d.ts +6 -0
- package/dist/migrations/registry.d.ts.map +1 -1
- package/dist/migrations/registry.js +2 -0
- package/dist/migrations/retained.d.ts +101 -0
- package/dist/migrations/retained.d.ts.map +1 -0
- package/dist/migrations/retained.js +243 -0
- package/dist/migrations/retainedDeclaration.d.ts +15 -0
- package/dist/migrations/retainedDeclaration.d.ts.map +1 -0
- package/dist/migrations/retainedDeclaration.js +49 -0
- package/dist/migrations/runner.d.ts +44 -7
- package/dist/migrations/runner.d.ts.map +1 -1
- package/dist/migrations/runner.js +41 -15
- package/dist/naming/environment.d.ts +12 -0
- package/dist/naming/environment.d.ts.map +1 -1
- package/dist/naming/environment.js +38 -1
- package/dist/naming/feature.d.ts +7 -1
- package/dist/naming/feature.d.ts.map +1 -1
- package/dist/naming/feature.js +10 -3
- package/dist/naming/provisionScope.d.ts +97 -9
- package/dist/naming/provisionScope.d.ts.map +1 -1
- package/dist/naming/provisionScope.js +87 -12
- package/dist/naming/resourceNames.d.ts +5 -2
- package/dist/naming/resourceNames.d.ts.map +1 -1
- package/dist/naming/resourceNames.js +1 -1
- package/dist/text/comments.d.ts +14 -0
- package/dist/text/comments.d.ts.map +1 -1
- package/dist/text/comments.js +28 -2
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/workflow/host.d.ts +8 -0
- package/dist/workflow/host.d.ts.map +1 -1
- package/dist/workflow/host.js +5 -2
- package/dist/workflow/hostEnv.d.ts +1 -1
- package/dist/workflow/hostEnv.js +1 -1
- package/package.json +1 -1
- package/src/capability/capability.ts +6 -0
- package/src/capability/settings.ts +15 -2
- package/src/data/databases.ts +11 -0
- package/src/migrations/registry.ts +10 -0
- package/src/migrations/retained.ts +319 -0
- package/src/migrations/retainedDeclaration.ts +56 -0
- package/src/migrations/runner.ts +89 -15
- package/src/naming/environment.ts +43 -0
- package/src/naming/feature.ts +10 -3
- package/src/naming/provisionScope.ts +130 -13
- package/src/naming/resourceNames.ts +6 -3
- package/src/text/comments.ts +31 -1
- package/src/version.generated.ts +1 -1
- package/src/workflow/host.ts +14 -4
- package/src/workflow/hostEnv.ts +1 -1
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
5
|
+
import { CamelCasePlugin } from "kysely";
|
|
6
|
+
import type { Migration, MigrationProvider } from "kysely/migration";
|
|
7
|
+
import { ValidationError } from "../error/pithyError";
|
|
8
|
+
import { declareRetainedDown, retainedTablesOf, retainedTablesOfDown } from "./retainedDeclaration";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* **A retained table holds rows that exist nowhere else (#588).**
|
|
12
|
+
*
|
|
13
|
+
* A migration's `down` is the tested inverse of its `up`, and for schema that is exactly right. For a
|
|
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.
|
|
17
|
+
*
|
|
18
|
+
* So a capability **declares** a table retained, beside the migrations that create it
|
|
19
|
+
* (`DatabaseSpec.retained`), and the declaration is recorded on those migrations' own `down` functions
|
|
20
|
+
* (`./retainedDeclaration`) — by `defineCapability`, and again by `createMigrationRegistry` for a set that
|
|
21
|
+
* carries it. The refusal is on the operation that runs a `down`, not on a list of commands:
|
|
22
|
+
* {@link guardRetained} wraps every `down` a provider yields, and the runner applies it to every provider
|
|
23
|
+
* it is handed.
|
|
24
|
+
*
|
|
25
|
+
* ## What is TRUE
|
|
26
|
+
*
|
|
27
|
+
* **No `down` runs against a database while a table declared retained in that database holds rows**,
|
|
28
|
+
* beyond what the caller's {@link RetainedBudget} agreed to destroy. A count, not a boolean, so a flag
|
|
29
|
+
* somebody has in their shell history for another database does not also authorize this one — and one
|
|
30
|
+
* budget spent across a whole run, so an agreement to five rows cannot destroy five in every database.
|
|
31
|
+
*
|
|
32
|
+
* The budget is the operator's number and nothing else. The CLI's preflight also demands it *equal* the
|
|
33
|
+
* rows in scope; this floor demands only that no more are spent, and it is deliberately not handed the
|
|
34
|
+
* preflight's count — a guard fed the answer by the check above it is not a second guard.
|
|
35
|
+
*
|
|
36
|
+
* ## Its reach, stated so it can be checked
|
|
37
|
+
*
|
|
38
|
+
* - **Database-wide, deliberately.** A `down` cannot be inspected for which tables it drops without running
|
|
39
|
+
* it, so any `down` against a database holding retained rows is refused — including a migration of a
|
|
40
|
+
* different capability that never touches them. Over-refusing costs an override; under-refusing costs a
|
|
41
|
+
* vault.
|
|
42
|
+
* - **Every `down` through `runner.ts`** — `rollbackMigration`, `resetMigrations`, `dropMigrations` — whose
|
|
43
|
+
* function was declared: the registry's providers, the fan-out's merged ones, and a provider written by
|
|
44
|
+
* hand, so long as it yields the capability's own migration objects (or spreads of them).
|
|
45
|
+
* - **Counted over the set the runner is handed as the database.** Rollback and reset are handed one
|
|
46
|
+
* provider, and Kysely refuses one that does not span the ledger, so it is the database's. A drop reverses a
|
|
47
|
+
* part, and is handed the part and the database separately (`DropSelection`): it counts the database's,
|
|
48
|
+
* and refuses to reverse a migration the database's set does not carry. The CLI's `remove --drop` builds
|
|
49
|
+
* that set from the Worker's whole composition and every Worker discovered beside it — a capability sharing
|
|
50
|
+
* `SECRETS` and declaring nothing is refused while the vault holds rows.
|
|
51
|
+
* - **Not seen:** a drop whose caller hands the part as the database too (`{ database: part, reverse: part }`
|
|
52
|
+
* counts only the part); for `remove --drop`, a table declared only by a Worker whose config does not load;
|
|
53
|
+
* a Kysely `Migrator` constructed outside `runner.ts`; a `down` whose capability was never constructed in
|
|
54
|
+
* this process and whose set never passed through the registry; a `down` re-wrapped in a new function by
|
|
55
|
+
* anything but {@link beforeEachDown} or {@link beforeEachMigration}; and a table no capability declares — a
|
|
56
|
+
* capability removed from the config takes its declaration with it.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/** The snake_case the `CamelCasePlugin` writes, read from the plugin itself so there is one definition. */
|
|
60
|
+
class TableNames extends CamelCasePlugin {
|
|
61
|
+
sqlName(key: string): string {
|
|
62
|
+
return this.snakeCase(key);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const NAMES = new TableNames();
|
|
67
|
+
|
|
68
|
+
/** The SQL name of a declared table key — `pithySecretsSystemSecrets` → `pithy_secrets_system_secrets`. */
|
|
69
|
+
export function retainedTableName(key: string): string {
|
|
70
|
+
return NAMES.sqlName(key);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** One retained table that holds rows, on the binding it was counted through. */
|
|
74
|
+
export interface RetainedRows {
|
|
75
|
+
/** The D1 binding the table lives behind — the name an adopter recognizes. */
|
|
76
|
+
binding: string;
|
|
77
|
+
/** The SQL table name. */
|
|
78
|
+
table: string;
|
|
79
|
+
/** How many rows it holds. Always positive: an empty or absent table is not at risk. */
|
|
80
|
+
rows: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The SQL names of the retained tables a provider's migrations declare. Reads no database. */
|
|
84
|
+
export async function retainedTableNames(provider: MigrationProvider): Promise<string[]> {
|
|
85
|
+
return retainedTablesOf(await provider.getMigrations()).map(retainedTableName);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Count the rows in every retained table these migrations declare that is present in the database.
|
|
90
|
+
* Read-only. Returns only the tables holding rows, so an empty result means nothing retained is at risk.
|
|
91
|
+
*/
|
|
92
|
+
export async function countRetainedRows(
|
|
93
|
+
database: D1Database,
|
|
94
|
+
migrations: Readonly<Record<string, Migration>>,
|
|
95
|
+
binding: string,
|
|
96
|
+
): Promise<RetainedRows[]> {
|
|
97
|
+
const counted: RetainedRows[] = [];
|
|
98
|
+
for (const key of retainedTablesOf(migrations)) {
|
|
99
|
+
const table = retainedTableName(key);
|
|
100
|
+
const present = await database
|
|
101
|
+
.prepare("select name from sqlite_master where type = 'table' and name = ?")
|
|
102
|
+
.bind(table)
|
|
103
|
+
.first<{ name: string }>();
|
|
104
|
+
if (!present) continue;
|
|
105
|
+
// The name came from a capability's declaration and through the plugin's own snake-casing, never from
|
|
106
|
+
// input, and it has just been matched against `sqlite_master` — quoting it is belt, not braces.
|
|
107
|
+
const row = await database.prepare(`select count(*) as n from "${table}"`).first<{ n: number }>();
|
|
108
|
+
const rows = row?.n ?? 0;
|
|
109
|
+
if (rows > 0) counted.push({ binding, table, rows });
|
|
110
|
+
}
|
|
111
|
+
return counted;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The refusals raised ahead of a `down`. A `Migrator` catches whatever a `down` throws and hands it back as
|
|
116
|
+
* a result, and the runner turns that into "Couldn't roll back" with a remedy to fix the migration — which
|
|
117
|
+
* is wrong for a refusal, where no migration is broken. So the runner asks {@link isDownRefusal} and
|
|
118
|
+
* rethrows one unchanged.
|
|
119
|
+
*/
|
|
120
|
+
const REFUSALS = new WeakSet<object>();
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Mark an error as a refusal to run a `down`, so it reaches the operator as itself rather than as a failed
|
|
124
|
+
* migration. For a guard written outside this module — the CLI's shared-database floor is one.
|
|
125
|
+
*/
|
|
126
|
+
export function downRefusal<E extends ValidationError>(error: E): E {
|
|
127
|
+
REFUSALS.add(error);
|
|
128
|
+
return error;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Whether `error` refused a `down` before it ran — as opposed to a migration that failed. */
|
|
132
|
+
export function isDownRefusal(error: unknown): error is ValidationError {
|
|
133
|
+
return typeof error === "object" && error !== null && REFUSALS.has(error);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** `5 rows`, `1 row`. */
|
|
137
|
+
function rowsLabel(count: number): string {
|
|
138
|
+
return `${count} row${count === 1 ? "" : "s"}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* What a refusal was raised ahead of, as the sentence ends: `Refused before <this>.` A `down` for the
|
|
143
|
+
* runner and the migration fan-out; a database deletion for a teardown that removes the database whole
|
|
144
|
+
* (#591), where "any down ran" would name an operation that was never going to happen.
|
|
145
|
+
*/
|
|
146
|
+
export type RefusedBefore = "any down against them ran" | "anything was deleted";
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The refusal, in one wording for every place that raises it — the fan-out's preflight, which names every
|
|
150
|
+
* database before any of them moves, {@link guardRetained}, which is the floor under it, and a teardown
|
|
151
|
+
* that deletes a database holding retained tables (`pithy secrets deprovision`, #591).
|
|
152
|
+
*
|
|
153
|
+
* `consent` is what the caller passed. The action always prints the number that would work, because that
|
|
154
|
+
* number is the one fact the operator has to read before typing it.
|
|
155
|
+
*/
|
|
156
|
+
export function retainedRefusal(
|
|
157
|
+
atRisk: readonly RetainedRows[],
|
|
158
|
+
consent: number | undefined,
|
|
159
|
+
before: RefusedBefore = "any down against them ran",
|
|
160
|
+
): ValidationError {
|
|
161
|
+
return downRefusal(refusalFor(atRisk, consent, before));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* **The preflight: refuse unless the operator counted exactly the retained rows in scope.**
|
|
166
|
+
*
|
|
167
|
+
* Absent a count, only an empty scope passes; given one, only the same number does — a count that is too
|
|
168
|
+
* high is a count of something else, and agreeing to it would let a stale number from shell history through
|
|
169
|
+
* the day the rows it described are joined by more. Call it with every database the operation will touch,
|
|
170
|
+
* counted before the first of them moves, so the refusal names them all with nothing changed.
|
|
171
|
+
*
|
|
172
|
+
* The floor under it is {@link RetainedBudget}, fed the operator's number and never this function's
|
|
173
|
+
* count.
|
|
174
|
+
*/
|
|
175
|
+
export function assertRetainedAgreed(
|
|
176
|
+
atRisk: readonly RetainedRows[],
|
|
177
|
+
consent: number | undefined,
|
|
178
|
+
before: RefusedBefore = "any down against them ran",
|
|
179
|
+
): void {
|
|
180
|
+
const total = atRisk.reduce((sum, entry) => sum + entry.rows, 0);
|
|
181
|
+
const agreed = consent === undefined ? total === 0 : consent === total;
|
|
182
|
+
if (!agreed) throw retainedRefusal(atRisk, consent, before);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function refusalFor(
|
|
186
|
+
atRisk: readonly RetainedRows[],
|
|
187
|
+
consent: number | undefined,
|
|
188
|
+
before: RefusedBefore,
|
|
189
|
+
): ValidationError {
|
|
190
|
+
const total = atRisk.reduce((sum, entry) => sum + entry.rows, 0);
|
|
191
|
+
const named = atRisk.map((entry) => `${entry.table} on ${entry.binding} (${rowsLabel(entry.rows)})`).join(", ");
|
|
192
|
+
const mismatch =
|
|
193
|
+
consent === undefined ? "" : `--destroy-retained ${consent} does not match the ${rowsLabel(total)} at risk. `;
|
|
194
|
+
if (total === 0) {
|
|
195
|
+
return new ValidationError({
|
|
196
|
+
message: `${mismatch}Nothing retained is at risk. Refused before ${before === "anything was deleted" ? before : "any down ran"}.`,
|
|
197
|
+
action: "Run it again without --destroy-retained.",
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return new ValidationError({
|
|
201
|
+
message: `${mismatch}Retained ${rowsLabel(total)} would be dropped: ${named}. Refused before ${before}.`,
|
|
202
|
+
action: `They exist nowhere else. Back them up, or pass --destroy-retained ${total} to drop them.`,
|
|
203
|
+
detail:
|
|
204
|
+
"A retained table is declared by its capability. Any down against its database, and any deletion of the database, is refused while it holds rows.",
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* **How many retained rows a run has agreed to destroy, spent as each database is reached.**
|
|
210
|
+
*
|
|
211
|
+
* One per run, shared by every database in it. Absent an agreement it holds nothing, so any retained row
|
|
212
|
+
* refuses. A database whose rows exceed what is left is refused whole — nothing is spent on a refusal.
|
|
213
|
+
*/
|
|
214
|
+
export class RetainedBudget {
|
|
215
|
+
/** What the caller agreed to, as they typed it — the number a refusal quotes back. */
|
|
216
|
+
readonly agreed: number | undefined;
|
|
217
|
+
#remaining: number;
|
|
218
|
+
|
|
219
|
+
constructor(agreed: number | undefined) {
|
|
220
|
+
this.agreed = agreed;
|
|
221
|
+
this.#remaining = agreed ?? 0;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Spend `rows` if the budget covers them. False, and nothing spent, when it does not. */
|
|
225
|
+
spend(rows: number): boolean {
|
|
226
|
+
if (rows > this.#remaining) return false;
|
|
227
|
+
this.#remaining -= rows;
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Wrap a provider so no `down` it yields runs while the database holds retained rows the budget does not
|
|
234
|
+
* cover. The check runs once, at the first `down`, and its answer stands for the rest of the run: a reset
|
|
235
|
+
* whose first `down` dropped the vault must not refuse its second for finding it empty.
|
|
236
|
+
*
|
|
237
|
+
* Counted at the `down`, not before: a row written after somebody else counted is a row this refuses.
|
|
238
|
+
*/
|
|
239
|
+
export function guardRetained(
|
|
240
|
+
provider: MigrationProvider,
|
|
241
|
+
database: D1Database,
|
|
242
|
+
options: { binding: string; budget: RetainedBudget },
|
|
243
|
+
): MigrationProvider {
|
|
244
|
+
let cleared: Promise<void> | undefined;
|
|
245
|
+
const check = async (): Promise<void> => {
|
|
246
|
+
const atRisk = await countRetainedRows(database, await provider.getMigrations(), options.binding);
|
|
247
|
+
const total = atRisk.reduce((sum, entry) => sum + entry.rows, 0);
|
|
248
|
+
if (!options.budget.spend(total)) throw retainedRefusal(atRisk, options.budget.agreed);
|
|
249
|
+
};
|
|
250
|
+
return beforeEachDown(provider, async () => {
|
|
251
|
+
cleared ??= check();
|
|
252
|
+
await cleared;
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Which way one migration is about to move — Kysely's own spelling, as a `MigrationResult` carries it. */
|
|
257
|
+
export type MigrationDirection = "Up" | "Down";
|
|
258
|
+
|
|
259
|
+
/** Called ahead of one migration's body, with its composed name and the direction it is about to move. */
|
|
260
|
+
export type BeforeMigration = (name: string, direction: MigrationDirection) => Promise<void>;
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Wrap a provider so `before` runs ahead of every migration body it yields, in each of `directions` — and a
|
|
264
|
+
* throw from `before` means that body never runs. A direction not listed passes through untouched.
|
|
265
|
+
*
|
|
266
|
+
* **Each new `down` keeps the declaration of the one it wraps.** That is the reason this is the one way to
|
|
267
|
+
* wrap a provider's migrations: a hand-rolled wrapper is a new function the declaration has never heard of,
|
|
268
|
+
* and {@link guardRetained} handed it would find nothing declared and let every `down` through.
|
|
269
|
+
*/
|
|
270
|
+
function wrapEach(
|
|
271
|
+
provider: MigrationProvider,
|
|
272
|
+
before: BeforeMigration,
|
|
273
|
+
directions: readonly MigrationDirection[],
|
|
274
|
+
): MigrationProvider {
|
|
275
|
+
return {
|
|
276
|
+
getMigrations: async (): Promise<Record<string, Migration>> => {
|
|
277
|
+
const migrations = await provider.getMigrations();
|
|
278
|
+
return Object.fromEntries(
|
|
279
|
+
Object.entries(migrations).map(([name, migration]) => {
|
|
280
|
+
const { up, down } = migration;
|
|
281
|
+
const wrappedUp = directions.includes("Up")
|
|
282
|
+
? async (db: Parameters<typeof up>[0]): Promise<void> => {
|
|
283
|
+
await before(name, "Up");
|
|
284
|
+
await up(db);
|
|
285
|
+
}
|
|
286
|
+
: up;
|
|
287
|
+
if (!down || !directions.includes("Down")) return [name, { ...migration, up: wrappedUp }];
|
|
288
|
+
const wrappedDown = async (db: Parameters<typeof down>[0]): Promise<void> => {
|
|
289
|
+
await before(name, "Down");
|
|
290
|
+
await down(db);
|
|
291
|
+
};
|
|
292
|
+
declareRetainedDown(wrappedDown, retainedTablesOfDown(down));
|
|
293
|
+
const wrapped: Migration = { up: wrappedUp, down: wrappedDown };
|
|
294
|
+
return [name, wrapped];
|
|
295
|
+
}),
|
|
296
|
+
);
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Wrap a provider so `before` runs ahead of every `down` it yields — and a throw from `before` means the
|
|
303
|
+
* `down` never runs. Ups pass through untouched. A declared `down` stays declared: see {@link wrapEach}.
|
|
304
|
+
*/
|
|
305
|
+
export function beforeEachDown(provider: MigrationProvider, before: () => Promise<void>): MigrationProvider {
|
|
306
|
+
return wrapEach(provider, () => before(), ["Down"]);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Wrap a provider so `before` hears each migration's name and direction ahead of its body, up or down (#583).
|
|
311
|
+
*
|
|
312
|
+
* The seam a caller uses to say which migration it is on while it is on it: the CLI raises a `▸` step here,
|
|
313
|
+
* because a remote migration is a D1 round trip per statement and an operator watching nothing happen
|
|
314
|
+
* cannot tell a slow schema change from a hung one. Core carries no terminal concern — it hands over the
|
|
315
|
+
* name and the direction, and what is done with them is the caller's. A declared `down` stays declared.
|
|
316
|
+
*/
|
|
317
|
+
export function beforeEachMigration(provider: MigrationProvider, before: BeforeMigration): MigrationProvider {
|
|
318
|
+
return wrapEach(provider, before, ["Up", "Down"]);
|
|
319
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Migration } from "kysely/migration";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* **Where a retained declaration is kept: on the `down` that would destroy the table (#588).**
|
|
8
|
+
*
|
|
9
|
+
* A capability says which of its tables are retained (`DatabaseSpec.retained`). The runner has to know it
|
|
10
|
+
* at the moment a `down` is about to run, and by then all it holds is a `MigrationProvider` — Kysely's
|
|
11
|
+
* interface, rebuilt by the registry, merged by the fan-out, wrapped by `batchedProvider`, and written by
|
|
12
|
+
* hand in every capability's own migration tests. A declaration kept on any of those objects is lost the
|
|
13
|
+
* first time somebody builds a new one.
|
|
14
|
+
*
|
|
15
|
+
* The `down` function survives all of that. A registry keeps the capability's migration object; a spread
|
|
16
|
+
* that rebuilds the record keeps the same `down`; a hand-built provider in a test names the very
|
|
17
|
+
* `secrets_0001_init` the capability ships. So the declaration is keyed on the function itself, and any
|
|
18
|
+
* provider that yields it carries it.
|
|
19
|
+
*
|
|
20
|
+
* Kept apart from `./retained` so `defineCapability` can declare without importing Kysely's runtime.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** The destructive function → the retained table keys (camelCase, as `tables` declares them) it can drop. */
|
|
24
|
+
const DECLARED = new WeakMap<object, readonly string[]>();
|
|
25
|
+
|
|
26
|
+
/** Record that every `down` in `migrations` runs against a database holding these retained tables. */
|
|
27
|
+
export function declareRetained(migrations: Readonly<Record<string, Migration>>, tables: readonly string[]): void {
|
|
28
|
+
if (tables.length === 0) return;
|
|
29
|
+
for (const migration of Object.values(migrations)) {
|
|
30
|
+
if (migration.down) declareRetainedDown(migration.down, tables);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Record one `down` — for a wrapper that replaces a declared `down` with its own function. */
|
|
35
|
+
export function declareRetainedDown(down: object, tables: readonly string[]): void {
|
|
36
|
+
if (tables.length === 0) return;
|
|
37
|
+
DECLARED.set(down, [...new Set([...(DECLARED.get(down) ?? []), ...tables])]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The retained table keys declared on one `down`. */
|
|
41
|
+
export function retainedTablesOfDown(down: object): readonly string[] {
|
|
42
|
+
return DECLARED.get(down) ?? [];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The retained table keys any migration in a set declares — the database-wide answer, since a `down` is
|
|
47
|
+
* refused for rows in *any* retained table of the database it runs against.
|
|
48
|
+
*/
|
|
49
|
+
export function retainedTablesOf(migrations: Readonly<Record<string, Migration>>): string[] {
|
|
50
|
+
const tables = new Set<string>();
|
|
51
|
+
for (const migration of Object.values(migrations)) {
|
|
52
|
+
if (!migration.down) continue;
|
|
53
|
+
for (const table of retainedTablesOfDown(migration.down)) tables.add(table);
|
|
54
|
+
}
|
|
55
|
+
return [...tables].sort();
|
|
56
|
+
}
|
package/src/migrations/runner.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { causeMessage } from "../error/cause";
|
|
|
8
8
|
import { InternalError } from "../error/pithyError";
|
|
9
9
|
import { batchedProvider } from "./batch";
|
|
10
10
|
import { MIGRATION_LOCK_TABLE, MIGRATION_TABLE, migrationKysely } from "./bookkeeping";
|
|
11
|
+
import { guardRetained, isDownRefusal, RetainedBudget } from "./retained";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* The per-database migration runner. The registry yields one `MigrationProvider` per database
|
|
@@ -49,12 +50,18 @@ import { MIGRATION_LOCK_TABLE, MIGRATION_TABLE, migrationKysely } from "./bookke
|
|
|
49
50
|
* capability arrives with its whole set. Refusing the add with an actionable error was the
|
|
50
51
|
* alternative; it explains the corner instead of removing it.
|
|
51
52
|
*/
|
|
52
|
-
function migrator(
|
|
53
|
+
function migrator(
|
|
54
|
+
database: D1Database,
|
|
55
|
+
provider: MigrationProvider,
|
|
56
|
+
target: MigrationTarget | undefined,
|
|
57
|
+
consent: RetainedConsent | undefined,
|
|
58
|
+
): Migrator {
|
|
53
59
|
return new Migrator({
|
|
54
60
|
db: migrationKysely(database),
|
|
55
61
|
// Each migration body applies in one `d1.batch()`; the ledger row stays on the ordinary path,
|
|
56
|
-
// so nothing batches across a migration boundary. See `./batch`.
|
|
57
|
-
|
|
62
|
+
// so nothing batches across a migration boundary. See `./batch`. Every `down` is guarded before it
|
|
63
|
+
// is batched: no reversal runs while a retained table in this database holds rows (#588).
|
|
64
|
+
provider: batchedProvider(guardRetained(provider, database, guardOptions(target, consent)), database),
|
|
58
65
|
migrationTableName: MIGRATION_TABLE,
|
|
59
66
|
migrationLockTableName: MIGRATION_LOCK_TABLE,
|
|
60
67
|
allowUnorderedMigrations: true,
|
|
@@ -76,13 +83,35 @@ export interface MigrationTarget {
|
|
|
76
83
|
database: string;
|
|
77
84
|
}
|
|
78
85
|
|
|
86
|
+
/**
|
|
87
|
+
* What a caller that reverses migrations says about retained rows (#588) — see `./retained`.
|
|
88
|
+
*
|
|
89
|
+
* Optional, and absent is the safe answer: a caller that says nothing has counted nothing, so any `down`
|
|
90
|
+
* against a database holding retained rows is refused.
|
|
91
|
+
*/
|
|
92
|
+
export interface RetainedConsent {
|
|
93
|
+
/**
|
|
94
|
+
* The retained rows the caller agreed to destroy. Pass one budget to every database in a run, so one
|
|
95
|
+
* agreement is spent once; a caller reversing a single database builds its own. Absent means none.
|
|
96
|
+
*/
|
|
97
|
+
budget?: RetainedBudget;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The guard's options: the binding its refusal names, and the budget the caller agreed to. */
|
|
101
|
+
function guardOptions(
|
|
102
|
+
target: MigrationTarget | undefined,
|
|
103
|
+
consent: RetainedConsent | undefined,
|
|
104
|
+
): { binding: string; budget: RetainedBudget } {
|
|
105
|
+
return { binding: target?.binding ?? "this database", budget: consent?.budget ?? new RetainedBudget(undefined) };
|
|
106
|
+
}
|
|
107
|
+
|
|
79
108
|
/** Run every pending migration to latest. An empty provider resolves to `[]`. */
|
|
80
109
|
export async function runMigrations(
|
|
81
110
|
database: D1Database,
|
|
82
111
|
provider: MigrationProvider,
|
|
83
112
|
target?: MigrationTarget,
|
|
84
113
|
): Promise<MigrationResult[]> {
|
|
85
|
-
const { error, results } = await migrator(database, provider).migrateToLatest();
|
|
114
|
+
const { error, results } = await migrator(database, provider, target, undefined).migrateToLatest();
|
|
86
115
|
return settle("run", error, results, target);
|
|
87
116
|
}
|
|
88
117
|
|
|
@@ -123,13 +152,18 @@ export async function readMigrationLedger(database: D1Database, provider: Migrat
|
|
|
123
152
|
};
|
|
124
153
|
}
|
|
125
154
|
|
|
126
|
-
/**
|
|
155
|
+
/**
|
|
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).
|
|
159
|
+
*/
|
|
127
160
|
export async function rollbackMigration(
|
|
128
161
|
database: D1Database,
|
|
129
162
|
provider: MigrationProvider,
|
|
130
163
|
target?: MigrationTarget,
|
|
164
|
+
consent?: RetainedConsent,
|
|
131
165
|
): Promise<MigrationResult[]> {
|
|
132
|
-
const { error, results } = await migrator(database, provider).migrateDown();
|
|
166
|
+
const { error, results } = await migrator(database, provider, target, consent).migrateDown();
|
|
133
167
|
return settle("rollback", error, results, target);
|
|
134
168
|
}
|
|
135
169
|
|
|
@@ -139,14 +173,16 @@ export async function rollbackMigration(
|
|
|
139
173
|
* `up` reapplies from empty. The seam behind `pithy seed --redo`'s destructive rebuild: because the
|
|
140
174
|
* schema comes back empty, the ordinary non-destructive seed writes (`INSERT OR IGNORE`, KV
|
|
141
175
|
* skip-if-exists) simply work afterward — there is no per-row identity problem to solve. An empty
|
|
142
|
-
* ledger rolls back nothing; an empty provider reapplies nothing.
|
|
176
|
+
* ledger rolls back nothing; an empty provider reapplies nothing. Refused, before the first `down`, while a
|
|
177
|
+
* retained table here holds rows the caller has not counted in `consent` (#588).
|
|
143
178
|
*/
|
|
144
179
|
export async function resetMigrations(
|
|
145
180
|
database: D1Database,
|
|
146
181
|
provider: MigrationProvider,
|
|
147
182
|
target?: MigrationTarget,
|
|
183
|
+
consent?: RetainedConsent,
|
|
148
184
|
): Promise<MigrationResult[]> {
|
|
149
|
-
const runner = migrator(database, provider);
|
|
185
|
+
const runner = migrator(database, provider, target, consent);
|
|
150
186
|
const down = await runner.migrateTo(NO_MIGRATIONS);
|
|
151
187
|
const downResults = settle("resetDown", down.error, down.results, target);
|
|
152
188
|
const up = await runner.migrateToLatest();
|
|
@@ -169,26 +205,57 @@ async function appliedMigrationNames(db: Kysely<unknown>): Promise<Set<string>>
|
|
|
169
205
|
}
|
|
170
206
|
|
|
171
207
|
/**
|
|
172
|
-
*
|
|
208
|
+
* What a drop reverses, and the database it reverses it in.
|
|
209
|
+
*
|
|
210
|
+
* Two providers because they answer two questions. `reverse` is the part being removed — one capability's
|
|
211
|
+
* migrations. `database` is every migration composed into that D1, and it is what the retained guard counts:
|
|
212
|
+
* a table is retained in a *database*, and the capability being dropped is the one least likely to declare
|
|
213
|
+
* the vault it shares a database with (#588). Handed only the part, the guard counted what the part declared
|
|
214
|
+
* — nothing — while the vault beside it held rows.
|
|
215
|
+
*/
|
|
216
|
+
export interface DropSelection {
|
|
217
|
+
/** Every migration composed into this database — the set whose retained declarations are counted. */
|
|
218
|
+
database: MigrationProvider;
|
|
219
|
+
/** The migrations to reverse: a part of `database`, by composed name. */
|
|
220
|
+
reverse: MigrationProvider;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Surgically drop **one capability's** migrations: run each of `drop.reverse`'s `down` functions in
|
|
173
225
|
* reverse order and delete only those ledger rows, leaving every other capability's tables and
|
|
174
226
|
* bookkeeping untouched. The seam behind `pithy remove --drop`. Kysely's stepwise `Migrator` refuses a
|
|
175
227
|
* provider that doesn't span the whole ledger (it reads a foreign row as corrupt state), so a
|
|
176
228
|
* per-capability drop can't go through it — this reverses the capability's own migrations directly.
|
|
177
|
-
* Only migrations recorded in the ledger are reversed; an absent ledger drops nothing.
|
|
229
|
+
* Only migrations recorded in the ledger are reversed; an absent ledger drops nothing. Refused, before the
|
|
230
|
+
* first `down`, while a retained table **anywhere in `drop.database`** holds rows the caller has not counted
|
|
231
|
+
* in `consent` (#588). A name `drop.reverse` carries that `drop.database` does not is an internal fault: the
|
|
232
|
+
* guard would not have counted the database the `down` runs in.
|
|
178
233
|
*/
|
|
179
234
|
export async function dropMigrations(
|
|
180
235
|
database: D1Database,
|
|
181
|
-
|
|
236
|
+
drop: DropSelection,
|
|
182
237
|
target?: MigrationTarget,
|
|
238
|
+
consent?: RetainedConsent,
|
|
183
239
|
): Promise<MigrationResult[]> {
|
|
184
240
|
const db = migrationKysely(database);
|
|
185
|
-
// Batched here too: `down` pays the same per-statement cost `up` does, and a drop is all DDL.
|
|
186
|
-
|
|
241
|
+
// Batched here too: `down` pays the same per-statement cost `up` does, and a drop is all DDL. Guarded
|
|
242
|
+
// first, exactly as the `Migrator` path is (#588) — over the whole database's set, so the count is too.
|
|
243
|
+
const guarded = guardRetained(drop.database, database, guardOptions(target, consent));
|
|
244
|
+
const migrations = await batchedProvider(guarded, database).getMigrations();
|
|
245
|
+
const reversing = Object.keys(await drop.reverse.getMigrations());
|
|
246
|
+
const stray = reversing.filter((name) => !(name in migrations));
|
|
247
|
+
if (stray.length > 0) {
|
|
248
|
+
throw new InternalError({
|
|
249
|
+
message: `Couldn't drop ${stray.map((name) => `"${name}"`).join(", ")}${on(target)}. The database's migrations do not carry it.`,
|
|
250
|
+
detail: `${where(target)} A drop counts retained rows over the database's whole set, so every migration it reverses must be in it.`,
|
|
251
|
+
action: "Build the drop from the database's composed migrations. Nothing was dropped.",
|
|
252
|
+
});
|
|
253
|
+
}
|
|
187
254
|
const applied = await appliedMigrationNames(db);
|
|
188
255
|
|
|
189
256
|
const results: MigrationResult[] = [];
|
|
190
257
|
// Reverse application order: drop the newest of the capability's migrations first.
|
|
191
|
-
for (const name of
|
|
258
|
+
for (const name of reversing.sort().reverse()) {
|
|
192
259
|
if (!applied.has(name)) continue;
|
|
193
260
|
// No `down` — the migration can't be reversed, so leave both its table and its ledger row in place
|
|
194
261
|
// (deleting the row would desync the ledger from the schema). Every Pithy migration ships a `down`.
|
|
@@ -199,6 +266,8 @@ export async function dropMigrations(
|
|
|
199
266
|
await sql`delete from ${sql.table(MIGRATION_TABLE)} where name = ${name}`.execute(db);
|
|
200
267
|
results.push({ migrationName: name, direction: "Down", status: "Success" });
|
|
201
268
|
} catch (error) {
|
|
269
|
+
// A refusal is not a failed drop: nothing is broken, and "fix the migration's down" would be a lie.
|
|
270
|
+
if (isDownRefusal(error)) throw error;
|
|
202
271
|
const dropped = results.map((result) => `"${result.migrationName}"`);
|
|
203
272
|
throw new InternalError(
|
|
204
273
|
{
|
|
@@ -223,7 +292,9 @@ const VOICE = {
|
|
|
223
292
|
rollback: {
|
|
224
293
|
failed: (key: string) => `Couldn't roll back "${key}"`,
|
|
225
294
|
fallback: "The rollback failed",
|
|
226
|
-
|
|
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.",
|
|
227
298
|
},
|
|
228
299
|
resetDown: {
|
|
229
300
|
failed: (key: string) => `Couldn't roll back "${key}" during reset`,
|
|
@@ -273,6 +344,9 @@ function settle(
|
|
|
273
344
|
target?: MigrationTarget,
|
|
274
345
|
): MigrationResult[] {
|
|
275
346
|
if (error !== undefined) {
|
|
347
|
+
// A guard refused before a `down` ran. It already names what it protected and the way past it, and
|
|
348
|
+
// nothing about it is a migration to fix — so it reaches the operator as itself (#588).
|
|
349
|
+
if (isDownRefusal(error)) throw error;
|
|
276
350
|
const voice = VOICE[verb];
|
|
277
351
|
const failed = results?.find((result) => result.status === "Error");
|
|
278
352
|
const applied = results?.filter((result) => result.status === "Success").map((result) => result.migrationName);
|
|
@@ -52,6 +52,41 @@ export const GLOBAL_SCOPE = "global";
|
|
|
52
52
|
*/
|
|
53
53
|
export const FEATURE_ENVIRONMENT = "feature";
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* **The segment a feature takes where an environment sits** — `f<issue>`, as in `<project>-f69-demo-db-d1`.
|
|
57
|
+
*
|
|
58
|
+
* Defined here, beside the environment rule, because the two share a slot in every composed name and the
|
|
59
|
+
* environment rule has to refuse this shape: `feature.ts` composes a feature's head with it, and
|
|
60
|
+
* {@link isValidEnvironment} refuses an environment that starts with one. A marker composed in one module and
|
|
61
|
+
* recognized in another would be two statements of one shape, and the next person to change one would not
|
|
62
|
+
* find the other.
|
|
63
|
+
*/
|
|
64
|
+
export function featureMarker(issue: string): string {
|
|
65
|
+
return `f${issue}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Every string {@link featureMarker} can compose: an `f` and one or more digits, as a whole segment. */
|
|
69
|
+
const FEATURE_MARKER = /^f[0-9]+$/;
|
|
70
|
+
|
|
71
|
+
/** Is this whole hyphen-delimited segment a feature's marker? `f1` and `f01` are; `f1a` and `feature` are not. */
|
|
72
|
+
export function isFeatureMarker(segment: string): boolean {
|
|
73
|
+
return FEATURE_MARKER.test(segment);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Does this environment start where a feature's name does?
|
|
78
|
+
*
|
|
79
|
+
* **Exactly the environments that can collide with a feature (#587).** A declared environment composes
|
|
80
|
+
* `<project>-<env>-<thing>`, a feature `<project>-f<issue>-<slug>-<thing>`, and a feature Worker's name has no
|
|
81
|
+
* kind suffix to tell the two apart. After the shared `<project>-`, the two strings can only be equal when the
|
|
82
|
+
* environment's first segment is the feature's marker — `f1-demo` meets feature 1-demo's Worker `api`, and `f1`
|
|
83
|
+
* meets it too, through a Worker called `demo-api`. Any other first segment differs from every marker, so no
|
|
84
|
+
* name composed under it can be a feature's. So this is the whole rule, not one case of it.
|
|
85
|
+
*/
|
|
86
|
+
function startsWithFeatureMarker(name: string): boolean {
|
|
87
|
+
return isFeatureMarker(name.split("-")[0] ?? "");
|
|
88
|
+
}
|
|
89
|
+
|
|
55
90
|
/**
|
|
56
91
|
* The longest an environment name may be — **the longest canonical one, `staging`**.
|
|
57
92
|
*
|
|
@@ -77,6 +112,7 @@ export const MAX_ENVIRONMENT_NAME = Math.max(...ENVIRONMENTS.map((environment) =
|
|
|
77
112
|
*/
|
|
78
113
|
export function isValidEnvironment(name: string): boolean {
|
|
79
114
|
if (name === GLOBAL_SCOPE) return false;
|
|
115
|
+
if (startsWithFeatureMarker(name)) return false;
|
|
80
116
|
return name.length <= MAX_ENVIRONMENT_NAME && NAME_SEGMENT.test(name);
|
|
81
117
|
}
|
|
82
118
|
|
|
@@ -104,6 +140,13 @@ export function assertValidEnvironment(name: string): void {
|
|
|
104
140
|
detail: `${GLOBAL_SCOPE} occupies the environment slot of a composed name for values shared across every environment.`,
|
|
105
141
|
});
|
|
106
142
|
}
|
|
143
|
+
if (startsWithFeatureMarker(name)) {
|
|
144
|
+
throw new ValidationError({
|
|
145
|
+
message: `"${name}" starts the way a feature's name does.`,
|
|
146
|
+
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
|
+
});
|
|
149
|
+
}
|
|
107
150
|
if (name.length > MAX_ENVIRONMENT_NAME && NAME_SEGMENT.test(name)) {
|
|
108
151
|
throw new ValidationError({
|
|
109
152
|
message: `"${name}" is ${name.length} characters. An environment stops at ${MAX_ENVIRONMENT_NAME}.`,
|
package/src/naming/feature.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
4
|
import { ValidationError } from "../error/pithyError";
|
|
5
|
+
import { featureMarker } from "./environment";
|
|
5
6
|
import { MAX_ISSUE_DIGITS, NAMESPACE_LIMITS } from "./limits";
|
|
6
7
|
import { assertValidProjectName, fitSegment, kebab } from "./resource";
|
|
7
8
|
|
|
@@ -63,7 +64,7 @@ function assertIssue(issue: string): void {
|
|
|
63
64
|
function head(identity: FeatureIdentity): string {
|
|
64
65
|
assertValidProjectName(identity.project);
|
|
65
66
|
assertIssue(identity.issue);
|
|
66
|
-
return `${kebab(identity.project)}
|
|
67
|
+
return `${kebab(identity.project)}-${featureMarker(identity.issue)}`;
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
/**
|
|
@@ -156,7 +157,13 @@ export function featureSecretEntryName(identity: FeatureIdentity, secret: string
|
|
|
156
157
|
* floored the slug at one character and never touched the worker segment, so it ran unbounded — 69
|
|
157
158
|
* characters for a worker called `collaboration-realtime-gateway`, 109 for a 70-character one. `apps/<name>`
|
|
158
159
|
* has a charset rule and no length rule, so nothing upstream was going to stop it either.
|
|
160
|
+
*
|
|
161
|
+
* **`app` is the `apps/<app>` directory, never the deploy name.** A scaffolded Worker deploys as
|
|
162
|
+
* `<project>-<app>`, and handing that here composed `<project>-f<issue>-<slug>-<project>-<app>` — the
|
|
163
|
+
* project twice, spent out of the budget above (#587). This function takes a string and cannot tell the
|
|
164
|
+
* two apart, so the provisioning path reaches it only through `featureScope`, which is handed both names
|
|
165
|
+
* and picks.
|
|
159
166
|
*/
|
|
160
|
-
export function featureWorkerName(identity: FeatureIdentity,
|
|
161
|
-
return composeFeatureName(head(identity), kebab(identity.slug), kebab(
|
|
167
|
+
export function featureWorkerName(identity: FeatureIdentity, app: string): string {
|
|
168
|
+
return composeFeatureName(head(identity), kebab(identity.slug), kebab(app), "", NAMESPACE_LIMITS.worker.maxLength);
|
|
162
169
|
}
|