@alchemy.run/axiom 2.0.0-beta.84 → 2.0.0-beta.86

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/lib/Annotation.d.ts.map +1 -1
  2. package/lib/Annotation.js +2 -2
  3. package/lib/Annotation.js.map +1 -1
  4. package/lib/ApiToken.d.ts +10 -0
  5. package/lib/ApiToken.d.ts.map +1 -1
  6. package/lib/ApiToken.js +10 -3
  7. package/lib/ApiToken.js.map +1 -1
  8. package/lib/AuthProvider.d.ts +1 -1
  9. package/lib/AuthProvider.d.ts.map +1 -1
  10. package/lib/AuthProvider.js.map +1 -1
  11. package/lib/Chart.d.ts.map +1 -1
  12. package/lib/Chart.js.map +1 -1
  13. package/lib/Contributions.d.ts +1 -1
  14. package/lib/Contributions.d.ts.map +1 -1
  15. package/lib/Contributions.js.map +1 -1
  16. package/lib/Credentials.d.ts +1 -1
  17. package/lib/Credentials.d.ts.map +1 -1
  18. package/lib/Credentials.js +7 -20
  19. package/lib/Credentials.js.map +1 -1
  20. package/lib/Dashboard.d.ts +24 -5
  21. package/lib/Dashboard.d.ts.map +1 -1
  22. package/lib/Dashboard.js +23 -10
  23. package/lib/Dashboard.js.map +1 -1
  24. package/lib/Dataset.d.ts +28 -1
  25. package/lib/Dataset.d.ts.map +1 -1
  26. package/lib/Dataset.js +24 -4
  27. package/lib/Dataset.js.map +1 -1
  28. package/lib/Monitor.d.ts +19 -1
  29. package/lib/Monitor.d.ts.map +1 -1
  30. package/lib/Monitor.js +15 -2
  31. package/lib/Monitor.js.map +1 -1
  32. package/lib/Notifier.d.ts +19 -1
  33. package/lib/Notifier.d.ts.map +1 -1
  34. package/lib/Notifier.js +15 -2
  35. package/lib/Notifier.js.map +1 -1
  36. package/lib/OtelHeaders.d.ts.map +1 -1
  37. package/lib/OtelHeaders.js.map +1 -1
  38. package/lib/Providers.d.ts +1 -1
  39. package/lib/Providers.d.ts.map +1 -1
  40. package/lib/Providers.js.map +1 -1
  41. package/lib/Telemetry.d.ts.map +1 -1
  42. package/lib/Telemetry.js.map +1 -1
  43. package/lib/View.d.ts +19 -1
  44. package/lib/View.d.ts.map +1 -1
  45. package/lib/View.js +15 -2
  46. package/lib/View.js.map +1 -1
  47. package/lib/VirtualField.d.ts +19 -1
  48. package/lib/VirtualField.d.ts.map +1 -1
  49. package/lib/VirtualField.js +15 -2
  50. package/lib/VirtualField.js.map +1 -1
  51. package/lib/index.d.ts.map +1 -1
  52. package/lib/index.js.map +1 -1
  53. package/package.json +8 -8
  54. package/src/Annotation.ts +2 -2
  55. package/src/ApiToken.ts +13 -3
  56. package/src/Credentials.ts +7 -49
  57. package/src/Dashboard.ts +43 -15
  58. package/src/Dataset.ts +41 -7
  59. package/src/Monitor.ts +27 -3
  60. package/src/Notifier.ts +30 -3
  61. package/src/View.ts +30 -3
  62. package/src/VirtualField.ts +27 -3
@@ -1,18 +1,8 @@
1
1
  import { Credentials } from "@distilled.cloud/axiom/Credentials";
2
2
  import { ConfigError } from "@distilled.cloud/core/errors";
3
- import {
4
- deferUntilFirstUse,
5
- orDieCredentialsUnavailable,
6
- resolveProviderConfig,
7
- } from "alchemy/Auth/Resolve";
8
- import * as Effect from "effect/Effect";
3
+ import { lazyCredentials } from "alchemy/Auth/Resolve";
9
4
  import * as Layer from "effect/Layer";
