@homeflare/alchemy 0.57.0 → 0.59.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.
Files changed (38) hide show
  1. package/README.md +17 -0
  2. package/dist/caddy/config.d.ts +6 -0
  3. package/dist/caddy/config.d.ts.map +1 -1
  4. package/dist/caddy/index.js +128 -61
  5. package/dist/caddy/index.js.map +6 -4
  6. package/dist/scope/duplicate-id.d.ts +32 -0
  7. package/dist/scope/duplicate-id.d.ts.map +1 -0
  8. package/dist/scope/guard.d.ts +28 -0
  9. package/dist/scope/guard.d.ts.map +1 -0
  10. package/dist/scope/index.d.ts +21 -0
  11. package/dist/scope/index.d.ts.map +1 -0
  12. package/dist/scope/index.js +71 -0
  13. package/dist/scope/index.js.map +11 -0
  14. package/dist/unifi/firewall-policy-drift.d.ts +3 -3
  15. package/dist/unifi/firewall-policy-drift.d.ts.map +1 -1
  16. package/dist/unifi/firewall-policy-form.d.ts +18 -15
  17. package/dist/unifi/firewall-policy-form.d.ts.map +1 -1
  18. package/dist/unifi/index.js +64 -8
  19. package/dist/unifi/index.js.map +5 -5
  20. package/dist/unifi/network-update.fixtures.d.ts +53 -0
  21. package/dist/unifi/network-update.fixtures.d.ts.map +1 -0
  22. package/dist/unifi/update-reconcile.d.ts.map +1 -1
  23. package/docs/duplicate-resource-id.md +91 -0
  24. package/docs/provider-standard.md +9 -0
  25. package/package.json +5 -1
  26. package/src/caddy/config.ts +12 -3
  27. package/src/scope/duplicate-id.test.ts +95 -0
  28. package/src/scope/duplicate-id.ts +113 -0
  29. package/src/scope/guard.test.ts +128 -0
  30. package/src/scope/guard.ts +49 -0
  31. package/src/scope/index.ts +20 -0
  32. package/src/unifi/firewall-policy-drift.test.ts +64 -0
  33. package/src/unifi/firewall-policy-drift.ts +24 -7
  34. package/src/unifi/firewall-policy-form.ts +61 -15
  35. package/src/unifi/network-update-accept.test.ts +59 -0
  36. package/src/unifi/network-update.fixtures.ts +74 -0
  37. package/src/unifi/network-update.test.ts +11 -69
  38. package/src/unifi/update-reconcile.ts +37 -3
