@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.
- 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 +16 -16
- package/dist/scope/duplicate-id.d.ts.map +1 -1
- package/dist/scope/index.d.ts +16 -14
- package/dist/scope/index.d.ts.map +1 -1
- package/dist/scope/index.js +52 -57
- package/dist/scope/index.js.map +5 -5
- package/dist/scope/patch-check.d.ts +11 -0
- package/dist/scope/patch-check.d.ts.map +1 -0
- package/docs/duplicate-resource-id.md +90 -60
- package/docs/provider-standard.md +9 -9
- package/package.json +2 -1
- package/patches/alchemy@2.0.0-beta.81.patch +147 -0
- package/src/caddy/config.ts +7 -12
- package/src/scope/duplicate-id.test.ts +128 -69
- package/src/scope/duplicate-id.ts +39 -97
- package/src/scope/index.ts +16 -14
- package/src/scope/patch-check.test.ts +114 -0
- package/src/scope/patch-check.ts +118 -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
package/src/caddy/config.ts
CHANGED
|
@@ -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
|
-
*
|
|
32
|
-
* 329:
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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 =
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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 {
|
|
18
|
+
import type { Resource } from 'alchemy/Resource';
|
|
10
19
|
import * as Effect from 'effect/Effect';
|
|
11
|
-
import
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
effect.pipe(Effect.provideService(Stack, fakeStack() as never));
|
|
31
|
+
type StackBody = Effect.Effect<unknown, unknown, unknown>;
|
|
18
32
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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('
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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('
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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('
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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('
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
await
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
*
|
|
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
|
-
* `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
|
-
*
|
|
39
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
79
|
-
*
|
|
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
|
|
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';
|
package/src/scope/index.ts
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
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`
|
|
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 {
|
|
12
|
+
* import { checkAlchemyPatch } from '@homeflare/alchemy/scope';
|
|
12
13
|
*
|
|
13
|
-
*
|
|
14
|
+
* const problems = await checkAlchemyPatch(import.meta.dirname);
|
|
15
|
+
* if (problems.length > 0) {
|
|
16
|
+
* console.error(problems.join('\n'));
|
|
17
|
+
* process.exit(1);
|
|
18
|
+
* }
|
|
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,
|
|
20
|
-
export {
|
|
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
|
+
});
|