@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.
@@ -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 = (
@@ -0,0 +1,149 @@
1
+ diff --git a/lib/Resource.d.ts b/lib/Resource.d.ts
2
+ index 07b92ae..bfd1a13 100644
3
+ --- a/lib/Resource.d.ts
4
+ +++ b/lib/Resource.d.ts
5
+ @@ -228,6 +228,24 @@ export declare class MissingImplementationError extends MissingImplementationErr
6
+ id: string;
7
+ }> {
8
+ }
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; re-cut for alchemy@2.0.0-beta.83) — see
14
+ + * Resource.ts for the full comment.
15
+ + * A second resource of a DIFFERENT type registered at a logical id an earlier resource
16
+ + * already claimed in the same scope (namespace + id, i.e. the same FQN).
17
+ + */
18
+ +export declare class DuplicateResourceIdError extends DuplicateResourceIdError_base<{
19
+ + message: string;
20
+ + fqn: string;
21
+ + /** The type that first claimed `fqn`. */
22
+ + firstType: string;
23
+ + /** The different type that tried to claim it again. */
24
+ + secondType: string;
25
+ +}> {
26
+ +}
27
+ export declare const missingImplementation: (type: string, id: string) => MissingImplementationError;
28
+ /**
29
+ * Creates a resource constructor for a concrete resource type.
30
+ diff --git a/lib/Resource.js b/lib/Resource.js
31
+ index cbc4f09..b57d530 100644
32
+ --- a/lib/Resource.js
33
+ +++ b/lib/Resource.js
34
+ @@ -4,7 +4,7 @@ import * as Effectable from "effect/Effectable";
35
+ import * as Layer from "effect/Layer";
36
+ import * as Option from "effect/Option";
37
+ import { AdoptPolicy } from "./AdoptPolicy.js";
38
+ -import { toFqn } from "./FQN.js";
39
+ +import { toFqn, toPath } from "./FQN.js";
40
+ import { CurrentNamespace } from "./Namespace.js";
41
+ import * as Output from "./Output.js";
42
+ import { Provider } from "./Provider.js";
43
+ @@ -58,6 +58,14 @@ export const isResourceOfType = (value, type) => (typeof value === "object" || t
44
+ */
45
+ export class MissingImplementationError extends Data.TaggedError("MissingImplementationError") {
46
+ }
47
+ +/**
48
+ + * HOMEFLARE PATCH (homeflare-kit PR superseding #468; re-cut for alchemy@2.0.0-beta.83) — see
49
+ + * Resource.ts for the full comment.
50
+ + * A second resource of a DIFFERENT type registered at a logical id an earlier resource
51
+ + * already claimed in the same scope (namespace + id, i.e. the same FQN).
52
+ + */
53
+ +export class DuplicateResourceIdError extends Data.TaggedError("DuplicateResourceIdError") {
54
+ +}
55
+ export const missingImplementation = (type, id) => new MissingImplementationError({
56
+ type,
57
+ id,
58
+ @@ -126,7 +134,17 @@ export function Resource(type, options) {
59
+ conflictingMode: ambientMode,
60
+ }));
61
+ }
62
+ - // // TODO(sam): check if props are different and die
63
+ + // HOMEFLARE PATCH: Type, not Props — see Resource.ts for why.
64
+ + if (existing.Type !== type) {
65
+ + return yield* Effect.die(new DuplicateResourceIdError({
66
+ + message: `REFUSING: duplicate resource id "${fqn}" in scope ` +
67
+ + `${toPath(namespace).join("/") || "(root)"}: first declared by ` +
68
+ + `${existing.Type}, again by ${type}`,
69
+ + fqn,
70
+ + firstType: existing.Type,
71
+ + secondType: type,
72
+ + }));
73
+ + }
74
+ return existing;
75
+ }
76
+ const bind = (...args) => typeof args[0] === "string"
77
+ diff --git a/src/Resource.ts b/src/Resource.ts
78
+ index c4d0a36..29e5ca7 100644
79
+ --- a/src/Resource.ts
80
+ +++ b/src/Resource.ts
81
+ @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer";
82
+ import * as Option from "effect/Option";
83
+ import type { Pipeable } from "effect/Pipeable";
84
+ import { AdoptPolicy } from "./AdoptPolicy.ts";
85
+ -import { toFqn } from "./FQN.ts";
86
+ +import { toFqn, toPath } from "./FQN.ts";
87
+ import type { Input, InputProps, PropsInput } from "./Input.ts";
88
+ import { CurrentNamespace, type NamespaceNode } from "./Namespace.ts";
89
+ import * as Output from "./Output.ts";
90
+ @@ -292,6 +292,30 @@ export class MissingImplementationError extends Data.TaggedError("MissingImpleme
91
+ id: string;
92
+ }> {}
93
+
94
+ +/**
95
+ + * A second resource of a DIFFERENT type registered (`yield*`ed) at a logical id an earlier
96
+ + * resource already claimed in the same scope (namespace + id, i.e. the same FQN).
97
+ + *
98
+ + * ★ HOMEFLARE PATCH (homeflare-kit PR superseding #468, 2026-10-19; re-cut for
99
+ + * alchemy@2.0.0-beta.83). Upstream's own idempotent-registration branch below used to
100
+ + * return the FIRST-registered resource for ANY second `yield*` at an already-claimed FQN —
101
+ + * correct for "the same resource read from two call sites," but it never compared `Type`,
102
+ + * so a second, DIFFERENT resource that merely picked the same id silently lost: its props
103
+ + * were never written to `stack.resources`, so neither `Plan.make` nor any later inspection
104
+ + * could tell two different resources ever registered. Measured: `NixJobRetirement` and
105
+ + * `Caddy.Config` both declaring id `caddy` in one stack planned `[caddy] noop` and dropped
106
+ + * the retirement entirely. This error, and the `Type` check below, close that gap. Only
107
+ + * `Type` is compared, not `Props` — see the check's own comment for why.
108
+ + */
109
+ +export class DuplicateResourceIdError extends Data.TaggedError("DuplicateResourceIdError")<{
110
+ + message: string;
111
+ + fqn: string;
112
+ + /** The type that first claimed `fqn`. */
113
+ + firstType: string;
114
+ + /** The different type that tried to claim it again. */
115
+ + secondType: string;
116
+ +}> {}
117
+ +
118
+ export const missingImplementation = (type: string, id: string) =>
119
+ new MissingImplementationError({
120
+ type,
121
+ @@ -372,7 +396,27 @@ export function Resource<R extends ResourceLike>(
122
+ }),
123
+ );
124
+ }
125
+ - // // TODO(sam): check if props are different and die
126
+ + // HOMEFLARE PATCH: the TODO above asked whether PROPS differ; this checks TYPE
127
+ + // instead, which is the question that actually matters. Comparing props would be
128
+ + // wrong — `Platform.ts`'s own tagged resources rewrite Props after first registration
129
+ + // (Platform.ts ~581), and a bare-tag forward reference legitimately registers with
130
+ + // `Props: undefined` until its `.make(...)` Layer builds (MissingImplementationError's
131
+ + // own doc comment above). Type is stable from the resource's very first `yield*` and is
132
+ + // exactly what distinguishes "the same resource, referenced twice" from "two different
133
+ + // resources that picked the same id."
134
+ + if (existing.Type !== type) {
135
+ + return yield* Effect.die(
136
+ + new DuplicateResourceIdError({
137
+ + message:
138
+ + `REFUSING: duplicate resource id "${fqn}" in scope ` +
139
+ + `${toPath(namespace).join("/") || "(root)"}: first declared by ` +
140
+ + `${existing.Type}, again by ${type}`,
141
+ + fqn,
142
+ + firstType: existing.Type,
143
+ + secondType: type,
144
+ + }),
145
+ + );
146
+ + }
147
+ return existing;
148
+ }
149
+ const bind = (
@@ -28,12 +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
- * ⛔ `CaddyConfig` IS WRAPPED IN `guardDuplicateIds` (scope/guard.ts). MEASURED, homeflare-mini PR
32
- * 329: a `NixJobRetirement('caddy', …)` declared in the same stack as this resource's `CADDY_ID
33
- * = 'caddy'` collided silently — Alchemy's own `Resource()` treated the second registration as
34
- * an idempotent re-reference to the first, so the retirement's props were never written
35
- * anywhere a plan could see (`scope/duplicate-id.ts` has the full mechanism). This resource was
36
- * one half of that collision, so it is the first to carry the guard.
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`.
37
35
  */
38
36
  import { Resource } from 'alchemy';
39
37
  import { Unowned } from 'alchemy/AdoptPolicy';
@@ -42,7 +40,6 @@ import * as Provider from 'alchemy/Provider';
42
40
  import * as Effect from 'effect/Effect';
43
41
  import { resolvedString } from '../launchd/host-effect.ts';
44
42
  import { adoptEnabled } from '../ownership/adopt.ts';
45
- import { guardDuplicateIds } from '../scope/guard.ts';
46
43
  import { CaddyAdminService, CaddyAdminTransport } from './admin.ts';
47
44
  import { isUnreachable } from './caddy-http-client.ts';
48
45
  import type { CaddyConfigAttributes, CaddyConfigProps } from './config-form.ts';
@@ -58,11 +55,9 @@ export interface CaddyConfig extends Resource<
58
55
  CaddyConfigAttributes
59
56
  > {}
60
57
 
61
- export const CaddyConfig = guardDuplicateIds(
62
- Resource<CaddyConfig>('Caddy.Config', {
63
- defaultRemovalPolicy: 'retain',
64
- }),
65
- );
58
+ export const CaddyConfig = Resource<CaddyConfig>('Caddy.Config', {
59
+ defaultRemovalPolicy: 'retain',
60
+ });
66
61
 
67
62
  /**
68
63
  * ⚠️ A STOPPED CADDY MUST NOT FAIL THE PLAN THAT BRINGS IT BACK. Its launchd job usually sits in the
@@ -1,95 +1,154 @@
1
1
  /**
2
- * `claimResourceId` against a minimal `Stack` + `Namespace` context — no real resources, no
3
- * provider, so these stay fast and pin the registry's own semantics directly. guard.test.ts
4
- * drives the same cases through a REAL `Alchemy.Stack(...)` with real `Resource()` classes, to
5
- * prove the wiring in guard.ts, not just this file's logic in isolation.
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.
6
14
  */
7
15
  import { describe, expect, test } from 'bun:test';
16
+ import * as Alchemy from 'alchemy';
8
17
  import { push as pushNamespace } from 'alchemy/Namespace';
9
- import { Stack } from 'alchemy/Stack';
18
+ import type { Resource } from 'alchemy/Resource';
10
19
  import * as Effect from 'effect/Effect';
11
- import { DuplicateResourceIdError, claimResourceId } from './duplicate-id.ts';
20
+ import * as Layer from 'effect/Layer';
21
+ import ownPackage from '../../package.json';
22
+ import { PATCHED_ALCHEMY_VERSION } from './duplicate-id.ts';
12
23
 
13
- /** A bare `Stack` service good enough for `claimResourceId`: only `resources` is ever `yield*`ed. */
14
- const fakeStack = () => ({ name: 'test', stage: 'test', resources: {}, bindings: {}, actions: {} });
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);
15
30
 