10
- import * as Match from "effect/Match";
11
- import {
12
- AXIOM_AUTH_PROVIDER_NAME,
13
- type AxiomAuthConfig,
14
- type AxiomResolvedCredentials,
15
- } from "./AuthProvider.ts";
5
+ import { AXIOM_AUTH_PROVIDER_NAME, type AxiomResolvedCredentials } from "./AuthProvider.ts";
16
6
 
17
7
  export {
18
8
  Credentials,
@@ -28,41 +18,9 @@ export {
28
18
  export const fromAuthProvider = () =>
29
19
  Layer.effect(
30
20
  Credentials,
31
- Effect.gen(function* () {
32
- // Defer profile lookup and credential resolution until first use, so
33
- // building the provider layers never requires a configured profile.
34
- const resolve = yield* resolveProviderConfig<AxiomAuthConfig, AxiomResolvedCredentials>(
35
- AXIOM_AUTH_PROVIDER_NAME,
36
- ).pipe(
37
- Effect.flatMap(({ profileName, resolve }) =>
38
- resolve.pipe(
39
- Effect.map((creds) =>
40
- Match.value(creds).pipe(
41
- Match.when({ type: "apiToken" }, (c) => ({
42
- apiKey: c.apiToken,
43
- apiBaseUrl: c.apiBaseUrl,
44
- orgId: c.orgId,
45
- })),
46
- Match.when({ type: "pat" }, (c) => ({
47
- apiKey: c.apiToken,
48
- apiBaseUrl: c.apiBaseUrl,
49
- orgId: c.orgId,
50
- })),
51
- Match.exhaustive,
52
- ),
53
- ),
54
- Effect.mapError((e) =>
55
- ConfigError.make({
56
- message: `Failed to resolve Axiom credentials from ${profileName === undefined ? "the CI environment" : `profile '${profileName}'`}: ${(e as { message?: string }).message ?? String(e)}`,
57
- }),
58
- ),
59
- ),
60
- ),
61
- deferUntilFirstUse,
62
- );
63
- return yield* resolve.pipe(
64
- orDieCredentialsUnavailable(AXIOM_AUTH_PROVIDER_NAME),
65
- Effect.cached,
66
- );
67
- }),
21
+ lazyCredentials<AxiomResolvedCredentials, ConfigError>()(AXIOM_AUTH_PROVIDER_NAME, (creds) => ({
22
+ apiKey: creds.apiToken,
23
+ apiBaseUrl: creds.apiBaseUrl,
24
+ orgId: creds.orgId,
25
+ })),
68
26
  );
package/src/Dashboard.ts CHANGED
@@ -14,17 +14,28 @@ export type Dashboard = Resource<
14
14
  /** Stable Axiom dashboard `uid` (used as the path identifier). */
15
15
  uid: string;
16
16
  id: string;
17
- createdAt: string;
18
- createdBy: string;
19
- updatedAt: string;
20
- updatedBy: string;
17
+ /** Axiom omits the audit fields on some dashboards. */
18
+ createdAt: string | undefined;
19
+ createdBy: string | undefined;
20
+ updatedAt: string | undefined;
21
+ updatedBy: string | undefined;
21
22
  /** The full dashboard document as returned by Axiom. */
22
23
  dashboard: Axiom.DashboardWriteResponse["dashboard"]["dashboard"];
23
24
  },
24
25
  never,
25
- Providers
26
+ Providers,
27
+ DashboardLookup
26
28
  >;
27
29
 
30
+ /**
31
+ * Props for {@link Dashboard.lookup}: find a dashboard this stack
32
+ * doesn't manage by its id.
33
+ */
34
+ export interface DashboardLookup {
35
+ /** Axiom dashboard id. */
36
+ uid: string;
37
+ }
38
+
28
39
  /**
29
40
  * An Axiom dashboard — a named, layout-driven collection of charts. Each
30
41
  * dashboard takes a full document (`charts` + `layout` array of grid cells +
@@ -114,6 +125,16 @@ export type Dashboard = Resource<
114
125
  * });
115
126
  * ```
116
127
  *
128
+ * ### Looking up an existing dashboard
129
+ * `Dashboard.lookup` references a dashboard Alchemy doesn't manage (created in
130
+ * the Axiom console or by another stack). It is read during plan/deploy,
131
+ * never created, updated or deleted.
132
+ *
133
+ * **Example:** Reference an existing dashboard
134
+ * ```typescript
135
+ * const ref = Axiom.Dashboard.lookup({ uid: "abc123" });
136
+ * ```
137
+ *
117
138
  * @resource
118
139
  * @product Dashboard
119
140
  */
@@ -132,19 +153,19 @@ export const DashboardProvider = () =>
132
153
  const toAttrsFromCreate = (envelope: Axiom.DashboardWriteResponse) => ({
133
154
  uid: envelope.dashboard.uid,
134
155
  id: envelope.dashboard.id,
135
- createdAt: envelope.dashboard.createdAt,
136
- createdBy: envelope.dashboard.createdBy,
137
- updatedAt: envelope.dashboard.updatedAt,
138
- updatedBy: envelope.dashboard.updatedBy,
156
+ createdAt: envelope.dashboard.createdAt ?? undefined,
157
+ createdBy: envelope.dashboard.createdBy ?? undefined,
158
+ updatedAt: envelope.dashboard.updatedAt ?? undefined,
159
+ updatedBy: envelope.dashboard.updatedBy ?? undefined,
139
160
  dashboard: envelope.dashboard.dashboard,
140
161
  });
141
162
  const toAttrsFromGet = (current: Axiom.DashboardResource) => ({
142
163
  uid: current.uid,
143
164
  id: current.id,
144
- createdAt: current.createdAt,
145
- createdBy: current.createdBy,
146
- updatedAt: current.updatedAt,
147
- updatedBy: current.updatedBy,
165
+ createdAt: current.createdAt ?? undefined,
166
+ createdBy: current.createdBy ?? undefined,
167
+ updatedAt: current.updatedAt ?? undefined,
168
+ updatedBy: current.updatedBy ?? undefined,
148
169
  dashboard: current.dashboard,
149
170
  });
150
171
 
@@ -157,7 +178,7 @@ export const DashboardProvider = () =>
157
178
  // by re-creating.
158
179
  const observed = output?.uid
159
180
  ? yield* get({ uid: output.uid }).pipe(
160
- Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
181
+ Effect.catchTag("NotFound", () => Effect.undefined),
161
182
  )
162
183
  : undefined;
163
184
 
@@ -174,11 +195,18 @@ export const DashboardProvider = () =>
174
195
  delete: Effect.fn(function* ({ output }) {
175
196
  yield* del({ uid: output.uid }).pipe(Effect.catchTag("NotFound", () => Effect.void));
176
197
  }),
198
+ lookup: Effect.fn(function* ({ uid }) {
199
+ return yield* get({ uid }).pipe(
200
+ Effect.map(toAttrsFromGet),
201
+ Effect.catchTag("NotFound", () => Effect.undefined),
202
+ );
203
+ }),
204
+
177
205
  read: Effect.fn(function* ({ output }) {
178
206
  if (!output?.uid) return undefined;
179
207
  return yield* get({ uid: output.uid }).pipe(
180
208
  Effect.map(toAttrsFromGet),
181
- Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
209
+ Effect.catchTag("NotFound", () => Effect.undefined),
182
210
  );
183
211
  }),
184
212
  // Enumerate every dashboard visible to the caller. `GET /v2/dashboards`
package/src/Dataset.ts CHANGED
@@ -6,10 +6,16 @@ import * as Provider from "alchemy/Provider";
6
6
  import { Resource } from "alchemy/Resource";
7
7
  import { Stack } from "alchemy/Stack";
8
8
  import { Stage } from "alchemy/Stage";
9
+ import * as Data from "effect/Data";
9
10
  import * as Effect from "effect/Effect";
10
11
  import { otelDatasetHeader } from "./OtelHeaders.ts";
11
12
  import type { Providers } from "./Providers.ts";
12
13
 
14
+ export class DatasetUnexpectedResponse extends Data.TaggedError("DatasetUnexpectedResponse")<{
15
+ readonly message: string;
16
+ readonly cause?: unknown;
17
+ }> {}
18
+
13
19
  export type DatasetKind =
14
20
  | "otel:metrics:v1"
15
21
  | "otel:traces:v1"
@@ -82,9 +88,19 @@ export type Dataset = Resource<
82
88
  otelHeaders: Record<string, string>;
83
89
  },
