1import * as Axiom from "@distilled.cloud/axiom";
2import * as Effect from "effect/Effect";
3import { isResolved } from "../Diff.ts";
4import * as Provider from "../Provider.ts";
5import { Resource } from "../Resource.ts";
6import type { Providers } from "./Providers.ts";
7
8export type ViewProps = Axiom.CreateViewRequest;
9
10export type View = Resource<
11 "Axiom.View",
12 ViewProps,
13 Axiom.View & {
14 /**
15 * Path identifier used by `updateView` / `getView` / `deleteView`.
16 * Currently derived from `name` because Axiom's view list/get responses
17 * don't expose a separate id field.
18 */
19 id: string;
20 },
21 never,
22 Providers
23>;
24
25/**
26 * An Axiom saved view — a named, shareable APL query. Useful for building
27 * starter dashboards, providing canned "open in Axiom" links from your app,
28 * or pinning common investigations the team revisits.
29 *
30 * The path identifier is `name`. Renaming a view triggers a replacement
31 * (the old one is deleted, a new one is created).
32 * @see https://axiom.co/docs/query-data/datasets — APL query reference
33 *
34 * ### Creating a View
35 * **Example:** Recent errors across one dataset
36 * ```typescript
37 * yield* Axiom.View("recent-errors", {
38 * name: "recent-errors",
39 * description: "Last 100 5xx responses",
40 * datasets: ["my-app-traces"],
41 * aplQuery: `
42 * ['my-app-traces']
43 * | where status >= 500
44 * | order by _time desc
45 * | take 100
46 * `,
47 * });
48 * ```
49 *
50 * **Example:** Cross-dataset join (logs + traces by trace_id)
51 * ```typescript
52 * yield* Axiom.View("trace-with-logs", {
53 * name: "trace-with-logs",
54 * datasets: ["my-app-traces", "my-app-logs"],
55 * aplQuery: `
56 * ['my-app-traces']
57 * | where duration_ms > 1000
58 * | join kind=leftouter (['my-app-logs']) on trace_id
59 * `,
60 * });
61 * ```
62 *
63 * @resource
64 * @product Dashboard
65 */
66export const View = Resource<View>("Axiom.View");
67
68export const ViewProvider = () =>
69 Provider.effect(
70 View,
71 Effect.gen(function* () {
72 const create = yield* Axiom.createView;
73 const update = yield* Axiom.updateView;
74 const get = yield* Axiom.getView;
75 const listViews = yield* Axiom.getViews;
76 const del = yield* Axiom.deleteView;
77
78 return {
79 stables: ["id"],
80 // Enumerate every view in the org. Axiom exposes a single account-wide
81 // `GET /v2/views` collection op (no pagination) that already returns
82 // the full view objects, so we fetch it once and hydrate each row into
83 // the exact `read` Attributes shape (`id` is derived from `name`, the
84 // path identifier) — directly usable by `delete` with no follow-up get.
85 list: () =>
86 Effect.gen(function* () {
87 const views = yield* listViews({});
88 return views.map((view) => ({ ...view, id: view.name }));
89 }),
90 diff: Effect.fn(function* ({ news, output }) {
91 if (!isResolved(news)) return undefined;
92 if (output && news.name !== output.name) {
93 return { action: "replace" } as const;
94 }
95 return undefined;
96 }),
97 reconcile: Effect.fn(function* ({ news, output }) {
98 // Observe — `name` is the path identifier for views. Renames are
99 // forced to a replacement by `diff` above, so the cached
100 // `output.id` (set to `news.name` on first create) and the
101 // current `news.name` always agree by the time we land here.
102 const viewId = output?.id ?? news.name;
103 const observed = yield* get({ id: viewId }).pipe(
104 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
105 );
106
107 // Ensure — POST creates the view under `news.name`.
108 if (observed === undefined) {
109 const result = yield* create(news);
110 return { ...result, id: news.name };
111 }
112
113 // Sync — the view exists; PUT against its id with the desired
114 // props.
115 const result = yield* update({ ...news, id: viewId });
116 return { ...result, id: viewId };
117 }),
118 delete: Effect.fn(function* ({ output }) {
119 yield* del({ id: output.id }).pipe(
120 Effect.catchTag("NotFound", () => Effect.void),
121 );
122 }),
123 read: Effect.fn(function* ({ output }) {
124 if (!output?.id) return undefined;
125 return yield* get({ id: output.id }).pipe(
126 Effect.map((current) => ({ ...current, id: output.id })),
127 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
128 );
129 }),
130 };
131 }),
132 );