16
- const withStack = <A, E>(effect: Effect.Effect<A, E, Stack>) =>
17
- effect.pipe(Effect.provideService(Stack, fakeStack() as never));
31
+ type StackBody = Effect.Effect<unknown, unknown, unknown>;
18
32
 
19
- describe('claimResourceId', () => {
20
- test('a fresh id is claimed without error', async () => {
21
- await Effect.runPromise(withStack(claimResourceId('Test.A', 'one')));
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);
22
50
  });
51
+ });
23
52
 
24
- test('the same type re-claiming its own id is fine (idempotent re-reference)', async () => {
25
- await Effect.runPromise(
26
- withStack(
27
- Effect.gen(function* () {
28
- yield* claimResourceId('Test.A', 'one');
29
- yield* claimResourceId('Test.A', 'one');
30
- }),
31
- ),
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',
32
73
  );
33
74
  });
34
75
 
35
- test('distinct ids never collide', async () => {
36
- await Effect.runPromise(
37
- withStack(
38
- Effect.gen(function* () {
39
- yield* claimResourceId('Test.A', 'one');
40
- yield* claimResourceId('Test.B', 'two');
41
- }),
42
- ),
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',
43
85
  );
86
+ expect(exit._tag).toBe('Failure');
44
87
  });
45
88
 
