@homeflare/alchemy 0.32.0 → 0.34.0

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 (83) hide show
  1. package/dist/grafana/alerting-provenance.d.ts +80 -0
  2. package/dist/grafana/alerting-provenance.d.ts.map +1 -0
  3. package/dist/grafana/contact-point-fixtures.d.ts +28 -0
  4. package/dist/grafana/contact-point-fixtures.d.ts.map +1 -0
  5. package/dist/grafana/contact-point.d.ts +99 -0
  6. package/dist/grafana/contact-point.d.ts.map +1 -0
  7. package/dist/grafana/dashboard-model.d.ts +47 -0
  8. package/dist/grafana/dashboard-model.d.ts.map +1 -0
  9. package/dist/grafana/dashboard.d.ts +98 -0
  10. package/dist/grafana/dashboard.d.ts.map +1 -0
  11. package/dist/grafana/datasource.d.ts +2 -9
  12. package/dist/grafana/datasource.d.ts.map +1 -1
  13. package/dist/grafana/folder.d.ts +100 -0
  14. package/dist/grafana/folder.d.ts.map +1 -0
  15. package/dist/grafana/index.d.ts +16 -9
  16. package/dist/grafana/index.d.ts.map +1 -1
  17. package/dist/grafana/index.js +325 -16
  18. package/dist/grafana/index.js.map +15 -5
  19. package/dist/grafana/message-template.d.ts +70 -0
  20. package/dist/grafana/message-template.d.ts.map +1 -0
  21. package/dist/grafana/mute-timing.d.ts +112 -0
  22. package/dist/grafana/mute-timing.d.ts.map +1 -0
  23. package/dist/grafana/providers.d.ts +1 -1
  24. package/dist/grafana/providers.d.ts.map +1 -1
  25. package/dist/grafana/provisioned.d.ts +11 -0
  26. package/dist/grafana/provisioned.d.ts.map +1 -0
  27. package/dist/grafana/secret-refs.d.ts +19 -0
  28. package/dist/grafana/secret-refs.d.ts.map +1 -0
  29. package/dist/grafana/subset-match.d.ts +2 -0
  30. package/dist/grafana/subset-match.d.ts.map +1 -0
  31. package/dist/linux/index.d.ts +1 -1
  32. package/dist/linux/index.d.ts.map +1 -1
  33. package/dist/linux/index.js +3 -1
  34. package/dist/linux/index.js.map +1 -1
  35. package/dist/proxmox/index.js +76 -45
  36. package/dist/proxmox/index.js.map +6 -6
  37. package/dist/proxmox/zfs-pool-form.d.ts +124 -0
  38. package/dist/proxmox/zfs-pool-form.d.ts.map +1 -0
  39. package/dist/proxmox/zfs-pool-wire.d.ts +78 -0
  40. package/dist/proxmox/zfs-pool-wire.d.ts.map +1 -0
  41. package/dist/proxmox/zfs-pool.d.ts +27 -52
  42. package/dist/proxmox/zfs-pool.d.ts.map +1 -1
  43. package/docs/grafana-alerting.md +145 -0
  44. package/docs/grafana-folder-dashboard.md +145 -0
  45. package/docs/grafana.md +12 -10
  46. package/docs/upstream-conformance.md +31 -4
  47. package/package.json +1 -1
  48. package/src/grafana/alerting-provenance.ts +109 -0
  49. package/src/grafana/contact-point-fixtures.ts +25 -0
  50. package/src/grafana/contact-point-provenance.test.ts +113 -0
  51. package/src/grafana/contact-point-secrets.test.ts +117 -0
  52. package/src/grafana/contact-point.test.ts +123 -0
  53. package/src/grafana/contact-point.ts +179 -0
  54. package/src/grafana/dashboard-model.test.ts +111 -0
  55. package/src/grafana/dashboard-model.ts +62 -0
  56. package/src/grafana/dashboard.test.ts +232 -0
  57. package/src/grafana/dashboard.ts +161 -0
  58. package/src/grafana/datasource.ts +5 -27
  59. package/src/grafana/folder.test.ts +212 -0
  60. package/src/grafana/folder.ts +161 -0
  61. package/src/grafana/index.ts +42 -9
  62. package/src/grafana/message-template.test.ts +212 -0
  63. package/src/grafana/message-template.ts +114 -0
  64. package/src/grafana/mute-timing.test.ts +227 -0
  65. package/src/grafana/mute-timing.ts +160 -0
  66. package/src/grafana/providers.ts +13 -1
  67. package/src/grafana/provisioned.ts +49 -0
  68. package/src/grafana/secret-refs.ts +48 -0
  69. package/src/grafana/subset-match.ts +71 -0
  70. package/src/linux/index-export.test.ts +25 -0
  71. package/src/linux/index.ts +15 -1
  72. package/src/proxmox/constraints-host-forms.test.ts +1 -1
  73. package/src/proxmox/zfs-pool-adopt.test.ts +26 -0
  74. package/src/proxmox/zfs-pool-form.ts +236 -0
  75. package/src/proxmox/zfs-pool-read-failure.test.ts +118 -0
  76. package/src/proxmox/zfs-pool-wire.ts +132 -0
  77. package/src/proxmox/zfs-pool.ts +106 -76
  78. package/dist/proxmox/zfs-pool-spec.d.ts +0 -27
  79. package/dist/proxmox/zfs-pool-spec.d.ts.map +0 -1
  80. package/dist/proxmox/zfs-pool-write.d.ts +0 -128
  81. package/dist/proxmox/zfs-pool-write.d.ts.map +0 -1
  82. package/src/proxmox/zfs-pool-spec.ts +0 -76
  83. package/src/proxmox/zfs-pool-write.ts +0 -196
