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 VirtualFieldProps = Axiom.CreateVirtualFieldRequest;
9
10export type VirtualField = Resource<
11 "Axiom.VirtualField",
12 VirtualFieldProps,
13 Axiom.CreateVirtualFieldResponse,
14 never,
15 Providers
16>;
17
18/**
19 * An Axiom virtual field — a saved APL expression that appears as a derived
20 * column on a dataset at query time. Use these to standardise common
21 * computations (status classes, latency buckets, parsed JSON paths) so
22 * dashboards and monitors don't have to redefine them.
23 *
24 * Bound to a single `dataset`; changing the dataset triggers a replacement.
25 * @see https://axiom.co/docs/query-data/virtual-fields
26 *
27 * ### Creating a Virtual Field
28 * **Example:** HTTP status class (e.g. 200 → "2xx")
29 * ```typescript
30 * yield* Axiom.VirtualField("status-class", {
31 * dataset: "my-app-traces",
32 * name: "status_class",
33 * description: "HTTP response class bucket",
34 * expression: 'strcat(tostring(toint(status / 100)), "xx")',
35 * type: "string",
36 * });
37 * ```
38 *
39 * **Example:** Latency bucket in seconds
40 * ```typescript
41 * yield* Axiom.VirtualField("latency-bucket", {
42 * dataset: "my-app-traces",
43 * name: "latency_bucket_s",
44 * expression: "bin(duration_ms / 1000.0, 0.5)",
45 * type: "number",
46 * unit: "s",
47 * });
48 * ```
49 *
50 * @resource
51 * @product Dataset
52 */
53export const VirtualField = Resource<VirtualField>("Axiom.VirtualField");
54
55export const VirtualFieldProvider = () =>
56 Provider.effect(
57 VirtualField,
58 Effect.gen(function* () {
59 const create = yield* Axiom.createVirtualField;
60 const update = yield* Axiom.updateVirtualField;
61 const get = yield* Axiom.getVirtualField;
62 const del = yield* Axiom.deleteVirtualField;
63 const listVirtualFields = yield* Axiom.getVirtualFields;
64 const listDatasets = yield* Axiom.getDatasets;
65
66 return {
67 stables: ["id"],
68 // Axiom only exposes a per-dataset virtual-field enumeration
69 // (`GET /v2/vfields?dataset=...`); there is no account-wide list. So
70 // enumerate every dataset (`GET /v2/datasets`), fan out the per-dataset
71 // vfields list with bounded concurrency, and flatten. Each row already
72 // matches the `read` Attributes shape (`CreateVirtualFieldOutput`), so
73 // it's directly usable by `delete` with no follow-up read.
74 list: () =>
75 Effect.gen(function* () {
76 const datasets = yield* listDatasets({});
77 const perDataset = yield* Effect.forEach(
78 datasets,
79 (ds) => listVirtualFields({ dataset: ds.name }),
80 { concurrency: 10 },
81 );
82 return perDataset.flat().map((vf) => ({ ...vf }));
83 }),
84 diff: Effect.fn(function* ({ news, output }) {
85 if (!isResolved(news)) return undefined;
86 if (output && news.dataset !== output.dataset) {
87 return { action: "replace" } as const;
88 }
89 return undefined;
90 }),
91 reconcile: Effect.fn(function* ({ news, output }) {
92 // Observe — Axiom assigns the virtual-field id server-side, so
93 // the only handle to a previously-created field is the cached
94 // `output.id`. Probe for live state with that id; treat NotFound
95 // (deleted out-of-band) as "no observed state" so we converge by
96 // re-creating.
97 const observed = output?.id
98 ? yield* get({ id: output.id }).pipe(
99 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
100 )
101 : undefined;
102
103 // Ensure — POST mints a new virtual field with a fresh id.
104 if (observed === undefined) {
105 return yield* create(news);
106 }
107
108 // Sync — the field exists; PUT against its id with the desired
109 // props. `dataset` is replacement-only (handled in diff).
110 return yield* update({ ...news, id: observed.id });
111 }),
112 delete: Effect.fn(function* ({ output }) {
113 yield* del({ id: output.id }).pipe(
114 Effect.catchTag("NotFound", () => Effect.void),
115 );
116 }),
117 read: Effect.fn(function* ({ output }) {
118 if (!output?.id) return undefined;
119 return yield* get({ id: output.id }).pipe(
120 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
121 );
122 }),
123 };
124 }),
125 );