@homeflare/alchemy 0.58.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.
@@ -0,0 +1,121 @@
1
+ # Duplicate resource ids — a patch to `alchemy` itself
2
+
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
+ 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).
8
+
9
+ ## The incident
10
+
11
+ Tim, 2026-10-19 ("lets not let two things claim the same"): in homeflare-mini,
12
+ `NixJobRetirement('caddy', …)` and the Caddy config resource (`CADDY_ID = 'caddy'`,
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
43
+ `Type`:
44
+
45
+ ```ts
46
+ const existing = stack.resources[fqn];
47
+ if (existing) {
48
+ // … an ambient-provider-mode check, unrelated …
49
+ // // TODO(sam): check if props are different and die
50
+ return existing;
51
+ }
52
+ ```
53
+
54
+ ## The patch
55
+
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
+ ```
62
+
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
+ **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.
74
+
75
+ ## Adopting it in a consuming stack
76
+
77
+ ```sh
78
+ cp node_modules/@homeflare/alchemy/patches/alchemy@2.0.0-beta.81.patch patches/
79
+ ```
80
+
81
+ then add, in the stack's own root `package.json`:
82
+
83
+ ```json
84
+ "patchedDependencies": {
85
+ "alchemy@2.0.0-beta.81": "patches/alchemy@2.0.0-beta.81.patch"
86
+ }
87
+ ```
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
+ }
108
+ ```
109
+
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.
115
+
116
+ ## Upstream
117
+
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,6 +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 (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.
66
75
 
67
76
  ## Code, errors, credentials
68
77
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.58.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",
@@ -131,6 +132,10 @@
131
132
  "types": "./dist/valkey/index.d.ts",
132
133
  "default": "./dist/valkey/index.js"
133
134
  },
135
+ "./scope": {
136
+ "types": "./dist/scope/index.d.ts",
137
+ "default": "./dist/scope/index.js"
138
+ },
134
139
  "./package.json": "./package.json"
135
140
  },
