@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.
@@ -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
  });
@@ -1,113 +1,55 @@
1
1
  /**
2
- * Refuse a second resource claiming a logical id already taken in the same stack scope.
2
+ * `DuplicateResourceIdError` and the pinned alchemy version the kit's patch targets.
3
3
  *
4
- * ★ WHY THIS EXISTS (measured, homeflare-mini PR 329, Tim 2026-10-19). `NixJobRetirement('caddy',
5
- * …)` and `Caddy.Config` (`CADDY_ID = 'caddy'`) both declared logical id `caddy` in one stack.
6
- * Alchemy's own `Resource()` constructor treats a second `yield*` at an already-claimed FQN as
7
- * an idempotent re-reference — correct for "the same resource, read from two call sites" — and
8
- * returns the FIRST-registered resource without ever comparing `Type` (`Resource.ts`, the
9
- * `if (existing) { … return existing; }` branch; a sibling TODO there already flags a related
10
- * gap — "check if props are different and die"). The SECOND resource's props are never written
11
- * anywhere: `stack.resources[fqn]` never changes, so nothing downstream — not `Plan.make`, not a
12
- * later inspection of the compiled stack — can tell two different registrations ever happened.
13
- * Measured directly (dup-experiment.ts, this PR): registering `Test.ResourceB('shared-id', …)`
14
- * after `Test.ResourceA('shared-id', …)` compiles a stack whose ONLY entry at that fqn reports
15
- * `Type: 'Test.ResourceA'` — the second declaration leaves no trace to check after the fact.
16
- * ★ WHY THE CHECK CANNOT BE BOLTED ON AFTER THE FACT. Because the losing registration leaves no
17
- * trace (above), a guard built from `CompiledStack.resources` — inspected after the stack's body
18
- * has run, e.g. before `Plan.make` — sees only one entry per fqn and cannot know a second,
19
- * different-typed claim ever happened. The only point where both the existing claim AND the new
20
- * claimant's `Type` are both in hand is at the moment of registration, which is why this module
21
- * is a REGISTRY populated and checked as each resource is `yield*`ed (see `guard.ts`), not a
22
- * post-hoc scan.
23
- * ⛔ UPSTREAM IS THE RIGHT HOME FOR THE REAL FIX. The comparison belongs in `Resource.ts` itself,
24
- * right where its own TODO already sits — every resource (ours and every vendor built-in) goes
25
- * through that one function, and this kit cannot wrap a type it does not author (e.g.
26
- * `Cloudflare.Worker`). This module is the house-side stopgap decision 49 allows ("a house rule
27
- * only fills a gap upstream is silent on"): it protects any TWO resources that both go through
28
- * `guardDuplicateIds` (guard.ts), which is everywhere this kit's own providers adopt it. The
29
- * upstream issue text proposing the real fix is in this PR's body.
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.
30
26
  */
31
- import { toFqn } from 'alchemy/FQN';
32
- import { CurrentNamespace, type NamespaceNode } from 'alchemy/Namespace';
33
- import { Stack } from 'alchemy/Stack';
34
27
  import * as Data from 'effect/Data';
35
- import * as Effect from 'effect/Effect';
36
28
 
37
29
  /**
38
- * Two resources of different types both claimed `fqn` in `scope`. Carries both type names so the
39
- * message — and any catcher — can tell the attacker from the defender without re-deriving them.
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).
40
41
  */
41
42
  export class DuplicateResourceIdError extends Data.TaggedError('DuplicateResourceIdError')<{
42
43
  message: string;
43
44
  fqn: string;
44
- scope: string;
45
+ /** The type that first claimed `fqn`. */
45
46
  firstType: string;
47
+ /** The different type that tried to claim it again. */
46
48
  secondType: string;
47
49
  }> {}
48
50
 
