@pithy-sh/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/package.json +47 -0
- package/pithy.manifest.json +74 -0
- package/src/address/address.ts +83 -0
- package/src/audit/auditEvent.ts +130 -0
- package/src/audit/recorder.ts +22 -0
- package/src/capability/bindings.ts +196 -0
- package/src/capability/capability.ts +555 -0
- package/src/capability/client.ts +136 -0
- package/src/capability/compose.ts +76 -0
- package/src/capability/composition.ts +98 -0
- package/src/capability/config.ts +19 -0
- package/src/capability/devSecret.ts +42 -0
- package/src/capability/manifest.ts +580 -0
- package/src/capability/secretOrigin.ts +253 -0
- package/src/capability/settings.ts +155 -0
- package/src/capability/validateBindings.ts +43 -0
- package/src/capability/vanishingKey.ts +92 -0
- package/src/cloudflare-test.d.ts +20 -0
- package/src/controlPlane/audit/actions.ts +81 -0
- package/src/controlPlane/capability.ts +228 -0
- package/src/controlPlane/config/config.ts +195 -0
- package/src/controlPlane/context.ts +63 -0
- package/src/controlPlane/data/connection.ts +123 -0
- package/src/controlPlane/data/keyLifecycle.ts +159 -0
- package/src/controlPlane/data/replay.ts +39 -0
- package/src/controlPlane/data/tables.ts +51 -0
- package/src/controlPlane/discovery/adminRoute.ts +250 -0
- package/src/controlPlane/discovery/configuration.ts +280 -0
- package/src/controlPlane/discovery/drift.ts +100 -0
- package/src/controlPlane/discovery/health.ts +213 -0
- package/src/controlPlane/discovery/healthSummary.ts +486 -0
- package/src/controlPlane/error/errors.ts +125 -0
- package/src/controlPlane/http/cors.ts +244 -0
- package/src/controlPlane/http/guard.ts +223 -0
- package/src/controlPlane/http/handlers.ts +346 -0
- package/src/controlPlane/http/responses.ts +92 -0
- package/src/controlPlane/http/routes.ts +115 -0
- package/src/controlPlane/http/schemas.ts +70 -0
- package/src/controlPlane/http/verify.ts +198 -0
- package/src/controlPlane/migrations/0001_init.ts +105 -0
- package/src/controlPlane/replay/d1Guard.ts +87 -0
- package/src/controlPlane/replay/guard.ts +55 -0
- package/src/controlPlane/replay/kvGuard.ts +143 -0
- package/src/controlPlane/scope/scope.ts +102 -0
- package/src/controlPlane/token/base64url.ts +65 -0
- package/src/controlPlane/token/claims.ts +151 -0
- package/src/controlPlane/token/digest.ts +63 -0
- package/src/controlPlane/token/jws.ts +112 -0
- package/src/controlPlane/token/mint.ts +93 -0
- package/src/controlPlane/wire.ts +138 -0
- package/src/createBackend.ts +292 -0
- package/src/createEntrypoint.ts +125 -0
- package/src/data/boundParameters.ts +197 -0
- package/src/data/codecs.ts +160 -0
- package/src/data/cursor.ts +127 -0
- package/src/data/databases.ts +84 -0
- package/src/data/db.ts +53 -0
- package/src/data/withD1Retry.ts +176 -0
- package/src/entitlement/entitlement.ts +191 -0
- package/src/entitlement/gateScan.ts +107 -0
- package/src/entitlement/require.ts +199 -0
- package/src/env/ambient.ts +67 -0
- package/src/env/ci.ts +43 -0
- package/src/env/stem.ts +34 -0
- package/src/error/cause.ts +208 -0
- package/src/error/client.ts +43 -0
- package/src/error/extend.ts +135 -0
- package/src/error/http.ts +92 -0
- package/src/error/payload.ts +2195 -0
- package/src/error/pithyError.ts +281 -0
- package/src/error/terminal.ts +36 -0
- package/src/http/authContext.ts +29 -0
- package/src/http/routeContract.ts +115 -0
- package/src/http/sameOrigin.ts +67 -0
- package/src/http/signedWebhook.ts +415 -0
- package/src/http/validation.ts +41 -0
- package/src/http/verification.ts +25 -0
- package/src/i18n/acceptLanguage.ts +70 -0
- package/src/i18n/catalog.ts +113 -0
- package/src/i18n/locale.ts +153 -0
- package/src/i18n/localeMarker.ts +116 -0
- package/src/i18n/match.ts +111 -0
- package/src/i18n/registry.ts +78 -0
- package/src/i18n/translator.ts +168 -0
- package/src/index.ts +116 -0
- package/src/kv/kv.ts +437 -0
- package/src/kv/namespaces.ts +102 -0
- package/src/logger/local.ts +91 -0
- package/src/logger/logger.ts +145 -0
- package/src/logger/record.ts +83 -0
- package/src/logger/worker.ts +117 -0
- package/src/migrations/batch.ts +226 -0
- package/src/migrations/bookkeeping.ts +85 -0
- package/src/migrations/owner.ts +166 -0
- package/src/migrations/registry.ts +121 -0
- package/src/migrations/runner.ts +295 -0
- package/src/naming/domains.ts +194 -0
- package/src/naming/environment.ts +224 -0
- package/src/naming/feature.ts +162 -0
- package/src/naming/limits.ts +223 -0
- package/src/naming/provisionScope.ts +143 -0
- package/src/naming/resource.ts +266 -0
- package/src/naming/resourceNames.ts +174 -0
- package/src/naming/segment.ts +32 -0
- package/src/projection/asRead.ts +211 -0
- package/src/projection/published.ts +210 -0
- package/src/schema/describedness.ts +250 -0
- package/src/seed/compose.ts +94 -0
- package/src/seed/devLogin.ts +67 -0
- package/src/seed/exampleIdentities.ts +43 -0
- package/src/seed/metadata.ts +27 -0
- package/src/seed/seed.ts +306 -0
- package/src/seed/seededRows.ts +41 -0
- package/src/seed/writeD1.ts +103 -0
- package/src/seed/writeKv.ts +99 -0
- package/src/semver/semver.ts +156 -0
- package/src/text/comments.ts +165 -0
- package/src/version.generated.ts +16 -0
- package/src/worker/health.ts +42 -0
- package/src/worker/identity.ts +243 -0
- package/src/workflow/bindings.ts +58 -0
- package/src/workflow/dispatch.ts +240 -0
- package/src/workflow/dispatchRoute.ts +184 -0
- package/src/workflow/faults.ts +219 -0
- package/src/workflow/host.ts +307 -0
- package/src/workflow/hostEntry.ts +71 -0
- package/src/workflow/hostEnv.ts +258 -0
- package/src/workflow/loopback.ts +149 -0
- package/src/workflow/naming.ts +170 -0
- package/src/workflow/register.ts +44 -0
- package/src/workflow/schemas.ts +84 -0
- package/src/workflow/spec.ts +86 -0
- package/src/workflow/stepMessage.ts +160 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
5
|
+
import type { DatabaseIntrospector, DatabaseMetadataOptions, SchemaMetadata, TableMetadata } from "kysely";
|
|
6
|
+
import { CamelCasePlugin, Kysely } from "kysely";
|
|
7
|
+
import { D1Dialect } from "kysely-d1";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The bookkeeping a Pithy database carries beside the tables its capabilities own — the ledger, the
|
|
11
|
+
* lock, the owning project — and the one Kysely every reader and writer of them is built from.
|
|
12
|
+
*
|
|
13
|
+
* It lives apart from the runner because two modules need it: `runner.ts` migrates through it, and
|
|
14
|
+
* `owner.ts` stamps the owning project through it. Sharing the dialect is not a convenience — the
|
|
15
|
+
* introspector below is a workaround, and a second copy of it would drift.
|
|
16
|
+
*
|
|
17
|
+
* Kysely's `Migrator` checks for its bookkeeping tables through the dialect's introspector, and the
|
|
18
|
+
* stock `SqliteIntrospector` joins `pragma_table_info(...)` — a table-valued pragma D1 rejects with
|
|
19
|
+
* `SQLITE_AUTH`. So this dialect's introspector reads `sqlite_master` alone, which is all the
|
|
20
|
+
* `Migrator` needs (table names). `CamelCasePlugin` is installed like every Pithy database — write
|
|
21
|
+
* camelCase, store snake_case.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** The `sqlite_master` rows the introspector reads; D1 permits plain selects against it. */
|
|
25
|
+
interface SqliteMasterDatabase {
|
|
26
|
+
sqlite_master: { name: string; type: string };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The applied-migration ledger. Renamed off Kysely's `kysely_migration` default: an adopter running
|
|
31
|
+
* their own Kysely migrations on the same D1 would otherwise collide on it (principle 1).
|
|
32
|
+
*/
|
|
33
|
+
export const MIGRATION_TABLE = "pithy_migrations";
|
|
34
|
+
|
|
35
|
+
/** The migration lock, renamed off Kysely's `kysely_migration_lock` default for the same reason. */
|
|
36
|
+
export const MIGRATION_LOCK_TABLE = "pithy_migrations_lock";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The owning project's stamp — one row, written by `claimMigrationOwnership`, read by `pithy migrate`
|
|
40
|
+
* before it applies anything. Deliberately *beside* the ledger rather than inside it: it is not a
|
|
41
|
+
* migration, so a rollback or a full reset must not remove it, and no capability has to ship it.
|
|
42
|
+
*/
|
|
43
|
+
export const MIGRATION_OWNER_TABLE = "pithy_migrations_owner";
|
|
44
|
+
|
|
45
|
+
/** Pithy's bookkeeping tables — hidden by the introspector the way Kysely hides its own defaults. */
|
|
46
|
+
const INTERNAL_TABLES = [MIGRATION_TABLE, MIGRATION_LOCK_TABLE, MIGRATION_OWNER_TABLE];
|
|
47
|
+
|
|
48
|
+
/** Table names from `sqlite_master` only — no pragma joins, no column metadata. */
|
|
49
|
+
class D1Introspector implements DatabaseIntrospector {
|
|
50
|
+
readonly #db: Kysely<SqliteMasterDatabase>;
|
|
51
|
+
|
|
52
|
+
constructor(db: Kysely<SqliteMasterDatabase>) {
|
|
53
|
+
this.#db = db;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async getSchemas(): Promise<SchemaMetadata[]> {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async getTables(options: DatabaseMetadataOptions = { withInternalKyselyTables: false }): Promise<TableMetadata[]> {
|
|
61
|
+
let query = this.#db
|
|
62
|
+
.selectFrom("sqlite_master")
|
|
63
|
+
.where("type", "in", ["table", "view"])
|
|
64
|
+
.where("name", "not like", "sqlite_%")
|
|
65
|
+
.select(["name", "type"])
|
|
66
|
+
.orderBy("name");
|
|
67
|
+
if (!options.withInternalKyselyTables) {
|
|
68
|
+
query = query.where("name", "not in", INTERNAL_TABLES);
|
|
69
|
+
}
|
|
70
|
+
const rows = await query.execute();
|
|
71
|
+
return rows.map((row) => ({ name: row.name, isView: row.type === "view", isForeign: false, columns: [] }));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The D1 dialect Pithy migrates through: stock kysely-d1, with the `sqlite_master`-only introspector. */
|
|
76
|
+
export class D1MigrationDialect extends D1Dialect {
|
|
77
|
+
override createIntrospector(db: Kysely<unknown>): DatabaseIntrospector {
|
|
78
|
+
return new D1Introspector(db as Kysely<SqliteMasterDatabase>);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A Kysely over one D1 binding, with Pithy's dialect and `CamelCasePlugin` — the one way in. */
|
|
83
|
+
export function migrationKysely<DB = unknown>(database: D1Database): Kysely<DB> {
|
|
84
|
+
return new Kysely<DB>({ dialect: new D1MigrationDialect({ database }), plugins: [new CamelCasePlugin()] });
|
|
85
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
5
|
+
import type { Kysely } from "kysely";
|
|
6
|
+
import { sql } from "kysely";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { SQLiteDate } from "../data/codecs";
|
|
9
|
+
import { ConflictError, ValidationError } from "../error/pithyError";
|
|
10
|
+
import { MIGRATION_OWNER_TABLE, migrationKysely } from "./bookkeeping";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Which project owns a database — the guard behind `pithy migrate`.
|
|
14
|
+
*
|
|
15
|
+
* Table names stay `pithy_<capability>_<table>` in every project, on purpose: they are baked into
|
|
16
|
+
* migration keys and every meta-test, and the prefix exists to avoid clashing with the *adopter's*
|
|
17
|
+
* tables, not with another Pithy app's. The cost is that two unrelated projects pointed at one D1
|
|
18
|
+
* merge their schemas silently, each applying migrations the other has never heard of. So the name
|
|
19
|
+
* stays and the database records its owner instead: the first run stamps the project, every later run
|
|
20
|
+
* checks it, and a second project is refused by name rather than quietly joined.
|
|
21
|
+
*
|
|
22
|
+
* The stamp is bookkeeping, not a migration. It sits in its own table beside the ledger and the lock,
|
|
23
|
+
* created on demand, so no capability ships it, `--rollback` cannot step it back, and `pithy seed
|
|
24
|
+
* --redo`'s full reset — which rolls every migration down and back up — cannot wipe it. A database
|
|
25
|
+
* that once belonged to a project keeps saying so even with no schema left in it.
|
|
26
|
+
*
|
|
27
|
+
* Workers sharing one database is the supported path and is unaffected: they share by declaring the
|
|
28
|
+
* same binding name *within one project*, so every one of them claims the same owner.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** The single bookkeeping row's fixed primary key — one owner per database, by construction. */
|
|
32
|
+
const OWNER_ROW_ID = 1;
|
|
33
|
+
|
|
34
|
+
/** The recorded owner of a database: who stamped it, and when. */
|
|
35
|
+
export const MigrationOwner = z
|
|
36
|
+
.object({
|
|
37
|
+
project: z
|
|
38
|
+
.string()
|
|
39
|
+
.describe("The owning project — the root pithy.config.ts `name`, kebab-cased, as it was at first migrate."),
|
|
40
|
+
stampedAt: SQLiteDate.describe("When the project first claimed this database. Never rewritten by a later run."),
|
|
41
|
+
})
|
|
42
|
+
.describe("One database's owning project, recorded in the migration bookkeeping the first time it is migrated.");
|
|
43
|
+
export type MigrationOwner = z.output<typeof MigrationOwner>;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The owner table as Kysely sees it: the schema's `z.input` (SQLite row) side — derived, never a
|
|
47
|
+
* hand-written row interface — plus the fixed primary key that makes a second owner unrepresentable.
|
|
48
|
+
* The key is the table's real name because `MIGRATION_OWNER_TABLE` is the single source of it;
|
|
49
|
+
* `CamelCasePlugin` passes an already-snake_case identifier through unchanged, and still maps the
|
|
50
|
+
* `stampedAt` column both ways.
|
|
51
|
+
*/
|
|
52
|
+
interface OwnerDatabase {
|
|
53
|
+
pithy_migrations_owner: z.input<typeof MigrationOwner> & { id: number };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** How a claim resolved: the database had no owner, or it already had this one. */
|
|
57
|
+
export type OwnershipOutcome = "adopted" | "confirmed";
|
|
58
|
+
|
|
59
|
+
/** The result of a successful claim — a refused one throws instead. */
|
|
60
|
+
export interface ProjectOwnership {
|
|
61
|
+
/** The claiming project, which is now (or already was) the recorded owner. */
|
|
62
|
+
project: string;
|
|
63
|
+
/** `adopted` on the first migrate of a database, `confirmed` on every run after it. */
|
|
64
|
+
outcome: OwnershipOutcome;
|
|
65
|
+
/** When the owner was first recorded — the original stamp, not this run. */
|
|
66
|
+
stampedAt: Date;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Who is claiming, and how to name the database if the claim is refused. */
|
|
70
|
+
export interface ClaimOwnershipOptions {
|
|
71
|
+
/** The claiming project — the root `pithy.config.ts` `name`, kebab-cased (`requireProjectName`). */
|
|
72
|
+
project: string;
|
|
73
|
+
/** The binding this database is reached by (`DB`), so a refusal names it. Optional. */
|
|
74
|
+
binding?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Whether the owner table has been created yet — a plain `sqlite_master` select, which D1 permits. */
|
|
78
|
+
async function ownerTableExists(db: Kysely<OwnerDatabase>): Promise<boolean> {
|
|
79
|
+
const { rows } = await sql<{
|
|
80
|
+
name: string;
|
|
81
|
+
}>`select name from sqlite_master where type = 'table' and name = ${MIGRATION_OWNER_TABLE}`.execute(db);
|
|
82
|
+
return rows.length > 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The recorded owner, validated through the codec, or `undefined` when nothing has claimed it. */
|
|
86
|
+
async function currentOwner(db: Kysely<OwnerDatabase>): Promise<MigrationOwner | undefined> {
|
|
87
|
+
if (!(await ownerTableExists(db))) return undefined;
|
|
88
|
+
const row = await db
|
|
89
|
+
.selectFrom("pithy_migrations_owner")
|
|
90
|
+
.select(["project", "stampedAt"])
|
|
91
|
+
.where("id", "=", OWNER_ROW_ID)
|
|
92
|
+
.executeTakeFirst();
|
|
93
|
+
return row ? MigrationOwner.parse(row) : undefined;
|
|
94
|
+
}
|
|
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 {
|
|
98
|
+
return new ConflictError({
|
|
99
|
+
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.`,
|
|
101
|
+
detail: `Recorded owner "${owner}" in ${MIGRATION_OWNER_TABLE}.`,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The project recorded as this database's owner, or `undefined` when none is — read-only, creating
|
|
107
|
+
* nothing. `pithy doctor` and any other reporter reads the stamp through this; only
|
|
108
|
+
* {@link claimMigrationOwnership} writes it.
|
|
109
|
+
*/
|
|
110
|
+
export async function readMigrationOwner(database: D1Database): Promise<MigrationOwner | undefined> {
|
|
111
|
+
return currentOwner(migrationKysely<OwnerDatabase>(database));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Claim a database for a project, or refuse it to a foreign one. Idempotent and safe to re-run: an
|
|
116
|
+
* unstamped database is adopted (nothing has shipped, so a first run has no ambiguity to preserve),
|
|
117
|
+
* the owning project re-claims as a no-op, and any other project throws a `core/conflict` naming both
|
|
118
|
+
* projects. The insert is `on conflict do nothing` against a fixed primary key and is followed by a
|
|
119
|
+
* re-read, so two runs racing the very first claim settle on one owner rather than two rows.
|
|
120
|
+
*/
|
|
121
|
+
export async function claimMigrationOwnership(
|
|
122
|
+
database: D1Database,
|
|
123
|
+
options: ClaimOwnershipOptions,
|
|
124
|
+
): Promise<ProjectOwnership> {
|
|
125
|
+
const project = options.project.trim();
|
|
126
|
+
if (!project) {
|
|
127
|
+
throw new ValidationError({
|
|
128
|
+
message: "A migration run needs a project name.",
|
|
129
|
+
action: "Set `name` in pithy.config.ts. It is what stamps the database as this project's.",
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const db = migrationKysely<OwnerDatabase>(database);
|
|
134
|
+
const existing = await currentOwner(db);
|
|
135
|
+
if (existing) {
|
|
136
|
+
if (existing.project !== project) throw foreignDatabase(existing.project, project, options.binding);
|
|
137
|
+
return { project, outcome: "confirmed", stampedAt: existing.stampedAt };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
await db.schema
|
|
141
|
+
.createTable(MIGRATION_OWNER_TABLE)
|
|
142
|
+
.ifNotExists()
|
|
143
|
+
.addColumn("id", "integer", (column) => column.primaryKey())
|
|
144
|
+
.addColumn("project", "text", (column) => column.notNull())
|
|
145
|
+
.addColumn("stampedAt", "integer", (column) => column.notNull())
|
|
146
|
+
.execute();
|
|
147
|
+
|
|
148
|
+
const record = MigrationOwner.encode({ project, stampedAt: new Date() });
|
|
149
|
+
await db
|
|
150
|
+
.insertInto("pithy_migrations_owner")
|
|
151
|
+
.values({ id: OWNER_ROW_ID, ...record })
|
|
152
|
+
.onConflict((conflict) => conflict.doNothing())
|
|
153
|
+
.execute();
|
|
154
|
+
|
|
155
|
+
// Re-read rather than trust the insert: a concurrent first run may have won the row.
|
|
156
|
+
const settled = await currentOwner(db);
|
|
157
|
+
if (!settled) {
|
|
158
|
+
throw new ConflictError({
|
|
159
|
+
message: `${options.binding ?? "This database"} could not be stamped as "${project}".`,
|
|
160
|
+
action: "Check the database is writable. Run pithy migrate again.",
|
|
161
|
+
detail: `No row in ${MIGRATION_OWNER_TABLE} after the claim insert.`,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
if (settled.project !== project) throw foreignDatabase(settled.project, project, options.binding);
|
|
165
|
+
return { project, outcome: "adopted", stampedAt: settled.stampedAt };
|
|
166
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Migration, MigrationProvider } from "kysely/migration";
|
|
5
|
+
import { InternalError } from "../error/pithyError";
|
|
6
|
+
|
|
7
|
+
/** Width of the zero-padded `order` prefix in a composed key — the stable anchor. */
|
|
8
|
+
const ORDER_DIGITS = 4;
|
|
9
|
+
|
|
10
|
+
/** Maximum per-capability `order`, derived from the prefix width so the two never drift. */
|
|
11
|
+
export const MAX_MIGRATION_ORDER = 10 ** ORDER_DIGITS - 1;
|
|
12
|
+
|
|
13
|
+
/** Capability namespace: lowercase, starts with a letter, no separators (keeps keys injective). */
|
|
14
|
+
export const NAMESPACE_PATTERN = /^[a-z][a-z0-9]*$/;
|
|
15
|
+
|
|
16
|
+
/** Local migration key: a zero-padded sequence + lowercase snake, e.g. "0001_init". */
|
|
17
|
+
const LOCAL_KEY_PATTERN = /^\d{4}_[a-z0-9_]+$/;
|
|
18
|
+
|
|
19
|
+
export interface NamespacedMigrations {
|
|
20
|
+
/**
|
|
21
|
+
* Target database name — the same name used in a capability's `databases:` map (e.g. "app",
|
|
22
|
+
* "analytics"). D1 is multi-database; each database migrates independently, so every set names
|
|
23
|
+
* the database it belongs to. A capability with tables in two databases contributes one set per.
|
|
24
|
+
*/
|
|
25
|
+
database: string;
|
|
26
|
+
/** Capability namespace, e.g. "core", "auth", "app". Must match `^[a-z][a-z0-9]*$`. */
|
|
27
|
+
namespace: string;
|
|
28
|
+
/** Sort order within its database (core low, app high). Unique per database; 0..MAX_MIGRATION_ORDER. */
|
|
29
|
+
order: number;
|
|
30
|
+
/** Stable, per-namespace keys, each `^\d{4}_[a-z0-9_]+$` (e.g. "0001_init"). */
|
|
31
|
+
migrations: Record<string, Migration>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// These guard build-time registry invariants — a capability author wired its migrations wrong.
|
|
35
|
+
// They surface at startup/build, not per request, so the offending value rides in the message.
|
|
36
|
+
function assertValidNamespace(namespace: string): void {
|
|
37
|
+
if (!NAMESPACE_PATTERN.test(namespace)) {
|
|
38
|
+
throw new InternalError({ message: `migration namespace "${namespace}" must match ${NAMESPACE_PATTERN}` });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function assertValidOrder(order: number, namespace: string): void {
|
|
43
|
+
if (!Number.isInteger(order) || order < 0 || order > MAX_MIGRATION_ORDER) {
|
|
44
|
+
throw new InternalError({
|
|
45
|
+
message: `migration order ${order} (namespace "${namespace}") must be an integer in 0..${MAX_MIGRATION_ORDER}`,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function assertValidLocalKey(localKey: string, namespace: string): void {
|
|
51
|
+
if (!LOCAL_KEY_PATTERN.test(localKey)) {
|
|
52
|
+
throw new InternalError({
|
|
53
|
+
message: `migration key "${localKey}" (namespace "${namespace}") must match ${LOCAL_KEY_PATTERN}`,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Merge one database's sets into a provider, enforcing the per-database invariants. */
|
|
59
|
+
function composeDatabase(database: string, sets: NamespacedMigrations[]): MigrationProvider {
|
|
60
|
+
const seenOrders = new Set<number>();
|
|
61
|
+
const seenNamespaces = new Set<string>();
|
|
62
|
+
|
|
63
|
+
for (const set of sets) {
|
|
64
|
+
assertValidNamespace(set.namespace);
|
|
65
|
+
assertValidOrder(set.order, set.namespace);
|
|
66
|
+
for (const localKey of Object.keys(set.migrations)) assertValidLocalKey(localKey, set.namespace);
|
|
67
|
+
if (seenOrders.has(set.order)) {
|
|
68
|
+
throw new InternalError({
|
|
69
|
+
message: `duplicate migration order ${set.order} in database "${database}" (namespace "${set.namespace}")`,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
if (seenNamespaces.has(set.namespace)) {
|
|
73
|
+
throw new InternalError({ message: `duplicate namespace "${set.namespace}" in database "${database}"` });
|
|
74
|
+
}
|
|
75
|
+
seenOrders.add(set.order);
|
|
76
|
+
seenNamespaces.add(set.namespace);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const composed: Record<string, Migration> = {};
|
|
80
|
+
for (const set of [...sets].sort((a, b) => a.order - b.order)) {
|
|
81
|
+
const prefix = String(set.order).padStart(ORDER_DIGITS, "0");
|
|
82
|
+
for (const localKey of Object.keys(set.migrations).sort()) {
|
|
83
|
+
const migration = set.migrations[localKey];
|
|
84
|
+
if (migration) composed[`${prefix}_${set.namespace}_${localKey}`] = migration;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
getMigrations: async (): Promise<Record<string, Migration>> => composed,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Merge namespaced migration sets into one ordered `MigrationProvider` **per database** — matching
|
|
95
|
+
* multi-database D1, where each `c.var.db.<database>` is its own binding and schema. The result is
|
|
96
|
+
* keyed by database name (the future `pithy migrate` maps each to its binding and runs its provider);
|
|
97
|
+
* an empty input yields `{}`.
|
|
98
|
+
*
|
|
99
|
+
* Within each database the keys are `NNNN_<namespace>_<localKey>` (NNNN = zero-padded `order`).
|
|
100
|
+
* Kysely orders migrations by name (`localeCompare`); the enforced namespace/key formats keep that
|
|
101
|
+
* sort equal to a plain lexicographic one and dependency-correct (core before app), and make the key
|
|
102
|
+
* scheme injective. Keys never change across releases — new migrations append within a namespace — so
|
|
103
|
+
* Kysely's recorded names stay valid and an upgrade ships migrations without renumbering.
|
|
104
|
+
*
|
|
105
|
+
* `order` and `namespace` uniqueness scope **per database**: the same value may recur across
|
|
106
|
+
* different databases; a duplicate within one database throws.
|
|
107
|
+
*/
|
|
108
|
+
export function createMigrationRegistry(sets: NamespacedMigrations[]): Record<string, MigrationProvider> {
|
|
109
|
+
const byDatabase = new Map<string, NamespacedMigrations[]>();
|
|
110
|
+
for (const set of sets) {
|
|
111
|
+
const group = byDatabase.get(set.database);
|
|
112
|
+
if (group) group.push(set);
|
|
113
|
+
else byDatabase.set(set.database, [set]);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const registry: Record<string, MigrationProvider> = {};
|
|
117
|
+
for (const [database, dbSets] of byDatabase) {
|
|
118
|
+
registry[database] = composeDatabase(database, dbSets);
|
|
119
|
+
}
|
|
120
|
+
return registry;
|
|
121
|
+
}
|
|
@@ -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 Kysely, sql } from "kysely";
|
|
6
|
+
import { type MigrationProvider, type MigrationResult, Migrator, NO_MIGRATIONS } from "kysely/migration";
|
|
7
|
+
import { causeMessage } from "../error/cause";
|
|
8
|
+
import { InternalError } from "../error/pithyError";
|
|
9
|
+
import { batchedProvider } from "./batch";
|
|
10
|
+
import { MIGRATION_LOCK_TABLE, MIGRATION_TABLE, migrationKysely } from "./bookkeeping";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The per-database migration runner. The registry yields one `MigrationProvider` per database
|
|
14
|
+
* (`createMigrationRegistry`); the caller pairs each provider with that database's D1 binding and
|
|
15
|
+
* runs them independently — there is no global run across databases. `pithy migrate` is a thin
|
|
16
|
+
* wrapper over these two functions.
|
|
17
|
+
*
|
|
18
|
+
* Both take the raw binding, not a Kysely instance: the dialect, the `CamelCasePlugin`, and the
|
|
19
|
+
* renamed bookkeeping tables all live in `./bookkeeping`, and the runner builds its Kysely from
|
|
20
|
+
* there (`owner.ts` builds the same one to stamp the owning project). Migration `up`/`down`
|
|
21
|
+
* functions receive that instance — write camelCase, store snake_case, like every Pithy database.
|
|
22
|
+
*
|
|
23
|
+
* One runner at a time per database. Kysely's SQLite adapter reports no transactional DDL and its
|
|
24
|
+
* migration lock is a no-op, so concurrent runs can interleave. That fits the deployment model —
|
|
25
|
+
* migrations run from `pithy migrate` (CLI/CI), not inside request handlers — but it is an
|
|
26
|
+
* assumption, not a guard. On failure the thrown `InternalError` names the failed key, the database
|
|
27
|
+
* it was running against, and **what the runtime actually said**, all in `message`; `detail` keeps
|
|
28
|
+
* the throw-site half — the database name behind the binding, and the migrations applied before the
|
|
29
|
+
* failure, since those stay applied.
|
|
30
|
+
*
|
|
31
|
+
* **Each migration body is one `d1.batch()` — see `./batch`, which is where the failure semantics
|
|
32
|
+
* are argued.** The short version: a migration is now all-or-nothing where it used to be able to
|
|
33
|
+
* half-apply, and nothing across a migration boundary changed, because the ledger names migrations
|
|
34
|
+
* and a partial chain has to stay representable in it.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* **`allowUnorderedMigrations` is on, and it is not a loosening.** A composed key leads with its
|
|
39
|
+
* capability's `migrationOrder` (`0250_audit_0001_init`), so the sorted registry *is* the order Pithy
|
|
40
|
+
* promises — and it is the same order whatever sequence an adopter typed `pithy add` in. Kysely's
|
|
41
|
+
* default mode additionally requires the applied ledger to be a prefix of that order, which nothing in
|
|
42
|
+
* the model can guarantee: add `email` (200), then `auth` (300), then `audit` (250), and audit's
|
|
43
|
+
* migration sorts between two applied ones. Every later run then failed, naming keys and index
|
|
44
|
+
* positions the adopter never chose, and the only recovery was wiping the database.
|
|
45
|
+
*
|
|
46
|
+
* Unordered mode still applies pending migrations in `migrationOrder`. It drops one thing: the demand
|
|
47
|
+
* that the past agree with it. That is sound here because no capability's tables reference another's
|
|
48
|
+
* — order across capabilities is arbitrary by design, and order *within* one is preserved, since a
|
|
49
|
+
* capability arrives with its whole set. Refusing the add with an actionable error was the
|
|
50
|
+
* alternative; it explains the corner instead of removing it.
|
|
51
|
+
*/
|
|
52
|
+
function migrator(database: D1Database, provider: MigrationProvider): Migrator {
|
|
53
|
+
return new Migrator({
|
|
54
|
+
db: migrationKysely(database),
|
|
55
|
+
// 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
|
+
provider: batchedProvider(provider, database),
|
|
58
|
+
migrationTableName: MIGRATION_TABLE,
|
|
59
|
+
migrationLockTableName: MIGRATION_LOCK_TABLE,
|
|
60
|
+
allowUnorderedMigrations: true,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Which database a run is reporting on — what a failure names.
|
|
66
|
+
*
|
|
67
|
+
* Optional at every entry point, and defaulted the same way `claimMigrationOwnership`'s refusal defaults
|
|
68
|
+
* its own binding — a runner handed nothing still has a sentence. The CLI always passes one, from the
|
|
69
|
+
* group it is running, because "which database" is the first question a failed migration raises and the
|
|
70
|
+
* runner is the only place that can answer it in the same breath as the error (#282).
|
|
71
|
+
*/
|
|
72
|
+
export interface MigrationTarget {
|
|
73
|
+
/** The D1 binding, as `wrangler.jsonc` declares it — the name an adopter recognizes. */
|
|
74
|
+
binding: string;
|
|
75
|
+
/** The database name: a capability's `databases` key. Throw-site context, not the adopter's handle. */
|
|
76
|
+
database: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Run every pending migration to latest. An empty provider resolves to `[]`. */
|
|
80
|
+
export async function runMigrations(
|
|
81
|
+
database: D1Database,
|
|
82
|
+
provider: MigrationProvider,
|
|
83
|
+
target?: MigrationTarget,
|
|
84
|
+
): Promise<MigrationResult[]> {
|
|
85
|
+
const { error, results } = await migrator(database, provider).migrateToLatest();
|
|
86
|
+
return settle("run", error, results, target);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** One database's ledger beside the declaration it is supposed to match — see {@link readMigrationLedger}. */
|
|
90
|
+
export interface MigrationLedger {
|
|
91
|
+
/** Declared and not yet applied, in the order they would run. */
|
|
92
|
+
pending: string[];
|
|
93
|
+
/**
|
|
94
|
+
* Applied and no longer declared, sorted. Kysely refuses the whole chain on any one of these, so
|
|
95
|
+
* this is not drift a later run works around — it is the reason nothing can run at all.
|
|
96
|
+
*/
|
|
97
|
+
undeclared: string[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* What this database has applied, against what this project declares — **both directions, one read**.
|
|
102
|
+
*
|
|
103
|
+
* `pithy doctor` used to ask only how many declared migrations had not run, because that is the
|
|
104
|
+
* question Kysely's own `getMigrations` answers: it maps over the *provider's* migrations and looks
|
|
105
|
+
* each one up in the ledger, so a row the provider does not carry is not in the result at all. A
|
|
106
|
+
* subtraction cannot see an extra. A database holding a migration the project has since deleted
|
|
107
|
+
* therefore reported `none pending ✓` while the migrator refused to run against it at all — Kysely
|
|
108
|
+
* reads an unrecognized applied migration as a corrupted chain and applies nothing (#282).
|
|
109
|
+
*
|
|
110
|
+
* Asking both halves in one function is the point. Two functions — one counting pending, one hunting
|
|
111
|
+
* undeclared — is how the first half shipped alone, and the caller reaching for the count is exactly
|
|
112
|
+
* the caller who needs the other answer.
|
|
113
|
+
*
|
|
114
|
+
* Read-only, applying nothing: the seam behind `pithy doctor`'s migrations line, `pithy deploy`'s
|
|
115
|
+
* warn-only "schema is behind" check, and the refusal `pithy migrate` raises before it writes.
|
|
116
|
+
*/
|
|
117
|
+
export async function readMigrationLedger(database: D1Database, provider: MigrationProvider): Promise<MigrationLedger> {
|
|
118
|
+
const declared = Object.keys(await provider.getMigrations()).sort();
|
|
119
|
+
const applied = await appliedMigrationNames(migrationKysely(database));
|
|
120
|
+
return {
|
|
121
|
+
pending: declared.filter((name) => !applied.has(name)),
|
|
122
|
+
undeclared: [...applied].filter((name) => !declared.includes(name)).sort(),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Step the latest applied migration back — one step, the `pithy migrate --rollback` seam. */
|
|
127
|
+
export async function rollbackMigration(
|
|
128
|
+
database: D1Database,
|
|
129
|
+
provider: MigrationProvider,
|
|
130
|
+
target?: MigrationTarget,
|
|
131
|
+
): Promise<MigrationResult[]> {
|
|
132
|
+
const { error, results } = await migrator(database, provider).migrateDown();
|
|
133
|
+
return settle("rollback", error, results, target);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Fully reset one database's schema: every applied migration's `down` runs, in one pass, in reverse
|
|
138
|
+
* chronological order — Kysely's `NO_MIGRATIONS` target, not just the latest — then every migration's
|
|
139
|
+
* `up` reapplies from empty. The seam behind `pithy seed --redo`'s destructive rebuild: because the
|
|
140
|
+
* schema comes back empty, the ordinary non-destructive seed writes (`INSERT OR IGNORE`, KV
|
|
141
|
+
* 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.
|
|
143
|
+
*/
|
|
144
|
+
export async function resetMigrations(
|
|
145
|
+
database: D1Database,
|
|
146
|
+
provider: MigrationProvider,
|
|
147
|
+
target?: MigrationTarget,
|
|
148
|
+
): Promise<MigrationResult[]> {
|
|
149
|
+
const runner = migrator(database, provider);
|
|
150
|
+
const down = await runner.migrateTo(NO_MIGRATIONS);
|
|
151
|
+
const downResults = settle("resetDown", down.error, down.results, target);
|
|
152
|
+
const up = await runner.migrateToLatest();
|
|
153
|
+
const upResults = settle("resetUp", up.error, up.results, target);
|
|
154
|
+
return [...downResults, ...upResults];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The applied migration names recorded in the ledger — empty when the ledger table doesn't exist yet.
|
|
159
|
+
* Existence is checked against `sqlite_master` (a plain select D1 permits) rather than by catching the
|
|
160
|
+
* read's error, so a genuine read failure surfaces instead of being silently treated as "none applied".
|
|
161
|
+
*/
|
|
162
|
+
async function appliedMigrationNames(db: Kysely<unknown>): Promise<Set<string>> {
|
|
163
|
+
const present = await sql<{
|
|
164
|
+
name: string;
|
|
165
|
+
}>`select name from sqlite_master where type = 'table' and name = ${MIGRATION_TABLE}`.execute(db);
|
|
166
|
+
if (present.rows.length === 0) return new Set();
|
|
167
|
+
const { rows } = await sql<{ name: string }>`select name from ${sql.table(MIGRATION_TABLE)}`.execute(db);
|
|
168
|
+
return new Set(rows.map((row) => row.name));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Surgically drop **one capability's** migrations: run each of the provider's `down` functions in
|
|
173
|
+
* reverse order and delete only those ledger rows, leaving every other capability's tables and
|
|
174
|
+
* bookkeeping untouched. The seam behind `pithy remove --drop`. Kysely's stepwise `Migrator` refuses a
|
|
175
|
+
* provider that doesn't span the whole ledger (it reads a foreign row as corrupt state), so a
|
|
176
|
+
* 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.
|
|
178
|
+
*/
|
|
179
|
+
export async function dropMigrations(
|
|
180
|
+
database: D1Database,
|
|
181
|
+
provider: MigrationProvider,
|
|
182
|
+
target?: MigrationTarget,
|
|
183
|
+
): Promise<MigrationResult[]> {
|
|
184
|
+
const db = migrationKysely(database);
|
|
185
|
+
// Batched here too: `down` pays the same per-statement cost `up` does, and a drop is all DDL.
|
|
186
|
+
const migrations = await batchedProvider(provider, database).getMigrations();
|
|
187
|
+
const applied = await appliedMigrationNames(db);
|
|
188
|
+
|
|
189
|
+
const results: MigrationResult[] = [];
|
|
190
|
+
// Reverse application order: drop the newest of the capability's migrations first.
|
|
191
|
+
for (const name of Object.keys(migrations).sort().reverse()) {
|
|
192
|
+
if (!applied.has(name)) continue;
|
|
193
|
+
// No `down` — the migration can't be reversed, so leave both its table and its ledger row in place
|
|
194
|
+
// (deleting the row would desync the ledger from the schema). Every Pithy migration ships a `down`.
|
|
195
|
+
const down = migrations[name]?.down;
|
|
196
|
+
if (!down) continue;
|
|
197
|
+
try {
|
|
198
|
+
await down(db);
|
|
199
|
+
await sql`delete from ${sql.table(MIGRATION_TABLE)} where name = ${name}`.execute(db);
|
|
200
|
+
results.push({ migrationName: name, direction: "Down", status: "Success" });
|
|
201
|
+
} catch (error) {
|
|
202
|
+
const dropped = results.map((result) => `"${result.migrationName}"`);
|
|
203
|
+
throw new InternalError(
|
|
204
|
+
{
|
|
205
|
+
message: `Couldn't drop "${name}"${on(target)}. ${reasonOf(error)}`,
|
|
206
|
+
detail: `${where(target)}${dropped.length ? ` Dropped before the failure: ${dropped.join(", ")}.` : ""}`,
|
|
207
|
+
action: "Fix the migration's down, or drop its table by hand. Run pithy remove --drop again.",
|
|
208
|
+
},
|
|
209
|
+
{ cause: error },
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return results;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Brand-voice problem and action lines per direction (docs/CLI.md §3.3). */
|
|
217
|
+
const VOICE = {
|
|
218
|
+
run: {
|
|
219
|
+
failed: (key: string) => `Couldn't apply "${key}"`,
|
|
220
|
+
fallback: "The migration run failed",
|
|
221
|
+
action: "Fix the migration. Run pithy migrate again.",
|
|
222
|
+
},
|
|
223
|
+
rollback: {
|
|
224
|
+
failed: (key: string) => `Couldn't roll back "${key}"`,
|
|
225
|
+
fallback: "The rollback failed",
|
|
226
|
+
action: "Fix the migration. Run pithy migrate --rollback again.",
|
|
227
|
+
},
|
|
228
|
+
resetDown: {
|
|
229
|
+
failed: (key: string) => `Couldn't roll back "${key}" during reset`,
|
|
230
|
+
fallback: "The schema reset failed while rolling back",
|
|
231
|
+
action: "Fix the migration. Run pithy seed --redo again.",
|
|
232
|
+
},
|
|
233
|
+
resetUp: {
|
|
234
|
+
failed: (key: string) => `Couldn't reapply "${key}" during reset`,
|
|
235
|
+
fallback: "The schema reset failed while reapplying",
|
|
236
|
+
action: "Fix the migration. Run pithy seed --redo again.",
|
|
237
|
+
},
|
|
238
|
+
} as const;
|
|
239
|
+
|
|
240
|
+
/** ` on DB`, or nothing at all — every problem line here ends with this and then a period. */
|
|
241
|
+
function on(target: MigrationTarget | undefined): string {
|
|
242
|
+
return target ? ` on ${target.binding}` : "";
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The throw-site half of the same fact: the database name behind the binding, for `detail`. */
|
|
246
|
+
function where(target: MigrationTarget | undefined): string {
|
|
247
|
+
return target ? `Database "${target.database}" on binding ${target.binding}.` : "";
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The underlying failure, as a sentence — **in `message`, where it is actually rendered**.
|
|
252
|
+
*
|
|
253
|
+
* It used to go to `detail` alone, and `detail` is the field the terminal renderer never prints and the
|
|
254
|
+
* HTTP codec strips. So `pithy migrate` said *Migration run failed. Fix the migration.* and nothing
|
|
255
|
+
* else, over a Kysely error that had already named the migration and the reason (#282). A migration
|
|
256
|
+
* failure is D1 answering our own SQL — `no such column: tenant`, `corrupted migrations: previously
|
|
257
|
+
* executed migration X is missing` — and that sentence *is* the actionable content. Withholding it is
|
|
258
|
+
* not a security boundary, it is the bug.
|
|
259
|
+
*
|
|
260
|
+
* Deliberately not through `safeReason`: that filter drops anything over 160 characters, and a silent
|
|
261
|
+
* drop is what this whole path is being fixed for. Color codes come off, because they are formatting
|
|
262
|
+
* a runtime added.
|
|
263
|
+
*/
|
|
264
|
+
function reasonOf(error: unknown): string {
|
|
265
|
+
const reason = (causeMessage(error) ?? String(error)).trim();
|
|
266
|
+
return reason.endsWith(".") ? reason : `${reason}.`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function settle(
|
|
270
|
+
verb: keyof typeof VOICE,
|
|
271
|
+
error: unknown,
|
|
272
|
+
results: MigrationResult[] | undefined,
|
|
273
|
+
target?: MigrationTarget,
|
|
274
|
+
): MigrationResult[] {
|
|
275
|
+
if (error !== undefined) {
|
|
276
|
+
const voice = VOICE[verb];
|
|
277
|
+
const failed = results?.find((result) => result.status === "Error");
|
|
278
|
+
const applied = results?.filter((result) => result.status === "Success").map((result) => result.migrationName);
|
|
279
|
+
const problem = failed ? voice.failed(failed.migrationName) : voice.fallback;
|
|
280
|
+
// The chain is applied one migration at a time, so name what stuck before the failure. The
|
|
281
|
+
// failed migration itself is not among them: its body was one batch, and the batch rolled back.
|
|
282
|
+
const stuck = applied?.length
|
|
283
|
+
? `Applied before the failure: ${applied.map((name) => `"${name}"`).join(", ")}.`
|
|
284
|
+
: "";
|
|
285
|
+
throw new InternalError(
|
|
286
|
+
{
|
|
287
|
+
message: `${problem}${on(target)}. ${reasonOf(error)}`,
|
|
288
|
+
detail: [where(target), stuck].filter(Boolean).join(" "),
|
|
289
|
+
action: voice.action,
|
|
290
|
+
},
|
|
291
|
+
{ cause: error },
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
return results ?? [];
|
|
295
|
+
}
|