84
90
  never,
85
- Providers
91
+ Providers,
92
+ DatasetLookup
86
93
  >;
87
94
 
95
+ /**
96
+ * Props for {@link Dataset.lookup}: find a dataset this stack
97
+ * doesn't manage by its name.
98
+ */
99
+ export interface DatasetLookup {
100
+ /** Dataset name. */
101
+ name: string;
102
+ }
103
+
88
104
  /**
89
105
  * An Axiom dataset — the top-level container that stores events, logs,
90
106
  * traces, or metrics. Pick a `kind` up-front: it determines schema and how
@@ -132,6 +148,16 @@ export type Dataset = Resource<
132
148
  * });
133
149
  * ```
134
150
  *
151
+ * ### Looking up an existing dataset
152
+ * `Dataset.lookup` references a dataset Alchemy doesn't manage (created in
153
+ * the Axiom console or by another stack). It is read during plan/deploy,
154
+ * never created, updated or deleted.
155
+ *
156
+ * **Example:** Ingest into an existing dataset
157
+ * ```typescript
158
+ * const logs = Axiom.Dataset.lookup({ name: "logs" });
159
+ * ```
160
+ *
135
161
  * @resource
136
162
  * @product Dataset
137
163
  */
@@ -169,7 +195,7 @@ export const DatasetProvider = () =>
169
195
  Provider.effect(
170
196
  Dataset,
171
197
  Effect.gen(function* () {
172
- const { apiBaseUrl } = yield* yield* Credentials;
198
+ const credentials = yield* Credentials;
173
199
  const create = yield* Axiom.createDataset;
174
200
  const update = yield* Axiom.updateDataset;
175
201
  const get = yield* Axiom.getDataset;
@@ -178,10 +204,11 @@ export const DatasetProvider = () =>
178
204
 
179
205
  const toAttrs = Effect.fn(function* (dataset: Axiom.Dataset) {
180
206
  if (!dataset.edgeDeployment || !dataset.edgeDeploymentUrl) {
181
- return yield* Effect.fail(
182
- new Error(`Axiom dataset "${dataset.name}" is missing its edge deployment metadata`),
183
- );
207
+ return yield* new DatasetUnexpectedResponse({
208
+ message: `Axiom dataset "${dataset.name}" is missing its edge deployment metadata`,
209
+ });
184
210
  }
211
+ const { apiBaseUrl } = yield* credentials;
185
212
  const apiRoot = apiBaseUrl.replace(/\/$/, "");
186
213
  const otelRoot = dataset.edgeDeploymentUrl.replace(/\/$/, "");
187
214
  return {
@@ -246,7 +273,7 @@ export const DatasetProvider = () =>
246
273
  // here mutation is safe.
247
274
  const datasetId = output?.id ?? news.name;
248
275
  const observed = yield* get({ dataset_id: datasetId }).pipe(
249
- Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
276
+ Effect.catchTag("NotFound", () => Effect.undefined),
250
277
  );
251
278
 
252
279
  // Ensure — POST creates the dataset. Tolerate Conflict/
@@ -303,13 +330,20 @@ export const DatasetProvider = () =>
303
330
  Effect.catchTag("NotFound", () => Effect.void),
304
331
  );
305
332
  }),
333
+ lookup: Effect.fn(function* ({ name }) {
334
+ const existing = yield* get({ dataset_id: name }).pipe(
335
+ Effect.catchTag("NotFound", () => Effect.undefined),
336
+ );
337
+ return existing === undefined ? undefined : yield* toAttrs(existing);
338
+ }),
339
+
306
340
  read: Effect.fn(function* ({ id, olds, output }) {
307
341
  const stack = yield* Stack;
308
342
  const stage = yield* Stage;
309
343
  const datasetId = output?.id ?? olds?.name;
310
344
  if (!datasetId) return undefined;
311
345
  const existing = yield* get({ dataset_id: datasetId }).pipe(
312
- Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
346
+ Effect.catchTag("NotFound", () => Effect.undefined),
313
347
  );
314
348
  if (!existing) return undefined;
315
349
  const ownership = parseMarker(existing.description);
package/src/Monitor.ts CHANGED
@@ -12,9 +12,19 @@ export type Monitor = Resource<
12
12
  MonitorProps,
13
13
  Axiom.CreateMonitorResponse,
14
14
  never,
15
- Providers
15
+ Providers,
16
+ MonitorLookup
16
17
  >;
17
18
 
19
+ /**
20
+ * Props for {@link Monitor.lookup}: find a monitor this stack
21
+ * doesn't manage by its id.
22
+ */
23
+ export interface MonitorLookup {
24
+ /** Axiom monitor id. */
25
+ id: string;
26
+ }
27
+
18
28
  /**
19
29
  * An Axiom monitor — a scheduled APL/MPL query that evaluates on a fixed
20
30
  * cadence and fires alerts via {@link Notifier notifiers} when its
@@ -79,6 +89,16 @@ export type Monitor = Resource<
79
89
  * });
80
90
  * ```
81
91
  *
92
+ * ### Looking up an existing monitor
93
+ * `Monitor.lookup` references a monitor Alchemy doesn't manage (created in
94
+ * the Axiom console or by another stack). It is read during plan/deploy,
95
+ * never created, updated or deleted.
96
+ *
97
+ * **Example:** Reference an existing monitor
98
+ * ```typescript
99
+ * const ref = Axiom.Monitor.lookup({ id: "m_abc123" });
100
+ * ```
101
+ *
82
102
  * @resource
83
103
  * @product Monitor
84
104
  */
@@ -121,7 +141,7 @@ export const MonitorProvider = () =>
121
141
  // re-creating.
122
142
  const observed = output?.id
123
143
  ? yield* get({ id: output.id }).pipe(
124
- Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
144
+ Effect.catchTag("NotFound", () => Effect.undefined),
125
145
  )
126
146
  : undefined;
127
147
 
@@ -137,10 +157,14 @@ export const MonitorProvider = () =>
137
157
  delete: Effect.fn(function* ({ output }) {
138
158
  yield* del({ id: output.id }).pipe(Effect.catchTag("NotFound", () => Effect.void));
139
159
  }),
160
+ lookup: Effect.fn(function* ({ id }) {
161
+ return yield* get({ id }).pipe(Effect.catchTag("NotFound", () => Effect.undefined));
162
+ }),
163
+
140
164
  read: Effect.fn(function* ({ output }) {
141
165
  if (!output?.id) return undefined;
142
166
  return yield* get({ id: output.id }).pipe(
143
- Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
167
+ Effect.catchTag("NotFound", () => Effect.undefined),
144
168
  );
145
169
  }),
146
170
  };
package/src/Notifier.ts CHANGED
@@ -11,9 +11,19 @@ export type Notifier = Resource<
11
11
  NotifierProps,
12
12
  Axiom.CreateNotifierResponse & { id: string },
13
13
  never,
14
- Providers
14
+ Providers,
15
+ NotifierLookup
15
16
  >;
16
17
 
18
+ /**
19
+ * Props for {@link Notifier.lookup}: find a notifier this stack
20
+ * doesn't manage by its id.
21
+ */
22
+ export interface NotifierLookup {
23
+ /** Axiom notifier id. */
24
+ id: string;
25
+ }
26
+
17
27
  /**
18
28
  * An Axiom notifier — an alert destination (Slack, email, PagerDuty,
19
29
  * Opsgenie, Discord, Microsoft Teams, generic webhook, or a fully custom
@@ -65,6 +75,16 @@ export type Notifier = Resource<
65
75
  * });
66
76
  * ```
67
77
  *
78
+ * ### Looking up an existing notifier
79
+ * `Notifier.lookup` references a notifier Alchemy doesn't manage (created in
80
+ * the Axiom console or by another stack). It is read during plan/deploy,
81
+ * never created, updated or deleted.
82
+ *
83
+ * **Example:** Reference an existing notifier
84
+ * ```typescript
85
+ * const ref = Axiom.Notifier.lookup({ id: "n_abc123" });
86
+ * ```
87
+ *
68
88
  * @resource
69
89
  * @product Monitor
70
90
  */
@@ -100,7 +120,7 @@ export const NotifierProvider = () =>
100
120
  // by re-creating.
101
121
  const observed = output?.id
102
122
  ? yield* get({ id: output.id }).pipe(
103
- Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
123
+ Effect.catchTag("NotFound", () => Effect.undefined),
104
124
  )
105
125
  : undefined;
106
126
 
@@ -121,6 +141,13 @@ export const NotifierProvider = () =>
121
141
  delete: Effect.fn(function* ({ output }) {
122
142
  yield* del({ id: output.id }).pipe(Effect.catchTag("NotFound", () => Effect.void));
123
143
  }),
144
+ lookup: Effect.fn(function* ({ id }) {
145
+ return yield* get({ id }).pipe(
146
+ Effect.map((current) => ({ ...current, id: current.id ?? id })),
147
+ Effect.catchTag("NotFound", () => Effect.undefined),
148
+ );
149
+ }),
150
+
124
151
  read: Effect.fn(function* ({ output }) {
125
152
  if (!output?.id) return undefined;
126
153
  return yield* get({ id: output.id }).pipe(
@@ -128,7 +155,7 @@ export const NotifierProvider = () =>
128
155
  ...current,
129
156
  id: current.id ?? output.id,
130
157
  })),
131
- Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
158
+ Effect.catchTag("NotFound", () => Effect.undefined),
132
159
  );
133
160
  }),
134
161
  };
