@homeflare/alchemy 0.59.0 → 0.60.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.
@@ -1,71 +1,66 @@
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 scopeLabel = (namespace) => {
11
- if (!namespace)
12
- return "(root)";
13
- const path = [];
14
- let node = namespace;
15
- while (node) {
16
- path.unshift(node.Id);
17
- node = node.Parent;
6
+ var PATCHED_ALCHEMY_VERSION = "2.0.0-beta.81";
7
+ // src/scope/patch-check.ts
8
+ var patchKey = (version) => `alchemy@${version}`;
9
+ var shippedPatchPath = (version) => new URL(`../../patches/${patchKey(version)}.patch`, import.meta.url).pathname;
10
+ var checkAlchemyPatch = async (projectDir, alchemyVersion = PATCHED_ALCHEMY_VERSION) => {
11
+ const problems = [];
12
+ const key = patchKey(alchemyVersion);
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
- return path.join("/");
20
- };
21
- var registries = new WeakMap;
22
- var registryFor = (stack) => {
23
- const existing = registries.get(stack);
24
- if (existing)
25
- return existing;
26
- const created = new Map;
27
- registries.set(stack, created);
28
- return created;
29
- };
30
- var claimResourceId = (type, id) => Effect.gen(function* () {
31
- const stack = yield* Stack;
32
- const namespace = yield* CurrentNamespace;
33
- const fqn = toFqn(namespace, id);
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
+ if (installedVersion !== alchemyVersion) {
20
+ problems.push(`node_modules/alchemy is ${installedVersion ?? "unknown"}, but the duplicate-resource-id ` + `patch targets ${alchemyVersion} — bump the patch (@homeflare/alchemy's own ` + "patches/) before bumping alchemy, or this install has no protection at all");
21
+ return problems;
22
+ }
23
+ const pkgFile = Bun.file(`${projectDir}/package.json`);
24
+ if (!await pkgFile.exists()) {
25
+ problems.push("package.json: missing");
26
+ return problems;
27
+ }
28
+ const pkg = await pkgFile.json();
29
+ const declaredPath = pkg.patchedDependencies?.[key];
30
+ if (declaredPath === undefined) {
31
+ 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");
32
+ return problems;
39
33
  }
40
- if (firstType !== type) {
41
- return yield* Effect.die(new DuplicateResourceIdError({
42
- message: `REFUSING: duplicate resource id "${fqn}" in scope ${scopeLabel(namespace)}: ` + `first declared by ${firstType}, again by ${type}`,
43
- fqn,
44
- scope: scopeLabel(namespace),
45
- firstType,
46
- secondType: type
47
- }));
34
+ const theirs = Bun.file(`${projectDir}/${declaredPath}`);
35
+ if (!await theirs.exists()) {
36
+ problems.push(`package.json declares "${key}" at ${declaredPath}, but that file is missing`);
37
+ return problems;
48
38
  }
49
- });
50
- // src/scope/guard.ts
51
- import * as Effect2 from "effect/Effect";
52
- var guardDuplicateIds = (resourceClass) => new Proxy(resourceClass, {
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
- });
39
+ const ours = Bun.file(shippedPatchPath(alchemyVersion));
40
+ if (!await ours.exists()) {
41
+ problems.push(`@homeflare/alchemy ships no patches/${key}.patch to compare against`);
42
+ return problems;
62
43
  }
63
- });
44
+ if (await theirs.text() !== await ours.text()) {
45
+ problems.push(`${declaredPath}: differs from @homeflare/alchemy's shipped patch — re-copy it, ` + "or say why it differs");
46
+ }
47
+ const resourceSrc = Bun.file(`${projectDir}/node_modules/alchemy/src/Resource.ts`);
48
+ const resourceLib = Bun.file(`${projectDir}/node_modules/alchemy/lib/Resource.js`);
49
+ const marker = "DuplicateResourceIdError";
50
+ const [srcText, libText] = await Promise.all([
51
+ resourceSrc.exists().then((ok) => ok ? resourceSrc.text() : ""),
52
+ resourceLib.exists().then((ok) => ok ? resourceLib.text() : "")
53
+ ]);
54
+ if (!srcText.includes(marker) && !libText.includes(marker)) {
55
+ 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`");
56
+ }
57
+ return problems;
58
+ };
64
59
  export {
65
60
  DuplicateResourceIdError,
66
- claimResourceId,
67
- guardDuplicateIds
61
+ PATCHED_ALCHEMY_VERSION,
62
+ checkAlchemyPatch
68
63
  };
69
64
 
70
- //# debugId=1AB58BD65E38742164756E2164756E21
65
+ //# debugId=BFEA50A1CBA14B6A64756E2164756E21
71
66
  //# sourceMappingURL=index.js.map
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "version": 3,
3
- "sources": ["../src/scope/duplicate-id.ts", "../src/scope/guard.ts"],
3
+ "sources": ["../src/scope/duplicate-id.ts", "../src/scope/patch-check.ts"],
4
4
  "sourcesContent": [
5
- "/**\n * Refuse a second resource claiming a logical id already taken in the same stack scope.\n *\n * ★ WHY THIS EXISTS (measured, homeflare-mini PR 329, Tim 2026-10-19). `NixJobRetirement('caddy',\n * …)` and `Caddy.Config` (`CADDY_ID = 'caddy'`) both declared logical id `caddy` in one stack.\n * Alchemy's own `Resource()` constructor treats a second `yield*` at an already-claimed FQN as\n * an idempotent re-reference — correct for \"the same resource, read from two call sites\" — and\n * returns the FIRST-registered resource without ever comparing `Type` (`Resource.ts`, the\n * `if (existing) { … return existing; }` branch; a sibling TODO there already flags a related\n * gap — \"check if props are different and die\"). The SECOND resource's props are never written\n * anywhere: `stack.resources[fqn]` never changes, so nothing downstream — not `Plan.make`, not a\n * later inspection of the compiled stack — can tell two different registrations ever happened.\n * Measured directly (dup-experiment.ts, this PR): registering `Test.ResourceB('shared-id', …)`\n * after `Test.ResourceA('shared-id', …)` compiles a stack whose ONLY entry at that fqn reports\n * `Type: 'Test.ResourceA'` — the second declaration leaves no trace to check after the fact.\n * ★ WHY THE CHECK CANNOT BE BOLTED ON AFTER THE FACT. Because the losing registration leaves no\n * trace (above), a guard built from `CompiledStack.resources` — inspected after the stack's body\n * has run, e.g. before `Plan.make` — sees only one entry per fqn and cannot know a second,\n * different-typed claim ever happened. The only point where both the existing claim AND the new\n * claimant's `Type` are both in hand is at the moment of registration, which is why this module\n * is a REGISTRY populated and checked as each resource is `yield*`ed (see `guard.ts`), not a\n * post-hoc scan.\n * ⛔ UPSTREAM IS THE RIGHT HOME FOR THE REAL FIX. The comparison belongs in `Resource.ts` itself,\n * right where its own TODO already sits — every resource (ours and every vendor built-in) goes\n * through that one function, and this kit cannot wrap a type it does not author (e.g.\n * `Cloudflare.Worker`). This module is the house-side stopgap decision 49 allows (\"a house rule\n * only fills a gap upstream is silent on\"): it protects any TWO resources that both go through\n * `guardDuplicateIds` (guard.ts), which is everywhere this kit's own providers adopt it. The\n * upstream issue text proposing the real fix is in this PR's body.\n */\nimport { toFqn } from 'alchemy/FQN';\nimport { CurrentNamespace, type NamespaceNode } from 'alchemy/Namespace';\nimport { Stack } from 'alchemy/Stack';\nimport * as Data from 'effect/Data';\nimport * as Effect from 'effect/Effect';\n\n/**\n * Two resources of different types both claimed `fqn` in `scope`. Carries both type names so the\n * message — and any catcher — can tell the attacker from the defender without re-deriving them.\n */\nexport class DuplicateResourceIdError extends Data.TaggedError('DuplicateResourceIdError')<{\n message: string;\n fqn: string;\n scope: string;\n firstType: string;\n secondType: string;\n}> {}\n\nconst scopeLabel = (namespace: NamespaceNode | undefined): string => {\n if (!namespace) return '(root)';\n const path: string[] = [];\n let node: NamespaceNode | undefined = namespace;\n while (node) {\n path.unshift(node.Id);\n node = node.Parent;\n }\n return path.join('/');\n};\n\n/**\n * One registry per compiled stack, keyed by the `Stack` service object `yield* Stack` returns\n * (a fresh `StackSpec` per `Alchemy.Stack(...)`/`make()` call — see `Stack.ts`'s `Layer.effect`).\n * A `WeakMap` so two unrelated stacks (sequential test runs, two stacks in one process) never see\n * each other's claims, and so the registry is collected with the stack instead of growing forever\n * in a long-running process (an `alchemy dev` watch loop, this package's own test suite).\n */\nconst registries = new WeakMap<object, Map<string, string>>();\n\nconst registryFor = (stack: object): Map<string, string> => {\n const existing = registries.get(stack);\n if (existing) return existing;\n const created = new Map<string, string>();\n registries.set(stack, created);\n return created;\n};\n\n/**\n * Claim `id` for `type` in the current namespace scope. Fine the first time, and fine again for\n * the SAME type (the legitimate \"yield the same resource from two call sites\" pattern Alchemy's\n * own idempotent registration supports). Dies with {@link DuplicateResourceIdError} the moment a\n * DIFFERENT type claims an id already taken — before that second resource's provider ever runs.\n *\n * ⚠️ `Effect.die`, NOT a typed failure. This runs at registration, not inside a lifecycle\n * operation (`read`/`diff`/`reconcile`/`delete`) — the provider-standard's S20 ban on\n * `Effect.orDie` in LIFECYCLE operations does not apply here, and `ConflictingProviderModeError`\n * right next door in upstream's own `Resource.ts` dies the same way for the same reason: there is\n * no recovery a caller could usefully attempt mid-registration.\n */\nexport const claimResourceId = (type: string, id: string): Effect.Effect<void, never, Stack> =>\n Effect.gen(function* () {\n const stack = yield* Stack;\n const namespace = yield* CurrentNamespace;\n const fqn = toFqn(namespace, id);\n const registry = registryFor(stack);\n const firstType = registry.get(fqn);\n if (firstType === undefined) {\n registry.set(fqn, type);\n return;\n }\n if (firstType !== type) {\n return yield* Effect.die(\n new DuplicateResourceIdError({\n message:\n `REFUSING: duplicate resource id \"${fqn}\" in scope ${scopeLabel(namespace)}: ` +\n `first declared by ${firstType}, again by ${type}`,\n fqn,\n scope: scopeLabel(namespace),\n firstType,\n secondType: type,\n }),\n );\n }\n });\n",
6
- "/**\n * `guardDuplicateIds` — wrap a Resource class so every `(\"id\", props)` call it makes first\n * claims that id through `duplicate-id.ts`'s per-stack registry, naming both resource types if\n * a second, different-typed resource already claimed it. See duplicate-id.ts's header for why\n * the check has to live HERE (at the moment of registration) rather than after the stack body\n * has finished running.\n *\n * Usage, in a resource's own file, right where it's exported today:\n *\n * ```ts\n * export const CaddyConfig = guardDuplicateIds(Resource<CaddyConfig>('Caddy.Config', { … }));\n * ```\n *\n * ★ A `Proxy` around the REAL class, not a reimplementation. `Resource()`'s return value is a\n * callable object wearing an `Effectable.Prototype` (`Effect.isEffect(MyResource) === true`,\n * for the bare-tag platform-resource pattern) plus `.ref`, `.Type`, `.Provider`, `.Self`,\n * `.Aliases` — reconstructing that shape risks silently dropping one of them. A `Proxy` whose\n * `get` trap is the default (`Reflect.get`) forwards every property read untouched; only the\n * `apply` trap — the `MyResource(\"id\", props)` call form — is new behaviour.\n * ⚠️ THE \"REGISTER METHODS\" OVERLOAD IS DELIBERATELY LEFT ALONE. `ResourceClass` is also callable\n * with a single object argument to attach methods (`Resource()`'s own `typeof args[0] ===\n * 'object' ? Object.assign(...) : constructor(...)` branch) — that call mutates and returns the\n * SAME class, carries no logical id, and has nothing to claim. Only a string first argument\n * (the `(id, props)` form) goes through `claimResourceId`.\n */\nimport type { ResourceClass, ResourceLike } from 'alchemy/Resource';\nimport * as Effect from 'effect/Effect';\nimport { claimResourceId } from './duplicate-id.ts';\n\nexport const guardDuplicateIds = <R extends ResourceLike>(\n resourceClass: ResourceClass<R>,\n): ResourceClass<R> =>\n new Proxy(resourceClass, {\n apply(target, thisArg, args: unknown[]) {\n const id = args[0];\n if (typeof id !== 'string') {\n // The \"attach methods\" overload — not a registration, nothing to claim.\n return Reflect.apply(target as (...a: unknown[]) => unknown, thisArg, args);\n }\n return Effect.gen(function* () {\n yield* claimResourceId(resourceClass.Type, id);\n return yield* Reflect.apply(\n target as (...a: unknown[]) => unknown,\n thisArg,\n args,\n ) as Effect.Effect<R>;\n });\n },\n }) as ResourceClass<R>;\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 the patch was cut 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 */\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.\n * `duplicate-id.test.ts` keeps this honest against `package.json`'s own `peerDependencies`.\n */\nexport const PATCHED_ALCHEMY_VERSION: string = '2.0.0-beta.81';\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 } 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 — defaults to the version this package's\n * own shipped patch targets ({@link PATCHED_ALCHEMY_VERSION}); a caller only overrides it to\n * test a different pin.\n */\nexport const checkAlchemyPatch = async (\n projectDir: string,\n alchemyVersion: string = PATCHED_ALCHEMY_VERSION,\n): Promise<readonly PatchProblem[]> => {\n const problems: PatchProblem[] = [];\n const key = patchKey(alchemyVersion);\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 if (installedVersion !== alchemyVersion) {\n problems.push(\n `node_modules/alchemy is ${installedVersion ?? 'unknown'}, but the duplicate-resource-id ` +\n `patch targets ${alchemyVersion} — bump the patch (@homeflare/alchemy's own ` +\n 'patches/) before bumping alchemy, or this install has no protection at all',\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(alchemyVersion));\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": ";AA8BA;AACA;AACA;AACA;AACA;AAAA;AAMO,MAAM,iCAAsC,iBAAY,0BAA0B,EAMtF;AAAC;AAEJ,IAAM,aAAa,CAAC,cAAiD;AAAA,EACnE,IAAI,CAAC;AAAA,IAAW,OAAO;AAAA,EACvB,MAAM,OAAiB,CAAC;AAAA,EACxB,IAAI,OAAkC;AAAA,EACtC,OAAO,MAAM;AAAA,IACX,KAAK,QAAQ,KAAK,EAAE;AAAA,IACpB,OAAO,KAAK;AAAA,EACd;AAAA,EACA,OAAO,KAAK,KAAK,GAAG;AAAA;AAUtB,IAAM,aAAa,IAAI;AAEvB,IAAM,cAAc,CAAC,UAAuC;AAAA,EAC1D,MAAM,WAAW,WAAW,IAAI,KAAK;AAAA,EACrC,IAAI;AAAA,IAAU,OAAO;AAAA,EACrB,MAAM,UAAU,IAAI;AAAA,EACpB,WAAW,IAAI,OAAO,OAAO;AAAA,EAC7B,OAAO;AAAA;AAeF,IAAM,kBAAkB,CAAC,MAAc,OACrC,WAAI,UAAU,GAAG;AAAA,EACtB,MAAM,QAAQ,OAAO;AAAA,EACrB,MAAM,YAAY,OAAO;AAAA,EACzB,MAAM,MAAM,MAAM,WAAW,EAAE;AAAA,EAC/B,MAAM,WAAW,YAAY,KAAK;AAAA,EAClC,MAAM,YAAY,SAAS,IAAI,GAAG;AAAA,EAClC,IAAI,cAAc,WAAW;AAAA,IAC3B,SAAS,IAAI,KAAK,IAAI;AAAA,IACtB;AAAA,EACF;AAAA,EACA,IAAI,cAAc,MAAM;AAAA,IACtB,OAAO,OAAc,WACnB,IAAI,yBAAyB;AAAA,MAC3B,SACE,oCAAoC,iBAAiB,WAAW,SAAS,QACzE,qBAAqB,uBAAuB;AAAA,MAC9C;AAAA,MACA,OAAO,WAAW,SAAS;AAAA,MAC3B;AAAA,MACA,YAAY;AAAA,IACd,CAAC,CACH;AAAA,EACF;AAAA,CACD;;ACtFH;AAGO,IAAM,oBAAoB,CAC/B,kBAEA,IAAI,MAAM,eAAe;AAAA,EACvB,KAAK,CAAC,QAAQ,SAAS,MAAiB;AAAA,IACtC,MAAM,KAAK,KAAK;AAAA,IAChB,IAAI,OAAO,OAAO,UAAU;AAAA,MAE1B,OAAO,QAAQ,MAAM,QAAwC,SAAS,IAAI;AAAA,IAC5E;AAAA,IACA,OAAc,YAAI,UAAU,GAAG;AAAA,MAC7B,OAAO,gBAAgB,cAAc,MAAM,EAAE;AAAA,MAC7C,OAAO,OAAO,QAAQ,MACpB,QACA,SACA,IACF;AAAA,KACD;AAAA;AAEL,CAAC;",
9
- "debugId": "1AB58BD65E38742164756E2164756E21",
8
+ "mappings": ";AA0BA;AAAA;AAeO,MAAM,iCAAsC,iBAAY,0BAA0B,EAOtF;AAAC;AAMG,IAAM,0BAAkC;;AC3B/C,IAAM,WAAW,CAAC,YAAoB,WAAW;AAGjD,IAAM,mBAAmB,CAAC,YACxB,IAAI,IAAI,iBAAiB,SAAS,OAAO,WAAW,YAAY,GAAG,EAAE;AAahE,IAAM,oBAAoB,OAC/B,YACA,iBAAyB,4BACY;AAAA,EACrC,MAAM,WAA2B,CAAC;AAAA,EAClC,MAAM,MAAM,SAAS,cAAc;AAAA,EAEnC,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,EAC/E,IAAI,qBAAqB,gBAAgB;AAAA,IACvC,SAAS,KACP,2BAA2B,oBAAoB,8CAC7C,iBAAiB,+DACjB,4EACJ;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,cAAc,CAAC;AAAA,EACtD,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": "BFEA50A1CBA14B6A64756E2164756E21",
10
10
  "names": []
11
11
  }
@@ -0,0 +1,11 @@
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 — defaults to the version this package's
7
+ * own shipped patch targets ({@link PATCHED_ALCHEMY_VERSION}); a caller only overrides it to
8
+ * test a different pin.
9
+ */
10
+ export declare const checkAlchemyPatch: (projectDir: string, alchemyVersion?: string) => Promise<readonly PatchProblem[]>;
11
+ //# 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;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,eAChB,MAAM,mBACF,MAAM,KACrB,OAAO,CAAC,SAAS,YAAY,EAAE,CAsEjC,CAAC"}
@@ -1,23 +1,45 @@
1
- # `@homeflare/alchemy/scope` — refuse a duplicate resource id
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 treating
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 this fixes
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. Alchemy did not error: the
12
- plan showed `[caddy] noop`, the retirement was silently dropped (deploy "49 succeeded" for 50
13
- rows), and a later deploy showed `[caddy] update` as state reconciled back. homeflare-mini PR 329
14
- prefixed that resource's ids as a one-off fix; nothing stopped the next collision in any stack.
15
-
16
- ## Why Alchemy's own engine lets this through
17
-
18
- Alchemy's `Resource()` constructor (`alchemy@2.0.0-beta.81`, `Resource.ts`) treats a second
19
- `yield*` at an already-registered FQN as an idempotent re-reference — correct for "the same
20
- resource, read from two call sites" — and returns the first-registered resource without comparing
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,71 @@ if (existing) {
29
51
  }
30
52
  ```
31
53
 
32
- That TODO is the gap. The second resource's props are never written anywhere — `stack.resources`
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
- This is why the check cannot be a post-hoc scan over the compiled stack — by the time a plan runs,
40
- the collision has already been silently resolved in the first resource's favor. The only point
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:
58
+
59
+ ```
60
+ REFUSING: duplicate resource id "caddy" in scope (root): first declared by Nix.JobRetirement, again by Caddy.Config
61
+ ```
42
62
 
43
- ## What this package ships
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.
44
68
 
45
- `guardDuplicateIds` wraps a `ResourceClass` so every `("id", props)` call first claims that id in
46
- a per-stack, per-namespace registry, and dies with `DuplicateResourceIdError` the moment a
47
- different type claims an id already taken:
69
+ **Only `Type` is compared, never `Props`.** Comparing props as well would be wrong: a
70
+ platform-typed resource's tagged pattern (`alchemy`'s own `Platform.ts` ~581) rewrites `Props`
71
+ on a resource already registered, and a bare-tag forward reference legitimately registers with
72
+ `Props: undefined` until its `.make(...)` Layer builds. `Type` is stable from a resource's very
73
+ first `yield*`; `Props` is not.
48
74
 
49
- ```ts
50
- import { Resource } from 'alchemy';
51
- import { guardDuplicateIds } from '@homeflare/alchemy/scope';
75
+ ## Adopting it in a consuming stack
52
76
 
53
- export const MyResource = guardDuplicateIds(Resource<MyResource>('My.Resource'));
77
+ ```sh
78
+ cp node_modules/@homeflare/alchemy/patches/alchemy@2.0.0-beta.81.patch patches/
54
79
  ```
55
80
 
56
- The error names both sides:
81
+ then add, in the stack's own root `package.json`:
57
82
 
83
+ ```json
84
+ "patchedDependencies": {
85
+ "alchemy@2.0.0-beta.81": "patches/alchemy@2.0.0-beta.81.patch"
86
+ }
58
87
  ```
59
- REFUSING: duplicate resource id "caddy" in scope (root): first declared by Nix.JobRetirement, again by Caddy.Config
88
+
89
+ and run `bun install`. This mirrors the house pattern for `bunfig.toml`
90
+ ("adoption is a copy, not an extend" — AGENTS.md) — the patch is copied, not referenced, so it
91
+ ships in the consumer's OWN lockfile and survives even if `@homeflare/alchemy` is later removed.
92
+
93
+ ## The consumer check — catching a bump that silently drops it
94
+
95
+ `patchedDependencies` keys a patch to an EXACT version string. Bumping `alchemy` without
96
+ re-cutting the patch leaves that key pointing at nothing — `bun install` neither errors nor
97
+ warns, it just stops applying the patch. `checkAlchemyPatch` (`@homeflare/alchemy/scope`) is
98
+ what a stack's own `check` calls to catch that:
99
+
100
+ ```ts
101
+ import { checkAlchemyPatch } from '@homeflare/alchemy/scope';
102
+
103
+ const problems = await checkAlchemyPatch(import.meta.dirname);
104
+ if (problems.length > 0) {
105
+ console.error(problems.join('\n'));
106
+ process.exit(1);
107
+ }
60
108
  ```
61
109
 
62
- `Caddy.Config` (this package's `caddy/config.ts`) carries the guard already — it was one half of
63
- the measured incident. Every other `@homeflare/alchemy` resource adopts it as its own file is next
64
- touched (provider-standard.md, rule S47); there is no mechanical migration of every family in this
65
- one change.
66
-
67
- ## What it does, and does not, cover
68
-
69
- - **Scope matches Alchemy's own FQN.** The registry is keyed by `toFqn(namespace, id)`, the exact
70
- string Alchemy itself uses for state storage — the same id in a nested namespace
71
- (`Namespace.push('child', …)`) is a different scope and never collides. Two different stacks
72
- (two different `Stack` service instances) never see each other's claims either.
73
- - **The same type re-registering its own id is still fine.** That is Alchemy's legitimate
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.
110
+ It is a static check — three file reads, no `effect` runtime spun up — covering: alchemy
111
+ installed at the wrong version for the patch, no `patchedDependencies` entry at all, the
112
+ referenced file missing or differing from this package's own shipped copy, and (the one failure
113
+ mode none of those other checks would catch) declared and byte-identical but never actually
114
+ applied.
85
115
 
86
116
  ## Upstream
87
117
 
88
- The real fix belongs in `alchemy`'s own `Resource.ts`, at the `if (existing)` branch quoted above,
89
- where the TODO already sits — every resource, ours and every vendor built-in, goes through that
90
- one function. This PR's body has the issue text proposed for `alchemy-run/alchemy`; it has not
91
- been filed.
118
+ The real fix belongs in `alchemy` itself, at the exact line the patch replaces — every resource,
119
+ vendor included, goes through that one function, which is the whole reason a kit-side fix can
120
+ only ever be a stopgap. The issue/PR text proposed for `alchemy-run/alchemy` is in this PR's
121
+ 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 (added 2026-10-19, kit-local — not yet one of the skill's 46; house gap,
67
- decision 49: upstream is silent, this rule fills it).** A touched or new resource's exported
68
- `ResourceClass` is wrapped in
69
- `guardDuplicateIds` (`@homeflare/alchemy/scope`): `export const X = guardDuplicateIds(Resource<X>(…))`.
70
- Without it, a second, different-typed resource declaring the same logical id in one stack
71
- silently wins — Alchemy's own idempotent-registration path returns the first-registered
72
- resource with no `Type` check (measured, homeflare-mini PR 329;
73
- [duplicate-resource-id.md](./duplicate-resource-id.md)). `Caddy.Config` carries it; every other
74
- family adopts it the next time its own file is touched, not as one mechanical sweep.
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.59.0",
3
+ "version": "0.60.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",
@@ -0,0 +1,147 @@
1
+ diff --git a/lib/Resource.d.ts b/lib/Resource.d.ts
2
+ index 07b92ae346b5f16475e8162e6d72284fceb8ce1c..868e8d1b6b23dbdfd558e1ff03a535c1be70801c 100644
3
+ --- a/lib/Resource.d.ts
4
+ +++ b/lib/Resource.d.ts
5
+ @@ -229,6 +229,23 @@ export declare class MissingImplementationError extends MissingImplementationErr
6
+ }> {
7
+ }
8
+ export declare const missingImplementation: (type: string, id: string) => MissingImplementationError;
9
+ +declare const DuplicateResourceIdError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
10
+ + readonly _tag: "DuplicateResourceIdError";
11
+ +} & Readonly<A>;
12
+ +/**
13
+ + * HOMEFLARE PATCH (homeflare-kit PR superseding #468) — see Resource.ts for the full comment.
14
+ + * A second resource of a DIFFERENT type registered at a logical id an earlier resource
15
+ + * already claimed in the same scope (namespace + id, i.e. the same FQN).
16
+ + */
17
+ +export declare class DuplicateResourceIdError extends DuplicateResourceIdError_base<{
18
+ + message: string;
19
+ + fqn: string;
20
+ + /** The type that first claimed `fqn`. */
21
+ + firstType: string;
22
+ + /** The different type that tried to claim it again. */
23
+ + secondType: string;
24
+ +}> {
25
+ +}
26
+ /**
27
+ * Creates a resource constructor for a concrete resource type.
28
+ *
29
+ diff --git a/lib/Resource.js b/lib/Resource.js
30
+ index 71d9a3a4e0784ed09484736677d735c7c77444f8..e3b64e60b29538bfe46b094cfcf755eaea6673e7 100644
31
+ --- a/lib/Resource.js
32
+ +++ b/lib/Resource.js
33
+ @@ -4,7 +4,7 @@ import * as Effectable from "effect/Effectable";
34
+ import * as Layer from "effect/Layer";
35
+ import * as Option from "effect/Option";
36
+ import { AdoptPolicy } from "./AdoptPolicy.js";
37
+ -import { toFqn } from "./FQN.js";
38
+ +import { toFqn, toPath } from "./FQN.js";
39
+ import { CurrentNamespace } from "./Namespace.js";
40
+ import * as Output from "./Output.js";
41
+ import { Provider } from "./Provider.js";
42
+ @@ -60,6 +60,13 @@ export const isResourceOfType = (value, type) => (typeof value === "object" || t
43
+ */
44
+ export class MissingImplementationError extends Data.TaggedError("MissingImplementationError") {
45
+ }
46
+ +/**
47
+ + * HOMEFLARE PATCH (homeflare-kit PR superseding #468) — see Resource.ts for the full comment.
48
+ + * A second resource of a DIFFERENT type registered at a logical id an earlier resource
49
+ + * already claimed in the same scope (namespace + id, i.e. the same FQN).
50
+ + */
51
+ +export class DuplicateResourceIdError extends Data.TaggedError("DuplicateResourceIdError") {
52
+ +}
53
+ export const missingImplementation = (type, id) => new MissingImplementationError({
54
+ type,
55
+ id,
56
+ @@ -128,7 +135,17 @@ export function Resource(type, options) {
57
+ conflictingMode: ambientMode,
58
+ }));
59
+ }
60
+ - // // TODO(sam): check if props are different and die
61
+ + // HOMEFLARE PATCH: Type, not Props — see Resource.ts for why.
62
+ + if (existing.Type !== type) {
63
+ + return yield* Effect.die(new DuplicateResourceIdError({
64
+ + message: `REFUSING: duplicate resource id "${fqn}" in scope ` +
65
+ + `${toPath(namespace).join("/") || "(root)"}: first declared by ` +
66
+ + `${existing.Type}, again by ${type}`,
67
+ + fqn,
68
+ + firstType: existing.Type,
69
+ + secondType: type,
70
+ + }));
71
+ + }
72
+ return existing;
73
+ }
74
+ const bind = (...args) => typeof args[0] === "string"
75
+ diff --git a/src/Resource.ts b/src/Resource.ts
76
+ index d34c1809b90aba6ff7dc7d3952e3ab87e62e61c1..24b4d4d4d8a26c67e17e761b6b1080d38068f858 100644
77
+ --- a/src/Resource.ts
78
+ +++ b/src/Resource.ts
79
+ @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer";
80
+ import * as Option from "effect/Option";
81
+ import type { Pipeable } from "effect/Pipeable";
82
+ import { AdoptPolicy } from "./AdoptPolicy.ts";
83
+ -import { toFqn } from "./FQN.ts";
84
+ +import { toFqn, toPath } from "./FQN.ts";
85
+ import type { Input, InputProps, PropsInput } from "./Input.ts";
86
+ import { CurrentNamespace, type NamespaceNode } from "./Namespace.ts";
87
+ import * as Output from "./Output.ts";
88
+ @@ -293,6 +293,30 @@ export class MissingImplementationError extends Data.TaggedError("MissingImpleme
89
+ id: string;
90
+ }> {}
91
+
92
+ +/**
93
+ + * A second resource of a DIFFERENT type registered (`yield*`ed) at a logical id an earlier
94
+ + * resource already claimed in the same scope (namespace + id, i.e. the same FQN).
95
+ + *
96
+ + * ★ HOMEFLARE PATCH (homeflare-kit PR superseding #468, 2026-10-19). Upstream's own
97
+ + * idempotent-registration branch below used to return the FIRST-registered resource for ANY
98
+ + * second `yield*` at an already-claimed FQN — correct for "the same resource read from two
99
+ + * call sites," but it never compared `Type`, so a second, DIFFERENT resource that merely
100
+ + * picked the same id silently lost: its props were never written to `stack.resources`, so
101
+ + * neither `Plan.make` nor any later inspection could tell two different resources ever
102
+ + * registered. Measured: `NixJobRetirement` and `Caddy.Config` both declaring id `caddy` in
103
+ + * one stack planned `[caddy] noop` and dropped the retirement entirely. This error, and the
104
+ + * `Type` check below, close that gap. Only `Type` is compared, not `Props` — see the check's
105
+ + * own comment for why.
106
+ + */
107
+ +export class DuplicateResourceIdError extends Data.TaggedError("DuplicateResourceIdError")<{
108
+ + message: string;
109
+ + fqn: string;
110
+ + /** The type that first claimed `fqn`. */
111
+ + firstType: string;
112
+ + /** The different type that tried to claim it again. */
113
+ + secondType: string;
114
+ +}> {}
115
+ +
116
+ export const missingImplementation = (type: string, id: string) =>
117
+ new MissingImplementationError({
118
+ type,
119
+ @@ -373,7 +397,27 @@ export function Resource<R extends ResourceLike>(
120
+ }),
121
+ );
122
+ }
123
+ - // // TODO(sam): check if props are different and die
124
+ + // HOMEFLARE PATCH: the TODO above asked whether PROPS differ; this checks TYPE
125
+ + // instead, which is the question that actually matters. Comparing props would be
126
+ + // wrong — `Platform.ts`'s own tagged resources rewrite Props after first registration
127
+ + // (Platform.ts ~581), and a bare-tag forward reference legitimately registers with
128
+ + // `Props: undefined` until its `.make(...)` Layer builds (MissingImplementationError's
129
+ + // own doc comment above). Type is stable from the resource's very first `yield*` and is
130
+ + // exactly what distinguishes "the same resource, referenced twice" from "two different
131
+ + // resources that picked the same id."
132
+ + if (existing.Type !== type) {
133
+ + return yield* Effect.die(
134
+ + new DuplicateResourceIdError({
135
+ + message:
136
+ + `REFUSING: duplicate resource id "${fqn}" in scope ` +
137
+ + `${toPath(namespace).join("/") || "(root)"}: first declared by ` +
138
+ + `${existing.Type}, again by ${type}`,
139
+ + fqn,
140
+ + firstType: existing.Type,
141
+ + secondType: type,
142
+ + }),
143
+ + );
144
+ + }
145
+ return existing;
146
+ }
147
+ const bind = (