@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.
@@ -1,113 +1,80 @@
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 THIS package's own peer pin and
20
+ * devDependency sit at, and the one `duplicate-id.test.ts` drives its real `Stack` against.
21
+ * Hardcoded, like `ESTATE_VERSIONS` in `@homeflare/config/versions` (the same reasoning:
22
+ * a second source of truth derived by import would cross `tsconfig.build.json`'s
23
+ * `rootDir: "src"` boundary to reach `package.json`). `duplicate-id.test.ts` asserts it
24
+ * still matches this package's own `peerDependencies.alchemy`, so a pin bump that forgets
25
+ * to re-cut the patch fails a test here instead of silently shipping a patch filename that
26
+ * no longer matches what consumers install.
27
+ * 3. `PATCHED_ALCHEMY_VERSIONS` — every alchemy version this package ships a CUT patch for
28
+ * (currently beta.81 and beta.83, kit PR shipping beta.83's — a consumer on beta.82 or
29
+ * beta.84 is NOT covered merely by being "close"; `bun`'s patch format keys to an exact
30
+ * version string, and a patch cut against one version is not guaranteed to even apply to
31
+ * another — see docs/duplicate-resource-id.md for why a diff was taken between the two
32
+ * tarballs rather than assumed). `checkAlchemyPatch` (`patch-check.ts`) walks this list to
33
+ * find the shipped patch matching a consumer's ACTUAL install, so a stack on beta.83 is not
34
+ * forced to pin the single oldest supported version just to pass its own check.
30
35
  */
31
- import { toFqn } from 'alchemy/FQN';
32
- import { CurrentNamespace, type NamespaceNode } from 'alchemy/Namespace';
33
- import { Stack } from 'alchemy/Stack';
34
36
  import * as Data from 'effect/Data';
35
- import * as Effect from 'effect/Effect';
36
37
 
37
38
  /**
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.
39
+ * Mirrors the error the patched `alchemy@2.0.0-beta.81` throws at registration — same `_tag`,
40
+ * same fields — for a consumer that wants to `catchTag` it without importing from `alchemy`'s
41
+ * own patched internals.
42
+ *
43
+ * ⚠️ ONLY `Type` IS COMPARED, NEVER `Props`. A platform-typed resource's tagged pattern
44
+ * (`alchemy`'s own `Platform.ts` ~581) rewrites `Props` on a resource already registered, and a
45
+ * bare-tag forward reference legitimately registers with `Props: undefined` until its
46
+ * `.make(...)` Layer builds (`MissingImplementationError`'s own doc comment in `Resource.ts`).
47
+ * Comparing props as well as type would make both of those die. `Type` is stable from a
48
+ * resource's very first `yield*` and is exactly what distinguishes "the same resource,
49
+ * referenced twice" (fine) from "two different resources that picked the same id" (refused).
40
50
  */
41
51
  export class DuplicateResourceIdError extends Data.TaggedError('DuplicateResourceIdError')<{
42
52
  message: string;
43
53
  fqn: string;
44
- scope: string;
54
+ /** The type that first claimed `fqn`. */
45
55
  firstType: string;
56
+ /** The different type that tried to claim it again. */
46
57
  secondType: string;
47
58
  }> {}
48
59
 
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
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).
61
+ * The exact `alchemy` version `patches/alchemy@2.0.0-beta.81.patch` was cut against — this
62
+ * package's own peer pin, and the version `duplicate-id.test.ts` drives a real `Stack` over.
63
+ * `duplicate-id.test.ts` keeps this honest against `package.json`'s own `peerDependencies`.
64
+ *
65
+ * ⛔ STAYS, MEANING BETA.81 — do not repoint this at beta.83. A consumer that calls
66
+ * `checkAlchemyPatch` with an explicit `alchemyVersion` (or reads this constant directly)
67
+ * must keep getting the same answer it always has; {@link PATCHED_ALCHEMY_VERSIONS} is the
68
+ * list a caller walks to find ALL supported pins, this one constant is not retargeted to mean
69
+ * "whichever is newest."
66
70
  */
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
- };
71
+ export const PATCHED_ALCHEMY_VERSION: string = '2.0.0-beta.81';
76
72
 