@@ -0,0 +1,232 @@
1
+ /**
2
+ * `Grafana.Dashboard` against a fake Grafana — the noop-despite-volatile-fields normalization, the
3
+ * provisioned-object refusal, and 412 propagating as a genuine failure rather than being caught or
4
+ * overwritten blindly. Mirrors `datasource.test.ts`/`folder.test.ts`.
5
+ */
6
+ import { describe, expect, test } from 'bun:test';
7
+ import * as Effect from 'effect/Effect';
8
+ import { spec } from './dashboard.ts';
9
+ import { fakeFailure, fakeGrafana, fakeGrafanaLayer } from './fake-grafana.ts';
10
+ import { GrafanaProvisionedObjectError } from './provisioned.ts';
11
+ import { grafanaOperations } from './resource.ts';
12
+
13
+ const DASH_PATH = '/api/dashboards/uid/fleet-overview';
14
+ const CREATE_PATH = '/api/dashboards/db';
15
+
16
+ const model = { panels: [{ id: 1, title: 'CPU' }], title: 'Fleet Overview' };
17
+
18
+ const liveJson = {
19
+ dashboard: { ...model, id: 42, uid: 'fleet-overview', version: 3 },
20
+ meta: {
21
+ folderUid: 'infra',
22
+ provisioned: false,
23
+ provisionedExternalId: '',
24
+ url: '/d/fleet-overview',
25
+ version: 3,
26
+ },
27
+ };
28
+
29
+ const props = { dashboard: model, folderUid: 'infra', uid: 'fleet-overview' };
30
+
31
+ describe('spec.fetchLive', () => {
32
+ test('a live dashboard decodes and normalizes into typed attributes', async () => {
33
+ const fake = fakeGrafana((method, url) =>
34
+ method === 'GET' && url.pathname === DASH_PATH
35
+ ? Response.json(liveJson)
36
+ : fakeFailure(404, 'not found'),
37
+ );
38
+ const live = await Effect.runPromise(
39
+ spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
40
+ );
41
+ expect(live !== undefined && spec.attributes(live, props)).toEqual({
42
+ dashboard: { ...model, uid: 'fleet-overview' },
43
+ folderUid: 'infra',
44
+ provisioned: false,
45
+ provisionedExternalId: '',
46
+ title: 'Fleet Overview',
47
+ uid: 'fleet-overview',
48
+ url: '/d/fleet-overview',
49
+ version: 3,
50
+ });
51
+ expect(fake.bodies[0]).toBeUndefined();
52
+ });
53
+
54
+ test('a 404 folds to undefined', async () => {
55
+ const fake = fakeGrafana(() => fakeFailure(404, 'dashboard not found'));
56
+ const result = await Effect.runPromise(
57
+ spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
58
+ );
59
+ expect(result).toBeUndefined();
60
+ });
61
+ });
62
+
63
+ describe('create', () => {
64
+ test('sends the normalized model and folderUid — no volatile fields, no extra keys', async () => {
65
+ const fake = fakeGrafana((method, url) =>
66
+ method === 'POST' && url.pathname === CREATE_PATH
67
+ ? Response.json({
68
+ folderUid: 'infra',
69
+ id: 42,
70
+ status: 'success',
71
+ title: 'Fleet Overview',
72
+ uid: 'fleet-overview',
73
+ url: '/d/fleet-overview',
74
+ version: 1,
75
+ })
76
+ : fakeFailure(404, 'unexpected request'),
77
+ );
78
+ await Effect.runPromise(spec.create(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))));
79
+ expect(fake.bodies[0]).toEqual({
80
+ dashboard: { ...model, uid: 'fleet-overview' },
81
+ folderUid: 'infra',
82
+ });
83
+ });
84
+ });
85
+
86
+ describe('reconcile — noop despite volatile fields', () => {
87
+ test('a declared model matching live content is a noop even though id/version/iteration differ', async () => {
88
+ const fake = fakeGrafana((method, url) =>
89
+ method === 'GET' && url.pathname === DASH_PATH
90
+ ? Response.json(liveJson)
91
+ : fakeFailure(500, 'reconcile should not write when content is unchanged'),
92
+ );
93
+ const after = await Effect.runPromise(
94
+ grafanaOperations(spec)
95
+ .reconcile(props)
96
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
97
+ );
98
+ expect(after.uid).toBe('fleet-overview');
99
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
100
+ });
101
+ });
102
+
103
+ describe('update', () => {
104
+ // ★ ALL THREE TESTS GO THROUGH `grafanaOperations(spec).reconcile`, NOT `spec.update` DIRECTLY —
105
+ // `update` is optional on the shared `GrafanaSpec` type, and `reconcile` is what production
106
+ // code actually calls; it also exercises the SAME `fetchLive` → `matches` → `update` sequence a
107
+ // real plan/deploy does, rather than a hand-assembled `live` argument.
108
+ test('a content change sends the live version for optimistic concurrency, never overwrite', async () => {
109
+ const changed = { ...model, title: 'Fleet Overview v2' };
110
+ let saved = false;
111
+ const fake = fakeGrafana((method, url) => {
112
+ if (method === 'GET' && url.pathname === DASH_PATH) {
113
+ return Response.json(
114
+ saved
115
+ ? {
116
+ dashboard: { ...changed, id: 42, uid: 'fleet-overview', version: 4 },
117
+ meta: { ...liveJson.meta, version: 4 },
118
+ }
119
+ : liveJson,
120
+ );
121
+ }
122
+ if (method === 'POST' && url.pathname === CREATE_PATH) {
123
+ saved = true;
124
+ return Response.json({
125
+ folderUid: 'infra',
126
+ id: 42,
127
+ status: 'success',
128
+ title: 'Fleet Overview v2',
129
+ uid: 'fleet-overview',
130
+ url: '/d/fleet-overview',
131
+ version: 4,
132
+ });
133
+ }
134
+ return fakeFailure(500, 'unexpected request');
135
+ });
136
+ await Effect.runPromise(
137
+ grafanaOperations(spec)
138
+ .reconcile({ ...props, dashboard: changed })
139
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
140
+ );
141
+ const post = fake.seen.findIndex((s) => s.method === 'POST');
142
+ expect(fake.bodies[post]).toMatchObject({
143
+ dashboard: { ...changed, uid: 'fleet-overview', version: 3 },
144
+ });
145
+ // ⛔ `overwrite` IS NEVER SET — Grafana's own version check stays live on every update.
146
+ expect((fake.bodies[post] as { overwrite?: boolean }).overwrite).toBeUndefined();
147
+ });
148
+
149
+ test('a 412 from a concurrent save propagates — never caught, never retried here', async () => {
150
+ const changed = { ...model, title: 'Fleet Overview v2' };
151
+ const fake = fakeGrafana((method, url) => {
152
+ if (method === 'GET' && url.pathname === DASH_PATH) return Response.json(liveJson);
153
+ if (method === 'POST' && url.pathname === CREATE_PATH) {
154
+ return fakeFailure(412, 'The dashboard has been changed by someone else');
155
+ }
156
+ return fakeFailure(500, 'unexpected request');
157
+ });
158
+ const failure = await Effect.runPromise(
159
+ Effect.flip(
160
+ grafanaOperations(spec)
161
+ .reconcile({ ...props, dashboard: changed })
162
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
163
+ ),
164
+ );
165
+ expect(failure._tag).toBe('PreconditionFailed');
166
+ });
167
+
168
+ test('a provisioned dashboard refuses the write, naming its owning file', async () => {
169
+ const provisioned = {
170
+ ...liveJson,
171
+ meta: { ...liveJson.meta, provisioned: true, provisionedExternalId: 'dashboards/fleet.json' },
172
+ };
173
+ const changed = { ...model, title: 'Fleet Overview v2' };
174
+ const fake = fakeGrafana((method, url) =>
175
+ method === 'GET' && url.pathname === DASH_PATH
176
+ ? Response.json(provisioned)
177
+ : fakeFailure(500, 'no write should be attempted'),
178
+ );
179
+ const failure = await Effect.runPromise(
180
+ Effect.flip(
181
+ grafanaOperations(spec)
182
+ .reconcile({ ...props, dashboard: changed })
183
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
184
+ ),
185
+ );
186
+ expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
187
+ expect(failure.message).toContain('dashboards/fleet.json');
188
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
189
+ });
190
+ });
191
+
192
+ describe('destroy', () => {
193
+ test('deletes a live, non-provisioned dashboard by uid', async () => {
194
+ const fake = fakeGrafana((method, url) => {
195
+ if (method === 'GET' && url.pathname === DASH_PATH) return Response.json(liveJson);
196
+ if (method === 'DELETE' && url.pathname === DASH_PATH) {
197
+ return Response.json({ message: 'ok', title: 'Fleet Overview', uid: 'fleet-overview' });
198
+ }
199
+ return fakeFailure(500, 'unexpected request');
200
+ });
201
+ await Effect.runPromise(
202
+ grafanaOperations(spec)
203
+ .destroy(props)
204
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
205
+ );
206
+ expect(fake.seen).toEqual([
207
+ { method: 'GET', path: DASH_PATH },
208
+ { method: 'DELETE', path: DASH_PATH },
209
+ ]);
210
+ });
211
+
212
+ test('a provisioned dashboard refuses destroy and sends no DELETE', async () => {
213
+ const provisioned = {
214
+ ...liveJson,
215
+ meta: { ...liveJson.meta, provisioned: true, provisionedExternalId: 'dashboards/fleet.json' },
216
+ };
217
+ const fake = fakeGrafana((method, url) =>
218
+ method === 'GET' && url.pathname === DASH_PATH
219
+ ? Response.json(provisioned)
220
+ : fakeFailure(500, 'no DELETE should be attempted'),
221
+ );
222
+ const failure = await Effect.runPromise(
223
+ Effect.flip(
224
+ grafanaOperations(spec)
225
+ .destroy(props)
226
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
227
+ ),
228
+ );
229
+ expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
230
+ expect(fake.seen).toEqual([{ method: 'GET', path: DASH_PATH }]);
231
+ });
232
+ });
@@ -0,0 +1,161 @@
1
+ /**
2
+ * `Grafana.Dashboard` — one Grafana dashboard, keyed by `uid`. Same uid-required doctrine as
3
+ * `datasource.ts`/`folder.ts` (`getDashboard`/`postDashboard`/`deleteDashboard` are all UID-keyed
4
+ * on the wire — `postDashboard`'s own `uid` travels INSIDE the `dashboard` JSON body, not as a
5
+ * request field, because Grafana's classic dashboard-save API has never had one; see
6
+ * `dashboard-model.ts`'s `normalizeModel`, which is what puts it there).
7
+ *
8
+ * ★ THE DECLARED CONTENT IS THE DASHBOARD JSON MODEL — `DashboardProps.dashboard`, the same shape
9
+ * Grafana's own "Export as JSON" produces. `dashboard-model.ts`'s `normalizeModel` strips the
10
+ * volatile fields (`id`, `version`, `iteration`) and forces `uid` to this resource's own value
11
+ * before ANY comparison, so a plan against an unchanged dashboard is a true noop despite those
12
+ * fields moving on every live save — see that file for the full reasoning, including which
13
+ * fields (`panels`, `targets`) are array-ORDER-significant and compared as such.
14
+ *
15
+ * ⛔ `matches` COMPARES "IS THE DECLARATION A SUBSET OF WHAT IS LIVE", NOT PLAIN EQUALITY —
16
+ * `dashboard-model.ts`'s `declaredContentMatches`, added after an adversarial review of this PR
17
+ * found plain `deepEqual` made an ordinary dashboard show `update` FOREVER: Grafana's own
18
+ * schema-migration on save decorates any panel/target whose `datasource` is absent with an
19
+ * explicit reference, so a declaration that never sets one would permanently disagree with what
20
+ * it reads back. See that function's own header for the full reasoning and the trade-off it
21
+ * accepts (an omitted field can no longer be used to CLEAR a previously-set one).
22
+ *
23
+ * ⛔ VERSION CONFLICTS (412) FAIL LOUDLY — NEVER OVERWRITTEN BLINDLY. `update` injects the
24
+ * `version` this resource just read (`live.meta.version`, from the SAME `fetchLive` call
25
+ * `resource.ts`'s `reconcile` already made before calling `update`) into the model it sends, and
26
+ * `overwrite` is never set. If Grafana's own version check then still fails — someone else saved
27
+ * in the gap between that read and this write — `postDashboard` answers `412` and
28
+ * `PreconditionFailed` is left IN the error union below, uncaught: the whole reconcile fails, the
29
+ * deploy stops, and nothing is written. The next run's `fetchLive` re-reads the new live version
30
+ * fresh, which is the "re-read" half of "re-read and fail loudly" — there is no separate retry
31
+ * loop in this file, because the next `plan`/`deploy` invocation already IS that re-read.
32
+ *
33
+ * ⛔ A PROVISIONED DASHBOARD (`meta.provisioned` — see provisioned.ts) REFUSES EVERY UPDATE AND
34
+ * DESTROY, naming `meta.provisionedExternalId` (the file) when Grafana reports one. `matches`
35
+ * still compares structurally regardless, so a mismatched declaration against a provisioned
36
+ * dashboard shows `update` in a plan rather than a silent, permanently-hidden noop.
37
+ *
38
+ * ⚠️ NO RETAIN DEFAULT, UNLIKE `Grafana.Folder`. Deleting one dashboard is a single-object blast
39
+ * radius — the same class `Grafana.Datasource` already accepts without a retain guard — not
40
+ * folder.ts's multi-object cascade (a folder's delete takes its dashboards AND their alert rules
41
+ * with it). See folder.ts's file header for the full contrast.
42
+ */
43
+ import { Resource } from 'alchemy';
44
+ import * as Provider from 'alchemy/Provider';
45
+ import * as grafana from '@distilled.cloud/grafana';
46
+ import * as Effect from 'effect/Effect';
47
+ import {
48
+ type DashboardModel,
49
+ declaredContentMatches,
50
+ modelTitle,
51
+ normalizeModel,
52
+ } from './dashboard-model.ts';
53
+ import { type GrafanaProvisionedObjectError, refuseIfProvisioned } from './provisioned.ts';
54
+ import { type GrafanaSpec, grafanaHandlers } from './resource.ts';
55
+
56
+ export type { DashboardModel };
57
+
58
+ export interface DashboardProps {
59
+ uid: string;
60
+ /** Omit for the General/root folder. */
61
+ folderUid?: string;
62
+ /** The dashboard JSON model — see the file header. `id`/`version`/`iteration`/`uid` inside it
63
+ * are ignored; `uid` above is the single source of truth. */
64
+ dashboard: DashboardModel;
65
+ }
66
+
67
+ export interface DashboardAttributes {
68
+ uid: string;
69
+ title: string;
70
+ version: number;
71
+ folderUid: string;
72
+ url: string;
73
+ /** True when Grafana reports this dashboard as file-provisioned (read-only — see provisioned.ts). */
74
+ provisioned: boolean;
75
+ provisionedExternalId: string;
76
+ /** The live model, normalized the same way `matches` compares it. */
77
+ dashboard: DashboardModel;
78
+ }
79
+
80
+ export interface GrafanaDashboard extends Resource<
81
+ 'Grafana.Dashboard',
82
+ DashboardProps,
83
+ DashboardAttributes,
84
+ never
85
+ > {}
86
+
87
+ export const GrafanaDashboard = Resource<GrafanaDashboard>('Grafana.Dashboard');
88
+
89
+ const modelOf = (live: grafana.DashboardFullWithMeta): DashboardModel =>
90
+ live.dashboard !== null && typeof live.dashboard === 'object'
91
+ ? (live.dashboard as DashboardModel)
92
+ : {};
93
+
94
+ export const spec: GrafanaSpec<
95
+ DashboardProps,
96
+ grafana.DashboardFullWithMeta,
97
+ DashboardAttributes,
98
+ | grafana.GetDashboardError
99
+ | grafana.PostDashboardError
100
+ | grafana.DeleteDashboardError
101
+ | GrafanaProvisionedObjectError
102
+ > = {
103
+ attributes: (live, props) => {
104
+ const model = normalizeModel(modelOf(live), props.uid);
105
+ return {
106
+ dashboard: model,
107
+ folderUid: live.meta?.folderUid ?? '',
108
+ provisioned: live.meta?.provisioned ?? false,
109
+ provisionedExternalId: live.meta?.provisionedExternalId ?? '',
110
+ title: modelTitle(model),
111
+ uid: props.uid,
112
+ url: live.meta?.url ?? '',
113
+ version: live.meta?.version ?? 0,
114
+ };
115
+ },
116
+ create: (props) =>
117
+ grafana.postDashboard({
118
+ dashboard: normalizeModel(props.dashboard, props.uid),
119
+ ...(props.folderUid === undefined ? {} : { folderUid: props.folderUid }),
120
+ }),
121
+ destroy: (props, live) =>
122
+ Effect.gen(function* () {
123
+ if (live.meta?.provisioned) {
124
+ return yield* refuseIfProvisioned(
125
+ 'Grafana.Dashboard',
126
+ props.uid,
127
+ live.meta.provisionedExternalId,
128
+ );
129
+ }
130
+ yield* grafana.deleteDashboard({ uid: props.uid });
131
+ }),
132
+ fetchLive: (props) =>
133
+ grafana
134
+ .getDashboard({ uid: props.uid })
135
+ .pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
136
+ matches: (attributes, props) =>
137
+ (props.folderUid === undefined || attributes.folderUid === props.folderUid) &&
138
+ declaredContentMatches(normalizeModel(props.dashboard, props.uid), attributes.dashboard),
139
+ update: (props, live) =>
140
+ Effect.gen(function* () {
141
+ if (live.meta?.provisioned) {
142
+ return yield* refuseIfProvisioned(
143
+ 'Grafana.Dashboard',
144
+ props.uid,
145
+ live.meta.provisionedExternalId,
146
+ );
147
+ }
148
+ return yield* grafana.postDashboard({
149
+ dashboard: {
150
+ ...normalizeModel(props.dashboard, props.uid),
151
+ ...(live.meta?.version === undefined ? {} : { version: live.meta.version }),
152
+ },
153
+ ...(props.folderUid === undefined ? {} : { folderUid: props.folderUid }),
154
+ });
155
+ }),
156
+ };
157
+
158
+ export const handlers = grafanaHandlers(spec);
159
+
160
+ export const GrafanaDashboardProvider = () =>
161
+ Provider.effect(GrafanaDashboard, Effect.succeed(GrafanaDashboard.Provider.of(handlers)));
@@ -20,9 +20,11 @@
20
20
  import { Resource } from 'alchemy';
