@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
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
diff --git a/lib/Resource.d.ts b/lib/Resource.d.ts
|
|
2
|
+
index 07b92ae346b5f16475e8162e6d72284fceb8ce1c..868e8d1b6b23dbdfd558e1ff03a535c1be70801c 100644
|
|
3
|
+
--- a/lib/Resource.d.ts
|
|
4
|
+
+++ b/lib/Resource.d.ts
|
|
5
|
+
@@ -229,6 +229,23 @@ export declare class MissingImplementationError extends MissingImplementationErr
|
|
6
|
+
}> {
|
|
7
|
+
}
|
|
8
|
+
export declare const missingImplementation: (type: string, id: string) => MissingImplementationError;
|
|
9
|
+
+declare const DuplicateResourceIdError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
|
|
10
|
+
+ readonly _tag: "DuplicateResourceIdError";
|
|
11
|
+
+} & Readonly<A>;
|
|
12
|
+
+/**
|
|
13
|
+
+ * HOMEFLARE PATCH (homeflare-kit PR superseding #468) — see Resource.ts for the full comment.
|
|
14
|
+
+ * A second resource of a DIFFERENT type registered at a logical id an earlier resource
|
|
15
|
+
+ * already claimed in the same scope (namespace + id, i.e. the same FQN).
|
|
16
|
+
+ */
|
|
17
|
+
+export declare class DuplicateResourceIdError extends DuplicateResourceIdError_base<{
|
|
18
|
+
+ message: string;
|
|
19
|
+
+ fqn: string;
|
|
20
|
+
+ /** The type that first claimed `fqn`. */
|
|
21
|
+
+ firstType: string;
|
|
22
|
+
+ /** The different type that tried to claim it again. */
|
|
23
|
+
+ secondType: string;
|
|
24
|
+
+}> {
|
|
25
|
+
+}
|
|
26
|
+
/**
|
|
27
|
+
* Creates a resource constructor for a concrete resource type.
|
|
28
|
+
*
|
|
29
|
+
diff --git a/lib/Resource.js b/lib/Resource.js
|
|
30
|
+
index 71d9a3a4e0784ed09484736677d735c7c77444f8..e3b64e60b29538bfe46b094cfcf755eaea6673e7 100644
|
|
31
|
+
--- a/lib/Resource.js
|
|
32
|
+
+++ b/lib/Resource.js
|
|
33
|
+
@@ -4,7 +4,7 @@ import * as Effectable from "effect/Effectable";
|
|
34
|
+
import * as Layer from "effect/Layer";
|
|
35
|
+
import * as Option from "effect/Option";
|
|
36
|
+
import { AdoptPolicy } from "./AdoptPolicy.js";
|
|
37
|
+
-import { toFqn } from "./FQN.js";
|
|
38
|
+
+import { toFqn, toPath } from "./FQN.js";
|
|
39
|
+
import { CurrentNamespace } from "./Namespace.js";
|
|
40
|
+
import * as Output from "./Output.js";
|
|
41
|
+
import { Provider } from "./Provider.js";
|
|
42
|
+
@@ -60,6 +60,13 @@ export const isResourceOfType = (value, type) => (typeof value === "object" || t
|
|
43
|
+
*/
|
|
44
|
+
export class MissingImplementationError extends Data.TaggedError("MissingImplementationError") {
|
|
45
|
+
}
|
|
46
|
+
+/**
|
|
47
|
+
+ * HOMEFLARE PATCH (homeflare-kit PR superseding #468) — see Resource.ts for the full comment.
|
|
48
|
+
+ * A second resource of a DIFFERENT type registered at a logical id an earlier resource
|
|
49
|
+
+ * already claimed in the same scope (namespace + id, i.e. the same FQN).
|
|
50
|
+
+ */
|
|
51
|
+
+export class DuplicateResourceIdError extends Data.TaggedError("DuplicateResourceIdError") {
|
|
52
|
+
+}
|
|
53
|
+
export const missingImplementation = (type, id) => new MissingImplementationError({
|
|
54
|
+
type,
|
|
55
|
+
id,
|
|
56
|
+
@@ -128,7 +135,17 @@ export function Resource(type, options) {
|
|
57
|
+
conflictingMode: ambientMode,
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
- // // TODO(sam): check if props are different and die
|
|
61
|
+
+ // HOMEFLARE PATCH: Type, not Props — see Resource.ts for why.
|
|
62
|
+
+ if (existing.Type !== type) {
|
|
63
|
+
+ return yield* Effect.die(new DuplicateResourceIdError({
|
|
64
|
+
+ message: `REFUSING: duplicate resource id "${fqn}" in scope ` +
|
|
65
|
+
+ `${toPath(namespace).join("/") || "(root)"}: first declared by ` +
|
|
66
|
+
+ `${existing.Type}, again by ${type}`,
|
|
67
|
+
+ fqn,
|
|
68
|
+
+ firstType: existing.Type,
|
|
69
|
+
+ secondType: type,
|
|
70
|
+
+ }));
|
|
71
|
+
+ }
|
|
72
|
+
return existing;
|
|
73
|
+
}
|
|
74
|
+
const bind = (...args) => typeof args[0] === "string"
|
|
75
|
+
diff --git a/src/Resource.ts b/src/Resource.ts
|
|
76
|
+
index d34c1809b90aba6ff7dc7d3952e3ab87e62e61c1..24b4d4d4d8a26c67e17e761b6b1080d38068f858 100644
|
|
77
|
+
--- a/src/Resource.ts
|
|
78
|
+
+++ b/src/Resource.ts
|
|
79
|
+
@@ -5,7 +5,7 @@ import * as Layer from "effect/Layer";
|
|
80
|
+
import * as Option from "effect/Option";
|
|
81
|
+
import type { Pipeable } from "effect/Pipeable";
|
|
82
|
+
import { AdoptPolicy } from "./AdoptPolicy.ts";
|
|
83
|
+
-import { toFqn } from "./FQN.ts";
|
|
84
|
+
+import { toFqn, toPath } from "./FQN.ts";
|
|
85
|
+
import type { Input, InputProps, PropsInput } from "./Input.ts";
|
|
86
|
+
import { CurrentNamespace, type NamespaceNode } from "./Namespace.ts";
|
|
87
|
+
import * as Output from "./Output.ts";
|
|
88
|
+
@@ -293,6 +293,30 @@ export class MissingImplementationError extends Data.TaggedError("MissingImpleme
|
|
89
|
+
id: string;
|
|
90
|
+
}> {}
|
|
91
|
+
|
|
92
|
+
+/**
|
|
93
|
+
+ * A second resource of a DIFFERENT type registered (`yield*`ed) at a logical id an earlier
|
|
94
|
+
+ * resource already claimed in the same scope (namespace + id, i.e. the same FQN).
|
|
95
|
+
+ *
|
|
96
|
+
+ * ★ HOMEFLARE PATCH (homeflare-kit PR superseding #468, 2026-10-19). Upstream's own
|
|
97
|
+
+ * idempotent-registration branch below used to return the FIRST-registered resource for ANY
|
|
98
|
+
+ * second `yield*` at an already-claimed FQN — correct for "the same resource read from two
|
|
99
|
+
+ * call sites," but it never compared `Type`, so a second, DIFFERENT resource that merely
|
|
100
|
+
+ * picked the same id silently lost: its props were never written to `stack.resources`, so
|
|
101
|
+
+ * neither `Plan.make` nor any later inspection could tell two different resources ever
|
|
102
|
+
+ * registered. Measured: `NixJobRetirement` and `Caddy.Config` both declaring id `caddy` in
|
|
103
|
+
+ * one stack planned `[caddy] noop` and dropped the retirement entirely. This error, and the
|
|
104
|
+
+ * `Type` check below, close that gap. Only `Type` is compared, not `Props` — see the check's
|
|
105
|
+
+ * own comment for why.
|
|
106
|
+
+ */
|
|
107
|
+
+export class DuplicateResourceIdError extends Data.TaggedError("DuplicateResourceIdError")<{
|
|
108
|
+
+ message: string;
|
|
109
|
+
+ fqn: string;
|
|
110
|
+
+ /** The type that first claimed `fqn`. */
|
|
111
|
+
+ firstType: string;
|
|
112
|
+
+ /** The different type that tried to claim it again. */
|
|
113
|
+
+ secondType: string;
|
|
114
|
+
+}> {}
|
|
115
|
+
+
|
|
116
|
+
export const missingImplementation = (type: string, id: string) =>
|
|
117
|
+
new MissingImplementationError({
|
|
118
|
+
type,
|
|
119
|
+
@@ -373,7 +397,27 @@ export function Resource<R extends ResourceLike>(
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
- // // TODO(sam): check if props are different and die
|
|
124
|
+
+ // HOMEFLARE PATCH: the TODO above asked whether PROPS differ; this checks TYPE
|
|
125
|
+
+ // instead, which is the question that actually matters. Comparing props would be
|
|
126
|
+
+ // wrong — `Platform.ts`'s own tagged resources rewrite Props after first registration
|
|
127
|
+
+ // (Platform.ts ~581), and a bare-tag forward reference legitimately registers with
|
|
128
|
+
+ // `Props: undefined` until its `.make(...)` Layer builds (MissingImplementationError's
|
|
129
|
+
+ // own doc comment above). Type is stable from the resource's very first `yield*` and is
|
|
130
|
+
+ // exactly what distinguishes "the same resource, referenced twice" from "two different
|
|
131
|
+
+ // resources that picked the same id."
|
|
132
|
+
+ if (existing.Type !== type) {
|
|
133
|
+
+ return yield* Effect.die(
|
|
134
|
+
+ new DuplicateResourceIdError({
|
|
135
|
+
+ message:
|
|
136
|
+
+ `REFUSING: duplicate resource id "${fqn}" in scope ` +
|
|
137
|
+
+ `${toPath(namespace).join("/") || "(root)"}: first declared by ` +
|
|
138
|
+
+ `${existing.Type}, again by ${type}`,
|
|
139
|
+
+ fqn,
|
|
140
|
+
+ firstType: existing.Type,
|
|
141
|
+
+ secondType: type,
|
|
142
|
+
+ }),
|
|
143
|
+
+ );
|
|
144
|
+
+ }
|
|
145
|
+
return existing;
|
|
146
|
+
}
|
|
147
|
+
const bind = (
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
diff --git a/lib/Resource.d.ts b/lib/Resource.d.ts
|
|
2
|
+
index 07b92ae..bfd1a13 100644
|
|
3
|
+
--- a/lib/Resource.d.ts
|
|
4
|
+
+++ b/lib/Resource.d.ts
|
|
5
|
+
@@ -228,6 +228,24 @@ export declare class MissingImplementationError extends MissingImplementationErr
|
|
6
|
+
id: string;
|
|
7
|
+
}> {
|
|
8
|
+
}
|
|
9
|
+
+declare const DuplicateResourceIdError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
|
|
10
|
+
+ readonly _tag: "DuplicateResourceIdError";
|
|
11
|
+
+} & Readonly<A>;
|
|
12
|
+
+/**
|
|
13
|
+
+ * HOMEFLARE PATCH (homeflare-kit PR superseding #468; re-cut for alchemy@2.0.0-beta.83) — see
|
|
14
|
+
+ * Resource.ts for the full comment.
|
|
15
|
+
+ * A second resource of a DIFFERENT type registered at a logical id an earlier resource
|
|
16
|
+
+ * already claimed in the same scope (namespace + id, i.e. the same FQN).
|
|
17
|
+
+ */
|
|
18
|
+
+export declare class DuplicateResourceIdError extends DuplicateResourceIdError_base<{
|
|
19
|
+
+ message: string;
|
|
20
|
+
+ fqn: string;
|
|
21
|
+
+ /** The type that first claimed `fqn`. */
|
|
22
|
+
+ firstType: string;
|
|
23
|
+
+ /** The different type that tried to claim it again. */
|
|
24
|
+
+ secondType: string;
|
|
25
|
+
+}> {
|
|
26
|
+
+}
|
|
27
|
+
export declare const missingImplementation: (type: string, id: string) => MissingImplementationError;
|
|
28
|
+
/**
|
|
29
|
+
* Creates a resource constructor for a concrete resource type.
|
|
30
|
+
diff --git a/lib/Resource.js b/lib/Resource.js
|
|
31
|
+
index cbc4f09..b57d530 100644
|
|
32
|
+
--- a/lib/Resource.js
|
|
33
|
+
+++ b/lib/Resource.js
|
|
34
|
+
@@ -4,7 +4,7 @@ import * as Effectable from "effect/Effectable";
|
|
35
|
+
import * as Layer from "effect/Layer";
|
|
36
|
+
import * as Option from "effect/Option";
|
|
37
|
+
import { AdoptPolicy } from "./AdoptPolicy.js";
|
|
38
|
+
-import { toFqn } from "./FQN.js";
|
|
39
|
+
+import { toFqn, toPath } from "./FQN.js";
|
|
40
|
+
import { CurrentNamespace } from "./Namespace.js";
|
|
41
|
+
import * as Output from "./Output.js";
|
|
42
|
+
import { Provider } from "./Provider.js";
|
|
43
|
+
@@ -58,6 +58,14 @@ export const isResourceOfType = (value, type) => (typeof value === "object" || t
|
|
44
|
+
*/
|
|
45
|
+
export class MissingImplementationError extends Data.TaggedError("MissingImplementationError") {
|
|
46
|
+
}
|
|
47
|
+
+/**
|
|
48
|
+
+ * HOMEFLARE PATCH (homeflare-kit PR superseding #468; re-cut for alchemy@2.0.0-beta.83) — see
|
|
49
|
+
+ * Resource.ts for the full comment.
|
|
50
|
+
+ * A second resource of a DIFFERENT type registered at a logical id an earlier resource
|
|
51
|
+
+ * already claimed in the same scope (namespace + id, i.e. the same FQN).
|
|
52
|
+
+ */
|
|
53
|
+
+export class DuplicateResourceIdError extends Data.TaggedError("DuplicateResourceIdError") {
|
|
54
|
+
+}
|
|
55
|
+
export const missingImplementation = (type, id) => new MissingImplementationError({
|
|
56
|
+
type,
|
|
57
|
+
id,
|
|
58
|
+
@@ -126,7 +134,17 @@ export function Resource(type, options) {
|
|
59
|
+
conflictingMode: ambientMode,
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
- // // TODO(sam): check if props are different and die
|
|
63
|
+
+ // HOMEFLARE PATCH: Type, not Props — see Resource.ts for why.
|
|
64
|
+
+ if (existing.Type !== type) {
|
|
65
|
+
+ return yield* Effect.die(new DuplicateResourceIdError({
|
|
66
|
+
+ message: `REFUSING: duplicate resource id "${fqn}" in scope ` +
|
|
67
|
+
+ `${toPath(namespace).join("/") || "(root)"}: first declared by ` +
|
|
68
|
+
+ `${existing.Type}, again by ${type}`,
|
|
69
|
+
+ fqn,
|
|
70
|
+
+ firstType: existing.Type,
|
|
71
|
+
+ secondType: type,
|
|
72
|
+
+ }));
|
|
73
|
+
+ }
|
|
74
|
+
return existing;
|
|
75
|
+
}
|
|
76
|
+
const bind = (...args) => typeof args[0] === "string"
|
|
77
|
+
diff --git a/src/Resource.ts b/src/Resource.ts
|
|
78
|
+
index c4d0a36..29e5ca7 100644
|
|
79
|
+
--- a/src/Resource.ts
|
|
80
|
+
+++ b/src/Resource.ts
|
|
81
|
+
@@ -5,7 +5,7 @@ import * as Layer from "effect/Layer";
|
|
82
|
+
import * as Option from "effect/Option";
|
|
83
|
+
import type { Pipeable } from "effect/Pipeable";
|
|
84
|
+
import { AdoptPolicy } from "./AdoptPolicy.ts";
|
|
85
|
+
-import { toFqn } from "./FQN.ts";
|
|
86
|
+
+import { toFqn, toPath } from "./FQN.ts";
|
|
87
|
+
import type { Input, InputProps, PropsInput } from "./Input.ts";
|
|
88
|
+
import { CurrentNamespace, type NamespaceNode } from "./Namespace.ts";
|
|
89
|
+
import * as Output from "./Output.ts";
|
|
90
|
+
@@ -292,6 +292,30 @@ export class MissingImplementationError extends Data.TaggedError("MissingImpleme
|
|
91
|
+
id: string;
|
|
92
|
+
}> {}
|
|
93
|
+
|
|
94
|
+
+/**
|
|
95
|
+
+ * A second resource of a DIFFERENT type registered (`yield*`ed) at a logical id an earlier
|
|
96
|
+
+ * resource already claimed in the same scope (namespace + id, i.e. the same FQN).
|
|
97
|
+
+ *
|
|
98
|
+
+ * ★ HOMEFLARE PATCH (homeflare-kit PR superseding #468, 2026-10-19; re-cut for
|
|
99
|
+
+ * alchemy@2.0.0-beta.83). Upstream's own idempotent-registration branch below used to
|
|
100
|
+
+ * return the FIRST-registered resource for ANY second `yield*` at an already-claimed FQN —
|
|
101
|
+
+ * correct for "the same resource read from two call sites," but it never compared `Type`,
|
|
102
|
+
+ * so a second, DIFFERENT resource that merely picked the same id silently lost: its props
|
|
103
|
+
+ * were never written to `stack.resources`, so neither `Plan.make` nor any later inspection
|
|
104
|
+
+ * could tell two different resources ever registered. Measured: `NixJobRetirement` and
|
|
105
|
+
+ * `Caddy.Config` both declaring id `caddy` in one stack planned `[caddy] noop` and dropped
|
|
106
|
+
+ * the retirement entirely. This error, and the `Type` check below, close that gap. Only
|
|
107
|
+
+ * `Type` is compared, not `Props` — see the check's own comment for why.
|
|
108
|
+
+ */
|
|
109
|
+
+export class DuplicateResourceIdError extends Data.TaggedError("DuplicateResourceIdError")<{
|
|
110
|
+
+ message: string;
|
|
111
|
+
+ fqn: string;
|
|
112
|
+
+ /** The type that first claimed `fqn`. */
|
|
113
|
+
+ firstType: string;
|
|
114
|
+
+ /** The different type that tried to claim it again. */
|
|
115
|
+
+ secondType: string;
|
|
116
|
+
+}> {}
|
|
117
|
+
+
|
|
118
|
+
export const missingImplementation = (type: string, id: string) =>
|
|
119
|
+
new MissingImplementationError({
|
|
120
|
+
type,
|
|
121
|
+
@@ -372,7 +396,27 @@ export function Resource<R extends ResourceLike>(
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
- // // TODO(sam): check if props are different and die
|
|
126
|
+
+ // HOMEFLARE PATCH: the TODO above asked whether PROPS differ; this checks TYPE
|
|
127
|
+
+ // instead, which is the question that actually matters. Comparing props would be
|
|
128
|
+
+ // wrong — `Platform.ts`'s own tagged resources rewrite Props after first registration
|
|
129
|
+
+ // (Platform.ts ~581), and a bare-tag forward reference legitimately registers with
|
|
130
|
+
+ // `Props: undefined` until its `.make(...)` Layer builds (MissingImplementationError's
|
|
131
|
+
+ // own doc comment above). Type is stable from the resource's very first `yield*` and is
|
|
132
|
+
+ // exactly what distinguishes "the same resource, referenced twice" from "two different
|
|
133
|
+
+ // resources that picked the same id."
|
|
134
|
+
+ if (existing.Type !== type) {
|
|
135
|
+
+ return yield* Effect.die(
|
|
136
|
+
+ new DuplicateResourceIdError({
|
|
137
|
+
+ message:
|
|
138
|
+
+ `REFUSING: duplicate resource id "${fqn}" in scope ` +
|
|
139
|
+
+ `${toPath(namespace).join("/") || "(root)"}: first declared by ` +
|
|
140
|
+
+ `${existing.Type}, again by ${type}`,
|
|
141
|
+
+ fqn,
|
|
142
|
+
+ firstType: existing.Type,
|
|
143
|
+
+ secondType: type,
|
|
144
|
+
+ }),
|
|
145
|
+
+ );
|
|
146
|
+
+ }
|
|
147
|
+
return existing;
|
|
148
|
+
}
|
|
149
|
+
const bind = (
|
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
|
});
|