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