@pithy-sh/core 0.10.0 → 0.12.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/audit/auditEvent.d.ts +5 -0
- package/dist/audit/auditEvent.d.ts.map +1 -1
- package/dist/audit/auditEvent.js +2 -0
- package/dist/audit/recorder.d.ts +10 -2
- package/dist/audit/recorder.d.ts.map +1 -1
- 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 +110 -28
- package/dist/capability/capability.d.ts.map +1 -1
- package/dist/capability/capability.js +2 -0
- 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/purge.d.ts +80 -0
- package/dist/capability/purge.d.ts.map +1 -0
- package/dist/capability/purge.js +76 -0
- package/dist/capability/settings.d.ts +86 -2
- package/dist/capability/settings.d.ts.map +1 -1
- package/dist/capability/settings.js +80 -1
- package/dist/cli/commandCatalog.generated.d.ts +238 -24
- package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
- package/dist/cli/commandCatalog.generated.js +251 -22
- package/dist/cli/commandFacts.d.ts +2 -1
- package/dist/cli/commandFacts.d.ts.map +1 -1
- package/dist/cli/commandFacts.js +2 -0
- package/dist/controlPlane/capability.d.ts.map +1 -1
- package/dist/controlPlane/capability.js +19 -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 +49 -12
- package/dist/controlPlane/data/connection.d.ts.map +1 -1
- package/dist/controlPlane/data/connection.js +56 -2
- package/dist/controlPlane/discovery/adminRoute.d.ts +29 -0
- package/dist/controlPlane/discovery/adminRoute.d.ts.map +1 -1
- package/dist/controlPlane/discovery/adminRoute.js +3 -0
- package/dist/controlPlane/error/errors.d.ts +4 -3
- package/dist/controlPlane/error/errors.d.ts.map +1 -1
- 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 +17 -1
- package/dist/controlPlane/http/handlers.d.ts +139 -0
- package/dist/controlPlane/http/handlers.d.ts.map +1 -1
- package/dist/controlPlane/http/handlers.js +67 -4
- 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 +245 -0
- package/dist/controlPlane/http/responses.d.ts.map +1 -1
- package/dist/controlPlane/http/responses.js +101 -4
- package/dist/controlPlane/http/routes.d.ts +2 -0
- package/dist/controlPlane/http/routes.d.ts.map +1 -1
- package/dist/controlPlane/http/routes.js +31 -3
- 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 +23 -2
- package/dist/controlPlane/scope/scope.d.ts.map +1 -1
- package/dist/controlPlane/scope/scope.js +24 -3
- package/dist/createBackend.d.ts.map +1 -1
- package/dist/createBackend.js +9 -2
- package/dist/createEntrypoint.d.ts.map +1 -1
- package/dist/createEntrypoint.js +5 -3
- package/dist/crypto/envelope.d.ts +86 -0
- package/dist/crypto/envelope.d.ts.map +1 -0
- package/dist/crypto/envelope.js +129 -0
- package/dist/crypto/fieldKeys.d.ts +41 -0
- package/dist/crypto/fieldKeys.d.ts.map +1 -0
- package/dist/crypto/fieldKeys.js +56 -0
- package/dist/data/codecs.d.ts +23 -10
- package/dist/data/codecs.d.ts.map +1 -1
- package/dist/data/codecs.js +50 -36
- 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/error/extend.d.ts +6 -5
- package/dist/error/extend.d.ts.map +1 -1
- package/dist/error/http.d.ts +168 -0
- package/dist/error/http.d.ts.map +1 -1
- package/dist/error/payload.d.ts +336 -0
- package/dist/error/payload.d.ts.map +1 -1
- package/dist/error/payload.js +116 -4
- package/dist/error/pithyError.d.ts +18 -7
- package/dist/error/pithyError.d.ts.map +1 -1
- package/dist/error/pithyError.js +6 -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/scopeGates.d.ts +20 -0
- package/dist/http/scopeGates.d.ts.map +1 -0
- package/dist/http/scopeGates.js +36 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/logger/logger.d.ts +12 -5
- package/dist/logger/logger.d.ts.map +1 -1
- package/dist/logger/record.d.ts +98 -0
- package/dist/logger/record.d.ts.map +1 -1
- 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/registry.d.ts +26 -0
- package/dist/migrations/registry.d.ts.map +1 -1
- package/dist/migrations/registry.js +38 -7
- 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/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/provisionScope.d.ts +6 -0
- package/dist/naming/provisionScope.d.ts.map +1 -1
- package/dist/naming/provisionScope.js +8 -3
- 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/privacy/blindIndex.d.ts +27 -0
- package/dist/privacy/blindIndex.d.ts.map +1 -0
- package/dist/privacy/blindIndex.js +57 -0
- package/dist/privacy/classes.d.ts +142 -0
- package/dist/privacy/classes.d.ts.map +1 -0
- package/dist/privacy/classes.js +209 -0
- package/dist/privacy/classify.d.ts +336 -0
- package/dist/privacy/classify.d.ts.map +1 -0
- package/dist/privacy/classify.js +1024 -0
- package/dist/privacy/encrypted.d.ts +210 -0
- package/dist/privacy/encrypted.d.ts.map +1 -0
- package/dist/privacy/encrypted.js +350 -0
- package/dist/privacy/encryptedColumns.d.ts +64 -0
- package/dist/privacy/encryptedColumns.d.ts.map +1 -0
- package/dist/privacy/encryptedColumns.js +134 -0
- package/dist/privacy/fieldRotation.d.ts +34 -0
- package/dist/privacy/fieldRotation.d.ts.map +1 -0
- package/dist/privacy/fieldRotation.js +186 -0
- package/dist/privacy/inventory.d.ts +12 -0
- package/dist/privacy/inventory.d.ts.map +1 -0
- package/dist/privacy/inventory.js +154 -0
- package/dist/privacy/posture.d.ts +114 -0
- package/dist/privacy/posture.d.ts.map +1 -0
- package/dist/privacy/posture.js +136 -0
- package/dist/privacy/redact.d.ts +7 -0
- package/dist/privacy/redact.d.ts.map +1 -0
- package/dist/privacy/redact.js +404 -0
- package/dist/privacy/report.d.ts +58 -0
- package/dist/privacy/report.d.ts.map +1 -0
- package/dist/privacy/report.js +15 -0
- package/dist/privacy/retirement.d.ts +68 -0
- package/dist/privacy/retirement.d.ts.map +1 -0
- package/dist/privacy/retirement.js +72 -0
- package/dist/privacy/sealed.d.ts +87 -0
- package/dist/privacy/sealed.d.ts.map +1 -0
- package/dist/privacy/sealed.js +221 -0
- package/dist/privacy/types.d.ts +559 -0
- package/dist/privacy/types.d.ts.map +1 -0
- package/dist/privacy/types.js +2 -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/version.generated.d.ts +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/faults.d.ts +12 -1
- package/dist/workflow/faults.d.ts.map +1 -1
- package/dist/workflow/faults.js +22 -15
- package/package.json +2 -2
- 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/audit/auditEvent.ts +4 -0
- package/src/audit/recorder.ts +10 -2
- package/src/capability/bindings.ts +159 -13
- package/src/capability/capability.ts +114 -29
- package/src/capability/purge.ts +124 -0
- package/src/capability/settings.ts +146 -2
- package/src/cli/commandCatalog.generated.ts +271 -22
- package/src/cli/commandFacts.ts +2 -0
- package/src/controlPlane/capability.ts +36 -1
- package/src/controlPlane/context.ts +4 -0
- package/src/controlPlane/data/connection.ts +90 -0
- package/src/controlPlane/discovery/adminRoute.ts +11 -0
- package/src/controlPlane/error/errors.ts +4 -3
- package/src/controlPlane/http/cors.ts +4 -4
- package/src/controlPlane/http/guard.ts +21 -0
- package/src/controlPlane/http/handlers.ts +98 -4
- package/src/controlPlane/http/readiness.ts +50 -0
- package/src/controlPlane/http/responses.ts +240 -2
- package/src/controlPlane/http/routes.ts +55 -1
- 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 +25 -2
- package/src/controlPlane/token/base64url.ts +1 -1
- package/src/createBackend.ts +28 -6
- package/src/createEntrypoint.ts +9 -3
- package/src/crypto/envelope.ts +196 -0
- package/src/crypto/fieldKeys.ts +76 -0
- package/src/data/codecs.ts +69 -37
- package/src/entitlement/allowance.ts +85 -0
- package/src/entitlement/entitlement.ts +38 -0
- package/src/entitlement/meter.ts +69 -0
- package/src/error/extend.ts +6 -5
- package/src/error/payload.ts +253 -4
- package/src/error/pithyError.ts +21 -7
- package/src/http/authContext.ts +13 -1
- package/src/http/scopeGates.ts +34 -0
- package/src/index.ts +2 -0
- package/src/logger/logger.ts +12 -5
- package/src/migrations/capabilitySets.ts +87 -0
- package/src/migrations/registry.ts +46 -2
- package/src/migrations/state.ts +94 -0
- package/src/naming/feature.ts +46 -7
- package/src/naming/limits.ts +35 -3
- package/src/naming/provisionScope.ts +19 -1
- package/src/naming/resourceNames.ts +52 -5
- package/src/privacy/blindIndex.ts +81 -0
- package/src/privacy/classes.ts +277 -0
- package/src/privacy/classify.ts +1447 -0
- package/src/privacy/encrypted.ts +513 -0
- package/src/privacy/encryptedColumns.ts +185 -0
- package/src/privacy/fieldRotation.ts +234 -0
- package/src/privacy/inventory.ts +147 -0
- package/src/privacy/posture.ts +205 -0
- package/src/privacy/redact.ts +423 -0
- package/src/privacy/report.ts +64 -0
- package/src/privacy/retirement.ts +102 -0
- package/src/privacy/sealed.ts +323 -0
- package/src/privacy/types.ts +804 -0
- package/src/seed/writeD1.ts +13 -4
- package/src/seed/writeKv.ts +6 -2
- package/src/version.generated.ts +1 -1
- package/src/worker/serviceLoopback.ts +105 -0
- package/src/workflow/faults.ts +51 -24
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { MigrationProvider } from "kysely/migration";
|
|
5
|
+
import type { Capability } from "../capability/capability";
|
|
6
|
+
import type { DatabaseSpec } from "../data/databases";
|
|
7
|
+
import { InternalError } from "../error/pithyError";
|
|
8
|
+
import { createMigrationRegistry, type NamespacedMigrations } from "./registry";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* **Where a capability's migrations become a registry's sets — one function, composed twice (#729).**
|
|
12
|
+
*
|
|
13
|
+
* `pithy migrate` and `pithy doctor` compose these sets from the project on disk; the Worker composes them
|
|
14
|
+
* from the capabilities it was built with, to answer `GET {basePath}/migrations`. It lived in the CLI until
|
|
15
|
+
* the Worker needed it too. It reads nothing but `Capability.databases`, so it belongs in core — and if
|
|
16
|
+
* the two sides composed with two functions, `pithy doctor` and the route could disagree about one
|
|
17
|
+
* environment with nothing to say which was wrong.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A retained declaration must name a table this slice declares, beside the migrations that create it.
|
|
22
|
+
*
|
|
23
|
+
* A misspelled key would count a table that does not exist, find no rows, and let the `down` through —
|
|
24
|
+
* the refusal quietly off for the one table it was written for. And retention on a slice with no
|
|
25
|
+
* migrations has no `down` to be recorded on, so nothing would ever enforce it. Both are an author's mistake,
|
|
26
|
+
* caught where the declaration is read rather than discovered when a vault is empty (#588).
|
|
27
|
+
*/
|
|
28
|
+
function assertRetainedDeclared(capability: string, database: string, spec: DatabaseSpec): void {
|
|
29
|
+
const retained = spec.retained ?? [];
|
|
30
|
+
if (retained.length === 0) return;
|
|
31
|
+
const unknown = retained.filter((table) => !(table in spec.tables));
|
|
32
|
+
if (unknown.length > 0) {
|
|
33
|
+
throw new InternalError({
|
|
34
|
+
message: `Capability "${capability}" retains ${unknown.map((table) => `"${table}"`).join(", ")} in database "${database}", which its tables do not declare.`,
|
|
35
|
+
action: "Name tables from that database spec's tables in retained.",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
if (!spec.migrations || Object.keys(spec.migrations).length === 0) {
|
|
39
|
+
throw new InternalError({
|
|
40
|
+
message: `Capability "${capability}" retains tables in database "${database}" but ships no migrations for it.`,
|
|
41
|
+
action: "Declare retained beside the migrations that create those tables.",
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Every capability's per-database migration set (`DatabaseSpec.migrations`, namespaced by capability
|
|
48
|
+
* name), in composition order — the raw material {@link buildRegistryFromCapabilities}, the CLI's
|
|
49
|
+
* fan-out runner and the control-plane seam all build on. The runner needs the sets rather than a
|
|
50
|
+
* finished provider because two Workers can be bound to one physical D1: their sets merge into a single
|
|
51
|
+
* ordered provider so that database migrates once, with every Worker's capabilities in it. A Worker
|
|
52
|
+
* composes only its own, which is why the seam's `undeclared` is relative to that Worker. Migrations
|
|
53
|
+
* without a `migrationOrder` fail here, attributed to their capability.
|
|
54
|
+
*/
|
|
55
|
+
export function collectMigrationSets(capabilities: Capability[]): NamespacedMigrations[] {
|
|
56
|
+
const sets: NamespacedMigrations[] = [];
|
|
57
|
+
for (const capability of capabilities) {
|
|
58
|
+
for (const [database, spec] of Object.entries(capability.databases ?? {})) {
|
|
59
|
+
assertRetainedDeclared(capability.name, database, spec);
|
|
60
|
+
if (!spec.migrations) continue;
|
|
61
|
+
if (spec.migrationOrder === undefined) {
|
|
62
|
+
throw new InternalError({
|
|
63
|
+
message: `Capability "${capability.name}" has migrations for database "${database}" but no migrationOrder.`,
|
|
64
|
+
action: "Set migrationOrder on that database spec (core low, app high).",
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
sets.push({
|
|
68
|
+
database,
|
|
69
|
+
namespace: capability.name,
|
|
70
|
+
order: spec.migrationOrder,
|
|
71
|
+
migrations: spec.migrations,
|
|
72
|
+
...(spec.retained && spec.retained.length > 0 ? { retained: spec.retained } : {}),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return sets;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Compose every capability's per-database migration sets (`DatabaseSpec.migrations`,
|
|
81
|
+
* namespaced by capability name) into one ordered provider per database. A database
|
|
82
|
+
* nobody migrates gets no provider; migrations without a `migrationOrder` fail here,
|
|
83
|
+
* attributed to their capability.
|
|
84
|
+
*/
|
|
85
|
+
export function buildRegistryFromCapabilities(capabilities: Capability[]): Record<string, MigrationProvider> {
|
|
86
|
+
return createMigrationRegistry(collectMigrationSets(capabilities));
|
|
87
|
+
}
|
|
@@ -62,6 +62,51 @@ function assertValidLocalKey(localKey: string, namespace: string): void {
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* The composed key for one of a set's migrations: `NNNN_<namespace>_<localKey>`. The one place the shape is
|
|
67
|
+
* written, so the registry and anything naming a declared migration's namespace build the same string.
|
|
68
|
+
*/
|
|
69
|
+
export function composedMigrationKey(set: Pick<NamespacedMigrations, "order" | "namespace">, localKey: string): string {
|
|
70
|
+
return `${String(set.order).padStart(ORDER_DIGITS, "0")}_${set.namespace}_${localKey}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A composed key taken back apart: the three parts {@link createMigrationRegistry} joined. */
|
|
74
|
+
export interface ComposedMigrationKey {
|
|
75
|
+
/** The capability's `migrationOrder` — the zero-padded prefix, as a number. */
|
|
76
|
+
order: number;
|
|
77
|
+
/** The capability namespace — its `name`, which the manifest reports beside every capability. */
|
|
78
|
+
namespace: string;
|
|
79
|
+
/** The capability's own stable key, e.g. `0001_init`. */
|
|
80
|
+
localKey: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The composed key shape, anchored at both ends: the order prefix, a namespace (which admits no separator,
|
|
85
|
+
* so the first underscore after it is the boundary), then a local key, which may hold any number.
|
|
86
|
+
*/
|
|
87
|
+
const COMPOSED_KEY_PATTERN = new RegExp(
|
|
88
|
+
`^(\\d{${ORDER_DIGITS}})_(${NAMESPACE_PATTERN.source.slice(1, -1)})_(${LOCAL_KEY_PATTERN.source.slice(1, -1)})$`,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Take a composed key back apart — the inverse of the composition below.
|
|
93
|
+
*
|
|
94
|
+
* A declared migration's namespace is known from the set it came from. An **undeclared** one — applied in
|
|
95
|
+
* the database, carried by no provider this build composes — has nothing but its key, so this is the only
|
|
96
|
+
* way to say whose it was. **Null for a key no registry could have composed**, a migration written by hand
|
|
97
|
+
* or by another tool: guessing a namespace for it would be inventing one.
|
|
98
|
+
*
|
|
99
|
+
* The patterns are the same constants the composition enforces, so the two cannot disagree about what a
|
|
100
|
+
* key looks like.
|
|
101
|
+
*/
|
|
102
|
+
export function decomposeMigrationKey(key: string): ComposedMigrationKey | null {
|
|
103
|
+
const match = COMPOSED_KEY_PATTERN.exec(key);
|
|
104
|
+
if (!match) return null;
|
|
105
|
+
const [, order, namespace, localKey] = match;
|
|
106
|
+
if (order === undefined || namespace === undefined || localKey === undefined) return null;
|
|
107
|
+
return { order: Number(order), namespace, localKey };
|
|
108
|
+
}
|
|
109
|
+
|
|
65
110
|
/** Merge one database's sets into a provider, enforcing the per-database invariants. */
|
|
66
111
|
function composeDatabase(database: string, sets: NamespacedMigrations[]): MigrationProvider {
|
|
67
112
|
const seenOrders = new Set<number>();
|
|
@@ -85,10 +130,9 @@ function composeDatabase(database: string, sets: NamespacedMigrations[]): Migrat
|
|
|
85
130
|
|
|
86
131
|
const composed: Record<string, Migration> = {};
|
|
87
132
|
for (const set of [...sets].sort((a, b) => a.order - b.order)) {
|
|
88
|
-
const prefix = String(set.order).padStart(ORDER_DIGITS, "0");
|
|
89
133
|
for (const localKey of Object.keys(set.migrations).sort()) {
|
|
90
134
|
const migration = set.migrations[localKey];
|
|
91
|
-
if (migration) composed[
|
|
135
|
+
if (migration) composed[composedMigrationKey(set, localKey)] = migration;
|
|
92
136
|
}
|
|
93
137
|
}
|
|
94
138
|
|
|
@@ -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/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
|
*
|
package/src/naming/limits.ts
CHANGED
|
@@ -107,6 +107,35 @@ export const NAMESPACE_LIMITS = {
|
|
|
107
107
|
* no longer hashes one at 63.
|
|
108
108
|
*/
|
|
109
109
|
kv: { label: "a KV namespace title", maxLength: 512, minLength: 1, policy: "truncate", source: "cloudflare" },
|
|
110
|
+
/**
|
|
111
|
+
* A Queue name: **1 to 63 characters**, lowercase letters, digits and dashes, starting and ending with a letter
|
|
112
|
+
* or number ([Queues · Get started](https://developers.cloudflare.com/queues/get-started/), verified
|
|
113
|
+
* 2026-10-18). Truncated rather than refused: Pithy recomputes a queue's name from its binding on every run,
|
|
114
|
+
* as it does a bucket's, so a deterministic hash is safe (#662). The same 63 as R2, so a feature's queue fits
|
|
115
|
+
* the shape {@link FEATURE_DERIVED_PROJECT_NAME} is derived against and moves no budget.
|
|
116
|
+
*/
|
|
117
|
+
queue: { label: "a Queue name", maxLength: 63, minLength: 1, policy: "truncate", source: "cloudflare" },
|
|
118
|
+
/**
|
|
119
|
+
* An Artifacts namespace: **2 to 63 characters**, starting with a letter or digit, the rest letters, digits, `.`,
|
|
120
|
+
* `_` and `-` ([Artifacts · Limits](https://developers.cloudflare.com/artifacts/platform/limits/), verified
|
|
121
|
+
* 2026-10-18). Truncated rather than refused: the kit derives it from the project and the environment on every
|
|
122
|
+
* run, like an R2 bucket name, and the longest project and environment compose 34 characters, so it never is (#673).
|
|
123
|
+
*/
|
|
124
|
+
artifactsNamespace: {
|
|
125
|
+
label: "an Artifacts namespace name",
|
|
126
|
+
maxLength: 63,
|
|
127
|
+
minLength: 2,
|
|
128
|
+
policy: "truncate",
|
|
129
|
+
source: "cloudflare",
|
|
130
|
+
},
|
|
131
|
+
/**
|
|
132
|
+
* An Artifacts repo name. **The charset is Cloudflare's** — a letter or digit, then letters, digits, `.`, `_` and
|
|
133
|
+
* `-` ([Artifacts · Limits](https://developers.cloudflare.com/artifacts/platform/limits/), verified 2026-10-18) —
|
|
134
|
+
* and **the length is ours**: that page states one for a namespace and none for a repo, so 2 to 63 mirrors the
|
|
135
|
+
* namespace rather than inventing a second number. Refused, never truncated: a repo name is either a UUID or a
|
|
136
|
+
* name somebody chose, and a name somebody chose is never silently shortened (#673).
|
|
137
|
+
*/
|
|
138
|
+
artifactsRepo: { label: "an Artifacts repo name", maxLength: 63, minLength: 2, policy: "refuse", source: "pithy" },
|
|
110
139
|
/**
|
|
111
140
|
* A D1 database name: `^[a-zA-Z0-9][a-zA-Z0-9_-]*$` in the OpenAPI schema, and **no documented
|
|
112
141
|
* length cap** — the limits page has no row for it. So the ceiling here is {@link MAX_PITHY_NAME}.
|
|
@@ -196,7 +225,10 @@ export const FEATURE_TAIL_SEPARATOR = "--";
|
|
|
196
225
|
*/
|
|
197
226
|
export const MIN_VERBATIM_BINDING = 12;
|
|
198
227
|
|
|
199
|
-
/**
|
|
228
|
+
/**
|
|
229
|
+
* The longest feature resource kind segment — `d1`, `kv`, `r2`, and a queue's `q` (#662). `feature.test.ts` pins
|
|
230
|
+
* every kind's `FEATURE_KIND_SEGMENT` to it, so a longer segment fails rather than shrinking every project's budget.
|
|
231
|
+
*/
|
|
200
232
|
export const MAX_FEATURE_KIND = 2;
|
|
201
233
|
|
|
202
234
|
/**
|
|
@@ -217,7 +249,7 @@ export const WORKFLOW_DERIVED_PROJECT_NAME =
|
|
|
217
249
|
|
|
218
250
|
/**
|
|
219
251
|
* What the longest feature resource name leaves a project — `<project>-f<issue>-<slug>--<binding>-<kind>`,
|
|
220
|
-
* against R2's 63 (the strictest of the
|
|
252
|
+
* against R2's 63 (the strictest of the kinds a feature provisions, a queue's number too, and the same number a
|
|
221
253
|
* feature Worker script gets).
|
|
222
254
|
*
|
|
223
255
|
* ```
|
|
@@ -226,7 +258,7 @@ export const WORKFLOW_DERIVED_PROJECT_NAME =
|
|
|
226
258
|
* - 6 the issue number, six digits — `f999999` (MAX_ISSUE_DIGITS)
|
|
227
259
|
* - 12 `-` + a legible slug (MIN_LEGIBLE_SLUG)
|
|
228
260
|
* - 14 `--` + a binding kept whole (FEATURE_TAIL_SEPARATOR, MIN_VERBATIM_BINDING)
|
|
229
|
-
* - 3 `-` + the kind, `d1` | `kv` | `r2`
|
|
261
|
+
* - 3 `-` + the kind, `d1` | `kv` | `r2` | `q`
|
|
230
262
|
* ----
|
|
231
263
|
* 26
|
|
232
264
|
* ```
|
|
@@ -8,6 +8,7 @@ import { assertValidEnvironment, FEATURE_ENVIRONMENT, GLOBAL_SCOPE, isFeatureMar
|
|
|
8
8
|
import {
|
|
9
9
|
type FeatureIdentity,
|
|
10
10
|
type FeatureResourceKind,
|
|
11
|
+
featureArtifactsNamespace,
|
|
11
12
|
featureResourceName,
|
|
12
13
|
featureSecretEntryName,
|
|
13
14
|
featureWorkerName,
|
|
@@ -189,10 +190,21 @@ export interface ProvisionScope {
|
|
|
189
190
|
* `<project>-feature-email-send` would be one Workflow every open branch deployed over every other's.
|
|
190
191
|
*/
|
|
191
192
|
readonly workflowHost: Omit<WorkflowHostNameParts, "capability">;
|
|
193
|
+
/**
|
|
194
|
+
* The Artifacts namespace an `artifacts` binding names in this scope (#673): `<project>-<env>` in a declared
|
|
195
|
+
* environment, `<project>-f<issue>-<slug>--artifacts` in a feature. On the scope for the reason every other name
|
|
196
|
+
* is: the stanza a run writes and the namespace it writes into it are one decision.
|
|
197
|
+
*/
|
|
198
|
+
readonly artifactsNamespace: string;
|
|
192
199
|
}
|
|
193
200
|
|
|
194
201
|
/** The wrangler binding array a resource kind's name is composed for. */
|
|
195
|
-
const KIND_NAMER: Record<FeatureResourceKind, "d1" | "kv" | "r2"
|
|
202
|
+
const KIND_NAMER: Record<FeatureResourceKind, "d1" | "kv" | "r2" | "queue"> = {
|
|
203
|
+
d1: "d1",
|
|
204
|
+
kv: "kv",
|
|
205
|
+
r2: "r2",
|
|
206
|
+
queue: "queue",
|
|
207
|
+
};
|
|
196
208
|
|
|
197
209
|
/**
|
|
198
210
|
* The one branch every scope shares: a `global` secret resolves to the project's single
|
|
@@ -345,6 +357,7 @@ export function environmentScope(project: string, environment: string): Provisio
|
|
|
345
357
|
secretEntry: (secret, secretScope) =>
|
|
346
358
|
secretEntryName(project, secret, secretScope, () => names.secretEntry(secret)),
|
|
347
359
|
workflowHost: { project, env: environment },
|
|
360
|
+
artifactsNamespace: names.artifactsNamespace(),
|
|
348
361
|
};
|
|
349
362
|
}
|
|
350
363
|
|
|
@@ -392,6 +405,11 @@ export function featureScope(identity: FeatureIdentity): ProvisionScope {
|
|
|
392
405
|
secretEntry: (secret) => featureSecretEntryName(identity, secret),
|
|
393
406
|
// `env` is the stanza — what the host's `ENVIRONMENT` var says — and `feature` is what its names take.
|
|
394
407
|
workflowHost: { project: identity.project, env: FEATURE_ENVIRONMENT, feature: identity },
|
|
408
|
+
// The feature's own, read back as its own by `isFeatureOwnedName`, so teardown reaps no sibling's repos. A
|
|
409
|
+
// getter, so a slug too long for it refuses where the namespace is asked for, never where the scope is made.
|
|
410
|
+
get artifactsNamespace() {
|
|
411
|
+
return featureArtifactsNamespace(identity);
|
|
412
|
+
},
|
|
395
413
|
};
|
|
396
414
|
}
|
|
397
415
|
|
|
@@ -4,7 +4,13 @@
|
|
|
4
4
|
import { InternalError, ValidationError } from "../error/pithyError";
|
|
5
5
|
import { workflowHostName, workflowScriptName } from "../workflow/naming";
|
|
6
6
|
import { assertValidEnvironment, GLOBAL_SCOPE } from "./environment";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
type FeatureIdentity,
|
|
9
|
+
type FeatureResourceKind,
|
|
10
|
+
featureArtifactsNamespace,
|
|
11
|
+
featureResourceName,
|
|
12
|
+
featureWorkerName,
|
|
13
|
+
} from "./feature";
|
|
8
14
|
import { NAMESPACE_LIMITS, type NamespaceLimit } from "./limits";
|
|
9
15
|
import { assertValidProjectName, kebab, resourceName } from "./resource";
|
|
10
16
|
|
|
@@ -57,6 +63,8 @@ export interface ScopedNames {
|
|
|
57
63
|
kv(binding: string): string;
|
|
58
64
|
/** An R2 bucket. Cloudflare's 63 — the one namespace where that number was always right. */
|
|
59
65
|
r2(binding: string): string;
|
|
66
|
+
/** A Queue. Cloudflare's 63, truncated with a stable hash like a bucket (#662). */
|
|
67
|
+
queue(binding: string): string;
|
|
60
68
|
/** A Vectorize index. 64 bytes, refused rather than truncated: a renamed index has no vectors. */
|
|
61
69
|
vectorizeIndex(index: string): string;
|
|
62
70
|
/** A Secrets Store entry. No Cloudflare cap; Pithy's ceiling. */
|
|
@@ -65,15 +73,33 @@ export interface ScopedNames {
|
|
|
65
73
|
apiToken(profile: string): string;
|
|
66
74
|
}
|
|
67
75
|
|
|
76
|
+
/**
|
|
77
|
+
* The names in one declared environment: everything a scope has, and the one name that exists per environment
|
|
78
|
+
* and never at `global`.
|
|
79
|
+
*/
|
|
80
|
+
export interface EnvironmentNames extends ScopedNames {
|
|
81
|
+
/**
|
|
82
|
+
* The Artifacts namespace — `<project>-<env>`, with no `<thing>` (#673). One per environment, so a feature's
|
|
83
|
+
* repos never sit beside production's and the per-namespace rate limit splits per environment. There is no
|
|
84
|
+
* global one, because a namespace shared across environments is exactly that mix.
|
|
85
|
+
*/
|
|
86
|
+
artifactsNamespace(): string;
|
|
87
|
+
}
|
|
88
|
+
|
|
68
89
|
/** The names a feature environment provisions under, with the project already bound. */
|
|
69
90
|
export interface FeatureNames {
|
|
70
|
-
/** A feature's D1, KV, or
|
|
91
|
+
/** A feature's D1, KV, R2 or Queue resource — `<project>-f<issue>-<slug>--<binding>-<kind>`. */
|
|
71
92
|
resource(binding: string, kind: FeatureResourceKind): string;
|
|
72
93
|
/**
|
|
73
94
|
* A feature's Worker script — `<project>-f<issue>-<slug>-<app>`, from the `apps/<app>` directory and
|
|
74
95
|
* never the deploy name, which already leads with the project (#587).
|
|
75
96
|
*/
|
|
76
97
|
worker(app: string): string;
|
|
98
|
+
/**
|
|
99
|
+
* A feature's Artifacts namespace — `<project>-f<issue>-<slug>--artifacts` (#673). The tail is what makes it a
|
|
100
|
+
* feature name `isFeatureOwnedName` reads back, so teardown reaps this feature's repos and no sibling's.
|
|
101
|
+
*/
|
|
102
|
+
artifactsNamespace(): string;
|
|
77
103
|
}
|
|
78
104
|
|
|
79
105
|
/** Everything a project can be asked to name. Created once, from the project, by {@link resourceNames}. */
|
|
@@ -81,7 +107,7 @@ export interface ProjectNames {
|
|
|
81
107
|
/** The kebabbed project name every name below leads with. */
|
|
82
108
|
readonly project: string;
|
|
83
109
|
/** The names in one environment. Throws if the environment is not one this scheme accepts. */
|
|
84
|
-
env(environment: string):
|
|
110
|
+
env(environment: string): EnvironmentNames;
|
|
85
111
|
/** The names shared by every environment — the `global` scope, without typing the word. */
|
|
86
112
|
readonly global: ScopedNames;
|
|
87
113
|
/** The names a feature environment provisions under. */
|
|
@@ -134,6 +160,22 @@ function scopedName(project: string, scope: string, thing: string, limit: Namesp
|
|
|
134
160
|
return name;
|
|
135
161
|
}
|
|
136
162
|
|
|
163
|
+
/**
|
|
164
|
+
* `<project>-<env>`, under the Artifacts namespace rule. The project and environment are both validated by now, and
|
|
165
|
+
* the longest of each compose 34 characters, so the cap is a check rather than a truncation that ever happens.
|
|
166
|
+
*/
|
|
167
|
+
function artifactsNamespaceName(project: string, environment: string): string {
|
|
168
|
+
const limit = NAMESPACE_LIMITS.artifactsNamespace;
|
|
169
|
+
const name = `${project}-${environment}`;
|
|
170
|
+
if (name.length > limit.maxLength || name.length < limit.minLength) {
|
|
171
|
+
throw new InternalError({
|
|
172
|
+
message: `"${name}" does not fit ${limit.label}, which takes ${limit.minLength} to ${limit.maxLength} characters.`,
|
|
173
|
+
action: "Shorten the project or environment name.",
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return name;
|
|
177
|
+
}
|
|
178
|
+
|
|
137
179
|
/** The getters for one scope. `global` and every environment share this, so nothing special-cases either. */
|
|
138
180
|
function scopedNames(project: string, scope: string): ScopedNames {
|
|
139
181
|
return {
|
|
@@ -142,6 +184,7 @@ function scopedNames(project: string, scope: string): ScopedNames {
|
|
|
142
184
|
d1: (binding) => scopedName(project, scope, binding, NAMESPACE_LIMITS.d1),
|
|
143
185
|
kv: (binding) => scopedName(project, scope, binding, NAMESPACE_LIMITS.kv),
|
|
144
186
|
r2: (binding) => scopedName(project, scope, binding, NAMESPACE_LIMITS.r2),
|
|
187
|
+
queue: (binding) => scopedName(project, scope, binding, NAMESPACE_LIMITS.queue),
|
|
145
188
|
vectorizeIndex: (index) => scopedName(project, scope, index, NAMESPACE_LIMITS.vectorizeIndex),
|
|
146
189
|
secretEntry: (secret) => scopedName(project, scope, secret, NAMESPACE_LIMITS.secretEntry),
|
|
147
190
|
apiToken: (profile) => scopedName(project, scope, profile, NAMESPACE_LIMITS.apiToken),
|
|
@@ -161,9 +204,12 @@ export function resourceNames(project: string): ProjectNames {
|
|
|
161
204
|
|
|
162
205
|
return {
|
|
163
206
|
project: normalized,
|
|
164
|
-
env(environment: string):
|
|
207
|
+
env(environment: string): EnvironmentNames {
|
|
165
208
|
assertValidEnvironment(environment);
|
|
166
|
-
return
|
|
209
|
+
return {
|
|
210
|
+
...scopedNames(normalized, environment),
|
|
211
|
+
artifactsNamespace: () => artifactsNamespaceName(normalized, environment),
|
|
212
|
+
};
|
|
167
213
|
},
|
|
168
214
|
global: scopedNames(normalized, GLOBAL_SCOPE),
|
|
169
215
|
feature(identity): FeatureNames {
|
|
@@ -171,6 +217,7 @@ export function resourceNames(project: string): ProjectNames {
|
|
|
171
217
|
return {
|
|
172
218
|
resource: (binding, kind) => featureResourceName(full, binding, kind),
|
|
173
219
|
worker: (app) => featureWorkerName(full, app),
|
|
220
|
+
artifactsNamespace: () => featureArtifactsNamespace(full),
|
|
174
221
|
};
|
|
175
222
|
},
|
|
176
223
|
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { type EncryptionConfig, keyBytes } from "../crypto/envelope";
|
|
5
|
+
import { FIELD_KEYS_BINDING } from "../crypto/fieldKeys";
|
|
6
|
+
import { type BlindIndexNormalizer, normalizeForIndex } from "./encrypted";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* **A blind index: a keyed hash an encrypted column can be found by** (#390).
|
|
10
|
+
*
|
|
11
|
+
* An encrypted column cannot answer `WHERE email = ?`. A sibling column holding `HMAC(key, normalize(value))` can,
|
|
12
|
+
* for equality and nothing else. One rule here is shared by the app Worker that seals, `sealed().match` that looks
|
|
13
|
+
* up, and the rotator that re-indexes inside the manager, so the three cannot hash one value two ways.
|
|
14
|
+
*
|
|
15
|
+
* - **The key is derived, on the field key's own version axis.** Each version's index key is HKDF-SHA256 over that
|
|
16
|
+
* version's field key, with the label `pithy/blind-index/<normalizer>`. One secret, one rotation.
|
|
17
|
+
* - **The value carries its version**: `<version>:<hmac>`, so the stale count reaches an index exactly as it reaches
|
|
18
|
+
* an envelope, by reading the prefix.
|
|
19
|
+
* - **The normalizer is in the label**, so changing a field's normalizer cannot silently miss against old hashes: they
|
|
20
|
+
* are hashes under a different key, and the change reads as a re-index.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** The HKDF label one normalizer's index keys are derived under. */
|
|
24
|
+
export function blindIndexLabel(normalizer: BlindIndexNormalizer): string {
|
|
25
|
+
return `pithy/blind-index/${normalizer}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Unpadded base64url, the alphabet a blind index is written in. */
|
|
29
|
+
function base64Url(bytes: Uint8Array): string {
|
|
30
|
+
let binary = "";
|
|
31
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
32
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The HMAC key for one version and normalizer: HKDF-SHA256 over that version's field key. */
|
|
36
|
+
export async function blindIndexKey(
|
|
37
|
+
config: EncryptionConfig,
|
|
38
|
+
version: number,
|
|
39
|
+
normalizer: BlindIndexNormalizer,
|
|
40
|
+
): Promise<CryptoKey> {
|
|
41
|
+
const material = await crypto.subtle.importKey(
|
|
42
|
+
"raw",
|
|
43
|
+
keyBytes(config, version, { keys: FIELD_KEYS_BINDING }),
|
|
44
|
+
"HKDF",
|
|
45
|
+
false,
|
|
46
|
+
["deriveBits"],
|
|
47
|
+
);
|
|
48
|
+
const bits = await crypto.subtle.deriveBits(
|
|
49
|
+
{
|
|
50
|
+
name: "HKDF",
|
|
51
|
+
hash: "SHA-256",
|
|
52
|
+
salt: new Uint8Array(0),
|
|
53
|
+
info: Uint8Array.from(new TextEncoder().encode(blindIndexLabel(normalizer))),
|
|
54
|
+
},
|
|
55
|
+
material,
|
|
56
|
+
256,
|
|
57
|
+
);
|
|
58
|
+
return crypto.subtle.importKey("raw", bits, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The blind index of `value` under `version`: `<version>:<hmac>`. The value is normalized first. */
|
|
62
|
+
export async function blindIndexValue(
|
|
63
|
+
config: EncryptionConfig,
|
|
64
|
+
version: number,
|
|
65
|
+
normalizer: BlindIndexNormalizer,
|
|
66
|
+
value: string,
|
|
67
|
+
): Promise<string> {
|
|
68
|
+
const key = await blindIndexKey(config, version, normalizer);
|
|
69
|
+
const mac = await crypto.subtle.sign(
|
|
70
|
+
"HMAC",
|
|
71
|
+
key,
|
|
72
|
+
Uint8Array.from(new TextEncoder().encode(normalizeForIndex(normalizer, value))),
|
|
73
|
+
);
|
|
74
|
+
return `${version}:${base64Url(new Uint8Array(mac))}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The version a blind-index value was written under, or `null` for a value that is not one. */
|
|
78
|
+
export function blindIndexVersion(value: string): number | null {
|
|
79
|
+
const match = /^(\d+):/.exec(value);
|
|
80
|
+
return match ? Number(match[1]) : null;
|
|
81
|
+
}
|