77
73
  /**
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.
74
+ * Every alchemy version `@homeflare/alchemy` ships a cut `patches/alchemy@<version>.patch`
75
+ * for, oldest first. `checkAlchemyPatch` uses this to pick the shipped patch matching a
76
+ * consumer's ACTUAL installed version instead of only ever checking against
77
+ * {@link PATCHED_ALCHEMY_VERSION}. `patch-check.test.ts` and the static patch-coverage test
78
+ * both assert every entry here has a real `patches/alchemy@<version>.patch` file.
88
79
  */
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
- });
80
+ export const PATCHED_ALCHEMY_VERSIONS: readonly string[] = ['2.0.0-beta.81', '2.0.0-beta.83'];
@@ -1,20 +1,27 @@
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`, one per supported alchemy version
6
+ * (`../../patches/alchemy@<version>.patch`, listed in `PATCHED_ALCHEMY_VERSIONS`: beta.81 and
7
+ * beta.83), applied at EACH consuming stack's own root
8
+ * (`alchemy` is a peer dependency here, so `patchedDependencies` in this package's manifest
9
+ * only reaches this package's own install, never a consumer's). docs/duplicate-resource-id.md
10
+ * has the full guide, the exact `bun patch` workflow, and what this does and does not cover.
9
11
  *
10
12
  * ```ts
11
- * import { guardDuplicateIds } from '@homeflare/alchemy/scope';
13
+ * import { checkAlchemyPatch } from '@homeflare/alchemy/scope';
12
14
  *