package/src/View.ts CHANGED
@@ -19,9 +19,19 @@ export type View = Resource<
19
19
  id: string;
20
20
  },
21
21
  never,
22
- Providers
22
+ Providers,
23
+ ViewLookup
23
24
  >;
24
25
 
26
+ /**
27
+ * Props for {@link View.lookup}: find a view this stack
28
+ * doesn't manage by its id.
29
+ */
30
+ export interface ViewLookup {
31
+ /** Axiom view id. */
32
+ id: string;
33
+ }
34
+
25
35
  /**
26
36
  * An Axiom saved view — a named, shareable APL query. Useful for building
27
37
  * starter dashboards, providing canned "open in Axiom" links from your app,
@@ -60,6 +70,16 @@ export type View = Resource<
60
70
  * });
61
71
  * ```
62
72
  *
73
+ * ### Looking up an existing view
74
+ * `View.lookup` references a view Alchemy doesn't manage (created in
75
+ * the Axiom console or by another stack). It is read during plan/deploy,
76
+ * never created, updated or deleted.
77
+ *
78
+ * **Example:** Reference an existing view
79
+ * ```typescript
80
+ * const ref = Axiom.View.lookup({ id: "v_abc123" });
81
+ * ```
82
+ *
63
83
  * @resource
64
84
  * @product Dashboard
65
85
  */