@@ -0,0 +1,91 @@
1
+ # `@homeflare/alchemy/scope` — refuse a duplicate resource id
2
+
3
+ Two resources of different types declaring the same logical id in one stack scope now fails at
4
+ registration — before `Plan.make`, before any provider runs — instead of Alchemy silently treating
5
+ the second one as a reference to the first.
6
+
7
+ ## The incident this fixes
8
+
9
+ Tim, 2026-10-19 ("lets not let two things claim the same"): in homeflare-mini,
10
+ `NixJobRetirement('caddy', …)` and the Caddy config resource (`CADDY_ID = 'caddy'`,
11
+ `src/caddy/declare.ts`) both declared logical id `caddy` in one stack. Alchemy did not error: the
12
+ plan showed `[caddy] noop`, the retirement was silently dropped (deploy "49 succeeded" for 50
13
+ rows), and a later deploy showed `[caddy] update` as state reconciled back. homeflare-mini PR 329
14
+ prefixed that resource's ids as a one-off fix; nothing stopped the next collision in any stack.
15
+
16
+ ## Why Alchemy's own engine lets this through
17
+
18
+ Alchemy's `Resource()` constructor (`alchemy@2.0.0-beta.81`, `Resource.ts`) treats a second
19
+ `yield*` at an already-registered FQN as an idempotent re-reference — correct for "the same
20
+ resource, read from two call sites" — and returns the first-registered resource without comparing
21
+ `Type`:
22
+
23
+ ```ts
24
+ const existing = stack.resources[fqn];
25
+ if (existing) {
26
+ // … an ambient-provider-mode check, unrelated …
27
+ // // TODO(sam): check if props are different and die
28
+ return existing;
29
+ }
30
+ ```
31
+
32
+ That TODO is the gap. The second resource's props are never written anywhere — `stack.resources`
33
+ never changes — so nothing downstream, including this package's own `hf-adopt-verify`, can tell
34
+ two different registrations happened. Measured directly (`src/scope/duplicate-id.ts`'s header
35
+ has the reproduction): registering `Test.ResourceB('shared-id', …)` right after
36
+ `Test.ResourceA('shared-id', …)` compiles a stack whose only entry at that fqn reports
37
+ `Type: 'Test.ResourceA'`.
38
+
39
+ This is why the check cannot be a post-hoc scan over the compiled stack — by the time a plan runs,
40
+ the collision has already been silently resolved in the first resource's favor. The only point
41
+ where both the existing claim and the new claimant's type are in hand is at registration itself.
42
+
43
+ ## What this package ships
44
+
45
+ `guardDuplicateIds` wraps a `ResourceClass` so every `("id", props)` call first claims that id in
46
+ a per-stack, per-namespace registry, and dies with `DuplicateResourceIdError` the moment a
47
+ different type claims an id already taken:
48
+
49
+ ```ts
50
+ import { Resource } from 'alchemy';
51
+ import { guardDuplicateIds } from '@homeflare/alchemy/scope';
52
+
53
+ export const MyResource = guardDuplicateIds(Resource<MyResource>('My.Resource'));
54
+ ```
55
+
56
+ The error names both sides:
57
+
58
+ ```
59
+ REFUSING: duplicate resource id "caddy" in scope (root): first declared by Nix.JobRetirement, again by Caddy.Config
60
+ ```
61
+
62
+ `Caddy.Config` (this package's `caddy/config.ts`) carries the guard already — it was one half of
63
+ the measured incident. Every other `@homeflare/alchemy` resource adopts it as its own file is next
64
+ touched (provider-standard.md, rule S47); there is no mechanical migration of every family in this
65
+ one change.
66
+
67
+ ## What it does, and does not, cover
68
+
69
+ - **Scope matches Alchemy's own FQN.** The registry is keyed by `toFqn(namespace, id)`, the exact
70
+ string Alchemy itself uses for state storage — the same id in a nested namespace
71
+ (`Namespace.push('child', …)`) is a different scope and never collides. Two different stacks
72
+ (two different `Stack` service instances) never see each other's claims either.
73
+ - **The same type re-registering its own id is still fine.** That is Alchemy's legitimate
74
+ "reference the same resource from two call sites" pattern; `guardDuplicateIds` only refuses a
75
+ _different_ type at an already-claimed id.
76
+ - **Both sides must be wrapped.** `guardDuplicateIds` is a `Proxy` around one resource class; it
77
+ has no way to see a DIFFERENT, unwrapped class's registration. A guarded `Caddy.Config` beside
78
+ an unguarded, hand-rolled `Resource('Nix.JobRetirement')` in a consuming stack still collides
79
+ silently — `guard.test.ts`'s "one guarded, one unguarded" case asserts this directly, so it is
80
+ documented behavior rather than a surprise. A stack's own custom resource types get full
81
+ protection the same way `Caddy.Config` does: wrap the export in `guardDuplicateIds`.
82
+ - **It does not catch a collision against a vendor built-in** (e.g. `Cloudflare.Worker`) unless
83
+ that export is also wrapped — this package cannot wrap a class it does not author. See "Upstream"
84
+ below for the fix that would close this for every resource, vendor included.
85
+
86
+ ## Upstream
87
+
88
+ The real fix belongs in `alchemy`'s own `Resource.ts`, at the `if (existing)` branch quoted above,
89
+ where the TODO already sits — every resource, ours and every vendor built-in, goes through that
90
+ one function. This PR's body has the issue text proposed for `alchemy-run/alchemy`; it has not
91
+ been filed.
@@ -63,6 +63,15 @@ already ships is a finding, even when it works.
63
63
  owned ones, as upstream's third-party providers do; the core contract does not ask for it. `nuke` is declared wherever nuke must never reach the object.
64
64
  - **Scope (S15).** The account, zone and region are resolved inside each operation. They
65
65
  are never props.
66
+ - **Duplicate ids (added 2026-10-19, kit-local — not yet one of the skill's 46; house gap,
67
+ decision 49: upstream is silent, this rule fills it).** A touched or new resource's exported
68
+ `ResourceClass` is wrapped in
69
+ `guardDuplicateIds` (`@homeflare/alchemy/scope`): `export const X = guardDuplicateIds(Resource<X>(…))`.
70
+ Without it, a second, different-typed resource declaring the same logical id in one stack
71
+ silently wins — Alchemy's own idempotent-registration path returns the first-registered
72
+ resource with no `Type` check (measured, homeflare-mini PR 329;
73
+ [duplicate-resource-id.md](./duplicate-resource-id.md)). `Caddy.Config` carries it; every other
74
+ family adopts it the next time its own file is touched, not as one mechanical sweep.
66
75
 
67
76
  ## Code, errors, credentials
68
77
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.57.0",
3
+ "version": "0.59.0",
4
4
  "description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
5
5
  "homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
6
6
  "bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
@@ -131,6 +131,10 @@
131
131
  "types": "./dist/valkey/index.d.ts",
132
132
  "default": "./dist/valkey/index.js"
133
133
  },