46
- test('a second, different type claiming a taken id dies naming both types', async () => {
47
- const run = Effect.runPromiseExit(
48
- withStack(
49
- Effect.gen(function* () {
50
- yield* claimResourceId('Nix.JobRetirement', 'caddy');
51
- yield* claimResourceId('Caddy.Config', 'caddy');
52
- }),
53
- ),
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',
54
97
  );
55
- const exit = await run;
56
- expect(exit._tag).toBe('Failure');
57
- if (exit._tag !== 'Failure') return;
58
- const [reason] = exit.cause.reasons;
59
- const defect = reason?._tag === 'Die' ? reason.defect : undefined;
60
- expect(defect).toBeInstanceOf(DuplicateResourceIdError);
61
- const error = defect as DuplicateResourceIdError;
62
- expect(error.message).toContain('REFUSING: duplicate resource id "caddy"');
63
- expect(error.message).toContain('first declared by Nix.JobRetirement');
64
- expect(error.message).toContain('again by Caddy.Config');
65
- expect(error.scope).toBe('(root)');
98
+ expect(exit._tag).toBe('Success');
66
99
  });
67
100
 
68
- test('the same id in a nested namespace is a different scope, so it does not collide', async () => {
69
- await Effect.runPromise(
70
- withStack(
71
- Effect.gen(function* () {
72
- yield* claimResourceId('Caddy.Config', 'caddy');
73
- yield* pushNamespace('child', claimResourceId('Nix.JobRetirement', 'caddy'));
74
- }),
75
- ),
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',
76
110
  );
111
+ expect(exit._tag).toBe('Success');
77
112
  });
78
113
 
79
- test('two unrelated stacks never see each others claims', async () => {
80
- const stackOne = fakeStack();
81
- const stackTwo = fakeStack();
82
- await Effect.runPromise(
83
- claimResourceId('Caddy.Config', 'caddy').pipe(
84
- Effect.provideService(Stack, stackOne as never),
85
- ),
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',
86
123
  );
87
- // A DIFFERENT stack object claiming the same id with a different type is fine — the
88
- // registry is keyed by the Stack instance (see duplicate-id.ts), not the bare fqn string.
89
- await Effect.runPromise(
90
- claimResourceId('Nix.JobRetirement', 'caddy').pipe(
91
- Effect.provideService(Stack, stackTwo as never),
92
- ),
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',
93
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');
94
153
  });
95
154
  });