@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.
- package/README.md +13 -11
- package/dist/caddy/config.d.ts +4 -6
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/index.js +61 -128
- package/dist/caddy/index.js.map +4 -6
- package/dist/scope/duplicate-id.d.ts +30 -15
- package/dist/scope/duplicate-id.d.ts.map +1 -1
- package/dist/scope/index.d.ts +17 -14
- package/dist/scope/index.d.ts.map +1 -1
- package/dist/scope/index.js +59 -57
- package/dist/scope/index.js.map +5 -5
- package/dist/scope/patch-check.d.ts +14 -0
- package/dist/scope/patch-check.d.ts.map +1 -0
- package/docs/duplicate-resource-id.md +124 -61
- package/docs/provider-standard.md +9 -9
- package/package.json +2 -1
- package/patches/alchemy@2.0.0-beta.81.patch +147 -0
- package/patches/alchemy@2.0.0-beta.83.patch +149 -0
- package/src/caddy/config.ts +7 -12
- package/src/scope/duplicate-id.test.ts +128 -69
- package/src/scope/duplicate-id.ts +61 -94
- package/src/scope/index.ts +21 -14
- package/src/scope/patch-check.test.ts +142 -0
- package/src/scope/patch-check.ts +139 -0
- package/src/scope/patch-coverage.test.ts +50 -0
- package/dist/scope/guard.d.ts +0 -28
- package/dist/scope/guard.d.ts.map +0 -1
- package/src/scope/guard.test.ts +0 -128
- package/src/scope/guard.ts +0 -49
|
@@ -1,113 +1,80 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* `DuplicateResourceIdError` and the pinned alchemy version the kit's patch targets.
|
|
3
3
|
*
|
|
4
|
-
* ★
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
-
*
|
|
39
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
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
|
|
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
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
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
|
|
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'];
|
package/src/scope/index.ts
CHANGED
|
@@ -1,20 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@homeflare/alchemy/scope` —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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 {
|
|
13
|
+
* import { checkAlchemyPatch } from '@homeflare/alchemy/scope';
|
|
12
14
|
*
|
|
13
|
-
*
|
|
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 {
|
|
20
|
-
|
|
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
|
+
});
|
package/dist/scope/guard.d.ts
DELETED
|
@@ -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"}
|