| 1 | import * as Data from "effect/Data"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import { State, type StateService, type StateStoreError } from "./State.ts"; |
| 4 | import { 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. */ |
| 12 | export 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. */ |
| 19 | export 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}. */ |
| 26 | export 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. */ |
| 33 | export class InvalidStatePath extends Data.TaggedError("InvalidStatePath")<{ |
| 34 | readonly path: string; |
| 35 | readonly reason: string; |
| 36 | }> {} |
| 37 | |
| 38 | export 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 | */ |
| 56 | export 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 | |
| 62 | const 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 | |
| 89 | type 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 | |
| 104 | const pathParts = (path: string | undefined): ReadonlyArray<string> => |
| 105 | (path ?? "").split("/").filter((part) => part !== "" && part !== "."); |
| 106 | |
| 107 | const invalidPath = (path: string, reason = "path does not exist") => |
| 108 | Effect.fail(new InvalidStatePath({ path, reason })); |
| 109 | |
| 110 | const 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 | |
| 117 | const 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. */ |
| 141 | const 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 | |
| 154 | const 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 | |
| 190 | export 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 | |
| 215 | export 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 | |
| 239 | export 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 | }); |