21
21
  import * as Provider from 'alchemy/Provider';
22
22
  import * as grafana from '@distilled.cloud/grafana';
23
- import * as Data from 'effect/Data';
24
23
  import * as Effect from 'effect/Effect';
25
24
  import { type GrafanaSpec, grafanaHandlers } from './resource.ts';
25
+ import { GrafanaSecretRefUnsetError, resolveSecretRefs } from './secret-refs.ts';
26
+
27
+ export { GrafanaSecretRefUnsetError };
26
28
 
27
29
  export interface DatasourceProps {
28
30
  uid: string;
@@ -64,33 +66,9 @@ export interface GrafanaDatasource extends Resource<
64
66
 
65
67
  export const GrafanaDatasource = Resource<GrafanaDatasource>('Grafana.Datasource');
66
68
 
67
- /** A named secure-field env var is unset — a domain refusal, not a distilled error. */
68
- export class GrafanaSecretRefUnsetError extends Data.TaggedError('GrafanaSecretRefUnsetError')<{
69
- readonly message: string;
70
- }> {}
71
-
72
69
  /** Reads every named env var fresh, inside the caller's effect (S24) — never at plan time. */
73
- const resolveSecureJsonData = (
74
- refs: Record<string, string> | undefined,
75
- ): Effect.Effect<Record<string, string> | undefined, GrafanaSecretRefUnsetError> =>
76
- Effect.gen(function* () {
77
- if (refs === undefined) return undefined;
78
- const out: Record<string, string> = {};
79
- for (const [field, envVar] of Object.entries(refs)) {
80
- const raw = process.env[envVar];
81
- if (raw === undefined || raw.trim() === '') {
82
- return yield* Effect.fail(
83
- new GrafanaSecretRefUnsetError({
84
- message:
85
- `${envVar} is unset. Export the value of secureJsonData.${field} there — ` +
86
- 'never as an Alchemy prop.',
87
- }),
88
- );
89
- }
90
- out[field] = raw.trim();
91
- }
92
- return out;
93
- });
70
+ const resolveSecureJsonData = (refs: Record<string, string> | undefined) =>
71
+ resolveSecretRefs(refs, (field) => `secureJsonData.${field}`);
94
72
 
95
73
  /** `exactOptionalPropertyTypes` means an undeclared field must be OMITTED, never sent as `undefined`. */
96
74
  const fields = (props: DatasourceProps) => ({
@@ -0,0 +1,212 @@
1
+ /**
2
+ * `Grafana.Folder` against a fake Grafana — the observe-before-write "adopt" posture (S9/S10),
3
+ * the provisioned-object refusal (provisioned.ts) and the parentUid-is-create-only refusal, all
4
+ * through the REAL `@distilled.cloud/grafana` protocol. Mirrors `datasource.test.ts`.
5
+ */
6
+ import { describe, expect, test } from 'bun:test';
7
+ import * as Effect from 'effect/Effect';
8
+ import { fakeFailure, fakeGrafana, fakeGrafanaLayer } from './fake-grafana.ts';
9
+ import { GrafanaFolderReparentError, spec } from './folder.ts';
10
+ import { GrafanaProvisionedObjectError } from './provisioned.ts';
11
+ import { grafanaOperations } from './resource.ts';
12
+
13
+ const FOLDER_PATH = '/api/folders/team-dashboards';
14
+ const CREATE_PATH = '/api/folders';
15
+
16
+ const liveJson = {
17
+ id: 3,
18
+ managedBy: '',
19
+ parentUid: '',
20
+ title: 'Team Dashboards',
21
+ uid: 'team-dashboards',
22
+ url: '/dashboards/f/team-dashboards',
23
+ version: 1,
24
+ };
25
+
26
+ const props = { title: 'Team Dashboards', uid: 'team-dashboards' };
27
+
28
+ describe('spec.fetchLive', () => {
29
+ test('a live folder decodes into typed attributes', async () => {
30
+ const fake = fakeGrafana((method, url) =>
31
+ method === 'GET' && url.pathname === FOLDER_PATH
32
+ ? Response.json(liveJson)
33
+ : fakeFailure(404, 'not found'),
34
+ );
35
+ const live = await Effect.runPromise(
36
+ spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
37
+ );
38
+ expect(live !== undefined && spec.attributes(live, props)).toEqual({
39
+ id: 3,
40
+ managedBy: '',
41
+ parentUid: '',
42
+ title: 'Team Dashboards',
43
+ uid: 'team-dashboards',
44
+ url: '/dashboards/f/team-dashboards',
45
+ version: 1,
46
+ });
47
+ expect(fake.seen).toEqual([{ method: 'GET', path: FOLDER_PATH }]);
48
+ // ⛔ THE POINT: a GET never carries a body — see the shared memory's "distilled JSON-encodes
49
+ // unknown keys as a body even on a GET" trap.
50
+ expect(fake.bodies[0]).toBeUndefined();
51
+ });
52
+
53
+ test('a 404 folds to undefined', async () => {
54
+ const fake = fakeGrafana(() => fakeFailure(404, 'folder not found'));
55
+ const result = await Effect.runPromise(
56
+ spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
57
+ );
58
+ expect(result).toBeUndefined();
59
+ });
60
+
61
+ test('a 403 propagates — never folded to absent', async () => {
62
+ const fake = fakeGrafana(() => fakeFailure(403, 'forbidden'));
63
+ const failure = await Effect.runPromise(
64
+ Effect.flip(spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch)))),
65
+ );
66
+ expect(failure._tag).toBe('Forbidden');
67
+ });
68
+ });
69
+
70
+ describe('create', () => {
71
+ test('sends only declared fields — parentUid omitted when undeclared', async () => {
72
+ const fake = fakeGrafana((method, url) =>
73
+ method === 'POST' && url.pathname === CREATE_PATH
74
+ ? Response.json(liveJson)
75
+ : fakeFailure(404, 'unexpected request'),
76
+ );
77
+ await Effect.runPromise(spec.create(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))));
78
+ expect(fake.bodies[0]).toEqual({ title: 'Team Dashboards', uid: 'team-dashboards' });
79
+ });
80
+
81
+ test('sends description and parentUid when declared', async () => {
82
+ const fake = fakeGrafana(() => Response.json(liveJson));
83
+ await Effect.runPromise(
84
+ spec
85
+ .create({ ...props, description: 'squad dashboards', parentUid: 'engineering' })
86
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
87
+ );
88
+ expect(fake.bodies[0]).toMatchObject({
89
+ description: 'squad dashboards',
90
+ parentUid: 'engineering',
91
+ });
92
+ });
93
+ });
94
+
95
+ describe('reconcile — adopt semantics', () => {
96
+ test('declaring exactly what is live is a noop: no write is ever sent', async () => {
97
+ const fake = fakeGrafana((method, url) =>
98
+ method === 'GET' && url.pathname === FOLDER_PATH
99
+ ? Response.json(liveJson)
100
+ : fakeFailure(500, 'reconcile should not write when nothing changed'),
101
+ );
102
+ const after = await Effect.runPromise(
103
+ grafanaOperations(spec)
104
+ .reconcile(props)
105
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
106
+ );
107
+ expect(after.uid).toBe('team-dashboards');
108
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
109
+ });
110
+ });
111
+
112
+ describe('update', () => {
113
+ test('a title mismatch sends the live version for optimistic concurrency', async () => {
114
+ let updated = false;
115
+ const fake = fakeGrafana((method, url) => {
116
+ if (method === 'GET' && url.pathname === FOLDER_PATH) {
117
+ return Response.json(updated ? { ...liveJson, title: 'Renamed' } : liveJson);
118
+ }
119
+ if (method === 'PUT' && url.pathname === FOLDER_PATH) {
120
+ updated = true;
121
+ return Response.json({ ...liveJson, title: 'Renamed' });
122
+ }
123
+ return fakeFailure(500, 'unexpected request');
124
+ });
125
+ await Effect.runPromise(
126
+ grafanaOperations(spec)
127
+ .reconcile({ ...props, title: 'Renamed' })
128
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
129
+ );
130
+ const put = fake.seen.findIndex((s) => s.method === 'PUT');
131
+ expect(put).toBeGreaterThanOrEqual(0);
132
+ expect(fake.bodies[put]).toMatchObject({ title: 'Renamed', version: 1 });
133
+ });
134
+
135
+ // ★ BOTH TESTS GO THROUGH `grafanaOperations(spec).reconcile`, NOT `spec.update` DIRECTLY —
136
+ // `update` is optional on the shared `GrafanaSpec` type, and `reconcile` is what production
137
+ // code actually calls; it also exercises the same `fetchLive` → `matches` → `update` sequence.
138
+ test('a parentUid mismatch refuses — updateFolder cannot reparent', async () => {
139
+ const fake = fakeGrafana((method, url) =>
140
+ method === 'GET' && url.pathname === FOLDER_PATH
141
+ ? Response.json(liveJson)
142
+ : fakeFailure(500, 'no write should be attempted'),
143
+ );
144
+ const failure = await Effect.runPromise(
145
+ Effect.flip(
146
+ grafanaOperations(spec)
147
+ .reconcile({ ...props, parentUid: 'engineering' })
148
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
149
+ ),
150
+ );
151
+ expect(failure).toBeInstanceOf(GrafanaFolderReparentError);
152
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
153
+ });
154
+
155
+ test('a provisioned (managedBy set) folder refuses the write and sends nothing', async () => {
156
+ const provisioned = { ...liveJson, managedBy: 'repo' };
157
+ const fake = fakeGrafana((method, url) =>
158
+ method === 'GET' && url.pathname === FOLDER_PATH
159
+ ? Response.json(provisioned)
160
+ : fakeFailure(500, 'no write should be attempted'),
161
+ );
162
+ const failure = await Effect.runPromise(
163
+ Effect.flip(
164
+ grafanaOperations(spec)
165
+ .reconcile({ ...props, title: 'Renamed' })
166
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
167
+ ),
168
+ );
169
+ expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
170
+ expect(failure.message).toContain('repo');
171
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
172
+ });
173
+ });
174
+
175
+ describe('destroy', () => {
176
+ test('deletes a live, non-provisioned folder by uid', async () => {
177
+ const fake = fakeGrafana((method, url) => {
178
+ if (method === 'GET' && url.pathname === FOLDER_PATH) return Response.json(liveJson);
179
+ if (method === 'DELETE' && url.pathname === FOLDER_PATH) {
180
+ return Response.json({ id: 3, message: 'ok', title: 'Team Dashboards' });
181
+ }
182
+ return fakeFailure(500, 'unexpected request');
183
+ });
184
+ await Effect.runPromise(
185
+ grafanaOperations(spec)
186
+ .destroy(props)
187
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
188
+ );
189
+ expect(fake.seen).toEqual([
190
+ { method: 'GET', path: FOLDER_PATH },
191
+ { method: 'DELETE', path: FOLDER_PATH },
192
+ ]);
193
+ });
194
+
195
+ test('a provisioned folder refuses destroy and sends no DELETE', async () => {
196
+ const provisioned = { ...liveJson, managedBy: 'repo' };
197
+ const fake = fakeGrafana((method, url) =>
198
+ method === 'GET' && url.pathname === FOLDER_PATH
199
+ ? Response.json(provisioned)
200
+ : fakeFailure(500, 'no DELETE should be attempted'),
201
+ );
202
+ const failure = await Effect.runPromise(
203
+ Effect.flip(
204
+ grafanaOperations(spec)
205
+ .destroy(props)
206
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
207
+ ),
208
+ );
209
+ expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
210
+ expect(fake.seen).toEqual([{ method: 'GET', path: FOLDER_PATH }]);
211
+ });
212
+ });