136
141
  "publishConfig": {
@@ -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 = (
@@ -28,6 +28,10 @@
28
28
  * place that discharges them — scoped to the handful of effects that actually call
29
29
  * `@distilled.cloud/caddy`'s operations, never leaking into a sibling fetch-based provider merged
30
30
  * into the same stack (admin.ts's own ⛔; MEASURED 2026-09-26, admin-transport-scope.test.ts).
31
+ * ⚠️ `CADDY_ID`-style collisions with another resource's id in the same stack (MEASURED,
32
+ * homeflare-mini PR 329: `NixJobRetirement('caddy', …)` beside this resource's own `caddy`)
33
+ * are refused by the patched `alchemy` every consuming stack applies — not by anything in
34
+ * this file. See `packages/alchemy/docs/duplicate-resource-id.md`.
31
35
  */
32
36
  import { Resource } from 'alchemy';
33
37
  import { Unowned } from 'alchemy/AdoptPolicy';
@@ -0,0 +1,154 @@
1
+ /**
2
+ * The regression test for the measured incident (homeflare-mini PR 329): two different
3
+ * resource types sharing a logical id in one stack, driven through a REAL `Alchemy.Stack(...)`
4
+ * with real `Resource()` classes over the patched `alchemy` this package's own root
5
+ * `patchedDependencies` applies (see `../../../patches/alchemy@2.0.0-beta.81.patch`) — the same
6
+ * shape `litellm/fake-stack.ts` uses for the same reason its own header gives: a diff answer
7
+ * proves nothing about what the ENGINE actually does; only running it does.
8
+ *
9
+ * ⚠️ IF THIS SUITE EVER PASSES WITHOUT THE PATCH APPLIED, THE PATCH IS NOT REACHING THIS
10
+ * PACKAGE'S OWN TESTS — re-run `bun install` at the workspace root and re-check
11
+ * `patchedDependencies` in the root `package.json`. `patch-check.test.ts` is the test that
12
+ * would catch that for a CONSUMER; this file has no equivalent self-check because it would
13
+ * just be this file re-asserting itself.
14
+ */
15
+ import { describe, expect, test } from 'bun:test';
16
+ import * as Alchemy from 'alchemy';
17
+ import { push as pushNamespace } from 'alchemy/Namespace';
18
+ import type { Resource } from 'alchemy/Resource';
19
+ import * as Effect from 'effect/Effect';
20
+ import * as Layer from 'effect/Layer';
21
+ import ownPackage from '../../package.json';
22
+ import { PATCHED_ALCHEMY_VERSION } from './duplicate-id.ts';
23
+
24
+ interface Props {
25
+ readonly value?: number;
26
+ }
27
+ /** A plain, otherwise-meaningless resource shape — only the `Type` string matters here. */
28
+ interface TestResource extends Resource<string, Props, object> {}
29
+ const testResource = (type: string) => Alchemy.Resource<TestResource>(type);
30
+
31
+ type StackBody = Effect.Effect<unknown, unknown, unknown>;
32
+
33
+ const stack = Alchemy.Stack as unknown as (
34
+ stackName: string,
35
+ options: { providers: Layer.Layer<never>; state: ReturnType<typeof Alchemy.inMemoryState> },
36
+ body: StackBody,
37
+ ) => Effect.Effect<{ readonly resources: Record<string, unknown> }, unknown, never>;
38
+
39
+ const run = (body: StackBody, name: string) =>
40
+ Effect.runPromiseExit(
41
+ stack(name, { providers: Layer.empty, state: Alchemy.inMemoryState() }, body).pipe(
42
+ Effect.provideService(Alchemy.Stage, 'test'),
43
+ Effect.scoped,
44
+ ),
45
+ );
46
+
47
+ describe('PATCHED_ALCHEMY_VERSION', () => {
48
+ test('matches this package’s own peer pin, so a bump that forgets to re-cut the patch fails here', () => {
49
+ expect(PATCHED_ALCHEMY_VERSION).toBe(ownPackage.peerDependencies.alchemy);
50
+ });
51
+ });
52
+
53
+ describe('the patched alchemy, driven through a real Stack', () => {
54
+ test('two different types sharing an id die naming both', async () => {
55
+ const Retirement = testResource('Nix.JobRetirement');
56
+ const Caddy = testResource('Caddy.Config');
57
+ const exit = await run(
58
+ Effect.gen(function* () {
59
+ yield* Retirement('caddy', { value: 1 });
60
+ yield* Caddy('caddy', { value: 2 });
61
+ }),
62
+ 'IncidentStack',
63
+ );
64
+ expect(exit._tag).toBe('Failure');
65
+ if (exit._tag !== 'Failure') return;
66
+ const [reason] = (exit.cause as { reasons: readonly { _tag: string; defect?: unknown }[] })
67
+ .reasons;
68
+ expect(reason === undefined ? undefined : reason._tag).toBe('Die');
69
+ const defect = reason === undefined ? undefined : (reason.defect as { message?: string });
70
+ expect(defect?.message).toContain(
71
+ 'REFUSING: duplicate resource id "caddy" in scope (root): ' +
72
+ 'first declared by Nix.JobRetirement, again by Caddy.Config',
73
+ );
74
+ });
75
+
76
+ test('the reverse order dies the same way — order never decides the winner', async () => {
77
+ const Retirement = testResource('Nix.JobRetirement');
78
+ const Caddy = testResource('Caddy.Config');
79
+ const exit = await run(
80
+ Effect.gen(function* () {
81
+ yield* Caddy('caddy', { value: 2 });
82
+ yield* Retirement('caddy', { value: 1 });
83
+ }),
84
+ 'IncidentStackReversed',
85
+ );
86
+ expect(exit._tag).toBe('Failure');
87
+ });
88
+
89
+ test('the same type re-registering its own id stays a noop, not a death', async () => {
90
+ const A = testResource('Test.A');
91
+ const exit = await run(
92
+ Effect.gen(function* () {
93
+ yield* A('shared', { value: 1 });
94
+ yield* A('shared', { value: 1 });
95
+ }),
96
+ 'GoodStack',
97
+ );
98
+ expect(exit._tag).toBe('Success');
99
+ });
100
+
101
+ test('distinct ids never collide', async () => {
102
+ const A = testResource('Test.A');
103
+ const B = testResource('Test.B');
104
+ const exit = await run(
105
+ Effect.gen(function* () {
106
+ yield* A('one', { value: 1 });
107
+ yield* B('two', { value: 2 });
108
+ }),
109
+ 'DistinctStack',
110
+ );
111
+ expect(exit._tag).toBe('Success');
112
+ });
113
+
114
+ test('the same id in a nested namespace is a different scope', async () => {
115
+ const A = testResource('Test.A');
116
+ const B = testResource('Test.B');
117
+ const exit = await run(
118
+ Effect.gen(function* () {
119
+ yield* A('shared', { value: 1 });
120
+ yield* pushNamespace('child', B('shared', { value: 2 }));
121
+ }),
122
+ 'NestedStack',
123
+ );
124
+ expect(exit._tag).toBe('Success');
125
+ });
126
+
127
+ test('the thrown defect carries the exact fields DuplicateResourceIdError documents', async () => {
128
+ const A = testResource('Test.A');
129
+ const B = testResource('Test.B');
130
+ const exit = await run(
131
+ Effect.gen(function* () {
132
+ yield* A('x', {});
133
+ yield* B('x', {});
134
+ }),
135
+ 'ShapeStack',
136
+ );
137
+ expect(exit._tag).toBe('Failure');
138
+ if (exit._tag !== 'Failure') return;
139
+ const [reason] = (exit.cause as { reasons: readonly { defect?: unknown }[] }).reasons;
140
+ // Not `instanceof DuplicateResourceIdError` — alchemy's patched copy and this package's
141
+ // export are two separately defined classes with the same `_tag` by construction
142
+ // (duplicate-id.ts's own header explains why `catchTag` still works across that split).
143
+ const defect = reason?.defect as {
144
+ _tag?: string;
145
+ fqn?: string;
146
+ firstType?: string;
147
+ secondType?: string;
148
+ };
149
+ expect(defect._tag).toBe('DuplicateResourceIdError');
150
+ expect(defect.fqn).toBe('x');
151
+ expect(defect.firstType).toBe('Test.A');
152
+ expect(defect.secondType).toBe('Test.B');
153
+ });
154
+ });
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `DuplicateResourceIdError` and the pinned alchemy version the kit's patch targets.
3
+ *
4
+ * ★ THE FIX ITSELF IS NOT HERE. A second resource of a different type claiming an id already
5
+ * taken in one stack scope is refused by a PATCH to `alchemy`'s own `Resource.ts` — see
6
+ * `../../patches/alchemy@2.0.0-beta.81.patch` and docs/duplicate-resource-id.md for why a
7
+ * kit-side `Proxy` wrapper (PR 468's first version, replaced by this PR) cannot actually
8
+ * catch the incident it was built for: wrapping `@homeflare/alchemy`'s OWN resource exports
9
+ * never reaches `NixJobRetirement`, a resource homeflare-mini declares directly against
10
+ * `alchemy`'s `Resource()`, so the measured collision still succeeded silently in BOTH
11
+ * orderings. This file now holds only:
12
+ *
13
+ * 1. `DuplicateResourceIdError` — the exact shape the patched `alchemy` throws, duplicated
14
+ * here (not imported — `alchemy` cannot depend on `@homeflare/alchemy`) purely so a
15
+ * consumer can write `Effect.catchTag("DuplicateResourceIdError", …)` against a type this
16
+ * package exports, without needing to know alchemy carries its own copy of the same tag.
17
+ * Effect dispatches `catchTag` on the `_tag` STRING, not `instanceof`, so either class
18
+ * works identically as a catch target.
19
+ * 2. `PATCHED_ALCHEMY_VERSION` — the exact alchemy version the patch was cut against.
20
+ * Hardcoded, like `ESTATE_VERSIONS` in `@homeflare/config/versions` (the same reasoning:
21
+ * a second source of truth derived by import would cross `tsconfig.build.json`'s
22
+ * `rootDir: "src"` boundary to reach `package.json`). `duplicate-id.test.ts` asserts it
23
+ * still matches this package's own `peerDependencies.alchemy`, so a pin bump that forgets
24
+ * to re-cut the patch fails a test here instead of silently shipping a patch filename that
25
+ * no longer matches what consumers install.
26
+ */
27
+ import * as Data from 'effect/Data';
28
+
29
+ /**
30
+ * Mirrors the error the patched `alchemy@2.0.0-beta.81` throws at registration — same `_tag`,
31
+ * same fields — for a consumer that wants to `catchTag` it without importing from `alchemy`'s
32
+ * own patched internals.
33
+ *
34
+ * ⚠️ ONLY `Type` IS COMPARED, NEVER `Props`. A platform-typed resource's tagged pattern
35
+ * (`alchemy`'s own `Platform.ts` ~581) rewrites `Props` on a resource already registered, and a
36
+ * bare-tag forward reference legitimately registers with `Props: undefined` until its
37
+ * `.make(...)` Layer builds (`MissingImplementationError`'s own doc comment in `Resource.ts`).
38
+ * Comparing props as well as type would make both of those die. `Type` is stable from a
39
+ * resource's very first `yield*` and is exactly what distinguishes "the same resource,
40
+ * referenced twice" (fine) from "two different resources that picked the same id" (refused).
41
+ */
42
+ export class DuplicateResourceIdError extends Data.TaggedError('DuplicateResourceIdError')<{
43
+ message: string;
44
+ fqn: string;
45
+ /** The type that first claimed `fqn`. */
46
+ firstType: string;
47
+ /** The different type that tried to claim it again. */
48
+ secondType: string;
49
+ }> {}
50
+
51
+ /**
52
+ * The exact `alchemy` version `patches/alchemy@2.0.0-beta.81.patch` was cut against.
53
+ * `duplicate-id.test.ts` keeps this honest against `package.json`'s own `peerDependencies`.
54
+ */
55
+ export const PATCHED_ALCHEMY_VERSION: string = '2.0.0-beta.81';
@@ -0,0 +1,22 @@
1
+ /**
2
+ * `@homeflare/alchemy/scope` — a consuming stack's own check that it carries the
3
+ * duplicate-resource-id patch, and the error shape that patch throws.
4
+ *
5
+ * The fix itself is a patch to `alchemy`'s own `Resource.ts`
6
+ * (`../../patches/alchemy@2.0.0-beta.81.patch`), applied at EACH consuming stack's own root
7
+ * (`alchemy` is a peer dependency here, so `patchedDependencies` in this package's manifest
8
+ * only reaches this package's own install, never a consumer's). docs/duplicate-resource-id.md
9
+ * has the full guide, the exact `bun patch` workflow, and what this does and does not cover.
10
+ *
11
+ * ```ts
12
+ * import { checkAlchemyPatch } from '@homeflare/alchemy/scope';
13
+ *
14
+ * const problems = await checkAlchemyPatch(import.meta.dirname);
15
+ * if (problems.length > 0) {
16
+ * console.error(problems.join('\n'));
17
+ * process.exit(1);
18
+ * }
19
+ * ```
20
+ */
21
+ export { DuplicateResourceIdError, PATCHED_ALCHEMY_VERSION } from './duplicate-id.ts';
22
+ export { checkAlchemyPatch, type PatchProblem } from './patch-check.ts';
@@ -0,0 +1,114 @@
1
+ /**
2
+ * `checkAlchemyPatch` against a fixture consumer directory — every failure mode a consuming
3
+ * stack's own `check` should catch, built one `mkdtemp` at a time rather than against this
4
+ * repo's own tree, so each case controls exactly what is "installed".
5
+ */
6
+ import { mkdtempSync, rmSync } from 'node:fs';
7
+ import { mkdir, writeFile } from 'node:fs/promises';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { afterEach, describe, expect, test } from 'bun:test';
11
+ import { PATCHED_ALCHEMY_VERSION } from './duplicate-id.ts';
12
+ import { checkAlchemyPatch } from './patch-check.ts';
13
+
14
+ const OUR_PATCH = await Bun.file(
15
+ new URL(`../../patches/alchemy@${PATCHED_ALCHEMY_VERSION}.patch`, import.meta.url),
16
+ ).text();
17
+
18
+ let dir: string | undefined;
19
+ afterEach(() => {
20
+ if (dir) rmSync(dir, { recursive: true, force: true });
21
+ dir = undefined;
22
+ });
23
+
24
+ /** A fresh scratch "consumer project" directory, empty except for what each test adds. */
25
+ const scratch = (): string => {
26
+ dir = mkdtempSync(join(tmpdir(), 'alchemy-patch-check-'));
27
+ return dir;
28
+ };
29
+
30
+ const writePkg = async (root: string, content: Record<string, unknown>) =>
31
+ writeFile(join(root, 'package.json'), JSON.stringify(content, null, 2));
32
+
33
+ const installAlchemy = async (
34
+ root: string,
35
+ options: { version?: string; applied?: boolean } = {},
36
+ ) => {
37
+ const { version = PATCHED_ALCHEMY_VERSION, applied = true } = options;
38
+ const alchemyDir = join(root, 'node_modules', 'alchemy');
39
+ await mkdir(join(alchemyDir, 'src'), { recursive: true });
40
+ await mkdir(join(alchemyDir, 'lib'), { recursive: true });
41
+ await writeFile(join(alchemyDir, 'package.json'), JSON.stringify({ version }));
42
+ const marker = applied ? 'class DuplicateResourceIdError' : '// // TODO(sam): unpatched';
43
+ await writeFile(join(alchemyDir, 'src', 'Resource.ts'), marker);
44
+ await writeFile(join(alchemyDir, 'lib', 'Resource.js'), marker);
45
+ };
46
+
47
+ describe('checkAlchemyPatch', () => {
48
+ test('alchemy not installed at all', async () => {
49
+ const root = scratch();
50
+ expect(await checkAlchemyPatch(root)).toEqual([expect.stringContaining('not installed')]);
51
+ });
52
+
53
+ test('installed alchemy is a different version than the patch targets', async () => {
54
+ const root = scratch();
55
+ await installAlchemy(root, { version: '2.0.0-beta.999' });
56
+ const problems = await checkAlchemyPatch(root);
57
+ expect(problems).toHaveLength(1);
58
+ expect(problems[0]).toContain('2.0.0-beta.999');
59
+ expect(problems[0]).toContain(PATCHED_ALCHEMY_VERSION);
60
+ });
61
+
62
+ test('right version installed, but package.json has no patchedDependencies entry', async () => {
63
+ const root = scratch();
64
+ await installAlchemy(root);
65
+ await writePkg(root, {});
66
+ const problems = await checkAlchemyPatch(root);
67
+ expect(problems).toEqual([expect.stringContaining('no entry')]);
68
+ });
69
+
70
+ test('patchedDependencies points at a file that does not exist', async () => {
71
+ const root = scratch();
72
+ await installAlchemy(root);
73
+ await writePkg(root, {
74
+ patchedDependencies: { [`alchemy@${PATCHED_ALCHEMY_VERSION}`]: 'patches/missing.patch' },
75
+ });
76
+ const problems = await checkAlchemyPatch(root);
77
+ expect(problems).toEqual([expect.stringContaining('missing')]);
78
+ });
79
+
80
+ test('the referenced patch file differs from what @homeflare/alchemy ships', async () => {
81
+ const root = scratch();
82
+ await installAlchemy(root);
83
+ await mkdir(join(root, 'patches'), { recursive: true });
84
+ await writeFile(join(root, 'patches', 'alchemy.patch'), 'not the real patch');
85
+ await writePkg(root, {
86
+ patchedDependencies: { [`alchemy@${PATCHED_ALCHEMY_VERSION}`]: 'patches/alchemy.patch' },
87
+ });
88
+ const problems = await checkAlchemyPatch(root);
89
+ expect(problems).toEqual([expect.stringContaining('differs')]);
90
+ });
91
+
92
+ test('declared and byte-identical, but never actually applied', async () => {
93
+ const root = scratch();
94
+ await installAlchemy(root, { applied: false });
95
+ await mkdir(join(root, 'patches'), { recursive: true });
96
+ await writeFile(join(root, 'patches', 'alchemy.patch'), OUR_PATCH);
97
+ await writePkg(root, {
98
+ patchedDependencies: { [`alchemy@${PATCHED_ALCHEMY_VERSION}`]: 'patches/alchemy.patch' },
99
+ });
100
+ const problems = await checkAlchemyPatch(root);
101
+ expect(problems).toEqual([expect.stringContaining('not applied')]);
102
+ });
103
+
104
+ test('declared, byte-identical, and applied: no problems', async () => {
105
+ const root = scratch();
106
+ await installAlchemy(root);
107
+ await mkdir(join(root, 'patches'), { recursive: true });
108
+ await writeFile(join(root, 'patches', 'alchemy.patch'), OUR_PATCH);
109
+ await writePkg(root, {
110
+ patchedDependencies: { [`alchemy@${PATCHED_ALCHEMY_VERSION}`]: 'patches/alchemy.patch' },
111
+ });
112
+ expect(await checkAlchemyPatch(root)).toEqual([]);
113
+ });
114
+ });