@patronage/factory-ci 1.0.0-alpha.32 → 1.0.0-alpha.34
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/README.md +67 -14
- package/dist/.build-fingerprint.json +1 -1
- package/dist/alchemy/index.d.ts +211 -33
- package/dist/alchemy/index.js +725 -254
- package/dist/{execute-alchemy-entry-Dbna-8xq.js → execute-alchemy-entry-BZpk0eOQ.js} +1 -1
- package/dist/index.d.ts +6 -5
- package/dist/index.js +25 -9
- package/package.json +4 -3
- package/src/alchemy/first-deploy-authorization.ts +49 -7
- package/src/alchemy/index.ts +9 -0
- package/src/alchemy/lifecycle.ts +50 -37
- package/src/alchemy/local-emulation.ts +89 -0
- package/src/alchemy/plan-effects.ts +388 -66
- package/src/alchemy/retained-identity.ts +9 -10
- package/src/alchemy/stage-policy.ts +24 -1
- package/src/alchemy/state-tree.ts +168 -0
- package/src/alchemy-baseline.ts +1 -1
- package/src/merge-freeze-job.ts +24 -8
package/README.md
CHANGED
|
@@ -574,7 +574,7 @@ policy.assertDestructiveStage(stage); // throws on a protected stage
|
|
|
574
574
|
policy.assertStackOwnsStage(stack, stage);
|
|
575
575
|
```
|
|
576
576
|
|
|
577
|
-
Two projects wrote the same three questions — is this stage protected, is it disposable, and may this stack run it — and each answered them with its own stage names compiled into the answer. `stagePolicy` takes the table and returns the decisions: `isProtectedStage`, `isDisposableStage`, `isKnownStage`, `assertKnownStage`, `assertDestructiveStage`, and `assertStackOwnsStage`. **No project
|
|
577
|
+
Two projects wrote the same three questions — is this stage protected, is it disposable, and may this stack run it — and each answered them with its own stage names compiled into the answer. `stagePolicy` takes the table and returns the decisions: `isProtectedStage`, `isDisposableStage`, `isKnownStage`, `assertKnownStage`, `assertDestructiveStage`, and `assertStackOwnsStage`. **No project or stack name appears in this package, and one stage name does — `local`, the local emulator's own name, described below.**
|
|
578
578
|
|
|
579
579
|
A `StageMatcher` is a name list, a `RegExp`, or a predicate — the predicate is how a project expresses a stage set it resolves itself, such as one config file per client. `anyStage(...)` composes matchers, and `LOCAL_PREVIEW_STAGES` is the disposable grammar this package already owns (`isLocalPreviewStage`), offered as a matcher rather than assumed, because a project's disposable set is wider than that one grammar.
|
|
580
580
|
|
|
@@ -584,6 +584,34 @@ Refusals are `StagePolicyError` with a stable `code` — `UNKNOWN_STAGE`, `AMBIG
|
|
|
584
584
|
|
|
585
585
|
What stays with the project: which environment variable authorizes an attended protected operation, what counts as a trusted hosted context, whether an outstanding state-transfer proof blocks a stack, and the shape of its own failure envelope. Those name credentials, issues, and contexts, and none of them is a stage question.
|
|
586
586
|
|
|
587
|
+
#### Local emulation
|
|
588
|
+
|
|
589
|
+
```ts
|
|
590
|
+
import {
|
|
591
|
+
isLocalEmulationStage,
|
|
592
|
+
localEmulationEnvironment,
|
|
593
|
+
} from "@patronage/factory-ci/alchemy";
|
|
594
|
+
|
|
595
|
+
if (isLocalEmulationStage(stage)) {
|
|
596
|
+
child.env = { ...child.env, ...localEmulationEnvironment({ stage }) };
|
|
597
|
+
}
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
`isLocalEmulationStage(stage)` is true for the literal `local` and nothing else. Two projects and HQ each answered this question with their own rule — a refusal list, a constant, and `$(whoami)` — and all three meant that one name, so the answer is a literal, not a pattern and not a table. A disposable preview stage such as `local-pr-1-abcdef0` shares the prefix but runs against a real account, so it is false. The comparison is exact: no trim, no case folding. The question sits outside `stagePolicy` because a project's table classifies the stages of its own estate, and the emulated stage belongs to no estate.
|
|
601
|
+
|
|
602
|
+
`localEmulationEnvironment({ stage })` returns the two Cloudflare variables a local-emulation child is launched with. Alchemy refuses to start when `CLOUDFLARE_ACCOUNT_ID` is missing or malformed, even for a run that calls no Cloudflare API, so projects handed the child real credentials to satisfy a check the run never needed. The helper hands it a sentinel pair instead:
|
|
603
|
+
|
|
604
|
+
| Variable | Value |
|
|
605
|
+
| --- | --- |
|
|
606
|
+
| `CLOUDFLARE_ACCOUNT_ID` | thirty-two zeros — well-formed, and addresses no account |
|
|
607
|
+
| `CLOUDFLARE_API_TOKEN` | `factory-ci-local-emulation-no-cloudflare-token` — spelled as words, so no reader and no scanner mistakes it for a credential |
|
|
608
|
+
|
|
609
|
+
The account id is the shape Alchemy validates (`/^[0-9a-f]{32}$/i`, `validateAccountId`); a placeholder such as `""` or `dummy` fails that check before a run starts. Neither value is a secret.
|
|
610
|
+
|
|
611
|
+
Set `FACTORY_CI_LIVE_CLOUDFLARE_BINDING` to a non-empty value and the helper returns no variables, so whatever credentials the parent carries pass through to the child and the run may bind a live Cloudflare resource. That opt-in is the only way a real credential reaches a local-emulation child. The presence of a real credential is never the signal, and the helper reads no credential: it reads the opt-in and nothing else. The result is an overlay — merge it over the environment the launcher has otherwise assembled. Any stage other than `local` throws, because a non-local stage deploys to a real account.
|
|
612
|
+
|
|
613
|
+
Scrubbing an ambient token out of the child stays with the launcher. This package owns the environment shape only.
|
|
614
|
+
|
|
587
615
|
#### Retained identities
|
|
588
616
|
|
|
589
617
|
```ts
|
|
@@ -611,10 +639,32 @@ assertRetainedIdentities({
|
|
|
611
639
|
|
|
612
640
|
A protected stage's value is the physical resources it already owns. An Alchemy deploy that no longer recognises one of them does not fail — it creates a second one and leaves the first orphaned with the data still in it. `assertRetainedIdentities` reads the recorded state before the operation runs and throws `RetainedIdentityError` when an identity is `missing`, `unexpected` (not the `value` the marker names), or `changed` between the two snapshots. Every violation is reported, not just the first.
|
|
613
641
|
|
|
614
|
-
`parseStateSnapshot` reads
|
|
642
|
+
`parseStateSnapshot` reads the persisted-state document — the bulk document, `{ resources: [{ stack, stage, fqn, state }] }`, not a single record. A document covering the whole estate holds one fqn once per stage; pass the second argument, `{ stack, stage }`, to narrow the document to the stage the markers are about. A scoped document that still records one fqn twice throws rather than letting a marker match whichever record sorted first. State it cannot read throws, because reading a broken state as an empty one turns a failed read into a passing assert. An evaluated stack graph is a different artifact carrying the same identities; a caller holding one projects it onto `StateSnapshot` at the call site.
|
|
615
643
|
|
|
616
644
|
Two inputs are refused outright rather than passing: an empty marker list, and a marker with neither a `value` nor a `before` snapshot to compare against. Both would resolve green having asserted nothing.
|
|
617
645
|
|
|
646
|
+
#### Stage inventory, record read, and stage delete
|
|
647
|
+
|
|
648
|
+
```ts
|
|
649
|
+
import {
|
|
650
|
+
deleteStageRows,
|
|
651
|
+
inventoryStages,
|
|
652
|
+
parseStateSnapshot,
|
|
653
|
+
readStageSnapshot,
|
|
654
|
+
} from "@patronage/factory-ci/alchemy";
|
|
655
|
+
|
|
656
|
+
const stages = await inventoryStages(state);
|
|
657
|
+
const snapshot = parseStateSnapshot(
|
|
658
|
+
await readStageSnapshot(state, { stack: "app", stage: "pr-12" }),
|
|
659
|
+
{ stack: "app", stage: "pr-12" }
|
|
660
|
+
);
|
|
661
|
+
await deleteStageRows(state, { stack: "app", stage: "pr-12" });
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
These three read and write the state layer the consumer passes in — the store it configured, and the only thing they address. They launch no child process, and each answer is the state layer's own. `inventoryStages` returns every `(stack, stage)` pair the store holds, ordered by stack then stage. `readStageSnapshot` returns one stage as the persisted-state document `parseStateSnapshot` reads, built from the same atomic stage view and per-resource records `runAlchemyLifecycle` uses; a listed resource with no readable record fails the read rather than being left out. `deleteStageRows` removes every row of one stage and no row of any other; a stage the store does not hold is refused, not reported as removed, while `readStageSnapshot` reads that same stage as a document with no resource in it. Which stages may be deleted stays with the consumer.
|
|
665
|
+
|
|
666
|
+
A stage is addressed by the path `stack/stage`, and a name that is empty, padded, or carries a separator addresses something wider: an empty stage collapses the path to the stack, so a delete meant for one stage would take every stage of that stack. All three refuse such a name before they build a path. `inventoryStages` also takes an optional `{ stack, stage }` filter, and a pinned value is taken at face value: it skips the matching list call, so a pinned `stage` returns that stage once per stack whether or not the stack holds it. Pass no filter to inventory what the store actually holds.
|
|
667
|
+
|
|
618
668
|
#### Lifecycle
|
|
619
669
|
|
|
620
670
|
```ts
|
|
@@ -641,7 +691,7 @@ The consumer passes typed policy and two capabilities. `state` is the store from
|
|
|
641
691
|
The sequence, per operation:
|
|
642
692
|
|
|
643
693
|
- **`plan`** takes the stage lease, reads the persisted state, runs `deploy --dry-run`, reads the state again, and refuses with `STATE_CHANGED` if any resource record or the stack output differs — all under the one lease, so no other writer can reach the stage while the child plans. That refusal wins over the child's own exit status, because a planning child that wrote is the more serious finding. On an empty protected stage with a `firstDeploy` policy the result carries `planHash`, the value an operator authorizes.
|
|
644
|
-
- **`deploy`** starts with the same leased read-only plan, then admits it under the same lease. An empty protected stage is a first deploy: the plan may only `create
|
|
694
|
+
- **`deploy`** starts with the same leased read-only plan, then admits it under the same lease. An empty protected stage is a first deploy: the plan may only `create` or `adopted` — `adopted` is the create of a resource the provider already finds, and it plans only for a record with no prior state — and its hash must be on `firstDeploy.authorizedHashes`, or the refusal is `FIRST_DEPLOY_UNAUTHORIZED` carrying the hash. A protected stage with state refuses `replace` and `delete` (`EFFECT_NOT_PERMITTED`), and every `retainedIdentities` marker must hold against the recorded values before the apply (`RESOURCE_IDENTITY_MISMATCH`); a protected stage with no markers is `INVALID_POLICY`, because a check with nothing in it must not read as a proof. A disposable stage is constrained by neither. Then the apply child (`--yes`, with `--adopt` when the policy says so), under the same lease. Then, still under it: the persisted state again, which must record every resource the plan created (`DEPLOY_INCOMPLETE`; the result's `created` names their FQNs — a planned create resolves to an FQN that was absent before the apply, or was recorded in flight, `creating` or `deleting`, and is exactly the FQN the create row names — a row carries the FQN, so an interrupted create recovers at any depth; the record must have left the in-flight state) and, on a protected stage, the same identities as before; then a second `deploy --dry-run`, bracketed by the same two-read check as the first so a convergence child that wrote is `STATE_CHANGED`, which must be a no-op (`NOT_CONVERGED`) except an `update` on a persisted FQN named in `nonConvergentResources`: a Worker carrying a write-only secret binding re-plans as `update` on every deploy because the value can never be read back. Only `update`, only on those FQNs. A task row (`run` or `drop`) prints only while the task still has work, so any task row is `NOT_CONVERGED` too.
|
|
645
695
|
- **`destroy`** is refused on a protected stage. Then, under the stage lease, the destroy child, then the atomic stage view must be empty (`DESTROY_RESIDUE`) and the empty output row is removed in the same lease (`cleanup: "removed" | "absent"`). An unreadable store fails with `STATE_STORE_UNAVAILABLE`; it never reads as empty.
|
|
646
696
|
|
|
647
697
|
Ownership follows #1074 and #1107. Each operation runs under **one** stage lease, held from its first read to its last decision, and every child it launches — the dry runs, the apply, the destroy — runs inside that lease: the lifecycle spreads the lease's `childEnvironment()` into the child's environment, and the adapter in the child adopts it. The stage is never unowned while an operation runs, so no other writer can interleave between the admitted plan and the apply, and a second run on the stage is `STAGE_HELD`. Before anything read under the lease is decided on, the lifecycle **renews** the lease: renewal is the adapter's fenced operation, it changes no rows unless this holder's unexpired row is still there, and it fails with `lost` when the lease expired or was taken over — `OWNERSHIP_LOST`, and the decision is not made. Independently of that, every exit from a leased window passes the same fence check, whether the window returned or threw and wherever it threw from: a lease lost anywhere in the window — during the apply child, during a state read, under a child failure — is `OWNERSHIP_LOST` at the phase the run was in, ahead of whatever else was found there. The check sits outside the window's code, so no early return or new failure path inside it can skip acceptance. A fence that holds lets a genuine store or child failure through unchanged. A successful `release` is not that check: the adapter's release is idempotent by design and succeeds when the row is gone or belongs to a later holder, so it proves nothing, and the lifecycle infers nothing from it. A release that _fails_ is a different fact — the store could not be reached and this holder's row may still be there, blocking the stage until it expires — so after a successful operation it is reported as `LEASE_RELEASE_FAILED`; a failure inside the operation is reported ahead of it.
|
|
@@ -650,7 +700,7 @@ The consumer's entry point passes its environment to the adapter: `d1State({ ...
|
|
|
650
700
|
|
|
651
701
|
Every failure is an `AlchemyLifecycleError` with an enumerated `code`, `phase` (`request`, `admission`, `state`, `execution`, `postcondition`), and `mutation` (`not-started`, `read-only-started`, `mutation-may-have-occurred`). The message is the fixed sentence `LIFECYCLE_FAILURE_MESSAGES[code]` and nothing else; no failure carries a `cause`. Nothing a child printed, nothing the store said, and nothing a credential resolver quoted can reach an evidence sink through this error. After a deploy or destroy child was started, no failure reports `not-started`.
|
|
652
702
|
|
|
653
|
-
The plan is read once, by `parsePlanEffects`, from the lines Alchemy's `formatPlanLines` prints: one `Plan:` summary
|
|
703
|
+
The plan is read once, by `parsePlanEffects`, from the lines Alchemy's `formatPlanLines` prints: one `Plan:` summary, one `[fqn] action` row per resource, one `[fqn/name] action` row per binding of that resource, and one `[fqn] run|drop [action]` row per task. Every line arrives wrapped in Effect's pretty log prefix (`[HH:MM:SS.mmm] INFO (#1): `) and, when the child inherits `FORCE_COLOR`, in colour; both are stripped first. The id in a row is the resource's fully qualified name, the identity it is persisted under, so every check that binds a row to a resource resolves it to the persisted FQN it names exactly — a planned create to that FQN recorded after the apply and not before it, a convergence row to that FQN recorded for it — and refuses when it is absent. A plan that prints one FQN twice cannot come from the renderer and is refused (`AMBIGUOUS_RESOURCE`). The summary counts `create`, `update`, `adopted`, `replace`, `delete` and `orphaned` resources, then `N binding changes` and `N tasks`; `noop` rows print but are not counted. A binding row extends its resource's FQN by `/name`, and so does a resource inside a namespace, so when a resource named `Parent` is followed by rows tagged `Parent/...` the renderer gives no evidence which are its bindings and which are namespaced resources; the summary's counts decide, the one reading that adds up is taken, a plan the counts can place in more than one way is refused (`ambiguous-rows`; the counts are global, so two such resources in one plan couple and are refused together), the search is memoised so a plan of many such resources costs rows times counts and never the product of their readings, and a `noop` row under such a resource — placed by no count and read by no decision — is read as a binding. `orphaned` (a retained resource leaving the stack) is refused wherever `delete` is; `adopted` (a resource the provider already finds, planned only for a record with no prior state) is permitted wherever `create` is. The adapter is bound to `PLAN_OUTPUT_ALCHEMY_VERSION`, which is `ALCHEMY_BASELINE.alchemy`, and the lifecycle refuses an installed Alchemy of any other version (`UNSUPPORTED_ALCHEMY_VERSION`) before any child runs, so a renderer change cannot be read as a plan with different effects. The version is read from the manifest of the package that owns the CLI the children run — `alchemy/bin/alchemy.js` is resolved from the consumer and the nearest `package.json` named `alchemy` above it is read from disk — because `alchemy/package.json` is not on the package's `exports` map and cannot be resolved as a specifier. The adapter fails on output it cannot account for — no summary, two summaries, an unknown action, a summary it cannot parse, a count that does not match the rows, rows the counts place in more than one way — and reports only the reason, never the output. An effect it could not account for is never reported as zero.
|
|
654
704
|
|
|
655
705
|
What stays with the consumer: the resource graph, the credentials and environment the child runs with, the stage table, the retained-identity markers, the first-deploy authorization list, the state store's configuration, and health checks after the deploy. The Paitronage stack keys, HQ's frozen identities, and every other product value are inputs. There is no `psf` command for the lifecycle: the capabilities are consumer code, not flags, so the consumer calls the function from its own script.
|
|
656
706
|
|
|
@@ -669,13 +719,17 @@ Four checks that surround an Alchemy run. Paitronage and firedup each built the
|
|
|
669
719
|
|
|
670
720
|
`credentialPreflight({ accountId, environment, requiredPermissionGroups, resolveCredential, resourcesFor?, source })` proves the deploy holds the credential it claims to. `source: "hosted"` requires `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` in the environment and requires the account to be the one the deploy allows; `source: "trusted-local"` requires that neither is there, because a local run resolves its credential from the project's own secret manager and an ambient token means the run is not the run it says it is. `resolveCredential` is where that resolution happens — a 1Password read, a hosted environment read, whatever the project uses. The token is then verified against Cloudflare, matched to the reviewed policy document by token id, and reduced to permission-group names, which must equal `requiredPermissionGroups` with nothing missing, extra, or repeated. `resourcesFor` names the exact resource identifiers a group may be scoped to and defaults to the account resource, which is how a zone-scoped group is declared. On the hosted path the resolved token must be the environment token, compared and never named, because that is the token the deploy will use. A `CloudflareCredentialUnavailableError` means Cloudflare could not be asked, or the project's own resolver failed; a resolver failure is reported as one fixed message with no `cause`, because a secret-manager error can quote the value it was reading. Every other failure means the credential is wrong. No error and no result carries a credential value.
|
|
671
721
|
|
|
672
|
-
`firstDeployAuthorization({ authorizedHashes, planText, sourceSha, stack, stage })` binds the one deploy no earlier state can constrain. The plan
|
|
722
|
+
`firstDeployAuthorization({ authorizedHashes, planText, sourceSha, stack, stage })` binds the one deploy no earlier state can constrain. The plan, the target, and the reviewed source hash to one value, and that value must appear on a list the project owns. `sourceSha` is required. A full lowercase 40-character SHA binds the plan to that commit; `null` binds the plan to no commit, and that is the project's explicit choice rather than a field it forgot — an omitted field and a deliberate `null` are different decisions, so only the second one is spellable. Anything else, an empty string included, is refused. It fails closed on an empty list, a list entry that is not a sha256 hash, an empty plan, plan output that is not well-formed, plan output the plan adapter cannot read, an unlisted plan, and a stack, stage, or source SHA carrying surrounding whitespace — padding would mint a second hash for one target. The refusal names the hash to review, never the plan. `firstDeployPlanHash` computes the same value on its own, for the run that produces a plan for an operator to read.
|
|
723
|
+
|
|
724
|
+
What the hash binds is the plan, not the run that printed it (#1232). `planText` is read by the same `parsePlanEffects` adapter above and reduced to a canonical form first: the `Plan:` summary line, every row under it as its tag and action — resource rows, binding rows and task rows alike, `noop` rows included, sorted and with duplicates kept — and the reading of those rows the adapter arrives at, the resource list and the binding-change count. That canonical form, the stack, the stage and the source SHA are what hash. The logger prefix Alchemy puts on every line (`[HH:MM:SS.mmm] INFO (#1): `), the run's own elapsed durations (`Plan ready (2.9s)`), ANSI colour, the apply-session and progress lines after the rows, and the order the rows were printed in are all facts about one process rather than about the plan, and none of them is bound.
|
|
725
|
+
|
|
726
|
+
Row order is not bound; the reading of the rows is, and those are not the same fact. A binding row and a resource inside the sibling's namespace print alike, so `[Parent/bound] create` and `[Parent/Zed] create` under `[Parent] create` are one row multiset that creates `Parent` and `Parent/Zed` in one order and `Parent` and `Parent/bound` in the other. Order alone carried that; the canonical form states it, so sorting the rows cannot make two plans that create different resources agree. A rename, a changed action, an added or removed row, a renamed binding whose count is unchanged, a row read as a resource in one plan and a binding in the other, or a different stack, stage or source each change the hash; so does output the adapter refuses, which throws a `PlanEffectsError` rather than hashing. Before this change the verbatim output was hashed, so two graph-identical runs disagreed and a reviewed plan could never be deployed: every first-deploy hash issued earlier is invalidated, and those hashes were unreproducible in any case.
|
|
673
727
|
|
|
674
728
|
`assertDestroyLeftNothing({ stack, stage, stateReader })` is the postcondition that a zero exit does not prove: after `destroy`, the state store lists nothing for the stage. The project supplies `stateReader`, because only the project knows which store it configured. A reader that returns anything other than a list of non-blank resource identifiers is a failure, not an empty stage — a check that cannot fail is not a check. A padded stack or stage is refused for the same reason: it addresses a stage the store does not know, which answers empty.
|
|
675
729
|
|
|
676
730
|
`secretNamePolicy({ names, values })` builds the `redact` function `executeAlchemyEntry` already takes. It is a policy, not a mechanism. The project passes the names; this package ships no secret-name list and infers none from a name pattern. The policy is fail-closed at construction: a declared name with no value, a value under eight characters, or an empty `names` list handed a populated `values` map, throws before there is any output to redact, rather than quietly covering less than the caller believes. Empty names beside empty values is the one pass-through: a project with no secrets in its Alchemy output declares none. Values are trimmed before replacement, so redaction never swallows the line break after a secret, and longer values are replaced first, so a secret containing another secret leaves no fragment.
|
|
677
731
|
|
|
678
|
-
Two things paitronage does are deliberately not here. Stage eligibility is dropped: which stages may take a first deploy is project policy, and `firstDeployAuthorization` answers only whether this exact plan was authorized. The live zone lookup on the hosted path stays with the consumer: which zones an account may deploy into is project policy, and this package makes one Cloudflare call, against the token, not against the account's inventory. Two things that were once left out now live in `runAlchemyLifecycle` above: the plan-read-only before/after state capture, which caught a real write in the shared D1 store (#1074), and the first-deploy rule that a plan may only create, which the plan adapter can now read.
|
|
732
|
+
Two things paitronage does are deliberately not here. Stage eligibility is dropped: which stages may take a first deploy is project policy, and `firstDeployAuthorization` answers only whether this exact plan was authorized. The live zone lookup on the hosted path stays with the consumer: which zones an account may deploy into is project policy, and this package makes one Cloudflare call, against the token, not against the account's inventory. Two things that were once left out now live in `runAlchemyLifecycle` above: the plan-read-only before/after state capture, which caught a real write in the shared D1 store (#1074), and the first-deploy rule that a plan may only create or adopt, which the plan adapter can now read.
|
|
679
733
|
|
|
680
734
|
#### `evaluateStack`
|
|
681
735
|
|
|
@@ -692,7 +746,7 @@ const graph = await evaluateStack({
|
|
|
692
746
|
|
|
693
747
|
`evaluateStack` runs a stack program at one stage and returns the graph Alchemy would plan, with no state store and no network. It wraps Alchemy's `Plan.make`: the program registers its resources under a `Stack` service, then the plan builds under `inMemoryState()` at the requested `Stage`. Every provider answers its `read` and `list` probes with "absent", so the cold-adoption probe `Plan.make` makes for a resource with resolved props and no prior state never leaves the process. A stage the program returns from early (paitronage's `placeholder` stage) evaluates to an empty graph.
|
|
694
748
|
|
|
695
|
-
`program` is the effect the consumer hands `Alchemy.Stack`, not the `Alchemy.Stack(...)` value. The value captures the consumer's real `providers` and `state` layers, and at Alchemy 2.0.0-beta.
|
|
749
|
+
`program` is the effect the consumer hands `Alchemy.Stack`, not the `Alchemy.Stack(...)` value. The value captures the consumer's real `providers` and `state` layers, and at Alchemy 2.0.0-beta.77 `Cloudflare.providers()` resolves credentials when its layer builds (`Credentials.fromAuthProvider` inside `CloudflareApiLive`), so evaluating the value cannot stay credential-free. Export the program next to the stack:
|
|
696
750
|
|
|
697
751
|
```ts
|
|
698
752
|
export const program = Effect.gen(function* () {
|
|
@@ -703,7 +757,7 @@ export default Alchemy.Stack("hq", { providers, state }, program);
|
|
|
703
757
|
|
|
704
758
|
Pass `providers` when the program depends on hand-written provider layers. Every provider that layer registers keeps its identity (`stables`, `aliases`, `diff`) and loses its probes; an unregistered resource type still evaluates. Each graph resource carries the `LogicalId`, `Type`, and raw `Props` of its plan node, so a prop may hold an unresolved Alchemy output. `upstreamByProp` names, per top-level prop, the logical ids that prop's value references, which is the only way to read a cross-resource reference out of raw props: an output is a function, so the prop that holds it says nothing on its own. A prop that references nothing carries no key. The references are kept per prop and never pooled into one list, so a caller asking which resources one prop names is never answered with an id a different prop mentioned. Each binding row comes from Alchemy's Worker binding channel: `worker` is the host's logical id, `name` and `type` are the strings Alchemy emits (`d1`, `kv_namespace`, `secrets_store_secret`, and so on), and `target` is the logical id of the resource the row references, when it references one.
|
|
705
759
|
|
|
706
|
-
The caller's `providers` layer is **built** before its probes are stubbed: `evaluateStack` wraps that layer, and Alchemy's own lookup resolves each provider out of it before `read` and `list` are replaced. So any side effect a layer performs at construction — a credential read, a network call, a file write — is the caller's, and happens. This is why `Cloudflare.providers()` must not be passed: at beta.
|
|
760
|
+
The caller's `providers` layer is **built** before its probes are stubbed: `evaluateStack` wraps that layer, and Alchemy's own lookup resolves each provider out of it before `read` and `list` are replaced. So any side effect a layer performs at construction — a credential read, a network call, a file write — is the caller's, and happens. This is why `Cloudflare.providers()` must not be passed: at beta.77 it resolves credentials when its layer builds (`Credentials.fromAuthProvider` inside `CloudflareApiLive`), before there is anything to stub. The no-network guarantee covers evaluation, not layer construction: pass only layers whose construction is inert.
|
|
707
761
|
|
|
708
762
|
The helper decides nothing about the graph. Invariants over it are separate exports; `assertUrlImpliesAuth` below is the first.
|
|
709
763
|
|
|
@@ -739,7 +793,7 @@ The factory's own security review prompt carries the same rule in prose, so a re
|
|
|
739
793
|
import { ALCHEMY_BASELINE, assertAlchemyBaseline } from "@patronage/factory-ci";
|
|
740
794
|
```
|
|
741
795
|
|
|
742
|
-
`ALCHEMY_BASELINE` is the exact `alchemy` and `effect` pair the fleet moves together on: `{ alchemy: "2.0.0-beta.
|
|
796
|
+
`ALCHEMY_BASELINE` is the exact `alchemy` and `effect` pair the fleet moves together on: `{ alchemy: "2.0.0-beta.77", effect: "4.0.0-rc.112" }`. It is a plain constant on the root entry — it imports neither package — so any consumer can read it without installing the `./alchemy` subpath's peers.
|
|
743
797
|
|
|
744
798
|
`assertAlchemyBaseline({ packageJson })` is a consumer contract helper: it fails when the consumer's own `dependencies` or `devDependencies` pin `alchemy` or `effect` to anything other than the baseline, and it fails when only one of the pair is present. Alchemy peers on Effect, so an unpinned auto-installed Effect can drift outside the baseline while a lone `alchemy` pin would otherwise pass. Both packages remaining absent is not drift — they are optional peers of the subpath, so a consumer that never imports it, such as this package's own CLI, carries neither and passes. The pin is exact, not a range: the fleet is pre-1.0 and moves together, so a range would let one project drift silently ahead of or behind the rest. `assertAlchemyBaseline` reads only the object it is handed; it never walks the filesystem or reads a lockfile. Call it from a consumer's own contract test, passing that consumer's parsed `package.json`.
|
|
745
799
|
|
|
@@ -801,11 +855,10 @@ The right-hand column says what this repository proves about each, because "cove
|
|
|
801
855
|
| 3 | `STAGE_HELD` contention between two runs on one stage | Yes, through `runAlchemyLifecycle` |
|
|
802
856
|
| 4 | A `deploy` into a disposable stage: the admission branch for an unprotected stage | Yes, through `runAlchemyLifecycle` |
|
|
803
857
|
| 5 | `nonConvergentResources` — the tolerated-`update` branch | Yes, through `runAlchemyLifecycle` |
|
|
804
|
-
| 6 | `policy.
|
|
805
|
-
| 7 | `
|
|
806
|
-
| 8 |
|
|
807
|
-
| 9 |
|
|
808
|
-
| 10 | More than one stack, or a stack claiming more than one stage | **Only at the stage table.** `stagePolicy` is tested with multi-stack tables; no test drives `runAlchemyLifecycle` with one |
|
|
858
|
+
| 6 | `policy.ownershipTtlMillis` | Yes — the lease window the database records, at the one acquire site every operation shares. Added by #1079. **Note the adoption behaviour it exposes**: adoption resets `expiresAt` to the database's current time plus the **adopting** process's TTL, without taking the maximum against the stored expiry, so it can shorten a parent's window as easily as lengthen it. A shortened window fails closed — the fenced renewal reports `OWNERSHIP_LOST` rather than proceeding — and exclusivity is unaffected. HQ never notices, because both sides take the ten-minute default. `alchemy-d1-state`'s README carries the full note |
|
|
859
|
+
| 7 | The `bundle` entry mode, and the `alias` map with it | Yes, through `runAlchemyLifecycle`, including the `alias` map: the operation bundles an entry that imports an aliased module, and the test reads the bundle the operation produced and asserts the aliased module's marker is in it. Losing the alias does not fail the build — `bundleAlchemyEntry` defaults an unresolved package to external, so the import survives unresolved and the bundle is still written — it fails that marker assertion. A consumer that hands the operation a `bundledEntry` never bundles |
|
|
860
|
+
| 8 | `LEASE_RELEASE_FAILED` — a store outage at exactly the release | Yes, through `runAlchemyLifecycle` |
|
|
861
|
+
| 9 | More than one stack, or a stack claiming more than one stage | **Only at the stage table.** `stagePolicy` is tested with multi-stack tables; no test drives `runAlchemyLifecycle` with one |
|
|
809
862
|
|
|
810
863
|
## No configuration surface
|
|
811
864
|
|
package/dist/alchemy/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { n as ExecuteAlchemyEntryOptions } from "../execute-alchemy-entry-DB8fFZ
|
|
|
2
2
|
import * as Alchemy from "alchemy";
|
|
3
3
|
import * as Effect from "effect/Effect";
|
|
4
4
|
import * as Layer from "effect/Layer";
|
|
5
|
+
import { StateService } from "alchemy/State";
|
|
5
6
|
import { ProviderServices } from "alchemy/Stack";
|
|
6
7
|
|
|
7
8
|
//#region src/alchemy/evaluate-stack.d.ts
|
|
@@ -87,7 +88,7 @@ interface RetainedIdentityMarker {
|
|
|
87
88
|
readonly value?: string;
|
|
88
89
|
}
|
|
89
90
|
/**
|
|
90
|
-
* One resource record
|
|
91
|
+
* One resource record in the persisted-state document: the `(stack, stage,
|
|
91
92
|
* fqn)` address, and under `state` the persisted record itself.
|
|
92
93
|
*/
|
|
93
94
|
interface StateSnapshotResource {
|
|
@@ -110,7 +111,7 @@ interface StateSnapshotScope {
|
|
|
110
111
|
readonly stage?: string;
|
|
111
112
|
}
|
|
112
113
|
/**
|
|
113
|
-
* A recorded stage state: the whole
|
|
114
|
+
* A recorded stage state: the whole persisted-state document.
|
|
114
115
|
*
|
|
115
116
|
* An evaluated stack graph is a different artifact with the same identities in
|
|
116
117
|
* it. A caller holding one projects it onto this shape at the call site;
|
|
@@ -135,15 +136,14 @@ declare class RetainedIdentityError extends Error {
|
|
|
135
136
|
constructor(violations: readonly RetainedIdentityViolation[]);
|
|
136
137
|
}
|
|
137
138
|
/**
|
|
138
|
-
* Read
|
|
139
|
+
* Read a persisted-state document into a snapshot of one stage.
|
|
139
140
|
*
|
|
140
|
-
*
|
|
141
|
-
* (`{ resources: [{ stack, stage, fqn, state }] }`);
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
* the stack and stage the markers are about.
|
|
141
|
+
* The persisted-state document holds every record in scope
|
|
142
|
+
* (`{ resources: [{ stack, stage, fqn, state }] }`); a single-record document
|
|
143
|
+
* is not this shape. A document covering the whole estate holds one fqn once
|
|
144
|
+
* per stage, so a marker naming it would match whichever record sorted
|
|
145
|
+
* first — a `prod` identity asserted against a `dev` record. `scope` narrows
|
|
146
|
+
* the document to the stack and stage the markers are about.
|
|
147
147
|
*
|
|
148
148
|
* Throws on anything that is not a resource inventory, and on a scoped
|
|
149
149
|
* document that still records one fqn twice. A state this module cannot read
|
|
@@ -178,6 +178,45 @@ declare const assertRetainedIdentities: ({
|
|
|
178
178
|
markers
|
|
179
179
|
}: AssertRetainedIdentitiesOptions) => void;
|
|
180
180
|
//#endregion
|
|
181
|
+
//#region src/alchemy/local-emulation.d.ts
|
|
182
|
+
/**
|
|
183
|
+
* The child environment a local-emulation Alchemy run needs (#1168).
|
|
184
|
+
*
|
|
185
|
+
* Alchemy refuses to start when `CLOUDFLARE_ACCOUNT_ID` is missing or
|
|
186
|
+
* malformed, even for a run that only drives the local emulator and calls no
|
|
187
|
+
* Cloudflare API. Every project worked around that by handing the child a real
|
|
188
|
+
* account id and a real token, so a purely local run carried live credentials.
|
|
189
|
+
* This module hands it a sentinel pair instead: a well-formed account id that
|
|
190
|
+
* addresses no account, and a token that is not a token.
|
|
191
|
+
*
|
|
192
|
+
* The helper never reads a real credential. It reads one variable — the
|
|
193
|
+
* live-binding opt-in — and nothing else. The presence of a real credential in
|
|
194
|
+
* the parent environment is never the signal: a developer who is logged in to
|
|
195
|
+
* Cloudflare still gets the sentinel pair unless the opt-in says otherwise.
|
|
196
|
+
*
|
|
197
|
+
* Scrubbing an ambient token out of the child is the launcher's job, not this
|
|
198
|
+
* module's. This module owns the environment shape only.
|
|
199
|
+
*/
|
|
200
|
+
/**
|
|
201
|
+
* Build the Cloudflare variables a local-emulation child is launched with.
|
|
202
|
+
*
|
|
203
|
+
* Returns exactly two variables for the local-emulation stage, or no variables
|
|
204
|
+
* when the live-binding opt-in is set. The result is an overlay: merge it over
|
|
205
|
+
* the child environment the launcher has otherwise assembled.
|
|
206
|
+
*
|
|
207
|
+
* Throws for any other stage. A non-local stage deploys to a real account, so
|
|
208
|
+
* a sentinel there would break that deploy, and returning nothing instead
|
|
209
|
+
* would let a caller believe a local run was protected when it was not.
|
|
210
|
+
*/
|
|
211
|
+
declare const localEmulationEnvironment: (options: {
|
|
212
|
+
/**
|
|
213
|
+
* Where the opt-in is read from. Defaults to this process's environment. No
|
|
214
|
+
* other variable is read.
|
|
215
|
+
*/
|
|
216
|
+
readonly environment?: Readonly<Record<string, string | undefined>>; /** The Alchemy stage the child runs. */
|
|
217
|
+
readonly stage: string;
|
|
218
|
+
}) => Readonly<Record<string, string>>;
|
|
219
|
+
//#endregion
|
|
181
220
|
//#region src/alchemy/stage-policy.d.ts
|
|
182
221
|
/**
|
|
183
222
|
* Stage and stack pairing, and protected-stage policy, from a project-supplied
|
|
@@ -187,7 +226,12 @@ declare const assertRetainedIdentities: ({
|
|
|
187
226
|
* is it disposable, and may this stack run it — and each answered them with its
|
|
188
227
|
* own stage names hard-coded into the answer. The names differ; the questions
|
|
189
228
|
* do not. This module owns the questions. A project supplies the table and gets
|
|
190
|
-
* decisions back, so no project
|
|
229
|
+
* decisions back, so no project or stack name appears here.
|
|
230
|
+
*
|
|
231
|
+
* One stage name does appear: `local`. It names the local emulator rather than
|
|
232
|
+
* any project's estate, every project spells it the same way, and the question
|
|
233
|
+
* it answers — is this the emulated stage — has one answer for the whole fleet.
|
|
234
|
+
* See {@link isLocalEmulationStage}.
|
|
191
235
|
*
|
|
192
236
|
* The disposable grammar this package already owns (`disposable-stage.ts`) is
|
|
193
237
|
* available as a matcher rather than assumed, because a project's disposable
|
|
@@ -253,6 +297,22 @@ interface StagePolicy {
|
|
|
253
297
|
/** Throw unless the table pairs the stack and the stage. */
|
|
254
298
|
readonly assertStackOwnsStage: (stack: string, stage: string) => void;
|
|
255
299
|
}
|
|
300
|
+
/**
|
|
301
|
+
* Is this the stage that runs against the local emulator?
|
|
302
|
+
*
|
|
303
|
+
* True for the literal `local` and nothing else (#991). Two projects and HQ
|
|
304
|
+
* each answered this with their own rule — a refusal list, a constant, and
|
|
305
|
+
* `$(whoami)` — and all three meant the same single name, so the answer is one
|
|
306
|
+
* literal rather than a pattern or a table. A disposable preview stage such as
|
|
307
|
+
* `local-pr-1-abcdef0` shares the prefix but runs against a real account, so it
|
|
308
|
+
* is false here. The comparison is exact: no trim and no case folding, because
|
|
309
|
+
* a stage name reaches Alchemy exactly as it is spelled.
|
|
310
|
+
*
|
|
311
|
+
* This question is deliberately outside {@link stagePolicy}: a project's table
|
|
312
|
+
* classifies the stages of its own estate, and the emulated stage belongs to
|
|
313
|
+
* no estate.
|
|
314
|
+
*/
|
|
315
|
+
declare const isLocalEmulationStage: (stage: string) => boolean;
|
|
256
316
|
/**
|
|
257
317
|
* Read a project's stage table and answer the stage questions from it.
|
|
258
318
|
*
|
|
@@ -419,7 +479,10 @@ declare const assertDestroyLeftNothing: ({
|
|
|
419
479
|
//#endregion
|
|
420
480
|
//#region src/alchemy/first-deploy-authorization.d.ts
|
|
421
481
|
interface FirstDeployPlanHashOptions {
|
|
422
|
-
/**
|
|
482
|
+
/**
|
|
483
|
+
* The Alchemy CLI's plan output, verbatim. It is canonicalised and hashed
|
|
484
|
+
* here; it is never stored and never logged.
|
|
485
|
+
*/
|
|
423
486
|
readonly planText: string;
|
|
424
487
|
/**
|
|
425
488
|
* The reviewed source commit this plan was produced from, as a full
|
|
@@ -444,9 +507,30 @@ interface FirstDeployAuthorization {
|
|
|
444
507
|
readonly planHash: string;
|
|
445
508
|
}
|
|
446
509
|
/**
|
|
447
|
-
* The hash a first deploy is authorized by: the plan
|
|
448
|
-
*
|
|
449
|
-
*
|
|
510
|
+
* The hash a first deploy is authorized by: the plan, the target, and the
|
|
511
|
+
* reviewed source, in one payload.
|
|
512
|
+
*
|
|
513
|
+
* The plan enters the payload as the sha256 of `canonicalPlan`, so the value
|
|
514
|
+
* that is compared never embeds plan output, and two runs of one plan agree.
|
|
515
|
+
* Bound: the summary line, every row's tag and action (resource, binding and
|
|
516
|
+
* task rows alike, `noop` included), which of those rows are resources and
|
|
517
|
+
* which are bindings, the stack, the stage, and the source SHA. Not bound:
|
|
518
|
+
* logger timestamps and fiber ids, the run's elapsed durations, ANSI colour,
|
|
519
|
+
* progress lines, and the order the rows were printed in.
|
|
520
|
+
*
|
|
521
|
+
* Row order is not bound but the reading of the rows is, because they are
|
|
522
|
+
* not the same fact. Order alone used to carry which rows are a resource's
|
|
523
|
+
* bindings and which are resources inside its namespace; the canonical form
|
|
524
|
+
* states that outright, so two plans creating different resources can never
|
|
525
|
+
* agree merely by printing one multiset of rows two ways.
|
|
526
|
+
*
|
|
527
|
+
* `planFormat` names that shape. A hash issued by an earlier version of this
|
|
528
|
+
* function, which hashed the verbatim output, can never equal one issued
|
|
529
|
+
* now — those hashes were not reproducible in the first place, so they
|
|
530
|
+
* authorized nothing that could be deployed.
|
|
531
|
+
*
|
|
532
|
+
* Throws a `PlanEffectsError` when the plan adapter cannot account for the
|
|
533
|
+
* output: an unreadable plan is refused, never hashed.
|
|
450
534
|
*/
|
|
451
535
|
declare const firstDeployPlanHash: ({
|
|
452
536
|
planText,
|
|
@@ -633,34 +717,73 @@ declare const isAlchemyLifecycleError: (error: unknown) => error is AlchemyLifec
|
|
|
633
717
|
*
|
|
634
718
|
* Alchemy has no structured plan output at the fleet baseline, so the plan
|
|
635
719
|
* is read from the lines `formatPlanLines` in `alchemy/src/Cli/LoggingCli.ts`
|
|
636
|
-
* prints: one `Plan:` summary
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
720
|
+
* prints: one `Plan:` summary, one `[fqn] action` line per resource, one
|
|
721
|
+
* `[fqn/name] action` line per binding of that resource, and one
|
|
722
|
+
* `[fqn] run|drop [action]` line per task. Paitronage and HQ each wrote this
|
|
723
|
+
* reader; this is the one copy, and it is bound to the Alchemy version whose
|
|
724
|
+
* renderer it reads. A consumer running a different Alchemy is refused by
|
|
725
|
+
* `runAlchemyLifecycle` before any child runs, so a renderer change cannot
|
|
726
|
+
* be read as a plan with different effects.
|
|
727
|
+
*
|
|
728
|
+
* The renderer logs every line through Effect's pretty console logger, so a
|
|
729
|
+
* line arrives as `[HH:MM:SS.mmm] INFO (#1): <line>`, and coloured when the
|
|
730
|
+
* child inherits `FORCE_COLOR`. Both wrappers are stripped before a line is
|
|
731
|
+
* read. The captured output under `tests/fixtures/plan-output/beta-77/` is
|
|
732
|
+
* the evidence for every shape this file reads.
|
|
641
733
|
*
|
|
642
734
|
* The adapter fails on anything it does not recognise: no summary, two
|
|
643
|
-
* summaries, an action outside the known set, a summary it cannot parse,
|
|
644
|
-
*
|
|
645
|
-
* not account for is
|
|
735
|
+
* summaries, an action outside the known set, a summary it cannot parse, a
|
|
736
|
+
* summary whose counts do not equal the lines under it, or rows the summary
|
|
737
|
+
* can place in more than one way. An effect it could not account for is
|
|
738
|
+
* never reported as zero.
|
|
739
|
+
*
|
|
740
|
+
* Two readings share that work. `parsePlanEffects` answers what the plan
|
|
741
|
+
* does, which is what the lifecycle's admission rules read. `canonicalPlan`
|
|
742
|
+
* answers what the plan *is*, as a stable string: the rows the renderer
|
|
743
|
+
* printed and the reading of them, with the per-run logger metadata it also
|
|
744
|
+
* prints left out. `firstDeployPlanHash` hashes that string, so a plan
|
|
745
|
+
* reviewed at one moment and deployed at another is the same plan (#1232).
|
|
646
746
|
*/
|
|
647
747
|
/** The Alchemy version whose plan renderer this adapter reads. */
|
|
648
748
|
declare const PLAN_OUTPUT_ALCHEMY_VERSION: string;
|
|
649
|
-
|
|
749
|
+
/**
|
|
750
|
+
* Every action a resource row can carry. The first six are the actions the
|
|
751
|
+
* summary counts, in the order the renderer prints them; `noop` rows print
|
|
752
|
+
* under the summary but are never counted in it.
|
|
753
|
+
*/
|
|
754
|
+
declare const PLAN_ACTIONS: readonly ["create", "update", "adopted", "replace", "delete", "orphaned", "noop"];
|
|
650
755
|
type PlanAction = (typeof PLAN_ACTIONS)[number];
|
|
651
756
|
/** One top-level resource row of a plan. */
|
|
652
757
|
interface PlanResourceEffect {
|
|
653
758
|
readonly action: PlanAction;
|
|
654
|
-
/**
|
|
759
|
+
/**
|
|
760
|
+
* The fully qualified name Alchemy printed in the row's tag: the namespace
|
|
761
|
+
* path and the logical id joined by `/`. A top-level resource's FQN is its
|
|
762
|
+
* logical id. This is the identity Alchemy persists the resource under.
|
|
763
|
+
*/
|
|
764
|
+
readonly id: string;
|
|
765
|
+
}
|
|
766
|
+
type PlanTaskAction = "drop" | "run";
|
|
767
|
+
/** One task (`Action`) row of a plan. A task with nothing to do prints no row. */
|
|
768
|
+
interface PlanTaskEffect {
|
|
769
|
+
readonly action: PlanTaskAction;
|
|
770
|
+
/** The task's fully qualified name, as printed in the row's tag. */
|
|
655
771
|
readonly id: string;
|
|
656
772
|
}
|
|
657
773
|
interface PlanEffects {
|
|
658
|
-
/**
|
|
774
|
+
/**
|
|
775
|
+
* Resource count per action. The six summary actions are what the summary
|
|
776
|
+
* line states; `noop` is counted from the rows, as the summary omits it.
|
|
777
|
+
*/
|
|
659
778
|
readonly counts: Readonly<Record<PlanAction, number>>;
|
|
660
|
-
/** Every
|
|
779
|
+
/** Every resource row, in the order printed. */
|
|
661
780
|
readonly resources: readonly PlanResourceEffect[];
|
|
781
|
+
/** Binding rows with a change, as the summary's `N binding changes` states. */
|
|
782
|
+
readonly bindingChanges: number;
|
|
783
|
+
/** Every task row, in the order printed. */
|
|
784
|
+
readonly tasks: readonly PlanTaskEffect[];
|
|
662
785
|
}
|
|
663
|
-
type PlanEffectsRejection = "no-summary" | "multiple-summaries" | "unknown-action" | "summary-unrecognized" | "count-mismatch" | "effects-under-no-changes";
|
|
786
|
+
type PlanEffectsRejection = "no-summary" | "multiple-summaries" | "unknown-action" | "summary-unrecognized" | "count-mismatch" | "ambiguous-rows" | "effects-under-no-changes";
|
|
664
787
|
/** The adapter could not read the output. `reason` is the whole detail. */
|
|
665
788
|
declare class PlanEffectsError extends Error {
|
|
666
789
|
readonly reason: PlanEffectsRejection;
|
|
@@ -786,11 +909,11 @@ interface AlchemyLifecyclePolicy {
|
|
|
786
909
|
* deploy because the value can never be read back; that is a property of
|
|
787
910
|
* the secret, not drift. Only `update` is tolerated, only on these FQNs.
|
|
788
911
|
* A top-level resource's FQN is its logical id; a nested one is
|
|
789
|
-
* `namespace/id`.
|
|
912
|
+
* `namespace/id`. A binding change is never tolerated: the plan adapter
|
|
913
|
+
* attributes no binding row to a resource, so a plan with binding work is
|
|
914
|
+
* not converged whatever this list names.
|
|
790
915
|
*/
|
|
791
916
|
readonly nonConvergentResources?: readonly string[];
|
|
792
|
-
/** The Alchemy auth profile, passed as `--profile`. */
|
|
793
|
-
readonly profile?: string;
|
|
794
917
|
}
|
|
795
918
|
type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
|
|
796
919
|
/**
|
|
@@ -847,7 +970,8 @@ interface AlchemyLifecycleResult {
|
|
|
847
970
|
* apply child, then the persisted state again: it must record what the plan
|
|
848
971
|
* created and, on a protected stage, the same identities as before. Then a
|
|
849
972
|
* second dry-run plan, which must be a no-op except an `update` on a resource
|
|
850
|
-
* the policy names non-convergent.
|
|
973
|
+
* the policy names non-convergent. A binding change or a task row in that
|
|
974
|
+
* plan is work left behind, and no policy tolerates it.
|
|
851
975
|
*
|
|
852
976
|
* `destroy`: refused on a protected stage. Then, under the stage lease, the
|
|
853
977
|
* destroy child, then the atomic stage view must be empty and the empty
|
|
@@ -864,6 +988,60 @@ interface AlchemyLifecycleResult {
|
|
|
864
988
|
*/
|
|
865
989
|
declare const runAlchemyLifecycle: (options: RunAlchemyLifecycleOptions) => Promise<AlchemyLifecycleResult>;
|
|
866
990
|
//#endregion
|
|
991
|
+
//#region src/alchemy/state-tree.d.ts
|
|
992
|
+
/**
|
|
993
|
+
* The subset of the state layer the record read goes through: the atomic
|
|
994
|
+
* stage view and the per-resource record. `@patronage/alchemy-d1-state`
|
|
995
|
+
* satisfies it structurally, the same way `runAlchemyLifecycle` takes it.
|
|
996
|
+
*/
|
|
997
|
+
type StageRecordStore = Pick<StageStateStore, "get" | "snapshotStage">;
|
|
998
|
+
/**
|
|
999
|
+
* Pins part of the inventory.
|
|
1000
|
+
*
|
|
1001
|
+
* A pinned `stack` or `stage` is taken at face value and skips the matching
|
|
1002
|
+
* list call, because naming a scope is not a claim that it exists. So a
|
|
1003
|
+
* pinned `stage` returns that stage once per stack whether or not the stack
|
|
1004
|
+
* holds it. Pass no filter to inventory what the store actually holds.
|
|
1005
|
+
*/
|
|
1006
|
+
interface StageFilter {
|
|
1007
|
+
readonly stack?: string;
|
|
1008
|
+
readonly stage?: string;
|
|
1009
|
+
}
|
|
1010
|
+
/**
|
|
1011
|
+
* Every `(stack, stage)` pair the state layer holds, or the subset a filter
|
|
1012
|
+
* pins. Ordered by stack then stage.
|
|
1013
|
+
*
|
|
1014
|
+
* The pairs come from the store's own `listStacks` and `listStages`, so an
|
|
1015
|
+
* empty result is the store reporting nothing, never a read this module
|
|
1016
|
+
* could not make: a state layer that cannot be read fails instead.
|
|
1017
|
+
*/
|
|
1018
|
+
declare const inventoryStages: (state: StateService, filter?: StageFilter) => Promise<readonly StageTarget[]>;
|
|
1019
|
+
/**
|
|
1020
|
+
* The persisted-state document for one stage, as `parseStateSnapshot` reads
|
|
1021
|
+
* it: `{ resources: [{ stack, stage, fqn, state }] }`, ordered by fqn.
|
|
1022
|
+
*
|
|
1023
|
+
* The read is the one `runAlchemyLifecycle` already makes — the atomic stage
|
|
1024
|
+
* view, then each listed resource record — so a caller holding the document
|
|
1025
|
+
* and a caller holding the lifecycle see the same records. A listed resource
|
|
1026
|
+
* with no readable record fails the read; it is never left out, because a
|
|
1027
|
+
* short document reads as a stage with less in it than it has.
|
|
1028
|
+
*
|
|
1029
|
+
* A stage the store does not hold reads as a document with no resource in
|
|
1030
|
+
* it, the same reading the lifecycle takes for a first deploy. Only a stage
|
|
1031
|
+
* the store cannot read fails. `deleteStageRows` answers an absent stage the
|
|
1032
|
+
* other way, because it addresses a path rather than a stage view.
|
|
1033
|
+
*/
|
|
1034
|
+
declare const readStageSnapshot: (state: StageRecordStore, target: StageTarget) => Promise<string>;
|
|
1035
|
+
/**
|
|
1036
|
+
* Remove every row the state layer holds for one stage. Other stages of the
|
|
1037
|
+
* same stack keep every row.
|
|
1038
|
+
*
|
|
1039
|
+
* A stage the store does not hold is refused by the state layer rather than
|
|
1040
|
+
* reported as removed, so a caller never reads a mistyped stage as a stage
|
|
1041
|
+
* that was already empty.
|
|
1042
|
+
*/
|
|
1043
|
+
declare const deleteStageRows: (state: StateService, target: StageTarget) => Promise<void>;
|
|
1044
|
+
//#endregion
|
|
867
1045
|
//#region src/alchemy/index.d.ts
|
|
868
1046
|
/**
|
|
869
1047
|
* The `@patronage/factory-ci/alchemy` subpath: the only place in this package
|
|
@@ -884,4 +1062,4 @@ declare const runAlchemyLifecycle: (options: RunAlchemyLifecycleOptions) => Prom
|
|
|
884
1062
|
/** The specifier a consumer imports this surface by. */
|
|
885
1063
|
declare const ALCHEMY_SUBPATH = "@patronage/factory-ci/alchemy";
|
|
886
1064
|
//#endregion
|
|
887
|
-
export { ALCHEMY_SUBPATH, type AlchemyLifecycleEntry, AlchemyLifecycleError, type AlchemyLifecycleFailureCode, type AlchemyLifecycleFailureFields, type AlchemyLifecycleOperation, type AlchemyLifecyclePhase, type AlchemyLifecyclePolicy, type AlchemyLifecycleRequest, type AlchemyLifecycleResult, type AssertDestroyLeftNothingOptions, type AssertRetainedIdentitiesOptions, type AssertUrlImpliesAuthOptions, type AuthBindingNameMatcher, type CloudflareCredential, CloudflareCredentialUnavailableError, type CredentialPreflightOptions, type CredentialPreflightResult, type CredentialSource, type EvaluateStackInput, type EvaluatedBinding, type EvaluatedGraph, type EvaluatedResource, type FirstDeployAuthorization, type FirstDeployAuthorizationOptions, type FirstDeployPlanHashOptions, type FirstDeployPolicy, HOSTED_CLOUDFLARE_CREDENTIAL_NAMES, LIFECYCLE_FAILURE_MESSAGES, LOCAL_PREVIEW_STAGES, MINIMUM_REDACTABLE_SECRET_LENGTH, type MutationCertainty, PLAN_ACTIONS, PLAN_OUTPUT_ALCHEMY_VERSION, type PlanAction, type PlanEffects, PlanEffectsError, type PlanEffectsRejection, type PlanResourceEffect, RetainedIdentityError, type RetainedIdentityMarker, type RetainedIdentitySection, type RetainedIdentityViolation, type RetainedIdentityViolationReason, type RunAlchemyLifecycleOptions, SECRET_REDACTION_PLACEHOLDER, type SecretNamePolicyOptions, type StackOutputOccupancy, type StackStageOwnership, type StageCleanupOutcome, type StageLease, type StageMatcher, type StagePolicy, StagePolicyError, type StagePolicyErrorCode, type StagePolicyTable, type StageSnapshotView, type StageStateReader, type StageStateStore, type StageStateTarget, type StageTarget, type StateSnapshot, type StateSnapshotResource, type StateSnapshotScope, type UrlImpliesAuthAllowance, UrlImpliesAuthError, type UrlImpliesAuthViolation, type UrlImpliesAuthViolationReason, anyStage, assertDestroyLeftNothing, assertRetainedIdentities, assertUrlImpliesAuth, credentialPreflight, evaluateStack, firstDeployAuthorization, firstDeployPlanHash, isAlchemyLifecycleError, parsePlanEffects, parseStateSnapshot, runAlchemyLifecycle, secretNamePolicy, stagePolicy };
|
|
1065
|
+
export { ALCHEMY_SUBPATH, type AlchemyLifecycleEntry, AlchemyLifecycleError, type AlchemyLifecycleFailureCode, type AlchemyLifecycleFailureFields, type AlchemyLifecycleOperation, type AlchemyLifecyclePhase, type AlchemyLifecyclePolicy, type AlchemyLifecycleRequest, type AlchemyLifecycleResult, type AssertDestroyLeftNothingOptions, type AssertRetainedIdentitiesOptions, type AssertUrlImpliesAuthOptions, type AuthBindingNameMatcher, type CloudflareCredential, CloudflareCredentialUnavailableError, type CredentialPreflightOptions, type CredentialPreflightResult, type CredentialSource, type EvaluateStackInput, type EvaluatedBinding, type EvaluatedGraph, type EvaluatedResource, type FirstDeployAuthorization, type FirstDeployAuthorizationOptions, type FirstDeployPlanHashOptions, type FirstDeployPolicy, HOSTED_CLOUDFLARE_CREDENTIAL_NAMES, LIFECYCLE_FAILURE_MESSAGES, LOCAL_PREVIEW_STAGES, MINIMUM_REDACTABLE_SECRET_LENGTH, type MutationCertainty, PLAN_ACTIONS, PLAN_OUTPUT_ALCHEMY_VERSION, type PlanAction, type PlanEffects, PlanEffectsError, type PlanEffectsRejection, type PlanResourceEffect, type PlanTaskAction, type PlanTaskEffect, RetainedIdentityError, type RetainedIdentityMarker, type RetainedIdentitySection, type RetainedIdentityViolation, type RetainedIdentityViolationReason, type RunAlchemyLifecycleOptions, SECRET_REDACTION_PLACEHOLDER, type SecretNamePolicyOptions, type StackOutputOccupancy, type StackStageOwnership, type StageCleanupOutcome, type StageLease, type StageMatcher, type StagePolicy, StagePolicyError, type StagePolicyErrorCode, type StagePolicyTable, type StageSnapshotView, type StageStateReader, type StageStateStore, type StageStateTarget, type StageTarget, type StateSnapshot, type StateSnapshotResource, type StateSnapshotScope, type UrlImpliesAuthAllowance, UrlImpliesAuthError, type UrlImpliesAuthViolation, type UrlImpliesAuthViolationReason, anyStage, assertDestroyLeftNothing, assertRetainedIdentities, assertUrlImpliesAuth, credentialPreflight, deleteStageRows, evaluateStack, firstDeployAuthorization, firstDeployPlanHash, inventoryStages, isAlchemyLifecycleError, isLocalEmulationStage, localEmulationEnvironment, parsePlanEffects, parseStateSnapshot, readStageSnapshot, runAlchemyLifecycle, secretNamePolicy, stagePolicy };
|