@@ -101,7 +121,7 @@ export const ViewProvider = () =>
101
121
  // current `news.name` always agree by the time we land here.
102
122
  const viewId = output?.id ?? news.name;
103
123
  const observed = yield* get({ id: viewId }).pipe(
104
- Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
124
+ Effect.catchTag("NotFound", () => Effect.undefined),
105
125
  );
106
126
 
107
127
  // Ensure — POST creates the view under `news.name`.
@@ -118,11 +138,18 @@ export const ViewProvider = () =>
118
138
  delete: Effect.fn(function* ({ output }) {
119
139
  yield* del({ id: output.id }).pipe(Effect.catchTag("NotFound", () => Effect.void));
120
140
  }),
141
+ lookup: Effect.fn(function* ({ id }) {
142
+ return yield* get({ id }).pipe(
143
+ Effect.map((current) => ({ ...current, id })),
144
+ Effect.catchTag("NotFound", () => Effect.undefined),
145
+ );
146
+ }),
147
+
121
148
  read: Effect.fn(function* ({ output }) {
122
149
  if (!output?.id) return undefined;
123
150
  return yield* get({ id: output.id }).pipe(
124
151
  Effect.map((current) => ({ ...current, id: output.id })),
125
- Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
152
+ Effect.catchTag("NotFound", () => Effect.undefined),
126
153
  );
127
154
  }),
