1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Stream from "effect/Stream";
5import { findProviderByType } from "../../Provider.ts";
6import { stampedMode } from "../../ProviderMode.ts";
7import * as State from "../../State/index.ts";
8import { AlchemistInvalidInput } from "../Errors.ts";
9import { open, type StackTarget } from "../Session.ts";
10
11export interface ResourceIdentity {
12 readonly fqn: string;
13 readonly logicalId: string;
14 readonly resourceType: string;
15}
16
17export interface LogResource extends ResourceIdentity {
18 readonly supportsQuery: boolean;
19 readonly supportsTail: boolean;
20}
21
22export interface LogEntry {
23 readonly resource: ResourceIdentity;
24 readonly timestamp: Date;
25 readonly message: string;
26}
27
28export interface LogInput {
29 readonly target: StackTarget;
30 /** Logical IDs to include. Empty or omitted means every resource. */
31 readonly resources?: ReadonlyArray<string>;
32}
33
34export interface QueryInput extends LogInput {
35 readonly limit?: number;
36 readonly since?: Date;
37}
38
39/**
40 * Open the stack and pair every deployed resource with the provider that
41 * can speak for it. `entries` and `tail` differ only in which provider hook
42 * they then call, so the selection lives here once.
43 */
44const select = Effect.fn(function* (input: LogInput) {
45 const session = yield* open(input.target);
46 // The local store sits UNDER the session context, so the stack's own
47 // store wins where it configures one: a project may only wire a remote
48 // store at deploy time, and `alchemy logs` still has to resolve what was
49 // deployed.
50 const context = Context.merge(
51 yield* Layer.build(State.localState()),
52 session.context,
53 );
54 const selected = new Set(input.resources ?? []);
55 const available = [
56 ...new Set(
57 Object.values(session.stack.resources).map(
58 (resource) => resource.LogicalId,
59 ),
60 ),
61 ].sort();
62 const unknown = [...selected].find((name) => !available.includes(name));
63 if (unknown !== undefined) {
64 return yield* Effect.fail(
65 new AlchemistInvalidInput({
66 field: "resources",
67 message: `Unknown resource '${unknown}'. Available: ${available.join(", ") || "(none)"}`,
68 }),
69 );
70 }
71 const rows = yield* Effect.provide(
72 Effect.gen(function* () {
73 const state = yield* yield* State.State;
74 return yield* Effect.forEach(
75 Object.entries(session.stack.resources),
76 ([fqn, resource]) =>
77 Effect.gen(function* () {
78 if (selected.size > 0 && !selected.has(resource.LogicalId)) {
79 return [];
80 }
81 const stored = yield* state.get({
82 stack: session.stack.name,
83 stage: session.stack.stage,
84 fqn,
85 });
86 if (!State.isResourceState(stored) || stored.attr === undefined) {
87 return [];
88 }
89 return [
90 {
91 resource: {
92 fqn,
93 logicalId: resource.LogicalId,
94 resourceType: resource.Type,
95 } satisfies ResourceIdentity,
96 provider: yield* findProviderByType(
97 resource.Type,
98 stampedMode(stored),
99 ),
100 request: {
101 id: resource.LogicalId,
102 fqn,
103 instanceId: stored.instanceId,
104 props: stored.props,
105 output: stored.attr,
106 },
107 },
108 ];
109 }),
110 ).pipe(Effect.map((rows) => rows.flat()));
111 }),
112 context,
113 );
114 return { context, rows };
115});
116
117/** Every deployed resource, with the log capabilities its provider offers. */
118export const resources = Effect.fn("Alchemist.logs.resources")(function* (
119 target: StackTarget,
120) {
121 const { rows } = yield* select({ target });
122 return rows.map(({ resource, provider }): LogResource => ({
123 ...resource,
124 supportsQuery: provider.logs !== undefined,
125 supportsTail: provider.tail !== undefined,
126 }));
127});
128
129/** Query past log entries across the selected resources, oldest first. */
130export const entries = Effect.fn("Alchemist.logs.entries")(function* (
131 input: QueryInput,
132) {
133 const { context, rows } = yield* select(input);
134 const entries = yield* Effect.provide(
135 Effect.forEach(rows, ({ resource, provider, request }) =>
136 provider.logs === undefined
137 ? Effect.succeed([])
138 : provider
139 .logs({
140 ...request,
141 options: {
142 limit: input.limit ?? 100,
143 since: input.since,
144 },
145 })
146 .pipe(
147 Effect.map((lines) =>
148 lines.map((line) => ({
149 resource,
150 timestamp: line.timestamp,
151 message: line.message,
152 })),
153 ),
154 ),
155 ),
156 context,
157 );
158 return entries
159 .flat()
160 .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
161});
162
163/** Live-stream log entries from every selected resource that supports it. */
164export const tail = (input: LogInput) =>
165 Stream.unwrap(
166 Effect.gen(function* () {
167 const { context, rows } = yield* select(input);
168 return Stream.mergeAll(
169 rows.flatMap(({ resource, provider, request }) =>
170 provider.tail === undefined
171 ? []
172 : [
173 Stream.provide(provider.tail(request), context).pipe(
174 Stream.map((line): LogEntry => ({
175 resource,
176 timestamp: line.timestamp,
177 message: line.message,
178 })),
179 ),
180 ],
181 ),
182 { concurrency: "unbounded" },
183 );
184 }),
185 );