import * as Axiom from "@distilled.cloud/axiom"; import * as Effect from "effect/Effect"; import { isResolved } from "../Diff.ts"; import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; import type { Providers } from "./Providers.ts"; export type VirtualFieldProps = Axiom.CreateVirtualFieldRequest; export type VirtualField = Resource< "Axiom.VirtualField", VirtualFieldProps, Axiom.CreateVirtualFieldResponse, never, Providers >; /** * An Axiom virtual field — a saved APL expression that appears as a derived * column on a dataset at query time. Use these to standardise common * computations (status classes, latency buckets, parsed JSON paths) so * dashboards and monitors don't have to redefine them. * * Bound to a single `dataset`; changing the dataset triggers a replacement. * @see https://axiom.co/docs/query-data/virtual-fields * * ### Creating a Virtual Field * **Example:** HTTP status class (e.g. 200 → "2xx") * ```typescript * yield* Axiom.VirtualField("status-class", { * dataset: "my-app-traces", * name: "status_class", * description: "HTTP response class bucket", * expression: 'strcat(tostring(toint(status / 100)), "xx")', * type: "string", * }); * ``` * * **Example:** Latency bucket in seconds * ```typescript * yield* Axiom.VirtualField("latency-bucket", { * dataset: "my-app-traces", * name: "latency_bucket_s", * expression: "bin(duration_ms / 1000.0, 0.5)", * type: "number", * unit: "s", * }); * ``` * * @resource */ export const VirtualField = Resource("Axiom.VirtualField"); export const VirtualFieldProvider = () => Provider.effect( VirtualField, Effect.gen(function* () { const create = yield* Axiom.createVirtualField; const update = yield* Axiom.updateVirtualField; const get = yield* Axiom.getVirtualField; const del = yield* Axiom.deleteVirtualField; const listVirtualFields = yield* Axiom.getVirtualFields; const listDatasets = yield* Axiom.getDatasets; return { stables: ["id"], // Axiom only exposes a per-dataset virtual-field enumeration // (`GET /v2/vfields?dataset=...`); there is no account-wide list. So // enumerate every dataset (`GET /v2/datasets`), fan out the per-dataset // vfields list with bounded concurrency, and flatten. Each row already // matches the `read` Attributes shape (`CreateVirtualFieldOutput`), so // it's directly usable by `delete` with no follow-up read. list: () => Effect.gen(function* () { const datasets = yield* listDatasets({}); const perDataset = yield* Effect.forEach( datasets, (ds) => listVirtualFields({ dataset: ds.name }), { concurrency: 10 }, ); return perDataset.flat().map((vf) => ({ ...vf })); }), diff: Effect.fn(function* ({ news, output }) { if (!isResolved(news)) return undefined; if (output && news.dataset !== output.dataset) { return { action: "replace" } as const; } return undefined; }), reconcile: Effect.fn(function* ({ news, output }) { // Observe — Axiom assigns the virtual-field id server-side, so // the only handle to a previously-created field is the cached // `output.id`. Probe for live state with that id; treat NotFound // (deleted out-of-band) as "no observed state" so we converge by // re-creating. const observed = output?.id ? yield* get({ id: output.id }).pipe( Effect.catchTag("NotFound", () => Effect.succeed(undefined)), ) : undefined; // Ensure — POST mints a new virtual field with a fresh id. if (observed === undefined) { return yield* create(news); } // Sync — the field exists; PUT against its id with the desired // props. `dataset` is replacement-only (handled in diff). return yield* update({ ...news, id: observed.id }); }), delete: Effect.fn(function* ({ output }) { yield* del({ id: output.id }).pipe( Effect.catchTag("NotFound", () => Effect.void), ); }), read: Effect.fn(function* ({ output }) { if (!output?.id) return undefined; return yield* get({ id: output.id }).pipe( Effect.catchTag("NotFound", () => Effect.succeed(undefined)), ); }), }; }), );