128
155
  };
@@ -12,9 +12,19 @@ export type VirtualField = Resource<
12
12
  VirtualFieldProps,
13
13
  Axiom.CreateVirtualFieldResponse,
14
14
  never,
15
- Providers
15
+ Providers,
16
+ VirtualFieldLookup
16
17
  >;
17
18
 
19
+ /**
20
+ * Props for {@link VirtualField.lookup}: find a virtual field this stack
21
+ * doesn't manage by its id.
22
+ */
23
+ export interface VirtualFieldLookup {
24
+ /** Axiom virtual field id. */
25
+ id: string;
26
+ }
27
+
18
28
  /**
19
29
  * An Axiom virtual field — a saved APL expression that appears as a derived
20
30
  * column on a dataset at query time. Use these to standardise common
@@ -47,6 +57,16 @@ export type VirtualField = Resource<
47
57
  * });
48
58
  * ```
49
59
  *
60
+ * ### Looking up an existing virtual field
61
+ * `VirtualField.lookup` references a virtual field Alchemy doesn't manage (created in
62
+ * the Axiom console or by another stack). It is read during plan/deploy,
63
+ * never created, updated or deleted.
64
+ *
65
+ * **Example:** Reference an existing virtual field
66
+ * ```typescript
67
+ * const ref = Axiom.VirtualField.lookup({ id: "vf_abc123" });
68
+ * ```
69
+ *
50
70
  * @resource
51
71
  * @product Dataset
52
72
  */
@@ -96,7 +116,7 @@ export const VirtualFieldProvider = () =>
96
116
  // re-creating.
97
117
  const observed = output?.id
98
118
  ? yield* get({ id: output.id }).pipe(
99
- Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
119
+ Effect.catchTag("NotFound", () => Effect.undefined),
100
120
  )
101
121
  : undefined;
102
122
 
@@ -112,10 +132,14 @@ export const VirtualFieldProvider = () =>
112
132
  delete: Effect.fn(function* ({ output }) {
113
133
  yield* del({ id: output.id }).pipe(Effect.catchTag("NotFound", () => Effect.void));
114
134
  }),
135
+ lookup: Effect.fn(function* ({ id }) {
136
+ return yield* get({ id }).pipe(Effect.catchTag("NotFound", () => Effect.undefined));
137
+ }),
138
+
115
139
  read: Effect.fn(function* ({ output }) {
116
140
  if (!output?.id) return undefined;
117
141
  return yield* get({ id: output.id }).pipe(
118
- Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
142
+ Effect.catchTag("NotFound", () => Effect.undefined),
119
143
  );
120
144
  }),
121
145
  };