13
- * export const NixJobRetirement = guardDuplicateIds(Resource<NixJobRetirement>('Nix.JobRetirement'));
15
+ * const problems = await checkAlchemyPatch(import.meta.dirname);
16
+ * if (problems.length > 0) {
17
+ * console.error(problems.join('\n'));
18
+ * process.exit(1);
19
+ * }
14
20
  * ```
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
21
  */
19
- export { DuplicateResourceIdError, claimResourceId } from './duplicate-id.ts';
20
- export { guardDuplicateIds } from './guard.ts';
22
+ export {
23
+ DuplicateResourceIdError,
24
+ PATCHED_ALCHEMY_VERSION,
25
+ PATCHED_ALCHEMY_VERSIONS,
26
+ } from './duplicate-id.ts';
27
+ export { checkAlchemyPatch, type PatchProblem } from './patch-check.ts';
@@ -0,0 +1,142 @@
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
+ * ★ TWO SUPPORTED VERSIONS, NOT ONE. `@homeflare/alchemy` ships a cut patch for both
7
+ * `2.0.0-beta.81` ({@link PATCHED_ALCHEMY_VERSION}) and `2.0.0-beta.83` — a consumer on
8
+ * either passes `checkAlchemyPatch` without pinning a specific version; one on neither is
9
+ * the "unlisted version" case below.
10
+ */
11
+ import { mkdtempSync, rmSync } from 'node:fs';
12
+ import { mkdir, writeFile } from 'node:fs/promises';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
15
+ import { afterEach, describe, expect, test } from 'bun:test';
16
+ import { PATCHED_ALCHEMY_VERSION, PATCHED_ALCHEMY_VERSIONS } from './duplicate-id.ts';
17
+ import { checkAlchemyPatch } from './patch-check.ts';
18
+
19
+ const BETA_83 = '2.0.0-beta.83';
20
+
21
+ const shippedPatch = (version: string) =>
22
+ Bun.file(new URL(`../../patches/alchemy@${version}.patch`, import.meta.url)).text();
23
+
24
+ const OUR_PATCH_81 = await shippedPatch(PATCHED_ALCHEMY_VERSION);
25
+ const OUR_PATCH_83 = await shippedPatch(BETA_83);
26
+
27
+ let dir: string | undefined;
28
+ afterEach(() => {
29
+ if (dir) rmSync(dir, { recursive: true, force: true });
30
+ dir = undefined;
31
+ });
32
+
33
+ /** A fresh scratch "consumer project" directory, empty except for what each test adds. */
34
+ const scratch = (): string => {
35
+ dir = mkdtempSync(join(tmpdir(), 'alchemy-patch-check-'));
36
+ return dir;
37
+ };
38
+
39
+ const writePkg = async (root: string, content: Record<string, unknown>) =>
40
+ writeFile(join(root, 'package.json'), JSON.stringify(content, null, 2));
41
+
42
+ const installAlchemy = async (
43
+ root: string,
44
+ options: { version?: string; applied?: boolean } = {},
45
+ ) => {
46
+ const { version = PATCHED_ALCHEMY_VERSION, applied = true } = options;
47
+ const alchemyDir = join(root, 'node_modules', 'alchemy');
48
+ await mkdir(join(alchemyDir, 'src'), { recursive: true });
49
+ await mkdir(join(alchemyDir, 'lib'), { recursive: true });
50
+ await writeFile(join(alchemyDir, 'package.json'), JSON.stringify({ version }));
51
+ const marker = applied ? 'class DuplicateResourceIdError' : '// // TODO(sam): unpatched';
52
+ await writeFile(join(alchemyDir, 'src', 'Resource.ts'), marker);
53
+ await writeFile(join(alchemyDir, 'lib', 'Resource.js'), marker);
54
+ };
55
+
56
+ /** Declares `patchedDependencies` for `version`, pointing at a copy of `patchContent`. */
57
+ const declarePatch = async (root: string, version: string, patchContent: string) => {
58
+ await mkdir(join(root, 'patches'), { recursive: true });
59
+ await writeFile(join(root, 'patches', 'alchemy.patch'), patchContent);
60
+ await writePkg(root, {
61
+ patchedDependencies: { [`alchemy@${version}`]: 'patches/alchemy.patch' },
62
+ });
63
+ };
64
+
65
+ describe('checkAlchemyPatch', () => {
66
+ test('alchemy not installed at all', async () => {
67
+ const root = scratch();
68
+ expect(await checkAlchemyPatch(root)).toEqual([expect.stringContaining('not installed')]);
69
+ });
70
+
71
+ test('installed alchemy is a version this package has no shipped patch for', async () => {
72
+ const root = scratch();
73
+ await installAlchemy(root, { version: '2.0.0-beta.999' });
74
+ const problems = await checkAlchemyPatch(root);
75
+ expect(problems).toHaveLength(1);
76
+ expect(problems[0]).toContain('2.0.0-beta.999');
77
+ // Every supported version is named, so the caller knows what to re-cut against.
78
+ for (const supported of PATCHED_ALCHEMY_VERSIONS) {
79
+ expect(problems[0]).toContain(supported);
80
+ }
81
+ });
82
+
83
+ test('right version installed, but package.json has no patchedDependencies entry', async () => {
84
+ const root = scratch();
85
+ await installAlchemy(root);
86
+ await writePkg(root, {});
87
+ const problems = await checkAlchemyPatch(root);
88
+ expect(problems).toEqual([expect.stringContaining('no entry')]);
89
+ });
90
+
91
+ test('patchedDependencies points at a file that does not exist', async () => {
92
+ const root = scratch();
93
+ await installAlchemy(root);
94
+ await writePkg(root, {
95
+ patchedDependencies: { [`alchemy@${PATCHED_ALCHEMY_VERSION}`]: 'patches/missing.patch' },
96
+ });
97
+ const problems = await checkAlchemyPatch(root);
98
+ expect(problems).toEqual([expect.stringContaining('missing')]);
99
+ });
100
+
101
+ test('the referenced patch file differs from what @homeflare/alchemy ships', async () => {
102
+ const root = scratch();
103
+ await installAlchemy(root);
104
+ await declarePatch(root, PATCHED_ALCHEMY_VERSION, 'not the real patch');
105
+ const problems = await checkAlchemyPatch(root);
106
+ expect(problems).toEqual([expect.stringContaining('differs')]);
107
+ });
108
+
109
+ test('declared and byte-identical, but never actually applied', async () => {
110
+ const root = scratch();
111
+ await installAlchemy(root, { applied: false });
112
+ await declarePatch(root, PATCHED_ALCHEMY_VERSION, OUR_PATCH_81);
113
+ const problems = await checkAlchemyPatch(root);
114
+ expect(problems).toEqual([expect.stringContaining('not applied')]);
115
+ });
116
+
117
+ test('beta.81, declared, byte-identical, and applied: no problems', async () => {
118
+ const root = scratch();
119
+ await installAlchemy(root);
120
+ await declarePatch(root, PATCHED_ALCHEMY_VERSION, OUR_PATCH_81);
121
+ expect(await checkAlchemyPatch(root)).toEqual([]);
122
+ });
123
+
124
+ test('beta.83, declared, byte-identical, and applied: no problems, no version argument needed', async () => {
125
+ const root = scratch();
126
+ await installAlchemy(root, { version: BETA_83 });
127
+ await declarePatch(root, BETA_83, OUR_PATCH_83);
128
+ // No explicit alchemyVersion — a beta.83 consumer must pass by what is ACTUALLY
129
+ // installed, not by also being pinned to PATCHED_ALCHEMY_VERSION (beta.81).
130
+ expect(await checkAlchemyPatch(root)).toEqual([]);
131
+ });
132
+
133
+ test('an explicit alchemyVersion that does not match what is installed is still a problem', async () => {
134
+ const root = scratch();
135
+ await installAlchemy(root, { version: BETA_83 });
136
+ await declarePatch(root, BETA_83, OUR_PATCH_83);
137
+ const problems = await checkAlchemyPatch(root, PATCHED_ALCHEMY_VERSION);
138
+ expect(problems).toHaveLength(1);
139
+ expect(problems[0]).toContain(BETA_83);
140
+ expect(problems[0]).toContain(PATCHED_ALCHEMY_VERSION);
141
+ });
142
+ });
@@ -0,0 +1,139 @@
1
+ /**
2
+ * A consuming stack's own `check` calls `checkAlchemyPatch` to assert it actually carries the
3
+ * duplicate-resource-id fix — not just that SOME version of `alchemy` is installed, but that
4
+ * THIS install carries the patch, for the alchemy version it was cut against.
5
+ *
6
+ * ★ WHY A BUMP CAN OTHERWISE DROP IT SILENTLY. `patchedDependencies` keys a patch to an EXACT
7
+ * version string (`alchemy@2.0.0-beta.81`). `bun add alchemy@2.0.0-beta.82` leaves that key
8
+ * pointing at a version nothing installs anymore — `bun install` neither errors nor warns,
9
+ * it just stops applying the patch, and the stack is back to the original silent collision
10
+ * with every other gate still green. This check is what turns that bump into a failing
11
+ * `check`, the same way `tests/dist.test.ts` turns a build regression into a failing test
12
+ * instead of a surprise three repos downstream (this package's own AGENTS.md, "the smoke
13
+ * test is not optional").
14
+ * ★ A STATIC CHECK, mirroring `@homeflare/config/check`'s `checkCopy`/`checkOxfmt` — not a
15
+ * functional probe that spins up a `Stack`/`Resource`/`Effect` harness on every `check` run.
16
+ * The functional proof that the patch's LOGIC works lives in this package's own
17
+ * `duplicate-id.test.ts`, against the exact pinned version; a consumer's `check` only needs
18
+ * to know whether ITS install carries that same patch, applied — three file reads, no
19
+ * `effect` runtime pulled into every repo's check step.
20
+ * ⛔ IT REPORTS, IT DOES NOT REPAIR, same as `checkProject` in `@homeflare/config/check` — this
21
+ * never edits `package.json` or re-runs `bun install` for the caller.
22
+ */
23
+ import { PATCHED_ALCHEMY_VERSION, PATCHED_ALCHEMY_VERSIONS } from './duplicate-id.ts';
24
+
25
+ /** One thing wrong with a consuming stack's alchemy patch, in the imperative. */
26
+ export type PatchProblem = string;
27
+
28
+ const patchKey = (version: string) => `alchemy@${version}`;
29
+
30
+ /** Where THIS package's own shipped copy of the patch lives, however it was installed. */
31
+ const shippedPatchPath = (version: string): string =>
32
+ new URL(`../../patches/${patchKey(version)}.patch`, import.meta.url).pathname;
33
+
34
+ type PackageJson = {
35
+ readonly patchedDependencies?: Readonly<Record<string, string>>;
36
+ };
37
+
38
+ /**
39
+ * @param projectDir The consuming stack's OWN repo root (where its `package.json` and
40
+ * `patches/` directory live) — never this kit's own.
41
+ * @param alchemyVersion The version to check for. Omitted (the normal case): the version
42
+ * actually installed at `projectDir` is used, so a consumer on ANY version this package
43
+ * ships a patch for ({@link PATCHED_ALCHEMY_VERSIONS}) passes without having to pin
44
+ * {@link PATCHED_ALCHEMY_VERSION} specifically — a stack on beta.83 is checked against the
45
+ * beta.83 patch, one on beta.81 against the beta.81 patch. A caller passes this explicitly
46
+ * only to assert a SPECIFIC pin regardless of what happens to be installed (as the tests do).
47
+ */
48
+ export const checkAlchemyPatch = async (
49
+ projectDir: string,
50
+ alchemyVersion?: string,
51
+ ): Promise<readonly PatchProblem[]> => {
52
+ const problems: PatchProblem[] = [];
53
+
54
+ const installedPkg = Bun.file(`${projectDir}/node_modules/alchemy/package.json`);
55
+ if (!(await installedPkg.exists())) {
56
+ problems.push('node_modules/alchemy: not installed — nothing to check the patch against');
57
+ return problems;
58
+ }
59
+ const installedVersion = ((await installedPkg.json()) as { version?: string }).version;
60
+
61
+ // No explicit ask: check against whatever is ACTUALLY installed, not a single hardcoded
62
+ // pin — that is what lets a beta.83 consumer pass without also being on beta.81.
63
+ const targetVersion = alchemyVersion ?? installedVersion ?? PATCHED_ALCHEMY_VERSION;
64
+ const key = patchKey(targetVersion);
65
+
66
+ if (!PATCHED_ALCHEMY_VERSIONS.includes(targetVersion)) {
67
+ problems.push(
68
+ // ★ Name the version being CHECKED: with an explicit `alchemyVersion` it can differ from
69
+ // the installed one, and naming the installed version then blames a supported one.
70
+ `alchemy ${targetVersion} (installed: ${installedVersion ?? 'unknown'}) is a version ` +
71
+ `@homeflare/alchemy has ` +
72
+ `no duplicate-resource-id patch for — supported versions are ` +
73
+ `${PATCHED_ALCHEMY_VERSIONS.join(', ')}; cut a new patches/${key}.patch ` +
74
+ "(@homeflare/alchemy's own patches/) before bumping alchemy, or this install has no " +
75
+ 'protection at all',
76
+ );
77
+ return problems;
78
+ }
79
+ if (installedVersion !== targetVersion) {
80
+ problems.push(
81
+ `node_modules/alchemy is ${installedVersion ?? 'unknown'}, but the duplicate-resource-id ` +
82
+ `check was asked for ${targetVersion} — pass the installed version explicitly, or omit ` +
83
+ 'alchemyVersion to check against whatever is actually installed',
84
+ );
85
+ return problems;
86
+ }
87
+
88
+ const pkgFile = Bun.file(`${projectDir}/package.json`);
89
+ if (!(await pkgFile.exists())) {
90
+ problems.push('package.json: missing');
91
+ return problems;
92
+ }
93
+ const pkg = (await pkgFile.json()) as PackageJson;
94
+ const declaredPath = pkg.patchedDependencies?.[key];
95
+ if (declaredPath === undefined) {
96
+ problems.push(
97
+ `package.json: patchedDependencies has no entry for "${key}" — this stack has no ` +
98
+ 'duplicate-resource-id protection at all; see @homeflare/alchemy/docs/duplicate-resource-id.md',
99
+ );
100
+ return problems;
101
+ }
102
+
103
+ const theirs = Bun.file(`${projectDir}/${declaredPath}`);
104
+ if (!(await theirs.exists())) {
105
+ problems.push(`package.json declares "${key}" at ${declaredPath}, but that file is missing`);
106
+ return problems;
107
+ }
108
+ const ours = Bun.file(shippedPatchPath(targetVersion));
109
+ if (!(await ours.exists())) {
110
+ // The kit's own tarball is missing its patch — a packaging bug, not the consumer's.
111
+ problems.push(`@homeflare/alchemy ships no patches/${key}.patch to compare against`);
112
+ return problems;
113
+ }
114
+ if ((await theirs.text()) !== (await ours.text())) {
115
+ problems.push(
116
+ `${declaredPath}: differs from @homeflare/alchemy's shipped patch — re-copy it, ` +
117
+ 'or say why it differs',
118
+ );
119
+ }
120
+
121
+ // Declared and byte-identical is not yet APPLIED: a hand-edited patchedDependencies entry,
122
+ // or an install under a package manager that does not support bun's patch format, leaves
123
+ // node_modules unpatched with nothing above noticing. One cheap textual proof it landed.
124
+ const resourceSrc = Bun.file(`${projectDir}/node_modules/alchemy/src/Resource.ts`);
125
+ const resourceLib = Bun.file(`${projectDir}/node_modules/alchemy/lib/Resource.js`);
126
+ const marker = 'DuplicateResourceIdError';
127
+ const [srcText, libText] = await Promise.all([
128
+ resourceSrc.exists().then((ok) => (ok ? resourceSrc.text() : '')),
129
+ resourceLib.exists().then((ok) => (ok ? resourceLib.text() : '')),
130
+ ]);
131
+ if (!srcText.includes(marker) && !libText.includes(marker)) {
132
+ problems.push(
133
+ 'node_modules/alchemy/{src/Resource.ts,lib/Resource.js}: neither contains the patch — ' +
134
+ 'declared in package.json but not applied; re-run `bun install`',
135
+ );
136
+ }
137
+
138
+ return problems;
139
+ };
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Static coverage: every version `PATCHED_ALCHEMY_VERSIONS` claims to support actually has a
3
+ * shipped `patches/alchemy@<version>.patch` carrying the `DuplicateResourceIdError` fix.
4
+ *
5
+ * ★ STATIC, NOT FUNCTIONAL — no network. Proving a patch APPLIES to the real upstream tarball
6
+ * needs `npm pack alchemy@<version>` pulled live and diffed, which this suite's own CI lane
7
+ * cannot assume has network access (and must not: patch-check.ts's own header explains why a
8
+ * consumer's `check` stays a static file read rather than a network-dependent probe). The
9
+ * patch for {@link PATCHED_ALCHEMY_VERSION} (beta.81) already has that proof the hard way —
10
+ * `duplicate-id.test.ts` drives a REAL patched `Stack` over the devDependency `alchemy`
11
+ * install, which only passes because `bun install`'s `patchedDependencies` actually applied
12
+ * it. This test is the one that would catch a version ADDED to the list here without a
13
+ * patch file ever being cut for it, or a patch file that silently regressed back to the
14
+ * upstream TODO.
15
+ */
16
+ import { describe, expect, test } from 'bun:test';
17
+ import { PATCHED_ALCHEMY_VERSIONS } from './duplicate-id.ts';
18
+
19
+ /** `test.each` wants a plain mutable array of a known element type, not `readonly string[]`. */
20
+ const versions: string[] = [...PATCHED_ALCHEMY_VERSIONS];
21
+
22
+ const patchFile = (version: string) =>
23
+ Bun.file(new URL(`../../patches/alchemy@${version}.patch`, import.meta.url));
24
+
25
+ describe('every supported alchemy version ships a cut patch', () => {
26
+ test.each(versions)('alchemy@%s has a patches/ file', async (version: string) => {
27
+ expect(await patchFile(version).exists()).toBe(true);
28
+ });
29
+
30
+ test.each(versions)(
31
+ 'alchemy@%s patch carries the DuplicateResourceIdError fix',
32
+ async (version: string) => {
33
+ const text = await patchFile(version).text();
34
+ expect(text).toContain('DuplicateResourceIdError');
35
+ // The TODO it replaces must be gone from the ADDED ("+") side of the diff — present
36
+ // only as part of a removed ("-") line, never re-introduced unpatched.
37
+ const addedLines = text.split('\n').filter((line) => line.startsWith('+'));
38
+ expect(addedLines.some((line) => line.includes('TODO(sam)'))).toBe(false);
39
+ },
40
+ );
41
+
42
+ test('touches the three files every consumer resolution path needs', async () => {
43
+ for (const version of PATCHED_ALCHEMY_VERSIONS) {
44
+ const text = await patchFile(version).text();
45
+ expect(text).toContain('a/src/Resource.ts');
46
+ expect(text).toContain('a/lib/Resource.js');
47
+ expect(text).toContain('a/lib/Resource.d.ts');
48
+ }
49
+ });
50
+ });
@@ -1,28 +0,0 @@
1
- /**
2
- * `guardDuplicateIds` — wrap a Resource class so every `("id", props)` call it makes first
3
- * claims that id through `duplicate-id.ts`'s per-stack registry, naming both resource types if
4
- * a second, different-typed resource already claimed it. See duplicate-id.ts's header for why
5
- * the check has to live HERE (at the moment of registration) rather than after the stack body
6
- * has finished running.
7
- *
8
- * Usage, in a resource's own file, right where it's exported today:
9
- *
10
- * ```ts
11
- * export const CaddyConfig = guardDuplicateIds(Resource<CaddyConfig>('Caddy.Config', { … }));
12
- * ```
13
- *
14
- * ★ A `Proxy` around the REAL class, not a reimplementation. `Resource()`'s return value is a
15
- * callable object wearing an `Effectable.Prototype` (`Effect.isEffect(MyResource) === true`,
16
- * for the bare-tag platform-resource pattern) plus `.ref`, `.Type`, `.Provider`, `.Self`,
17
- * `.Aliases` — reconstructing that shape risks silently dropping one of them. A `Proxy` whose
18
- * `get` trap is the default (`Reflect.get`) forwards every property read untouched; only the
19
- * `apply` trap — the `MyResource("id", props)` call form — is new behaviour.
20
- * ⚠️ THE "REGISTER METHODS" OVERLOAD IS DELIBERATELY LEFT ALONE. `ResourceClass` is also callable
21
- * with a single object argument to attach methods (`Resource()`'s own `typeof args[0] ===
22
- * 'object' ? Object.assign(...) : constructor(...)` branch) — that call mutates and returns the
23
- * SAME class, carries no logical id, and has nothing to claim. Only a string first argument
24
- * (the `(id, props)` form) goes through `claimResourceId`.
25
- */
26
- import type { ResourceClass, ResourceLike } from 'alchemy/Resource';
27
- export declare const guardDuplicateIds: <R extends ResourceLike>(resourceClass: ResourceClass<R>) => ResourceClass<R>;
28
- //# sourceMappingURL=guard.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"guard.d.ts","sourceRoot":"","sources":["../../src/scope/guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAIpE,eAAO,MAAM,iBAAiB,GAAI,CAAC,SAAS,YAAY,iBACvC,aAAa,CAAC,CAAC,CAAC,KAC9B,aAAa,CAAC,CAAC,CAiBM,CAAC"}