Open History to see the last commit
1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import picomatch from "picomatch";
4
5/** Selection applies after the whole stack declaration evaluates. */
6export interface ResourceSelection {
7 /** Exact FQNs, unique logical IDs, or FQN globs; dependencies are included. */
8 readonly include?: ReadonlyArray<string>;
9 /** Hard exclusions. A selected node depending on an excluded node fails planning. */
10 readonly exclude?: ReadonlyArray<string>;
11}
12
13/** Required filters return no output; possibly supplied filters make output optional. */
14export type SelectionOutput<A, Options> = Options extends
15 | { include: ReadonlyArray<string> }
16 | { exclude: ReadonlyArray<string> }
17 ? undefined
18 : Options extends undefined
19 ? A
20 : Extract<Options[keyof Options & keyof ResourceSelection], ReadonlyArray<string>> extends never
21 ? A
22 : A | undefined;
23
24export class InvalidResourceSelection extends Data.TaggedError("InvalidResourceSelection")<{
25 message: string;
26}> {}
27
28export class UnsafeSelectionBoundary extends Data.TaggedError("UnsafeSelectionBoundary")<{
29 message: string;
30}> {}
31
32/** Resolve and close the selection before accessing state or providers. */
33export const selectResources = <Node extends { FQN: string; LogicalId: string }>(
34 declared: ReadonlyArray<Node>,
35 upstream: (node: Node) => ReadonlyArray<string>,
36 options: ResourceSelection,
37) =>
38 Effect.gen(function* () {
39 if (options.include === undefined && options.exclude === undefined) {
40 return undefined;
41 }
42 const byFqn = new Map(declared.map((node) => [node.FQN, node]));
43 const available = declared
44 .map((node) => node.FQN)
45 .sort()
46 .join(", ");
47 const invalid = (message: string) =>
48 Effect.die(
49 new InvalidResourceSelection({
50 message: `${message}. Available resources and Actions: ${available || "(none)"}`,
51 }),
52 );
53 const resolve = (kind: "include" | "exclude", patterns: ReadonlyArray<string>) =>
54 Effect.gen(function* () {
55 if (patterns.length === 0) return yield* invalid(`${kind} must not be empty`);
56 const matches = new Map<string, string>();
57 for (const pattern of new Set(patterns)) {
58 if (pattern.trim() === "") return yield* invalid(`${kind} pattern must not be empty`);
59 const exact = byFqn.get(pattern);
60 let nodes: ReadonlyArray<Node>;
61 if (exact) {
62 nodes = [exact];
63 } else {
64 const scanned = picomatch.scan(pattern);
65 const parsed = yield* Effect.try(() => picomatch.parse(pattern)).pipe(
66 Effect.catch(() => invalid(`Invalid ${kind} pattern '${pattern}'`)),
67 );
68 if (
69 scanned.negated ||
70 scanned.negatedExtglob ||
71 parsed.negated ||
72 parsed.negatedExtglob
73 ) {
74 return yield* invalid(
75 `Negated pattern '${pattern}' is not supported; use --exclude instead`,
76 );
77 }
78 const matcher = yield* Effect.try(() =>
79 picomatch(pattern, {
80 dot: true,
81 strictBrackets: true,
82 nonegate: true,
83 }),
84 ).pipe(Effect.catch(() => invalid(`Invalid ${kind} pattern '${pattern}'`)));
85 const glob = scanned.isGlob;
86 nodes = declared.filter((node) =>
87 glob ? matcher(node.FQN) : node.LogicalId === pattern,
88 );
89 if (!glob && nodes.length > 1) {
90 return yield* invalid(
91 `Ambiguous ${kind} '${pattern}'; use an FQN: ${nodes
92 .map((node) => node.FQN)
93 .sort()
94 .join(", ")}`,
95 );
96 }
97 }
98 if (nodes.length === 0) {
99 if (kind === "include")
100 return yield* invalid(`Unknown or unmatched include '${pattern}'`);
101 yield* Effect.logWarning(
102 `Exclusion '${pattern}' matched no declared resources or Actions.`,
103 );
104 }
105 for (const node of nodes) {
106 if (!matches.has(node.FQN)) matches.set(node.FQN, pattern);
107 }
108 }
109 return matches;
110 });
111 const included =
112 options.include === undefined
113 ? new Map(declared.map((node) => [node.FQN, node.FQN]))
114 : yield* resolve("include", options.include);
115 const excluded =
116 options.exclude === undefined
117 ? new Map<string, string>()
118 : yield* resolve("exclude", options.exclude);
119 const selected = new Set([...included.keys()].filter((fqn) => !excluded.has(fqn)));
120 if (selected.size === 0) return yield* invalid("Resource selection is empty");
121 const chains = new Map([...selected].map((fqn) => [fqn, [fqn]]));
122 // Set iteration visits additions and terminates on dependency cycles.
123 for (const fqn of selected) {
124 for (const dependency of upstream(byFqn.get(fqn)!)) {
125 const chain = [...chains.get(fqn)!, dependency];
126 if (excluded.has(dependency)) {
127 return yield* invalid(
128 `Dependency ${chain.join(" -> ")} is blocked by exclude pattern '${excluded.get(dependency)}'`,
129 );
130 }
131 if (!byFqn.has(dependency)) {
132 return yield* invalid(`Dependency ${chain.join(" -> ")} is undeclared`);
133 }
134 if (!selected.has(dependency)) {
135 selected.add(dependency);
136 chains.set(dependency, chain);
137 }
138 }
139 }
140 return selected;
141 });