@homeflare/alchemy 0.59.0 → 0.61.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/README.md +13 -11
- package/dist/caddy/config.d.ts +4 -6
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/index.js +61 -128
- package/dist/caddy/index.js.map +4 -6
- package/dist/scope/duplicate-id.d.ts +30 -15
- package/dist/scope/duplicate-id.d.ts.map +1 -1
- package/dist/scope/index.d.ts +17 -14
- package/dist/scope/index.d.ts.map +1 -1
- package/dist/scope/index.js +59 -57
- package/dist/scope/index.js.map +5 -5
- package/dist/scope/patch-check.d.ts +14 -0
- package/dist/scope/patch-check.d.ts.map +1 -0
- package/docs/duplicate-resource-id.md +124 -61
- package/docs/provider-standard.md +9 -9
- package/package.json +2 -1
- package/patches/alchemy@2.0.0-beta.81.patch +147 -0
- package/patches/alchemy@2.0.0-beta.83.patch +149 -0
- package/src/caddy/config.ts +7 -12
- package/src/scope/duplicate-id.test.ts +128 -69
- package/src/scope/duplicate-id.ts +61 -94
- package/src/scope/index.ts +21 -14
- package/src/scope/patch-check.test.ts +142 -0
- package/src/scope/patch-check.ts +139 -0
- package/src/scope/patch-coverage.test.ts +50 -0
- package/dist/scope/guard.d.ts +0 -28
- package/dist/scope/guard.d.ts.map +0 -1
- package/src/scope/guard.test.ts +0 -128
- package/src/scope/guard.ts +0 -49
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/scope/index.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/scope/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,EACL,wBAAwB,EACxB,uBAAuB,EACvB,wBAAwB,GACzB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,iBAAiB,EAAE,KAAK,YAAY,EAAE,MAAM,kBAAkB,CAAC"}
|
package/dist/scope/index.js
CHANGED
|
@@ -1,71 +1,73 @@
|
|
|
1
1
|
// src/scope/duplicate-id.ts
|
|
2
|
-
import { toFqn } from "alchemy/FQN";
|
|
3
|
-
import { CurrentNamespace } from "alchemy/Namespace";
|
|
4
|
-
import { Stack } from "alchemy/Stack";
|
|
5
2
|
import * as Data from "effect/Data";
|
|
6
|
-
import * as Effect from "effect/Effect";
|
|
7
3
|
|
|
8
4
|
class DuplicateResourceIdError extends Data.TaggedError("DuplicateResourceIdError") {
|
|
9
5
|
}
|
|
10
|
-
var
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
6
|
+
var PATCHED_ALCHEMY_VERSION = "2.0.0-beta.81";
|
|
7
|
+
var PATCHED_ALCHEMY_VERSIONS = ["2.0.0-beta.81", "2.0.0-beta.83"];
|
|
8
|
+
// src/scope/patch-check.ts
|
|
9
|
+
var patchKey = (version) => `alchemy@${version}`;
|
|
10
|
+
var shippedPatchPath = (version) => new URL(`../../patches/${patchKey(version)}.patch`, import.meta.url).pathname;
|
|
11
|
+
var checkAlchemyPatch = async (projectDir, alchemyVersion) => {
|
|
12
|
+
const problems = [];
|
|
13
|
+
const installedPkg = Bun.file(`${projectDir}/node_modules/alchemy/package.json`);
|
|
14
|
+
if (!await installedPkg.exists()) {
|
|
15
|
+
problems.push("node_modules/alchemy: not installed — nothing to check the patch against");
|
|
16
|
+
return problems;
|
|
18
17
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const registry = registryFor(stack);
|
|
35
|
-
const firstType = registry.get(fqn);
|
|
36
|
-
if (firstType === undefined) {
|
|
37
|
-
registry.set(fqn, type);
|
|
38
|
-
return;
|
|
18
|
+
const installedVersion = (await installedPkg.json()).version;
|
|
19
|
+
const targetVersion = alchemyVersion ?? installedVersion ?? PATCHED_ALCHEMY_VERSION;
|
|
20
|
+
const key = patchKey(targetVersion);
|
|
21
|
+
if (!PATCHED_ALCHEMY_VERSIONS.includes(targetVersion)) {
|
|
22
|
+
problems.push(`alchemy ${targetVersion} (installed: ${installedVersion ?? "unknown"}) is a version ` + `@homeflare/alchemy has ` + `no duplicate-resource-id patch for — supported versions are ` + `${PATCHED_ALCHEMY_VERSIONS.join(", ")}; cut a new patches/${key}.patch ` + "(@homeflare/alchemy's own patches/) before bumping alchemy, or this install has no " + "protection at all");
|
|
23
|
+
return problems;
|
|
24
|
+
}
|
|
25
|
+
if (installedVersion !== targetVersion) {
|
|
26
|
+
problems.push(`node_modules/alchemy is ${installedVersion ?? "unknown"}, but the duplicate-resource-id ` + `check was asked for ${targetVersion} — pass the installed version explicitly, or omit ` + "alchemyVersion to check against whatever is actually installed");
|
|
27
|
+
return problems;
|
|
28
|
+
}
|
|
29
|
+
const pkgFile = Bun.file(`${projectDir}/package.json`);
|
|
30
|
+
if (!await pkgFile.exists()) {
|
|
31
|
+
problems.push("package.json: missing");
|
|
32
|
+
return problems;
|
|
39
33
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
firstType,
|
|
46
|
-
secondType: type
|
|
47
|
-
}));
|
|
34
|
+
const pkg = await pkgFile.json();
|
|
35
|
+
const declaredPath = pkg.patchedDependencies?.[key];
|
|
36
|
+
if (declaredPath === undefined) {
|
|
37
|
+
problems.push(`package.json: patchedDependencies has no entry for "${key}" — this stack has no ` + "duplicate-resource-id protection at all; see @homeflare/alchemy/docs/duplicate-resource-id.md");
|
|
38
|
+
return problems;
|
|
48
39
|
}
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
apply(target, thisArg, args) {
|
|
54
|
-
const id = args[0];
|
|
55
|
-
if (typeof id !== "string") {
|
|
56
|
-
return Reflect.apply(target, thisArg, args);
|
|
57
|
-
}
|
|
58
|
-
return Effect2.gen(function* () {
|
|
59
|
-
yield* claimResourceId(resourceClass.Type, id);
|
|
60
|
-
return yield* Reflect.apply(target, thisArg, args);
|
|
61
|
-
});
|
|
40
|
+
const theirs = Bun.file(`${projectDir}/${declaredPath}`);
|
|
41
|
+
if (!await theirs.exists()) {
|
|
42
|
+
problems.push(`package.json declares "${key}" at ${declaredPath}, but that file is missing`);
|
|
43
|
+
return problems;
|
|
62
44
|
}
|
|
63
|
-
|
|
45
|
+
const ours = Bun.file(shippedPatchPath(targetVersion));
|
|
46
|
+
if (!await ours.exists()) {
|
|
47
|
+
problems.push(`@homeflare/alchemy ships no patches/${key}.patch to compare against`);
|
|
48
|
+
return problems;
|
|
49
|
+
}
|
|
50
|
+
if (await theirs.text() !== await ours.text()) {
|
|
51
|
+
problems.push(`${declaredPath}: differs from @homeflare/alchemy's shipped patch — re-copy it, ` + "or say why it differs");
|
|
52
|
+
}
|
|
53
|
+
const resourceSrc = Bun.file(`${projectDir}/node_modules/alchemy/src/Resource.ts`);
|
|
54
|
+
const resourceLib = Bun.file(`${projectDir}/node_modules/alchemy/lib/Resource.js`);
|
|
55
|
+
const marker = "DuplicateResourceIdError";
|
|
56
|
+
const [srcText, libText] = await Promise.all([
|
|
57
|
+
resourceSrc.exists().then((ok) => ok ? resourceSrc.text() : ""),
|
|
58
|
+
resourceLib.exists().then((ok) => ok ? resourceLib.text() : "")
|
|
59
|
+
]);
|
|
60
|
+
if (!srcText.includes(marker) && !libText.includes(marker)) {
|
|
61
|
+
problems.push("node_modules/alchemy/{src/Resource.ts,lib/Resource.js}: neither contains the patch — " + "declared in package.json but not applied; re-run `bun install`");
|
|
62
|
+
}
|
|
63
|
+
return problems;
|
|
64
|
+
};
|
|
64
65
|
export {
|
|
65
66
|
DuplicateResourceIdError,
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
PATCHED_ALCHEMY_VERSION,
|
|
68
|
+
PATCHED_ALCHEMY_VERSIONS,
|
|
69
|
+
checkAlchemyPatch
|
|
68
70
|
};
|
|
69
71
|
|
|
70
|
-
//# debugId=
|
|
72
|
+
//# debugId=461D34CD68FB220064756E2164756E21
|
|
71
73
|
//# sourceMappingURL=index.js.map
|
package/dist/scope/index.js.map
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
|
-
"sources": ["../src/scope/duplicate-id.ts", "../src/scope/
|
|
3
|
+
"sources": ["../src/scope/duplicate-id.ts", "../src/scope/patch-check.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
-
"/**\n *
|
|
6
|
-
"/**\n * `
|
|
5
|
+
"/**\n * `DuplicateResourceIdError` and the pinned alchemy version the kit's patch targets.\n *\n * ★ THE FIX ITSELF IS NOT HERE. A second resource of a different type claiming an id already\n * taken in one stack scope is refused by a PATCH to `alchemy`'s own `Resource.ts` — see\n * `../../patches/alchemy@2.0.0-beta.81.patch` and docs/duplicate-resource-id.md for why a\n * kit-side `Proxy` wrapper (PR 468's first version, replaced by this PR) cannot actually\n * catch the incident it was built for: wrapping `@homeflare/alchemy`'s OWN resource exports\n * never reaches `NixJobRetirement`, a resource homeflare-mini declares directly against\n * `alchemy`'s `Resource()`, so the measured collision still succeeded silently in BOTH\n * orderings. This file now holds only:\n *\n * 1. `DuplicateResourceIdError` — the exact shape the patched `alchemy` throws, duplicated\n * here (not imported — `alchemy` cannot depend on `@homeflare/alchemy`) purely so a\n * consumer can write `Effect.catchTag(\"DuplicateResourceIdError\", …)` against a type this\n * package exports, without needing to know alchemy carries its own copy of the same tag.\n * Effect dispatches `catchTag` on the `_tag` STRING, not `instanceof`, so either class\n * works identically as a catch target.\n * 2. `PATCHED_ALCHEMY_VERSION` — the exact alchemy version THIS package's own peer pin and\n * devDependency sit at, and the one `duplicate-id.test.ts` drives its real `Stack` against.\n * Hardcoded, like `ESTATE_VERSIONS` in `@homeflare/config/versions` (the same reasoning:\n * a second source of truth derived by import would cross `tsconfig.build.json`'s\n * `rootDir: \"src\"` boundary to reach `package.json`). `duplicate-id.test.ts` asserts it\n * still matches this package's own `peerDependencies.alchemy`, so a pin bump that forgets\n * to re-cut the patch fails a test here instead of silently shipping a patch filename that\n * no longer matches what consumers install.\n * 3. `PATCHED_ALCHEMY_VERSIONS` — every alchemy version this package ships a CUT patch for\n * (currently beta.81 and beta.83, kit PR shipping beta.83's — a consumer on beta.82 or\n * beta.84 is NOT covered merely by being \"close\"; `bun`'s patch format keys to an exact\n * version string, and a patch cut against one version is not guaranteed to even apply to\n * another — see docs/duplicate-resource-id.md for why a diff was taken between the two\n * tarballs rather than assumed). `checkAlchemyPatch` (`patch-check.ts`) walks this list to\n * find the shipped patch matching a consumer's ACTUAL install, so a stack on beta.83 is not\n * forced to pin the single oldest supported version just to pass its own check.\n */\nimport * as Data from 'effect/Data';\n\n/**\n * Mirrors the error the patched `alchemy@2.0.0-beta.81` throws at registration — same `_tag`,\n * same fields — for a consumer that wants to `catchTag` it without importing from `alchemy`'s\n * own patched internals.\n *\n * ⚠️ ONLY `Type` IS COMPARED, NEVER `Props`. A platform-typed resource's tagged pattern\n * (`alchemy`'s own `Platform.ts` ~581) rewrites `Props` on a resource already registered, and a\n * bare-tag forward reference legitimately registers with `Props: undefined` until its\n * `.make(...)` Layer builds (`MissingImplementationError`'s own doc comment in `Resource.ts`).\n * Comparing props as well as type would make both of those die. `Type` is stable from a\n * resource's very first `yield*` and is exactly what distinguishes \"the same resource,\n * referenced twice\" (fine) from \"two different resources that picked the same id\" (refused).\n */\nexport class DuplicateResourceIdError extends Data.TaggedError('DuplicateResourceIdError')<{\n message: string;\n fqn: string;\n /** The type that first claimed `fqn`. */\n firstType: string;\n /** The different type that tried to claim it again. */\n secondType: string;\n}> {}\n\n/**\n * The exact `alchemy` version `patches/alchemy@2.0.0-beta.81.patch` was cut against — this\n * package's own peer pin, and the version `duplicate-id.test.ts` drives a real `Stack` over.\n * `duplicate-id.test.ts` keeps this honest against `package.json`'s own `peerDependencies`.\n *\n * ⛔ STAYS, MEANING BETA.81 — do not repoint this at beta.83. A consumer that calls\n * `checkAlchemyPatch` with an explicit `alchemyVersion` (or reads this constant directly)\n * must keep getting the same answer it always has; {@link PATCHED_ALCHEMY_VERSIONS} is the\n * list a caller walks to find ALL supported pins, this one constant is not retargeted to mean\n * \"whichever is newest.\"\n */\nexport const PATCHED_ALCHEMY_VERSION: string = '2.0.0-beta.81';\n\n/**\n * Every alchemy version `@homeflare/alchemy` ships a cut `patches/alchemy@<version>.patch`\n * for, oldest first. `checkAlchemyPatch` uses this to pick the shipped patch matching a\n * consumer's ACTUAL installed version instead of only ever checking against\n * {@link PATCHED_ALCHEMY_VERSION}. `patch-check.test.ts` and the static patch-coverage test\n * both assert every entry here has a real `patches/alchemy@<version>.patch` file.\n */\nexport const PATCHED_ALCHEMY_VERSIONS: readonly string[] = ['2.0.0-beta.81', '2.0.0-beta.83'];\n",
|
|
6
|
+
"/**\n * A consuming stack's own `check` calls `checkAlchemyPatch` to assert it actually carries the\n * duplicate-resource-id fix — not just that SOME version of `alchemy` is installed, but that\n * THIS install carries the patch, for the alchemy version it was cut against.\n *\n * ★ WHY A BUMP CAN OTHERWISE DROP IT SILENTLY. `patchedDependencies` keys a patch to an EXACT\n * version string (`alchemy@2.0.0-beta.81`). `bun add alchemy@2.0.0-beta.82` leaves that key\n * pointing at a version nothing installs anymore — `bun install` neither errors nor warns,\n * it just stops applying the patch, and the stack is back to the original silent collision\n * with every other gate still green. This check is what turns that bump into a failing\n * `check`, the same way `tests/dist.test.ts` turns a build regression into a failing test\n * instead of a surprise three repos downstream (this package's own AGENTS.md, \"the smoke\n * test is not optional\").\n * ★ A STATIC CHECK, mirroring `@homeflare/config/check`'s `checkCopy`/`checkOxfmt` — not a\n * functional probe that spins up a `Stack`/`Resource`/`Effect` harness on every `check` run.\n * The functional proof that the patch's LOGIC works lives in this package's own\n * `duplicate-id.test.ts`, against the exact pinned version; a consumer's `check` only needs\n * to know whether ITS install carries that same patch, applied — three file reads, no\n * `effect` runtime pulled into every repo's check step.\n * ⛔ IT REPORTS, IT DOES NOT REPAIR, same as `checkProject` in `@homeflare/config/check` — this\n * never edits `package.json` or re-runs `bun install` for the caller.\n */\nimport { PATCHED_ALCHEMY_VERSION, PATCHED_ALCHEMY_VERSIONS } from './duplicate-id.ts';\n\n/** One thing wrong with a consuming stack's alchemy patch, in the imperative. */\nexport type PatchProblem = string;\n\nconst patchKey = (version: string) => `alchemy@${version}`;\n\n/** Where THIS package's own shipped copy of the patch lives, however it was installed. */\nconst shippedPatchPath = (version: string): string =>\n new URL(`../../patches/${patchKey(version)}.patch`, import.meta.url).pathname;\n\ntype PackageJson = {\n readonly patchedDependencies?: Readonly<Record<string, string>>;\n};\n\n/**\n * @param projectDir The consuming stack's OWN repo root (where its `package.json` and\n * `patches/` directory live) — never this kit's own.\n * @param alchemyVersion The version to check for. Omitted (the normal case): the version\n * actually installed at `projectDir` is used, so a consumer on ANY version this package\n * ships a patch for ({@link PATCHED_ALCHEMY_VERSIONS}) passes without having to pin\n * {@link PATCHED_ALCHEMY_VERSION} specifically — a stack on beta.83 is checked against the\n * beta.83 patch, one on beta.81 against the beta.81 patch. A caller passes this explicitly\n * only to assert a SPECIFIC pin regardless of what happens to be installed (as the tests do).\n */\nexport const checkAlchemyPatch = async (\n projectDir: string,\n alchemyVersion?: string,\n): Promise<readonly PatchProblem[]> => {\n const problems: PatchProblem[] = [];\n\n const installedPkg = Bun.file(`${projectDir}/node_modules/alchemy/package.json`);\n if (!(await installedPkg.exists())) {\n problems.push('node_modules/alchemy: not installed — nothing to check the patch against');\n return problems;\n }\n const installedVersion = ((await installedPkg.json()) as { version?: string }).version;\n\n // No explicit ask: check against whatever is ACTUALLY installed, not a single hardcoded\n // pin — that is what lets a beta.83 consumer pass without also being on beta.81.\n const targetVersion = alchemyVersion ?? installedVersion ?? PATCHED_ALCHEMY_VERSION;\n const key = patchKey(targetVersion);\n\n if (!PATCHED_ALCHEMY_VERSIONS.includes(targetVersion)) {\n problems.push(\n // ★ Name the version being CHECKED: with an explicit `alchemyVersion` it can differ from\n // the installed one, and naming the installed version then blames a supported one.\n `alchemy ${targetVersion} (installed: ${installedVersion ?? 'unknown'}) is a version ` +\n `@homeflare/alchemy has ` +\n `no duplicate-resource-id patch for — supported versions are ` +\n `${PATCHED_ALCHEMY_VERSIONS.join(', ')}; cut a new patches/${key}.patch ` +\n \"(@homeflare/alchemy's own patches/) before bumping alchemy, or this install has no \" +\n 'protection at all',\n );\n return problems;\n }\n if (installedVersion !== targetVersion) {\n problems.push(\n `node_modules/alchemy is ${installedVersion ?? 'unknown'}, but the duplicate-resource-id ` +\n `check was asked for ${targetVersion} — pass the installed version explicitly, or omit ` +\n 'alchemyVersion to check against whatever is actually installed',\n );\n return problems;\n }\n\n const pkgFile = Bun.file(`${projectDir}/package.json`);\n if (!(await pkgFile.exists())) {\n problems.push('package.json: missing');\n return problems;\n }\n const pkg = (await pkgFile.json()) as PackageJson;\n const declaredPath = pkg.patchedDependencies?.[key];\n if (declaredPath === undefined) {\n problems.push(\n `package.json: patchedDependencies has no entry for \"${key}\" — this stack has no ` +\n 'duplicate-resource-id protection at all; see @homeflare/alchemy/docs/duplicate-resource-id.md',\n );\n return problems;\n }\n\n const theirs = Bun.file(`${projectDir}/${declaredPath}`);\n if (!(await theirs.exists())) {\n problems.push(`package.json declares \"${key}\" at ${declaredPath}, but that file is missing`);\n return problems;\n }\n const ours = Bun.file(shippedPatchPath(targetVersion));\n if (!(await ours.exists())) {\n // The kit's own tarball is missing its patch — a packaging bug, not the consumer's.\n problems.push(`@homeflare/alchemy ships no patches/${key}.patch to compare against`);\n return problems;\n }\n if ((await theirs.text()) !== (await ours.text())) {\n problems.push(\n `${declaredPath}: differs from @homeflare/alchemy's shipped patch — re-copy it, ` +\n 'or say why it differs',\n );\n }\n\n // Declared and byte-identical is not yet APPLIED: a hand-edited patchedDependencies entry,\n // or an install under a package manager that does not support bun's patch format, leaves\n // node_modules unpatched with nothing above noticing. One cheap textual proof it landed.\n const resourceSrc = Bun.file(`${projectDir}/node_modules/alchemy/src/Resource.ts`);\n const resourceLib = Bun.file(`${projectDir}/node_modules/alchemy/lib/Resource.js`);\n const marker = 'DuplicateResourceIdError';\n const [srcText, libText] = await Promise.all([\n resourceSrc.exists().then((ok) => (ok ? resourceSrc.text() : '')),\n resourceLib.exists().then((ok) => (ok ? resourceLib.text() : '')),\n ]);\n if (!srcText.includes(marker) && !libText.includes(marker)) {\n problems.push(\n 'node_modules/alchemy/{src/Resource.ts,lib/Resource.js}: neither contains the patch — ' +\n 'declared in package.json but not applied; re-run `bun install`',\n );\n }\n\n return problems;\n};\n"
|
|
7
7
|
],
|
|
8
|
-
"mappings": ";
|
|
9
|
-
"debugId": "
|
|
8
|
+
"mappings": ";AAmCA;AAAA;AAeO,MAAM,iCAAsC,iBAAY,0BAA0B,EAOtF;AAAC;AAaG,IAAM,0BAAkC;AASxC,IAAM,2BAA8C,CAAC,iBAAiB,eAAe;;ACpD5F,IAAM,WAAW,CAAC,YAAoB,WAAW;AAGjD,IAAM,mBAAmB,CAAC,YACxB,IAAI,IAAI,iBAAiB,SAAS,OAAO,WAAW,YAAY,GAAG,EAAE;AAgBhE,IAAM,oBAAoB,OAC/B,YACA,mBACqC;AAAA,EACrC,MAAM,WAA2B,CAAC;AAAA,EAElC,MAAM,eAAe,IAAI,KAAK,GAAG,8CAA8C;AAAA,EAC/E,IAAI,CAAE,MAAM,aAAa,OAAO,GAAI;AAAA,IAClC,SAAS,KAAK,0EAA0E;AAAA,IACxF,OAAO;AAAA,EACT;AAAA,EACA,MAAM,oBAAqB,MAAM,aAAa,KAAK,GAA4B;AAAA,EAI/E,MAAM,gBAAgB,kBAAkB,oBAAoB;AAAA,EAC5D,MAAM,MAAM,SAAS,aAAa;AAAA,EAElC,IAAI,CAAC,yBAAyB,SAAS,aAAa,GAAG;AAAA,IACrD,SAAS,KAGP,WAAW,6BAA6B,oBAAoB,6BAC1D,4BACA,iEACA,GAAG,yBAAyB,KAAK,IAAI,wBAAwB,eAC7D,wFACA,mBACJ;AAAA,IACA,OAAO;AAAA,EACT;AAAA,EACA,IAAI,qBAAqB,eAAe;AAAA,IACtC,SAAS,KACP,2BAA2B,oBAAoB,8CAC7C,uBAAuB,oEACvB,gEACJ;AAAA,IACA,OAAO;AAAA,EACT;AAAA,EAEA,MAAM,UAAU,IAAI,KAAK,GAAG,yBAAyB;AAAA,EACrD,IAAI,CAAE,MAAM,QAAQ,OAAO,GAAI;AAAA,IAC7B,SAAS,KAAK,uBAAuB;AAAA,IACrC,OAAO;AAAA,EACT;AAAA,EACA,MAAM,MAAO,MAAM,QAAQ,KAAK;AAAA,EAChC,MAAM,eAAe,IAAI,sBAAsB;AAAA,EAC/C,IAAI,iBAAiB,WAAW;AAAA,IAC9B,SAAS,KACP,uDAAuD,8BACrD,+FACJ;AAAA,IACA,OAAO;AAAA,EACT;AAAA,EAEA,MAAM,SAAS,IAAI,KAAK,GAAG,cAAc,cAAc;AAAA,EACvD,IAAI,CAAE,MAAM,OAAO,OAAO,GAAI;AAAA,IAC5B,SAAS,KAAK,0BAA0B,WAAW,wCAAwC;AAAA,IAC3F,OAAO;AAAA,EACT;AAAA,EACA,MAAM,OAAO,IAAI,KAAK,iBAAiB,aAAa,CAAC;AAAA,EACrD,IAAI,CAAE,MAAM,KAAK,OAAO,GAAI;AAAA,IAE1B,SAAS,KAAK,uCAAuC,8BAA8B;AAAA,IACnF,OAAO;AAAA,EACT;AAAA,EACA,IAAK,MAAM,OAAO,KAAK,MAAQ,MAAM,KAAK,KAAK,GAAI;AAAA,IACjD,SAAS,KACP,GAAG,iFACD,uBACJ;AAAA,EACF;AAAA,EAKA,MAAM,cAAc,IAAI,KAAK,GAAG,iDAAiD;AAAA,EACjF,MAAM,cAAc,IAAI,KAAK,GAAG,iDAAiD;AAAA,EACjF,MAAM,SAAS;AAAA,EACf,OAAO,SAAS,WAAW,MAAM,QAAQ,IAAI;AAAA,IAC3C,YAAY,OAAO,EAAE,KAAK,CAAC,OAAQ,KAAK,YAAY,KAAK,IAAI,EAAG;AAAA,IAChE,YAAY,OAAO,EAAE,KAAK,CAAC,OAAQ,KAAK,YAAY,KAAK,IAAI,EAAG;AAAA,EAClE,CAAC;AAAA,EACD,IAAI,CAAC,QAAQ,SAAS,MAAM,KAAK,CAAC,QAAQ,SAAS,MAAM,GAAG;AAAA,IAC1D,SAAS,KACP,0FACE,gEACJ;AAAA,EACF;AAAA,EAEA,OAAO;AAAA;",
|
|
9
|
+
"debugId": "461D34CD68FB220064756E2164756E21",
|
|
10
10
|
"names": []
|
|
11
11
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** One thing wrong with a consuming stack's alchemy patch, in the imperative. */
|
|
2
|
+
export type PatchProblem = string;
|
|
3
|
+
/**
|
|
4
|
+
* @param projectDir The consuming stack's OWN repo root (where its `package.json` and
|
|
5
|
+
* `patches/` directory live) — never this kit's own.
|
|
6
|
+
* @param alchemyVersion The version to check for. Omitted (the normal case): the version
|
|
7
|
+
* actually installed at `projectDir` is used, so a consumer on ANY version this package
|
|
8
|
+
* ships a patch for ({@link PATCHED_ALCHEMY_VERSIONS}) passes without having to pin
|
|
9
|
+
* {@link PATCHED_ALCHEMY_VERSION} specifically — a stack on beta.83 is checked against the
|
|
10
|
+
* beta.83 patch, one on beta.81 against the beta.81 patch. A caller passes this explicitly
|
|
11
|
+
* only to assert a SPECIFIC pin regardless of what happens to be installed (as the tests do).
|
|
12
|
+
*/
|
|
13
|
+
export declare const checkAlchemyPatch: (projectDir: string, alchemyVersion?: string) => Promise<readonly PatchProblem[]>;
|
|
14
|
+
//# sourceMappingURL=patch-check.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"patch-check.d.ts","sourceRoot":"","sources":["../../src/scope/patch-check.ts"],"names":[],"mappings":"AAwBA,iFAAiF;AACjF,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC;AAYlC;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB,eAChB,MAAM,mBACD,MAAM,KACtB,OAAO,CAAC,SAAS,YAAY,EAAE,CAwFjC,CAAC"}
|
|
@@ -1,23 +1,45 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Duplicate resource ids — a patch to `alchemy` itself
|
|
2
2
|
|
|
3
3
|
Two resources of different types declaring the same logical id in one stack scope now fails at
|
|
4
|
-
registration — before `Plan.make`, before any provider runs — instead of Alchemy silently
|
|
5
|
-
the second one as a reference to the first.
|
|
4
|
+
registration — before `Plan.make`, before any provider runs — instead of Alchemy silently
|
|
5
|
+
treating the second one as a reference to the first. The fix is a **patch to `alchemy`'s own
|
|
6
|
+
`Resource.ts`**, applied at each consuming stack's own root (`alchemy` is a peer dependency
|
|
7
|
+
here, so this package's own `patchedDependencies` only reaches ITS OWN install).
|
|
6
8
|
|
|
7
|
-
## The incident
|
|
9
|
+
## The incident
|
|
8
10
|
|
|
9
11
|
Tim, 2026-10-19 ("lets not let two things claim the same"): in homeflare-mini,
|
|
10
12
|
`NixJobRetirement('caddy', …)` and the Caddy config resource (`CADDY_ID = 'caddy'`,
|
|
11
|
-
`src/caddy/declare.ts`) both declared logical id `caddy` in one stack.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
13
|
+
`src/caddy/declare.ts`) both declared logical id `caddy` in one stack. The plan showed
|
|
14
|
+
`[caddy] noop`, the retirement was silently dropped (deploy "49 succeeded" for 50 rows), and a
|
|
15
|
+
later deploy showed `[caddy] update` as state reconciled back.
|
|
16
|
+
|
|
17
|
+
## Why a kit-side wrapper cannot fix this (PR 468's first version, replaced here)
|
|
18
|
+
|
|
19
|
+
PR 468 shipped `guardDuplicateIds`, a `Proxy` wrapped around `@homeflare/alchemy`'s own
|
|
20
|
+
`CaddyConfig` export. A second-round review found it never actually closed the incident:
|
|
21
|
+
|
|
22
|
+
- `NixJobRetirement` is declared directly in homeflare-mini against `alchemy`'s raw
|
|
23
|
+
`Resource()` — never through anything this kit exports — so wrapping `CaddyConfig` alone
|
|
24
|
+
leaves the OTHER half of the collision unguarded. The incident reproduced identically in
|
|
25
|
+
BOTH orderings, Proxy or not.
|
|
26
|
+
- Wrapping every `@homeflare/alchemy` export still could not reach a hand-rolled, consumer-local
|
|
27
|
+
resource type, or any vendor built-in (`Cloudflare.Worker`, etc.) — this package cannot wrap a
|
|
28
|
+
class it does not author.
|
|
29
|
+
- The `Proxy` had its own bypasses besides: `yield* SomeGuardedClass` (no call, the bare-tag
|
|
30
|
+
pattern) resolves through `Effectable.Prototype`'s `evaluate()`, which closes over the
|
|
31
|
+
UNWRAPPED constructor directly; and the "attach methods" call form
|
|
32
|
+
(`SomeGuardedClass({ ...methods })`) returns the raw, unwrapped class.
|
|
33
|
+
|
|
34
|
+
None of that is fixable by wrapping harder. The real registration happens once, inside
|
|
35
|
+
`alchemy`'s own `Resource()`, and every resource — ours, a consumer's own, and every vendor
|
|
36
|
+
built-in — goes through that one function. A kit-side wrapper is structurally unable to be the
|
|
37
|
+
single place a fix can live.
|
|
38
|
+
|
|
39
|
+
## The root cause
|
|
40
|
+
|
|
41
|
+
`alchemy@2.0.0-beta.81`'s `Resource.ts` treats a second `yield*` at an already-registered FQN as
|
|
42
|
+
an idempotent re-reference and returns the first-registered resource without ever comparing
|
|
21
43
|
`Type`:
|
|
22
44
|
|
|
23
45
|
```ts
|
|
@@ -29,63 +51,104 @@ if (existing) {
|
|
|
29
51
|
}
|
|
30
52
|
```
|
|
31
53
|
|
|
32
|
-
|
|
33
|
-
never changes — so nothing downstream, including this package's own `hf-adopt-verify`, can tell
|
|
34
|
-
two different registrations happened. Measured directly (`src/scope/duplicate-id.ts`'s header
|
|
35
|
-
has the reproduction): registering `Test.ResourceB('shared-id', …)` right after
|
|
36
|
-
`Test.ResourceA('shared-id', …)` compiles a stack whose only entry at that fqn reports
|
|
37
|
-
`Type: 'Test.ResourceA'`.
|
|
54
|
+
## The patch
|
|
38
55
|
|
|
39
|
-
|
|
40
|
-
the
|
|
41
|
-
where both the existing claim and the new claimant's type are in hand is at registration itself.
|
|
56
|
+
`patches/alchemy@2.0.0-beta.81.patch` replaces that `TODO` with a `Type` comparison, dying with
|
|
57
|
+
`DuplicateResourceIdError` the moment a different type claims an already-taken id:
|
|
42
58
|
|
|
43
|
-
|
|
59
|
+
```
|
|
60
|
+
REFUSING: duplicate resource id "caddy" in scope (root): first declared by Nix.JobRetirement, again by Caddy.Config
|
|
61
|
+
```
|
|
44
62
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
63
|
+
It touches three files so every consumer resolution path is covered: `src/Resource.ts` (the
|
|
64
|
+
"bun" export condition), `lib/Resource.js` (the compiled "default"/node condition), and
|
|
65
|
+
`lib/Resource.d.ts` (so `DuplicateResourceIdError` has types). Verified directly against both:
|
|
66
|
+
registering the same collision under `bun test` and under plain `node` both die with the
|
|
67
|
+
identical message.
|
|
68
|
+
|
|
69
|
+
### Supported versions
|
|
70
|
+
|
|
71
|
+
`patchedDependencies` keys to an EXACT version string, and `bun`'s patch format is not
|
|
72
|
+
guaranteed to apply across versions even when the surrounding file is otherwise unchanged — so
|
|
73
|
+
each alchemy version this covers gets its OWN cut, byte-diffed against that version's real
|
|
74
|
+
tarball, not a renamed copy:
|
|
75
|
+
|
|
76
|
+
| alchemy version | patch file |
|
|
77
|
+
| --------------- | ------------------------------------- |
|
|
78
|
+
| `2.0.0-beta.81` | `patches/alchemy@2.0.0-beta.81.patch` |
|
|
79
|
+
| `2.0.0-beta.83` | `patches/alchemy@2.0.0-beta.83.patch` |
|
|
80
|
+
|
|
81
|
+
Between beta.81 and beta.83, upstream's only change touching these three files was an
|
|
82
|
+
unrelated, cosmetic rewrite of `isResource` (a function body collapsed to an arrow
|
|
83
|
+
expression) earlier in `src/Resource.ts` / `lib/Resource.js` — `lib/Resource.d.ts` is
|
|
84
|
+
byte-identical between the two. The patched spot itself (the `TODO(sam)` branch and its
|
|
85
|
+
surrounding context) is unchanged content, shifted by the line count that earlier rewrite
|
|
86
|
+
added or removed. `PATCHED_ALCHEMY_VERSIONS` (`@homeflare/alchemy/scope`) is the authoritative
|
|
87
|
+
list; `checkAlchemyPatch` walks it to find the entry matching what is actually installed.
|
|
88
|
+
A version not in that list — beta.82 included — has no patch cut for it; adopt the nearest
|
|
89
|
+
supported version's workflow below and open an issue, rather than assuming an untested patch
|
|
90
|
+
applies.
|
|
91
|
+
|
|
92
|
+
**Only `Type` is compared, never `Props`.** Comparing props as well would be wrong: a
|
|
93
|
+
platform-typed resource's tagged pattern (`alchemy`'s own `Platform.ts` ~581) rewrites `Props`
|
|
94
|
+
on a resource already registered, and a bare-tag forward reference legitimately registers with
|
|
95
|
+
`Props: undefined` until its `.make(...)` Layer builds. `Type` is stable from a resource's very
|
|
96
|
+
first `yield*`; `Props` is not.
|
|
97
|
+
|
|
98
|
+
## Adopting it in a consuming stack
|
|
99
|
+
|
|
100
|
+
Pick the file matching the stack's OWN installed `alchemy` version from the table above —
|
|
101
|
+
here shown for beta.83; beta.81 is identical with the version string swapped:
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
cp node_modules/@homeflare/alchemy/patches/alchemy@2.0.0-beta.83.patch patches/
|
|
105
|
+
```
|
|
48
106
|
|
|
49
|
-
|
|
50
|
-
import { Resource } from 'alchemy';
|
|
51
|
-
import { guardDuplicateIds } from '@homeflare/alchemy/scope';
|
|
107
|
+
then add, in the stack's own root `package.json`:
|
|
52
108
|
|
|
53
|
-
|
|
109
|
+
```json
|
|
110
|
+
"patchedDependencies": {
|
|
111
|
+
"alchemy@2.0.0-beta.83": "patches/alchemy@2.0.0-beta.83.patch"
|
|
112
|
+
}
|
|
54
113
|
```
|
|
55
114
|
|
|
56
|
-
|
|
115
|
+
and run `bun install`. This mirrors the house pattern for `bunfig.toml`
|
|
116
|
+
("adoption is a copy, not an extend" — AGENTS.md) — the patch is copied, not referenced, so it
|
|
117
|
+
ships in the consumer's OWN lockfile and survives even if `@homeflare/alchemy` is later removed.
|
|
57
118
|
|
|
119
|
+
## The consumer check — catching a bump that silently drops it
|
|
120
|
+
|
|
121
|
+
`patchedDependencies` keys a patch to an EXACT version string. Bumping `alchemy` without
|
|
122
|
+
re-cutting the patch leaves that key pointing at nothing — `bun install` neither errors nor
|
|
123
|
+
warns, it just stops applying the patch. `checkAlchemyPatch` (`@homeflare/alchemy/scope`) is
|
|
124
|
+
what a stack's own `check` calls to catch that:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
import { checkAlchemyPatch } from '@homeflare/alchemy/scope';
|
|
128
|
+
|
|
129
|
+
const problems = await checkAlchemyPatch(import.meta.dirname);
|
|
130
|
+
if (problems.length > 0) {
|
|
131
|
+
console.error(problems.join('\n'));
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
58
134
|
```
|
|
59
|
-
REFUSING: duplicate resource id "caddy" in scope (root): first declared by Nix.JobRetirement, again by Caddy.Config
|
|
60
|
-
```
|
|
61
135
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
"reference the same resource from two call sites" pattern; `guardDuplicateIds` only refuses a
|
|
75
|
-
_different_ type at an already-claimed id.
|
|
76
|
-
- **Both sides must be wrapped.** `guardDuplicateIds` is a `Proxy` around one resource class; it
|
|
77
|
-
has no way to see a DIFFERENT, unwrapped class's registration. A guarded `Caddy.Config` beside
|
|
78
|
-
an unguarded, hand-rolled `Resource('Nix.JobRetirement')` in a consuming stack still collides
|
|
79
|
-
silently — `guard.test.ts`'s "one guarded, one unguarded" case asserts this directly, so it is
|
|
80
|
-
documented behavior rather than a surprise. A stack's own custom resource types get full
|
|
81
|
-
protection the same way `Caddy.Config` does: wrap the export in `guardDuplicateIds`.
|
|
82
|
-
- **It does not catch a collision against a vendor built-in** (e.g. `Cloudflare.Worker`) unless
|
|
83
|
-
that export is also wrapped — this package cannot wrap a class it does not author. See "Upstream"
|
|
84
|
-
below for the fix that would close this for every resource, vendor included.
|
|
136
|
+
With no second argument, it checks against whatever `alchemy` version is ACTUALLY installed
|
|
137
|
+
at that stack's root — a stack on beta.83 is checked against the beta.83 patch, one on beta.81
|
|
138
|
+
against the beta.81 patch, neither has to pin `PATCHED_ALCHEMY_VERSION` (which stays beta.81,
|
|
139
|
+
for the consumer that already reads it directly) to pass. Pass a version explicitly only to
|
|
140
|
+
assert a SPECIFIC pin regardless of what is installed.
|
|
141
|
+
|
|
142
|
+
It is a static check — three file reads, no `effect` runtime spun up — covering: alchemy
|
|
143
|
+
installed at a version `@homeflare/alchemy` has no shipped patch for at all (`PATCHED_ALCHEMY_VERSIONS`
|
|
144
|
+
names every version it DOES cover), an explicit ask that disagrees with what is installed, no
|
|
145
|
+
`patchedDependencies` entry at all, the referenced file missing or differing from this
|
|
146
|
+
package's own shipped copy, and (the one failure mode none of those other checks would catch)
|
|
147
|
+
declared and byte-identical but never actually applied.
|
|
85
148
|
|
|
86
149
|
## Upstream
|
|
87
150
|
|
|
88
|
-
The real fix belongs in `alchemy`
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
been filed.
|
|
151
|
+
The real fix belongs in `alchemy` itself, at the exact line the patch replaces — every resource,
|
|
152
|
+
vendor included, goes through that one function, which is the whole reason a kit-side fix can
|
|
153
|
+
only ever be a stopgap. The issue/PR text proposed for `alchemy-run/alchemy` is in this PR's
|
|
154
|
+
body; it has not been filed — posting it publicly needs Tim's sign-off.
|
|
@@ -63,15 +63,15 @@ already ships is a finding, even when it works.
|
|
|
63
63
|
owned ones, as upstream's third-party providers do; the core contract does not ask for it. `nuke` is declared wherever nuke must never reach the object.
|
|
64
64
|
- **Scope (S15).** The account, zone and region are resolved inside each operation. They
|
|
65
65
|
are never props.
|
|
66
|
-
- **Duplicate ids (
|
|
67
|
-
|
|
68
|
-
`
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
66
|
+
- **Duplicate ids (revised 2026-10-19 — a patch, not a provider rule).** A second, different
|
|
67
|
+
typed resource declaring a logical id another resource already claims in the same stack is
|
|
68
|
+
refused by a PATCH to `alchemy` itself, applied at each consuming stack's own root
|
|
69
|
+
(measured, homeflare-mini PR 329; [duplicate-resource-id.md](./duplicate-resource-id.md)).
|
|
70
|
+
Because the fix lives in `alchemy`'s own `Resource()` — the one function EVERY resource
|
|
71
|
+
(ours, a consumer's own, every vendor built-in) goes through — no individual provider file
|
|
72
|
+
in this package needs anything added or wrapped for this; a first attempt that wrapped each
|
|
73
|
+
resource's export in a kit-side `Proxy` turned out structurally unable to reach a
|
|
74
|
+
consumer-declared resource type, so it was replaced with the patch.
|
|
75
75
|
|
|
76
76
|
## Code, errors, credentials
|
|
77
77
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/alchemy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.61.0",
|
|
4
4
|
"description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
|
|
5
5
|
"homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
|
|
6
6
|
"bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"files": [
|
|
18
18
|
"dist",
|
|
19
19
|
"docs",
|
|
20
|
+
"patches",
|
|
20
21
|
"src",
|
|
21
22
|
"!src/**/fixtures",
|
|
22
23
|
"!src/**/trust-boundary.seam.ts",
|