49
- const scopeLabel = (namespace: NamespaceNode | undefined): string => {
50
- if (!namespace) return '(root)';
51
- const path: string[] = [];
52
- let node: NamespaceNode | undefined = namespace;
53
- while (node) {
54
- path.unshift(node.Id);
55
- node = node.Parent;
56
- }
57
- return path.join('/');
58
- };
59
-
60
- /**
61
- * One registry per compiled stack, keyed by the `Stack` service object `yield* Stack` returns
62
- * (a fresh `StackSpec` per `Alchemy.Stack(...)`/`make()` call — see `Stack.ts`'s `Layer.effect`).
63
- * A `WeakMap` so two unrelated stacks (sequential test runs, two stacks in one process) never see
64
- * each other's claims, and so the registry is collected with the stack instead of growing forever
65
- * in a long-running process (an `alchemy dev` watch loop, this package's own test suite).
66
- */
67
- const registries = new WeakMap<object, Map<string, string>>();
68
-
69
- const registryFor = (stack: object): Map<string, string> => {
70
- const existing = registries.get(stack);
71
- if (existing) return existing;
72
- const created = new Map<string, string>();
73
- registries.set(stack, created);
74
- return created;
75
- };
76
-
77
51
  /**
78
- * Claim `id` for `type` in the current namespace scope. Fine the first time, and fine again for
79
- * the SAME type (the legitimate "yield the same resource from two call sites" pattern Alchemy's
80
- * own idempotent registration supports). Dies with {@link DuplicateResourceIdError} the moment a
81
- * DIFFERENT type claims an id already taken — before that second resource's provider ever runs.
82
- *
83
- * ⚠️ `Effect.die`, NOT a typed failure. This runs at registration, not inside a lifecycle
84
- * operation (`read`/`diff`/`reconcile`/`delete`) — the provider-standard's S20 ban on
85
- * `Effect.orDie` in LIFECYCLE operations does not apply here, and `ConflictingProviderModeError`
86
- * right next door in upstream's own `Resource.ts` dies the same way for the same reason: there is
87
- * no recovery a caller could usefully attempt mid-registration.
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`.
88
54
  */
89
- export const claimResourceId = (type: string, id: string): Effect.Effect<void, never, Stack> =>
90
- Effect.gen(function* () {
91
- const stack = yield* Stack;
92
- const namespace = yield* CurrentNamespace;
93
- const fqn = toFqn(namespace, id);
94
- const registry = registryFor(stack);
95
- const firstType = registry.get(fqn);
96
- if (firstType === undefined) {
97
- registry.set(fqn, type);
98
- return;
99
- }
100
- if (firstType !== type) {
101
- return yield* Effect.die(
102
- new DuplicateResourceIdError({
103
- message:
104
- `REFUSING: duplicate resource id "${fqn}" in scope ${scopeLabel(namespace)}: ` +
105
- `first declared by ${firstType}, again by ${type}`,
106
- fqn,
107
- scope: scopeLabel(namespace),
108
- firstType,
109
- secondType: type,
110
- }),
111
- );
112
- }
113
- });
55
+ export const PATCHED_ALCHEMY_VERSION: string = '2.0.0-beta.81';
@@ -1,20 +1,22 @@
1
1
  /**
2
- * `@homeflare/alchemy/scope` — refuse a second resource claiming a logical id already taken in
3
- * the same stack scope, instead of Alchemy's own `Resource()` silently treating it as the same
4
- * resource re-referenced (see duplicate-id.ts for the measured incident and why the check has to
5
- * run at registration time). Every `@homeflare/alchemy` resource export wrapped in
6
- * `guardDuplicateIds` gets this for free; a stack's OWN custom resource types (declared directly
7
- * with Alchemy's `Resource()`, the way homeflare-mini's `NixJobRetirement` is) get it too, by
8
- * wrapping their export the same way:
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.
9
10
  *
10
11
  * ```ts
11
- * import { guardDuplicateIds } from '@homeflare/alchemy/scope';
12
+ * import { checkAlchemyPatch } from '@homeflare/alchemy/scope';
12
13
  *
13
- * export const NixJobRetirement = guardDuplicateIds(Resource<NixJobRetirement>('Nix.JobRetirement'));
14
+ * const problems = await checkAlchemyPatch(import.meta.dirname);
15
+ * if (problems.length > 0) {
16
+ * console.error(problems.join('\n'));
17
+ * process.exit(1);
18
+ * }
14
19
  * ```
15
- *
16
- * docs/duplicate-resource-id.md has the full guide, the three test cases, and what this does and
17
- * does not cover.
18
20
  */
19
- export { DuplicateResourceIdError, claimResourceId } from './duplicate-id.ts';
20
- export { guardDuplicateIds } from './guard.ts';
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
+ });