1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import { State, type StateService, type StateStoreError } from "./State.ts";
4import { encodeState } from "./StateEncoding.ts";
5
6// The store viewed as a filesystem: `stack/stage/fqn` for a resource record,
7// `stack/stage/output` for a stage's outputs. Everything here addresses that
8// tree by slash-separated path, so a CLI or an RPC client can walk state
9// without knowing the backend's key layout.
10
11/** Reading or listing a path. Omit `path` for the root. */
12export interface TreeQuery {
13 readonly path?: string;
14 /** Recurse into every descendant instead of stopping at immediate children. */
15 readonly recursive?: boolean;
16}
17
18/** Deleting a path. The path is required — the root is not deletable. */
19export interface TreeDelete {
20 readonly path: string;
21 /** Delete every descendant when the path identifies a directory. */
22 readonly recursive?: boolean;
23}
24
25/** One decoded record produced by {@link readState}. */
26export interface StateEntry {
27 readonly path: string;
28 readonly kind: "resource" | "output";
29 readonly value: unknown;
30}
31
32/** The path does not exist, or does not support the requested operation. */
33export class InvalidStatePath extends Data.TaggedError("InvalidStatePath")<{
34 readonly path: string;
35 readonly reason: string;
36}> {}
37
38export interface StateDeleted {
39 readonly _tag: "StateDeleted";
40 readonly path: string;
41 readonly deleted: ReadonlyArray<string>;
42}
43
44/**
45 * Every `(stack, stage)` pair the store holds, or the subset a filter pins.
46 *
47 * Enumerating them means `listStacks` then a `listStages` per stack, so the
48 * fan-out runs concurrently: against a remote store a serial walk is one
49 * round trip per stack before the first stage is even known. A pinned
50 * `stack`/`stage` is taken at face value and skips the corresponding call —
51 * naming a scope is not a claim that it exists, and callers report a missing
52 * one from the emptiness of what comes back.
53 *
54 * Ordered by stack then stage so every traversal built on it is deterministic.
55 */
56export const allStages = Effect.fn("allStages")(function* (
57 filter: { readonly stack?: string; readonly stage?: string } = {},
58) {
59 return yield* stagesOf(yield* yield* State, filter);
60});
61
62const stagesOf = (
63 state: StateService,
64 filter: { readonly stack?: string; readonly stage?: string },
65): Effect.Effect<
66 ReadonlyArray<{ readonly stack: string; readonly stage: string }>,
67 StateStoreError
68> =>
69 Effect.gen(function* () {
70 const stacks =
71 filter.stack !== undefined
72 ? [filter.stack]
73 : [...(yield* state.listStacks())].sort();
74 if (filter.stage !== undefined) {
75 const stage = filter.stage;
76 return stacks.map((stack) => ({ stack, stage }));
77 }
78 const perStack = yield* Effect.forEach(
79 stacks,
80 (stack) =>
81 Effect.map(state.listStages(stack), (stages) =>
82 [...stages].sort().map((stage) => ({ stack, stage })),
83 ),
84 { concurrency: "unbounded" },
85 );
86 return perStack.flat();
87 });
88
89type StateFile =
90 | {
91 readonly kind: "resource";
92 readonly path: string;
93 readonly stack: string;
94 readonly stage: string;
95 readonly fqn: string;
96 }
97 | {
98 readonly kind: "output";
99 readonly path: string;
100 readonly stack: string;
101 readonly stage: string;
102 };
103
104const pathParts = (path: string | undefined): ReadonlyArray<string> =>
105 (path ?? "").split("/").filter((part) => part !== "" && part !== ".");
106
107const invalidPath = (path: string, reason = "path does not exist") =>
108 Effect.fail(new InvalidStatePath({ path, reason }));
109
110const parsePath = (path: string | undefined) => {
111 const parts = pathParts(path);
112 return parts.includes("..")
113 ? invalidPath(path ?? "/", "parent path segments are not allowed")
114 : Effect.succeed(parts);
115};
116
117const stageFiles = Effect.fn(function* (
118 state: StateService,
119 stack: string,
120 stage: string,
121) {
122 const fqns = yield* state.list({ stack, stage });
123 return [
124 ...fqns.map((fqn): StateFile => ({
125 kind: "resource",
126 path: `${stack}/${stage}/${fqn}`,
127 stack,
128 stage,
129 fqn,
130 })),
131 {
132 kind: "output",
133 path: `${stack}/${stage}/output`,
134 stack,
135 stage,
136 } as const,
137 ];
138});
139
140/** Every file under the given scope, listed one stage at a time in parallel. */
141const filesUnder = Effect.fn(function* (
142 state: StateService,
143 filter: { readonly stack?: string } = {},
144) {
145 const stages = yield* stagesOf(state, filter);
146 const perStage = yield* Effect.forEach(
147 stages,
148 ({ stack, stage }) => stageFiles(state, stack, stage),
149 { concurrency: "unbounded" },
150 );
151 return perStage.flat();
152});
153
154const filesAt = Effect.fn(function* (
155 state: StateService,
156 parts: ReadonlyArray<string>,
157) {
158 const path = parts.join("/");
159 if (parts.length === 0) {
160 return { directory: true as const, files: yield* filesUnder(state) };
161 }
162 if (parts.length === 1) {
163 const stack = parts[0]!;
164 if (!(yield* state.listStacks()).includes(stack)) {
165 return yield* invalidPath(path);
166 }
167 return {
168 directory: true as const,
169 files: yield* filesUnder(state, { stack }),
170 };
171 }
172 if (parts.length === 2) {
173 const [stack, stage] = parts as [string, string];
174 if (!(yield* state.listStages(stack)).includes(stage)) {
175 return yield* invalidPath(path);
176 }
177 return {
178 directory: true as const,
179 files: yield* stageFiles(state, stack, stage),
180 };
181 }
182 const files = yield* stageFiles(state, parts[0]!, parts[1]!);
183 const exact = files.find((file) => file.path === path);
184 if (exact !== undefined) return { directory: false as const, files: [exact] };
185 const descendants = files.filter((file) => file.path.startsWith(`${path}/`));
186 if (descendants.length === 0) return yield* invalidPath(path);
187 return { directory: true as const, files: descendants };
188});
189
190export const listState = Effect.fn("listState")(function* ({
191 path,
192 recursive = false,
193}: TreeQuery) {
194 const state = yield* yield* State;
195 const parts = yield* parsePath(path);
196 const normalizedPath = parts.join("/");
197 if (parts.length === 0 && !recursive) {
198 return (yield* state.listStacks()).map((stack) => `${stack}/`);
199 }
200 const { directory, files } = yield* filesAt(state, parts);
201 if (!directory) return [normalizedPath];
202 const prefix = normalizedPath === "" ? "" : `${normalizedPath}/`;
203 if (recursive) return files.map((file) => file.path);
204 return [
205 ...new Set(
206 files.map((file) => {
207 const rest = file.path.slice(prefix.length);
208 const child = rest.split("/")[0]!;
209 return rest.includes("/") ? `${prefix}${child}/` : file.path;
210 }),
211 ),
212 ];
213});
214
215export const readState = Effect.fn("readState")(function* ({
216 path,
217 recursive = false,
218}: TreeQuery) {
219 const state = yield* yield* State;
220 const parts = yield* parsePath(path);
221 const target = yield* filesAt(state, parts);
222 if (target.directory && !recursive) {
223 return yield* invalidPath(
224 path ?? "/",
225 "path is a directory; set recursive to read its descendants",
226 );
227 }
228 return yield* Effect.forEach(target.files, (file) =>
229 (file.kind === "output" ? state.getOutput(file) : state.get(file)).pipe(
230 Effect.map((value): StateEntry => ({
231 path: file.path,
232 kind: file.kind,
233 value: encodeState(value) ?? null,
234 })),
235 ),
236 );
237});
238
239export const deleteState = Effect.fn("deleteState")(function* ({
240 path,
241 recursive = false,
242}: TreeDelete) {
243 const state = yield* yield* State;
244 const parts = yield* parsePath(path);
245 if (parts.length === 0) {
246 return yield* invalidPath(path, "the state root cannot be deleted");
247 }
248 const target = yield* filesAt(state, parts);
249 if (target.directory && !recursive) {
250 return yield* invalidPath(
251 path,
252 "path is a directory; set recursive to delete its descendants",
253 );
254 }
255 if (parts.length === 1) {
256 yield* state.deleteStack({ stack: parts[0]! });
257 } else if (parts.length === 2) {
258 yield* state.deleteStack({ stack: parts[0]!, stage: parts[1]! });
259 } else {
260 const resources = target.files.filter(
261 (file): file is Extract<StateFile, { kind: "resource" }> =>
262 file.kind === "resource",
263 );
264 if (resources.length === 0) {
265 return yield* invalidPath(path, "output cannot be deleted independently");
266 }
267 yield* Effect.forEach(resources, (file) => state.delete(file), {
268 concurrency: 32,
269 discard: true,
270 });
271 }
272 return {
273 _tag: "StateDeleted" as const,
274 path: parts.join("/"),
275 deleted: target.files.map((file) => file.path),
276 };
277});