@pithy-sh/core 0.10.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/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 +98 -28
- package/dist/capability/capability.d.ts.map +1 -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 +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 +184 -22
- package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
- package/dist/cli/commandCatalog.generated.js +196 -22
- package/dist/cli/commandFacts.d.ts +1 -1
- package/dist/cli/commandFacts.d.ts.map +1 -1
- package/dist/cli/commandFacts.js +1 -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/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 +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 +1 -0
- package/dist/controlPlane/http/routes.d.ts.map +1 -1
- package/dist/controlPlane/http/routes.js +17 -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 +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.map +1 -1
- package/dist/createBackend.js +7 -2
- 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/error/http.d.ts +144 -0
- package/dist/error/http.d.ts.map +1 -1
- package/dist/error/payload.d.ts +288 -0
- package/dist/error/payload.d.ts.map +1 -1
- package/dist/error/payload.js +100 -4
- 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/record.d.ts +84 -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/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/capability/bindings.ts +159 -13
- package/src/capability/capability.ts +99 -29
- package/src/capability/settings.ts +146 -2
- package/src/cli/commandCatalog.generated.ts +212 -22
- package/src/cli/commandFacts.ts +1 -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/http/cors.ts +4 -4
- package/src/controlPlane/http/guard.ts +21 -0
- 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 +25 -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 +14 -2
- package/src/createBackend.ts +24 -6
- 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/error/payload.ts +211 -4
- package/src/http/authContext.ts +13 -1
- package/src/http/scopeGates.ts +34 -0
- package/src/index.ts +1 -0
- 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/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
package/src/error/payload.ts
CHANGED
|
@@ -501,6 +501,28 @@ const TurnstileConfigPublic = z
|
|
|
501
501
|
})
|
|
502
502
|
.describe("The Turnstile middleware is misconfigured (500).");
|
|
503
503
|
|
|
504
|
+
const TurnstileAnalyticsWindowPublic = z
|
|
505
|
+
.object({
|
|
506
|
+
code: z
|
|
507
|
+
.literal("turnstile/analytics_window")
|
|
508
|
+
.describe(
|
|
509
|
+
"The requested challenge window reaches past what Cloudflare keeps, or is wider than one read may ask for.",
|
|
510
|
+
),
|
|
511
|
+
status: z.literal(400).describe("Bad Request — narrow the window or move it later."),
|
|
512
|
+
...publicFields,
|
|
513
|
+
})
|
|
514
|
+
.describe("A Turnstile-analytics window outside Cloudflare's retention or range (400).");
|
|
515
|
+
|
|
516
|
+
const TurnstileAnalyticsUnavailablePublic = z
|
|
517
|
+
.object({
|
|
518
|
+
code: z
|
|
519
|
+
.literal("turnstile/analytics_unavailable")
|
|
520
|
+
.describe("Cloudflare does not serve Turnstile analytics to this account, so no outcome can be read."),
|
|
521
|
+
status: z.literal(409).describe("Conflict — the account's configuration rules the read out."),
|
|
522
|
+
...publicFields,
|
|
523
|
+
})
|
|
524
|
+
.describe("Turnstile analytics are not served to this account (409).");
|
|
525
|
+
|
|
504
526
|
// --- @pithy-sh/audit: audit-trail recorder codes ---
|
|
505
527
|
|
|
506
528
|
const AuditInvalidEventPublic = z
|
|
@@ -535,6 +557,18 @@ const MediaNotFoundPublic = z
|
|
|
535
557
|
})
|
|
536
558
|
.describe("A requested media record does not exist (404).");
|
|
537
559
|
|
|
560
|
+
const MediaForbiddenPublic = z
|
|
561
|
+
.object({
|
|
562
|
+
code: z
|
|
563
|
+
.literal("media/forbidden")
|
|
564
|
+
.describe(
|
|
565
|
+
"The caller may read the media record but not change it: only its owner, acting for the record's organization, or a role holding `media:manage` there may edit, delete or re-scope it.",
|
|
566
|
+
),
|
|
567
|
+
status: z.literal(403).describe("Forbidden."),
|
|
568
|
+
...publicFields,
|
|
569
|
+
})
|
|
570
|
+
.describe("The caller may read the record but not change it (403).");
|
|
571
|
+
|
|
538
572
|
const MediaUnsupportedPublic = z
|
|
539
573
|
.object({
|
|
540
574
|
code: z
|
|
@@ -565,6 +599,30 @@ const MediaEnrichmentFailedPublic = z
|
|
|
565
599
|
})
|
|
566
600
|
.describe("A media AI-enrichment step failed (500).");
|
|
567
601
|
|
|
602
|
+
const MediaAnalyticsWindowPublic = z
|
|
603
|
+
.object({
|
|
604
|
+
code: z
|
|
605
|
+
.literal("media/analytics_window")
|
|
606
|
+
.describe(
|
|
607
|
+
"The requested delivery window reaches past what Cloudflare keeps, or is wider than one read may ask for.",
|
|
608
|
+
),
|
|
609
|
+
status: z.literal(400).describe("Bad Request — narrow the window or move it later."),
|
|
610
|
+
...publicFields,
|
|
611
|
+
})
|
|
612
|
+
.describe("A delivery-analytics window outside Cloudflare's retention or range (400).");
|
|
613
|
+
|
|
614
|
+
const MediaAnalyticsUnavailablePublic = z
|
|
615
|
+
.object({
|
|
616
|
+
code: z
|
|
617
|
+
.literal("media/analytics_unavailable")
|
|
618
|
+
.describe(
|
|
619
|
+
"Cloudflare does not serve the delivery analytics this read needs to this account — a Customer Metadata Boundary outside the US carries none of them.",
|
|
620
|
+
),
|
|
621
|
+
status: z.literal(409).describe("Conflict — the account's configuration rules the read out."),
|
|
622
|
+
...publicFields,
|
|
623
|
+
})
|
|
624
|
+
.describe("Delivery analytics are not served to this account (409).");
|
|
625
|
+
|
|
568
626
|
// --- @pithy-sh/leaderboard: optional ranking capability codes ---
|
|
569
627
|
|
|
570
628
|
const LeaderboardBoardNotFoundPublic = z
|
|
@@ -931,7 +989,7 @@ const StorageNotFoundPublic = z
|
|
|
931
989
|
.object({
|
|
932
990
|
code: z
|
|
933
991
|
.literal("storage/not_found")
|
|
934
|
-
.describe("No object row matches, or the object exists but
|
|
992
|
+
.describe("No object row matches, or the object exists but this caller may not read it."),
|
|
935
993
|
status: z.literal(404).describe("Not Found."),
|
|
936
994
|
...publicFields,
|
|
937
995
|
})
|
|
@@ -939,18 +997,22 @@ const StorageNotFoundPublic = z
|
|
|
939
997
|
|
|
940
998
|
const StorageForbiddenPublic = z
|
|
941
999
|
.object({
|
|
942
|
-
code: z
|
|
1000
|
+
code: z
|
|
1001
|
+
.literal("storage/forbidden")
|
|
1002
|
+
.describe(
|
|
1003
|
+
"The caller may read the object but not change it: only its owner, acting for the object's organization, or a role holding `storage:manage` there may.",
|
|
1004
|
+
),
|
|
943
1005
|
status: z.literal(403).describe("Forbidden."),
|
|
944
1006
|
...publicFields,
|
|
945
1007
|
})
|
|
946
|
-
.describe("The caller
|
|
1008
|
+
.describe("The caller may read the object but not change it (403).");
|
|
947
1009
|
|
|
948
1010
|
const StorageQuotaExceededPublic = z
|
|
949
1011
|
.object({
|
|
950
1012
|
code: z
|
|
951
1013
|
.literal("storage/quota_exceeded")
|
|
952
1014
|
.describe(
|
|
953
|
-
"The declared upload size would push the owner past
|
|
1015
|
+
"The declared upload size would push the owner past the configured `bytesPerOwner` quota. Checked at init against pending and stored rows together, so concurrent inits cannot race past the limit. Where the `storageBytes` meter is enforced, the same refusal is `payments/quota_exceeded` (402).",
|
|
954
1016
|
),
|
|
955
1017
|
status: z.literal(413).describe("Content Too Large."),
|
|
956
1018
|
...publicFields,
|
|
@@ -1257,6 +1319,30 @@ const PaymentsSubscriptionChangeRefusedPublic = z
|
|
|
1257
1319
|
})
|
|
1258
1320
|
.describe("A subscription's state forbids the change asked of it (409).");
|
|
1259
1321
|
|
|
1322
|
+
const PaymentsOverrideStalePublic = z
|
|
1323
|
+
.object({
|
|
1324
|
+
code: z
|
|
1325
|
+
.literal("payments/override_stale")
|
|
1326
|
+
.describe(
|
|
1327
|
+
"A control-plane override write named a version behind the subject's override row. Two operators share one customer: both opened the same allowance, and the second would silently overwrite the first while the first believed they had succeeded. So the write is refused and nothing is written. 409, because the request is well-formed and the same request against the version it now holds would be accepted — the caller re-reads, and tries again.",
|
|
1328
|
+
),
|
|
1329
|
+
status: z.literal(409).describe("Conflict — the override changed since the caller read it."),
|
|
1330
|
+
...publicFields,
|
|
1331
|
+
})
|
|
1332
|
+
.describe("An override write named a stale version (409).");
|
|
1333
|
+
|
|
1334
|
+
const PaymentsQuotaExceededPublic = z
|
|
1335
|
+
.object({
|
|
1336
|
+
code: z
|
|
1337
|
+
.literal("payments/quota_exceeded")
|
|
1338
|
+
.describe(
|
|
1339
|
+
"An enforced kit meter — emails enqueued, bytes stored, media items held — would pass the subject's effective quota for the period. Nothing was written. 402 Payment Required, because it is the honest status: the plan does not allow more and the remedy is a purchase, a bigger tier, or a number pinned for this subject from the control plane. A meter refuses and never bills — the kit does not sell overage.",
|
|
1340
|
+
),
|
|
1341
|
+
status: z.literal(402).describe("Payment Required — the plan's allowance for this period is used up."),
|
|
1342
|
+
...publicFields,
|
|
1343
|
+
})
|
|
1344
|
+
.describe("An enforced meter is at its quota for this period (402).");
|
|
1345
|
+
|
|
1260
1346
|
// --- @pithy-sh/core: the `control-plane` verification strategy ---
|
|
1261
1347
|
//
|
|
1262
1348
|
// NOT Cloudflare's control plane. Everywhere else in this repo "control plane" means the Cloudflare
|
|
@@ -1407,6 +1493,68 @@ const OrganizationNominationInvalidPublic = z
|
|
|
1407
1493
|
})
|
|
1408
1494
|
.describe("No standing ownership offer this caller may accept (400).");
|
|
1409
1495
|
|
|
1496
|
+
// --- @pithy-sh/artifacts: Git repositories, ownership, grants and short-lived tokens ---
|
|
1497
|
+
|
|
1498
|
+
const ArtifactsNotFoundPublic = z
|
|
1499
|
+
.object({
|
|
1500
|
+
code: z
|
|
1501
|
+
.literal("artifacts/not_found")
|
|
1502
|
+
.describe(
|
|
1503
|
+
"No such repository, **or** one the caller holds no grant on and does not own. One code for both, byte for byte: a repository's name is in its clone URL, and a refusal that told the two apart would hand it over. Also what the credential route answers for a repository with remote access off. Which it was survives in `detail`, which the HTTP codec strips.",
|
|
1504
|
+
),
|
|
1505
|
+
status: z.literal(404).describe("Not Found."),
|
|
1506
|
+
...publicFields,
|
|
1507
|
+
})
|
|
1508
|
+
.describe("No such repository, or none this caller may see — the same answer for both (404).");
|
|
1509
|
+
|
|
1510
|
+
const ArtifactsForbiddenPublic = z
|
|
1511
|
+
.object({
|
|
1512
|
+
code: z
|
|
1513
|
+
.literal("artifacts/forbidden")
|
|
1514
|
+
.describe(
|
|
1515
|
+
"The caller can see the repository and may not do this to it: a grant holder asking for what only an owner may do, or a member of an organization whose role lacks the configured `managePower`. A 403 rather than the 404 above, because by then the caller already knows the repository exists.",
|
|
1516
|
+
),
|
|
1517
|
+
status: z.literal(403).describe("Forbidden."),
|
|
1518
|
+
...publicFields,
|
|
1519
|
+
})
|
|
1520
|
+
.describe("The caller may see this repository and may not do this (403).");
|
|
1521
|
+
|
|
1522
|
+
const ArtifactsInvalidNamePublic = z
|
|
1523
|
+
.object({
|
|
1524
|
+
code: z
|
|
1525
|
+
.literal("artifacts/invalid_name")
|
|
1526
|
+
.describe(
|
|
1527
|
+
"A supplied repository name is outside Cloudflare's charset or the 2-to-63 cap. Refused and never shortened: a name somebody chose is in every clone URL, and a silently different one is a repository nobody can find.",
|
|
1528
|
+
),
|
|
1529
|
+
status: z.literal(400).describe("Bad Request."),
|
|
1530
|
+
...publicFields,
|
|
1531
|
+
})
|
|
1532
|
+
.describe("A repository name breaks the naming rule (400).");
|
|
1533
|
+
|
|
1534
|
+
const ArtifactsNameTakenPublic = z
|
|
1535
|
+
.object({
|
|
1536
|
+
code: z
|
|
1537
|
+
.literal("artifacts/name_taken")
|
|
1538
|
+
.describe(
|
|
1539
|
+
"The owner already holds a repository with that display name, or a supplied physical name is already in the namespace. The second happens only where the adopter supplies names, so uniqueness is theirs.",
|
|
1540
|
+
),
|
|
1541
|
+
status: z.literal(409).describe("Conflict."),
|
|
1542
|
+
...publicFields,
|
|
1543
|
+
})
|
|
1544
|
+
.describe("That repository name is already in use (409).");
|
|
1545
|
+
|
|
1546
|
+
const ArtifactsInvalidConfigPublic = z
|
|
1547
|
+
.object({
|
|
1548
|
+
code: z
|
|
1549
|
+
.literal("artifacts/invalid_config")
|
|
1550
|
+
.describe(
|
|
1551
|
+
"The capability's configuration cannot be honored: a `tokenTtlSeconds` above the ceiling, or `organization` composed with no `managePower` named. Raised at boot, so a mistake fails on deploy rather than at the first mint.",
|
|
1552
|
+
),
|
|
1553
|
+
status: z.literal(400).describe("Bad Request — the configuration is malformed."),
|
|
1554
|
+
...publicFields,
|
|
1555
|
+
})
|
|
1556
|
+
.describe("The artifacts configuration is invalid (400).");
|
|
1557
|
+
|
|
1410
1558
|
// --- @pithy-sh/support: inbound inbox, taxonomy, and classification codes ---
|
|
1411
1559
|
|
|
1412
1560
|
const SupportNotFoundPublic = z
|
|
@@ -1639,12 +1787,17 @@ export const KitPublicErrorPayload = z
|
|
|
1639
1787
|
TurnstileMissingTokenPublic,
|
|
1640
1788
|
TurnstileFailedPublic,
|
|
1641
1789
|
TurnstileConfigPublic,
|
|
1790
|
+
TurnstileAnalyticsWindowPublic,
|
|
1791
|
+
TurnstileAnalyticsUnavailablePublic,
|
|
1642
1792
|
AuditInvalidEventPublic,
|
|
1643
1793
|
AuditWriteFailedPublic,
|
|
1644
1794
|
MediaNotFoundPublic,
|
|
1795
|
+
MediaForbiddenPublic,
|
|
1645
1796
|
MediaUnsupportedPublic,
|
|
1646
1797
|
MediaStorageFailedPublic,
|
|
1647
1798
|
MediaEnrichmentFailedPublic,
|
|
1799
|
+
MediaAnalyticsWindowPublic,
|
|
1800
|
+
MediaAnalyticsUnavailablePublic,
|
|
1648
1801
|
LeaderboardBoardNotFoundPublic,
|
|
1649
1802
|
LeaderboardEntryNotFoundPublic,
|
|
1650
1803
|
LeaderboardScoreRejectedPublic,
|
|
@@ -1707,6 +1860,8 @@ export const KitPublicErrorPayload = z
|
|
|
1707
1860
|
PaymentsEntitlementNotInCatalogPublic,
|
|
1708
1861
|
PaymentsSubjectUnresolvedPublic,
|
|
1709
1862
|
PaymentsSubscriptionChangeRefusedPublic,
|
|
1863
|
+
PaymentsOverrideStalePublic,
|
|
1864
|
+
PaymentsQuotaExceededPublic,
|
|
1710
1865
|
ControlPlaneNotConnectedPublic,
|
|
1711
1866
|
ControlPlaneInvalidCredentialPublic,
|
|
1712
1867
|
ControlPlaneInsufficientScopePublic,
|
|
@@ -1719,6 +1874,11 @@ export const KitPublicErrorPayload = z
|
|
|
1719
1874
|
OrganizationLastAdministratorPublic,
|
|
1720
1875
|
OrganizationSlugTakenPublic,
|
|
1721
1876
|
OrganizationNominationInvalidPublic,
|
|
1877
|
+
ArtifactsNotFoundPublic,
|
|
1878
|
+
ArtifactsForbiddenPublic,
|
|
1879
|
+
ArtifactsInvalidNamePublic,
|
|
1880
|
+
ArtifactsNameTakenPublic,
|
|
1881
|
+
ArtifactsInvalidConfigPublic,
|
|
1722
1882
|
SupportNotFoundPublic,
|
|
1723
1883
|
SupportInvalidCategoryPublic,
|
|
1724
1884
|
SupportUnparseableMessagePublic,
|
|
@@ -1815,6 +1975,12 @@ const TurnstileMissingToken = TurnstileMissingTokenPublic.extend(internalFields)
|
|
|
1815
1975
|
);
|
|
1816
1976
|
const TurnstileFailed = TurnstileFailedPublic.extend(internalFields).describe(TurnstileFailedPublic.description ?? "");
|
|
1817
1977
|
const TurnstileConfig = TurnstileConfigPublic.extend(internalFields).describe(TurnstileConfigPublic.description ?? "");
|
|
1978
|
+
const TurnstileAnalyticsWindow = TurnstileAnalyticsWindowPublic.extend(internalFields).describe(
|
|
1979
|
+
TurnstileAnalyticsWindowPublic.description ?? "",
|
|
1980
|
+
);
|
|
1981
|
+
const TurnstileAnalyticsUnavailable = TurnstileAnalyticsUnavailablePublic.extend(internalFields).describe(
|
|
1982
|
+
TurnstileAnalyticsUnavailablePublic.description ?? "",
|
|
1983
|
+
);
|
|
1818
1984
|
const AuditInvalidEvent = AuditInvalidEventPublic.extend(internalFields).describe(
|
|
1819
1985
|
AuditInvalidEventPublic.description ?? "",
|
|
1820
1986
|
);
|
|
@@ -1822,6 +1988,7 @@ const AuditWriteFailed = AuditWriteFailedPublic.extend(internalFields).describe(
|
|
|
1822
1988
|
AuditWriteFailedPublic.description ?? "",
|
|
1823
1989
|
);
|
|
1824
1990
|
const MediaNotFound = MediaNotFoundPublic.extend(internalFields).describe(MediaNotFoundPublic.description ?? "");
|
|
1991
|
+
const MediaForbidden = MediaForbiddenPublic.extend(internalFields).describe(MediaForbiddenPublic.description ?? "");
|
|
1825
1992
|
const MediaUnsupported = MediaUnsupportedPublic.extend(internalFields).describe(
|
|
1826
1993
|
MediaUnsupportedPublic.description ?? "",
|
|
1827
1994
|
);
|
|
@@ -1831,6 +1998,12 @@ const MediaStorageFailed = MediaStorageFailedPublic.extend(internalFields).descr
|
|
|
1831
1998
|
const MediaEnrichmentFailed = MediaEnrichmentFailedPublic.extend(internalFields).describe(
|
|
1832
1999
|
MediaEnrichmentFailedPublic.description ?? "",
|
|
1833
2000
|
);
|
|
2001
|
+
const MediaAnalyticsWindow = MediaAnalyticsWindowPublic.extend(internalFields).describe(
|
|
2002
|
+
MediaAnalyticsWindowPublic.description ?? "",
|
|
2003
|
+
);
|
|
2004
|
+
const MediaAnalyticsUnavailable = MediaAnalyticsUnavailablePublic.extend(internalFields).describe(
|
|
2005
|
+
MediaAnalyticsUnavailablePublic.description ?? "",
|
|
2006
|
+
);
|
|
1834
2007
|
const LeaderboardBoardNotFound = LeaderboardBoardNotFoundPublic.extend(internalFields).describe(
|
|
1835
2008
|
LeaderboardBoardNotFoundPublic.description ?? "",
|
|
1836
2009
|
);
|
|
@@ -2016,6 +2189,12 @@ const PaymentsSubjectUnresolved = PaymentsSubjectUnresolvedPublic.extend(interna
|
|
|
2016
2189
|
const PaymentsSubscriptionChangeRefused = PaymentsSubscriptionChangeRefusedPublic.extend(internalFields).describe(
|
|
2017
2190
|
PaymentsSubscriptionChangeRefusedPublic.description ?? "",
|
|
2018
2191
|
);
|
|
2192
|
+
const PaymentsOverrideStale = PaymentsOverrideStalePublic.extend(internalFields).describe(
|
|
2193
|
+
PaymentsOverrideStalePublic.description ?? "",
|
|
2194
|
+
);
|
|
2195
|
+
const PaymentsQuotaExceeded = PaymentsQuotaExceededPublic.extend(internalFields).describe(
|
|
2196
|
+
PaymentsQuotaExceededPublic.description ?? "",
|
|
2197
|
+
);
|
|
2019
2198
|
const ControlPlaneNotConnected = ControlPlaneNotConnectedPublic.extend(internalFields).describe(
|
|
2020
2199
|
ControlPlaneNotConnectedPublic.description ?? "",
|
|
2021
2200
|
);
|
|
@@ -2086,6 +2265,22 @@ const OrganizationNominationInvalid = OrganizationNominationInvalidPublic.extend
|
|
|
2086
2265
|
OrganizationNominationInvalidPublic.description ?? "",
|
|
2087
2266
|
);
|
|
2088
2267
|
|
|
2268
|
+
const ArtifactsNotFound = ArtifactsNotFoundPublic.extend(internalFields).describe(
|
|
2269
|
+
ArtifactsNotFoundPublic.description ?? "",
|
|
2270
|
+
);
|
|
2271
|
+
const ArtifactsForbidden = ArtifactsForbiddenPublic.extend(internalFields).describe(
|
|
2272
|
+
ArtifactsForbiddenPublic.description ?? "",
|
|
2273
|
+
);
|
|
2274
|
+
const ArtifactsInvalidName = ArtifactsInvalidNamePublic.extend(internalFields).describe(
|
|
2275
|
+
ArtifactsInvalidNamePublic.description ?? "",
|
|
2276
|
+
);
|
|
2277
|
+
const ArtifactsNameTaken = ArtifactsNameTakenPublic.extend(internalFields).describe(
|
|
2278
|
+
ArtifactsNameTakenPublic.description ?? "",
|
|
2279
|
+
);
|
|
2280
|
+
const ArtifactsInvalidConfig = ArtifactsInvalidConfigPublic.extend(internalFields).describe(
|
|
2281
|
+
ArtifactsInvalidConfigPublic.description ?? "",
|
|
2282
|
+
);
|
|
2283
|
+
|
|
2089
2284
|
const SupportNotFound = SupportNotFoundPublic.extend(internalFields).describe(SupportNotFoundPublic.description ?? "");
|
|
2090
2285
|
const SupportInvalidCategory = SupportInvalidCategoryPublic.extend(internalFields).describe(
|
|
2091
2286
|
SupportInvalidCategoryPublic.description ?? "",
|
|
@@ -2145,12 +2340,17 @@ export const KitErrorPayload = z
|
|
|
2145
2340
|
TurnstileMissingToken,
|
|
2146
2341
|
TurnstileFailed,
|
|
2147
2342
|
TurnstileConfig,
|
|
2343
|
+
TurnstileAnalyticsWindow,
|
|
2344
|
+
TurnstileAnalyticsUnavailable,
|
|
2148
2345
|
AuditInvalidEvent,
|
|
2149
2346
|
AuditWriteFailed,
|
|
2150
2347
|
MediaNotFound,
|
|
2348
|
+
MediaForbidden,
|
|
2151
2349
|
MediaUnsupported,
|
|
2152
2350
|
MediaStorageFailed,
|
|
2153
2351
|
MediaEnrichmentFailed,
|
|
2352
|
+
MediaAnalyticsWindow,
|
|
2353
|
+
MediaAnalyticsUnavailable,
|
|
2154
2354
|
LeaderboardBoardNotFound,
|
|
2155
2355
|
LeaderboardEntryNotFound,
|
|
2156
2356
|
LeaderboardScoreRejected,
|
|
@@ -2213,6 +2413,8 @@ export const KitErrorPayload = z
|
|
|
2213
2413
|
PaymentsEntitlementNotInCatalog,
|
|
2214
2414
|
PaymentsSubjectUnresolved,
|
|
2215
2415
|
PaymentsSubscriptionChangeRefused,
|
|
2416
|
+
PaymentsOverrideStale,
|
|
2417
|
+
PaymentsQuotaExceeded,
|
|
2216
2418
|
ControlPlaneNotConnected,
|
|
2217
2419
|
ControlPlaneInvalidCredential,
|
|
2218
2420
|
ControlPlaneInsufficientScope,
|
|
@@ -2225,6 +2427,11 @@ export const KitErrorPayload = z
|
|
|
2225
2427
|
OrganizationLastAdministrator,
|
|
2226
2428
|
OrganizationSlugTaken,
|
|
2227
2429
|
OrganizationNominationInvalid,
|
|
2430
|
+
ArtifactsNotFound,
|
|
2431
|
+
ArtifactsForbidden,
|
|
2432
|
+
ArtifactsInvalidName,
|
|
2433
|
+
ArtifactsNameTaken,
|
|
2434
|
+
ArtifactsInvalidConfig,
|
|
2228
2435
|
SupportNotFound,
|
|
2229
2436
|
SupportInvalidCategory,
|
|
2230
2437
|
SupportUnparseableMessage,
|
package/src/http/authContext.ts
CHANGED
|
@@ -15,12 +15,24 @@ import { Locale } from "../i18n/locale";
|
|
|
15
15
|
* stored choice outrank their device's `Accept-Language`. That link read `c.var.locale` at first,
|
|
16
16
|
* which nothing ever wrote, so it silently contributed nothing and the chain degraded to
|
|
17
17
|
* `param → cookie → header → default` while the docs said otherwise.
|
|
18
|
+
*
|
|
19
|
+
* **`scopes` answers one question: did this request come from the adopter's own trusted backend, or from a
|
|
20
|
+
* device?** The name reads like a grant to the user, and it is not one. Tenancy roles went to `c.var.acting`
|
|
21
|
+
* with `@pithy-sh/organization`; management access is the control plane's, with its own credential and its
|
|
22
|
+
* own scope type. What is left is an attestation about the caller, which `@pithy-sh/auth`'s `resolveScopes`
|
|
23
|
+
* makes on every request from something the request proves — a service binding, a verified client
|
|
24
|
+
* certificate, a short-lived token bound to this origin — and never from anything it merely says (#560).
|
|
18
25
|
*/
|
|
19
26
|
export const AuthContext = z
|
|
20
27
|
.object({
|
|
21
28
|
userId: z.string().describe("ID of the authenticated user (populated by @pithy-sh/auth)."),
|
|
22
29
|
sessionId: z.string().describe("ID of the active session this request belongs to."),
|
|
23
|
-
scopes: z
|
|
30
|
+
scopes: z
|
|
31
|
+
.array(z.string())
|
|
32
|
+
.default([])
|
|
33
|
+
.describe(
|
|
34
|
+
"Attestations about the caller, not permissions granted to the user: what this request's caller was established to be — the adopter's own trusted backend, say — by the `resolveScopes` seam on `@pithy-sh/auth`, resolved per request and never cached. Empty when no resolver is configured. Tenancy roles are not here; they are `c.var.acting`.",
|
|
35
|
+
),
|
|
24
36
|
locale: Locale.nullish().describe(
|
|
25
37
|
"The reader's own stored language, from `pithy_auth_users.locale`; null or absent when they have never chosen.",
|
|
26
38
|
),
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { Hono } from "hono";
|
|
5
|
+
import type { PithyHonoEnv, ScopeGate } from "../capability/capability";
|
|
6
|
+
import { routeSpecOf } from "./routeSpec";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* **A capability's scope gates, read off its own routes rather than listed beside them (#560).**
|
|
10
|
+
*
|
|
11
|
+
* `Capability.scopeGates` is what `pithy doctor` names when nothing composed fills `AuthContext.scopes`.
|
|
12
|
+
* A list written out by hand next to the routes is a second copy of something the routes already say: every
|
|
13
|
+
* gated route carries `describeRoute({ scopes })`, because that is how the OpenAPI contract learns it. So the
|
|
14
|
+
* registrar is run against a throwaway app and the specs are read back — the same walk `openApiDocument`
|
|
15
|
+
* makes over a composed app, and a route whose scope changes changes what this returns with it.
|
|
16
|
+
*
|
|
17
|
+
* **Only `bearer` and `session` routes.** Those are the strategies `requireAuth()` takes, so a scope on one
|
|
18
|
+
* is an `AuthContext` scope. A `control-plane` route also names scopes, and they are a different credential
|
|
19
|
+
* entirely — a signed management token with its own type, never `c.var.auth` — so naming one here would tell
|
|
20
|
+
* an adopter to configure a resolver that could never open it.
|
|
21
|
+
*/
|
|
22
|
+
export function scopeGatesOf(register: (app: Hono<PithyHonoEnv>) => void): ScopeGate[] {
|
|
23
|
+
const app = new Hono<PithyHonoEnv>();
|
|
24
|
+
register(app);
|
|
25
|
+
const gates: ScopeGate[] = [];
|
|
26
|
+
for (const route of app.routes) {
|
|
27
|
+
const spec = routeSpecOf(route.handler);
|
|
28
|
+
if (!spec?.scopes || spec.scopes.length === 0) continue;
|
|
29
|
+
const verification = [spec.verification].flat();
|
|
30
|
+
if (!verification.some((strategy) => strategy === "bearer" || strategy === "session")) continue;
|
|
31
|
+
for (const scope of spec.scopes) gates.push({ scope, route: `${route.method} ${route.path}` });
|
|
32
|
+
}
|
|
33
|
+
return gates;
|
|
34
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -101,6 +101,7 @@ export {
|
|
|
101
101
|
ControlPlaneScope,
|
|
102
102
|
KEYS_ROTATE_SCOPE,
|
|
103
103
|
MANIFEST_READ_SCOPE,
|
|
104
|
+
MIGRATIONS_READ_SCOPE,
|
|
104
105
|
SEAM_SCOPES,
|
|
105
106
|
} from "./controlPlane/scope/scope";
|
|
106
107
|
export { exportPublicJwk, type MintControlPlaneToken, mintControlPlaneToken } from "./controlPlane/token/mint";
|
|
@@ -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
|
+
}
|