134
+ "./scope": {
135
+ "types": "./dist/scope/index.d.ts",
136
+ "default": "./dist/scope/index.js"
137
+ },
134
138
  "./package.json": "./package.json"
135
139
  },
136
140
  "publishConfig": {
@@ -28,6 +28,12 @@
28
28
  * place that discharges them — scoped to the handful of effects that actually call
29
29
  * `@distilled.cloud/caddy`'s operations, never leaking into a sibling fetch-based provider merged
30
30
  * into the same stack (admin.ts's own ⛔; MEASURED 2026-09-26, admin-transport-scope.test.ts).
31
+ * ⛔ `CaddyConfig` IS WRAPPED IN `guardDuplicateIds` (scope/guard.ts). MEASURED, homeflare-mini PR
32
+ * 329: a `NixJobRetirement('caddy', …)` declared in the same stack as this resource's `CADDY_ID
33
+ * = 'caddy'` collided silently — Alchemy's own `Resource()` treated the second registration as
34
+ * an idempotent re-reference to the first, so the retirement's props were never written
35
+ * anywhere a plan could see (`scope/duplicate-id.ts` has the full mechanism). This resource was
36
+ * one half of that collision, so it is the first to carry the guard.
31
37
  */
32
38
  import { Resource } from 'alchemy';
33
39
  import { Unowned } from 'alchemy/AdoptPolicy';
@@ -36,6 +42,7 @@ import * as Provider from 'alchemy/Provider';
36
42
  import * as Effect from 'effect/Effect';
37
43
  import { resolvedString } from '../launchd/host-effect.ts';
38
44
  import { adoptEnabled } from '../ownership/adopt.ts';
45
+ import { guardDuplicateIds } from '../scope/guard.ts';
39
46
  import { CaddyAdminService, CaddyAdminTransport } from './admin.ts';
40
47
  import { isUnreachable } from './caddy-http-client.ts';
41
48
  import type { CaddyConfigAttributes, CaddyConfigProps } from './config-form.ts';
@@ -51,9 +58,11 @@ export interface CaddyConfig extends Resource<
51
58
  CaddyConfigAttributes
52
59
  > {}
53
60
 
54
- export const CaddyConfig = Resource<CaddyConfig>('Caddy.Config', {
55
- defaultRemovalPolicy: 'retain',
56
- });
61
+ export const CaddyConfig = guardDuplicateIds(
62
+ Resource<CaddyConfig>('Caddy.Config', {
63
+ defaultRemovalPolicy: 'retain',
64
+ }),
65
+ );
57
66
 
58
67
  /**
59
68
  * ⚠️ A STOPPED CADDY MUST NOT FAIL THE PLAN THAT BRINGS IT BACK. Its launchd job usually sits in the
@@ -0,0 +1,95 @@
1
+ /**
2
+ * `claimResourceId` against a minimal `Stack` + `Namespace` context — no real resources, no
3
+ * provider, so these stay fast and pin the registry's own semantics directly. guard.test.ts
4
+ * drives the same cases through a REAL `Alchemy.Stack(...)` with real `Resource()` classes, to
5
+ * prove the wiring in guard.ts, not just this file's logic in isolation.
6
+ */
7
+ import { describe, expect, test } from 'bun:test';
8
+ import { push as pushNamespace } from 'alchemy/Namespace';
9
+ import { Stack } from 'alchemy/Stack';
10
+ import * as Effect from 'effect/Effect';
11
+ import { DuplicateResourceIdError, claimResourceId } from './duplicate-id.ts';
12
+
13
+ /** A bare `Stack` service good enough for `claimResourceId`: only `resources` is ever `yield*`ed. */
14
+ const fakeStack = () => ({ name: 'test', stage: 'test', resources: {}, bindings: {}, actions: {} });
15
+
16
+ const withStack = <A, E>(effect: Effect.Effect<A, E, Stack>) =>
17
+ effect.pipe(Effect.provideService(Stack, fakeStack() as never));
18
+
19
+ describe('claimResourceId', () => {
20
+ test('a fresh id is claimed without error', async () => {
21
+ await Effect.runPromise(withStack(claimResourceId('Test.A', 'one')));
22
+ });
23
+
24
+ test('the same type re-claiming its own id is fine (idempotent re-reference)', async () => {
25
+ await Effect.runPromise(
26
+ withStack(
27
+ Effect.gen(function* () {
28
+ yield* claimResourceId('Test.A', 'one');
29
+ yield* claimResourceId('Test.A', 'one');
30
+ }),
31
+ ),
32
+ );
33
+ });
34
+
35
+ test('distinct ids never collide', async () => {
36
+ await Effect.runPromise(
37
+ withStack(
38
+ Effect.gen(function* () {
39
+ yield* claimResourceId('Test.A', 'one');
40
+ yield* claimResourceId('Test.B', 'two');
41
+ }),
42
+ ),
43
+ );
44
+ });
45
+
46
+ test('a second, different type claiming a taken id dies naming both types', async () => {
47
+ const run = Effect.runPromiseExit(
48
+ withStack(
49
+ Effect.gen(function* () {
50
+ yield* claimResourceId('Nix.JobRetirement', 'caddy');
51
+ yield* claimResourceId('Caddy.Config', 'caddy');
52
+ }),
53
+ ),
54
+ );
55
+ const exit = await run;
56
+ expect(exit._tag).toBe('Failure');
57
+ if (exit._tag !== 'Failure') return;
58
+ const [reason] = exit.cause.reasons;
59
+ const defect = reason?._tag === 'Die' ? reason.defect : undefined;
60
+ expect(defect).toBeInstanceOf(DuplicateResourceIdError);
61
+ const error = defect as DuplicateResourceIdError;
62
+ expect(error.message).toContain('REFUSING: duplicate resource id "caddy"');
63
+ expect(error.message).toContain('first declared by Nix.JobRetirement');
64
+ expect(error.message).toContain('again by Caddy.Config');
65
+ expect(error.scope).toBe('(root)');
66
+ });
67
+
68
+ test('the same id in a nested namespace is a different scope, so it does not collide', async () => {
69
+ await Effect.runPromise(
70
+ withStack(
71
+ Effect.gen(function* () {
72
+ yield* claimResourceId('Caddy.Config', 'caddy');
73
+ yield* pushNamespace('child', claimResourceId('Nix.JobRetirement', 'caddy'));
74
+ }),
75
+ ),
76
+ );
77
+ });
78
+
79
+ test('two unrelated stacks never see each others claims', async () => {
80
+ const stackOne = fakeStack();
81
+ const stackTwo = fakeStack();
82
+ await Effect.runPromise(
83
+ claimResourceId('Caddy.Config', 'caddy').pipe(
84
+ Effect.provideService(Stack, stackOne as never),
85
+ ),
86
+ );
87
+ // A DIFFERENT stack object claiming the same id with a different type is fine — the
88
+ // registry is keyed by the Stack instance (see duplicate-id.ts), not the bare fqn string.
89
+ await Effect.runPromise(
90
+ claimResourceId('Nix.JobRetirement', 'caddy').pipe(
91
+ Effect.provideService(Stack, stackTwo as never),
92
+ ),
93
+ );
94
+ });
95
+ });
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Refuse a second resource claiming a logical id already taken in the same stack scope.
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.
30
+ */
31
+ import { toFqn } from 'alchemy/FQN';
32
+ import { CurrentNamespace, type NamespaceNode } from 'alchemy/Namespace';
33
+ import { Stack } from 'alchemy/Stack';
34
+ import * as Data from 'effect/Data';
35
+ import * as Effect from 'effect/Effect';
36
+
37
+ /**
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.
40
+ */
41
+ export class DuplicateResourceIdError extends Data.TaggedError('DuplicateResourceIdError')<{
42
+ message: string;
43
+ fqn: string;
44
+ scope: string;
45
+ firstType: string;
46
+ secondType: string;
47
+ }> {}
48
+
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
+ /**
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.
88
+ */
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
+ });
@@ -0,0 +1,128 @@
1
+ /**
2
+ * `guardDuplicateIds` driven through a REAL `Alchemy.Stack(...)` body with real `Resource()`
3
+ * classes — the same shape `litellm/fake-stack.ts` and `github/repo-apply-order-fake-stack.ts`
4
+ * use for the same reason their own headers give: a check built only against this module's own
5
+ * logic (duplicate-id.test.ts) proves the registry works, not that wiring it onto a resource
6
+ * class via a `Proxy` actually intercepts the call Alchemy itself makes when a stack body
7
+ * `yield*`s it.
8
+ *
9
+ * ⚠️ `dup-experiment.ts` (this PR's own throwaway script, not checked in) first measured the
10
+ * UNGUARDED failure mode this guards against: two different `Resource()` classes sharing a
11
+ * logical id compile a stack whose only entry at that fqn is the FIRST type, with no trace the
12
+ * second one ever ran. These tests are the regression guard for exactly that, once wrapped.
13
+ */
14
+ import { describe, expect, test } from 'bun:test';
15
+ import * as Alchemy from 'alchemy';
16
+ import { push as pushNamespace } from 'alchemy/Namespace';
17
+ import type { Resource, ResourceLike } from 'alchemy/Resource';
18
+ import * as Effect from 'effect/Effect';
19
+ import * as Layer from 'effect/Layer';
20
+ import { guardDuplicateIds } from './guard.ts';
21
+
22
+ interface Props {
23
+ readonly value: number;
24
+ }
25
+ interface TestResource extends Resource<string, Props, object> {}
26
+
27
+ /** A plain `Resource()` class, wrapped, for a given vendor-style type string. */
28
+ const guarded = (type: string) => guardDuplicateIds(Alchemy.Resource<TestResource>(type));
29
+
30
+ type StackBody = Effect.Effect<unknown, unknown, unknown>;
31
+
32
+ /** Same cast-at-the-typed-boundary shape as `litellm/fake-stack.ts`; see that file's own header. */
33
+ const runStack = (body: StackBody) => {
34
+ const state = Alchemy.inMemoryState();
35
+ const stack = Alchemy.Stack as unknown as (
36
+ stackName: string,
37
+ options: { providers: Layer.Layer<never>; state: typeof state },
38
+ body: StackBody,
39
+ ) => Effect.Effect<
40
+ { readonly resources: Readonly<Record<string, ResourceLike>> },
41
+ unknown,
42
+ never
43
+ >;
44
+ return Effect.runPromiseExit(
45
+ stack('GuardTestStack', { providers: Layer.empty, state }, body).pipe(
46
+ Effect.provideService(Alchemy.Stage, 'test'),
47
+ Effect.scoped,
48
+ ),
49
+ );
50
+ };
51
+
52
+ describe('guardDuplicateIds, through a real stack', () => {
53
+ test('distinct ids both register', async () => {
54
+ const A = guarded('Test.A');
55
+ const B = guarded('Test.B');
56
+ const exit = await runStack(
57
+ Effect.gen(function* () {
58
+ yield* A('one', { value: 1 });
59
+ yield* B('two', { value: 2 });
60
+ }),
61
+ );
62
+ expect(exit._tag).toBe('Success');
63
+ if (exit._tag !== 'Success') return;
64
+ expect(Object.keys(exit.value.resources).sort()).toEqual(['one', 'two']);
65
+ });
66
+
67
+ test('the same guarded type re-registering its own id stays a noop, not a death', async () => {
68
+ const A = guarded('Test.A');
69
+ const exit = await runStack(
70
+ Effect.gen(function* () {
71
+ yield* A('shared', { value: 1 });
72
+ yield* A('shared', { value: 1 });
73
+ }),
74
+ );
75
+ expect(exit._tag).toBe('Success');
76
+ });
77
+
78
+ test('two different guarded types sharing an id die naming both, before either resource "wins"', async () => {
79
+ const Retirement = guarded('Nix.JobRetirement');
80
+ const Caddy = guarded('Caddy.Config');
81
+ const exit = await runStack(
82
+ Effect.gen(function* () {
83
+ yield* Retirement('caddy', { value: 1 });
84
+ yield* Caddy('caddy', { value: 2 });
85
+ }),
86
+ );
87
+ expect(exit._tag).toBe('Failure');
88
+ if (exit._tag !== 'Failure') return;
89
+ const reasons = (exit.cause as { reasons: readonly { _tag: string; defect?: unknown }[] })
90
+ .reasons;
91
+ const reason = reasons[0];
92
+ expect(reason === undefined ? undefined : reason._tag).toBe('Die');
93
+ const defect = reason === undefined ? undefined : (reason.defect as Error | undefined);
94
+ expect(defect === undefined ? '' : String(defect.message)).toContain(
95
+ 'REFUSING: duplicate resource id "caddy" in scope (root): ' +
96
+ 'first declared by Nix.JobRetirement, again by Caddy.Config',
97
+ );
98
+ });
99
+
100
+ test('one guarded, one unguarded type sharing an id still dies (the guarded side still claims it)', async () => {
101
+ const Guarded = guarded('Test.Guarded');
102
+ const Unguarded = Alchemy.Resource<TestResource>('Test.Unguarded');
103
+ const exit = await runStack(
104
+ Effect.gen(function* () {
105
+ yield* Guarded('shared', { value: 1 });
106
+ yield* Unguarded('shared', { value: 2 });
107
+ }),
108
+ );
109
+ // The unguarded side never calls `claimResourceId`, so Alchemy's own idempotent-registration
110
+ // branch runs instead (Resource.ts) and this SUCCEEDS as a silent noop — exactly the gap this
111
+ // PR documents as out of scope for a kit-only fix (docs/duplicate-resource-id.md). Asserting
112
+ // the current behaviour, not endorsing it: `guardDuplicateIds` only protects pairs where BOTH
113
+ // sides are wrapped.
114
+ expect(exit._tag).toBe('Success');
115
+ });
116
+
117
+ test('the same id in a nested namespace is a different scope', async () => {
118
+ const Retirement = guarded('Nix.JobRetirement');
119
+ const Caddy = guarded('Caddy.Config');
120
+ const exit = await runStack(
121
+ Effect.gen(function* () {
122
+ yield* Retirement('caddy', { value: 1 });
123
+ yield* pushNamespace('child', Caddy('caddy', { value: 2 }));
124
+ }),
125
+ );
126
+ expect(exit._tag).toBe('Success');
127
+ });
128
+ });
@@ -0,0 +1,49 @@
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
+ import * as Effect from 'effect/Effect';
28
+ import { claimResourceId } from './duplicate-id.ts';
29
+
30
+ export const guardDuplicateIds = <R extends ResourceLike>(
31
+ resourceClass: ResourceClass<R>,
32
+ ): ResourceClass<R> =>
33
+ new Proxy(resourceClass, {
34
+ apply(target, thisArg, args: unknown[]) {
35
+ const id = args[0];
36
+ if (typeof id !== 'string') {
37
+ // The "attach methods" overload — not a registration, nothing to claim.
38
+ return Reflect.apply(target as (...a: unknown[]) => unknown, thisArg, args);
39
+ }
40
+ return Effect.gen(function* () {
41
+ yield* claimResourceId(resourceClass.Type, id);
42
+ return yield* Reflect.apply(
43
+ target as (...a: unknown[]) => unknown,
44
+ thisArg,
45
+ args,
46
+ ) as Effect.Effect<R>;
47
+ });
48
+ },
49
+ }) as ResourceClass<R>;
@@ -0,0 +1,20 @@
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:
9
+ *
10
+ * ```ts
11
+ * import { guardDuplicateIds } from '@homeflare/alchemy/scope';
12
+ *
13
+ * export const NixJobRetirement = guardDuplicateIds(Resource<NixJobRetirement>('Nix.JobRetirement'));
14
+ * ```
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
+ */
19
+ export { DuplicateResourceIdError, claimResourceId } from './duplicate-id.ts';
20
+ export { guardDuplicateIds } from './guard.ts';
@@ -149,3 +149,67 @@ describe('I2 -- every declarable field is wired into the drift field list', () =
149
149
  },
150
150
  );
151
151
  });
152
+
153
+ describe('set-like nested filter arrays compare as sets (homeflare-network PR 79)', () => {
154
+ const dest = (ports: number[], ips: string[]) =>
155
+ ({
156
+ zoneId: 'zone-storage',
157
+ trafficFilter: {
158
+ type: 'IP_ADDRESS',
159
+ ipAddressFilter: {
160
+ type: 'IP_ADDRESSES',
161
+ matchOpposite: false,
162
+ items: ips.map((value) => ({ type: 'IP_ADDRESS', value })),
163
+ },
164
+ portFilter: {
165
+ type: 'PORTS',
166
+ matchOpposite: false,
167
+ items: ports.map((value) => ({ type: 'PORT_NUMBER', value })),
168
+ },
169
+ },
170
+ }) as unknown as FirewallPolicyProps['destination'];
171
+
172
+ test('the measured case: declared [443, 9100], live [9100, 443] is a noop', () => {
173
+ const props = { ...PROPS, destination: dest([443, 9100], ['10.20.14.10']) };
174
+ const live = liveFirewallPolicy({ destination: dest([9100, 443], ['10.20.14.10']) as never });
175
+ expect(matches(attributesOf(live, props), props)).toBe(true);
176
+ expect(driftOf(live, props)).toEqual([]);
177
+ });
178
+
179
+ test('IP items in another order are a noop too, on source as well as destination', () => {
180
+ const props = { ...PROPS, source: dest([], ['10.0.0.1', '10.0.0.2']) as never };
181
+ const live = liveFirewallPolicy({ source: dest([], ['10.0.0.2', '10.0.0.1']) as never });
182
+ expect(driftOf(live, props)).toEqual([]);
183
+ });
184
+
185
+ test('a membership change still drifts: a port swapped, added or dropped', () => {
186
+ const props = { ...PROPS, destination: dest([443, 9100], ['10.20.14.10']) };
187
+ for (const ports of [[443, 22], [443, 9100, 22], [443]]) {
188
+ const live = liveFirewallPolicy({ destination: dest(ports, ['10.20.14.10']) as never });
189
+ expect(
190
+ driftOf(live, props).map((d) => d.field),
191
+ String(ports),
192
+ ).toEqual(['destination']);
193
+ }
194
+ });
195
+
196
+ test('a duplicate is not collapsed away: [443, 443] differs from [443]', () => {
197
+ const props = { ...PROPS, destination: dest([443], ['10.20.14.10']) };
198
+ const live = liveFirewallPolicy({ destination: dest([443, 443], ['10.20.14.10']) as never });
199
+ expect(driftOf(live, props).map((d) => d.field)).toEqual(['destination']);
200
+ });
201
+
202
+ test('mixed port items sort the same whatever their key order (range vs number)', () => {
203
+ const range = { start: 8000, stop: 8100, type: 'PORT_NUMBER_RANGE' };
204
+ const rangeDecoded = { type: 'PORT_NUMBER_RANGE', start: 8000, stop: 8100 };
205
+ const one = { type: 'PORT_NUMBER', value: 443 };
206
+ const withItems = (items: unknown[]) =>
207
+ ({
208
+ zoneId: 'zone-storage',
209
+ trafficFilter: { type: 'PORT', portFilter: { type: 'PORTS', matchOpposite: false, items } },
210
+ }) as unknown as FirewallPolicyProps['destination'];
211
+ const props = { ...PROPS, destination: withItems([range, one]) };
212
+ const live = liveFirewallPolicy({ destination: withItems([one, rangeDecoded]) as never });
213
+ expect(driftOf(live, props)).toEqual([]);
214
+ });
215
+ });