@homeflare/alchemy 0.29.1 → 0.30.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 (87) hide show
  1. package/README.md +20 -0
  2. package/dist/caddy/config-reconcile.d.ts.map +1 -1
  3. package/dist/caddy/config.d.ts.map +1 -1
  4. package/dist/caddy/format-warnings.d.ts +29 -0
  5. package/dist/caddy/format-warnings.d.ts.map +1 -0
  6. package/dist/caddy/format.d.ts +50 -0
  7. package/dist/caddy/format.d.ts.map +1 -0
  8. package/dist/caddy/index.d.ts +1 -0
  9. package/dist/caddy/index.d.ts.map +1 -1
  10. package/dist/caddy/index.js +78 -10
  11. package/dist/caddy/index.js.map +7 -5
  12. package/dist/google-workspace/credentials.d.ts +54 -0
  13. package/dist/google-workspace/credentials.d.ts.map +1 -0
  14. package/dist/google-workspace/domain-alias.d.ts +74 -0
  15. package/dist/google-workspace/domain-alias.d.ts.map +1 -0
  16. package/dist/google-workspace/fake-google-workspace.d.ts +41 -0
  17. package/dist/google-workspace/fake-google-workspace.d.ts.map +1 -0
  18. package/dist/google-workspace/group-member.d.ts +73 -0
  19. package/dist/google-workspace/group-member.d.ts.map +1 -0
  20. package/dist/google-workspace/group.d.ts +87 -0
  21. package/dist/google-workspace/group.d.ts.map +1 -0
  22. package/dist/google-workspace/index.d.ts +19 -0
  23. package/dist/google-workspace/index.d.ts.map +1 -0
  24. package/dist/google-workspace/index.js +283 -0
  25. package/dist/google-workspace/index.js.map +17 -0
  26. package/dist/google-workspace/org-unit.d.ts +75 -0
  27. package/dist/google-workspace/org-unit.d.ts.map +1 -0
  28. package/dist/google-workspace/providers.d.ts +16 -0
  29. package/dist/google-workspace/providers.d.ts.map +1 -0
  30. package/dist/google-workspace/resource.d.ts +80 -0
  31. package/dist/google-workspace/resource.d.ts.map +1 -0
  32. package/dist/google-workspace/values.d.ts +28 -0
  33. package/dist/google-workspace/values.d.ts.map +1 -0
  34. package/dist/grafana/credentials.d.ts +38 -0
  35. package/dist/grafana/credentials.d.ts.map +1 -0
  36. package/dist/grafana/datasource.d.ts +90 -0
  37. package/dist/grafana/datasource.d.ts.map +1 -0
  38. package/dist/grafana/fake-grafana.d.ts +48 -0
  39. package/dist/grafana/fake-grafana.d.ts.map +1 -0
  40. package/dist/grafana/index.d.ts +17 -0
  41. package/dist/grafana/index.d.ts.map +1 -0
  42. package/dist/grafana/index.js +149 -0
  43. package/dist/grafana/index.js.map +13 -0
  44. package/dist/grafana/providers.d.ts +17 -0
  45. package/dist/grafana/providers.d.ts.map +1 -0
  46. package/dist/grafana/resource.d.ts +68 -0
  47. package/dist/grafana/resource.d.ts.map +1 -0
  48. package/docs/caddy-fmt.md +59 -0
  49. package/docs/caddy.md +7 -0
  50. package/docs/credentials.md +11 -0
  51. package/docs/google-workspace.md +105 -0
  52. package/docs/grafana.md +102 -0
  53. package/docs/peers.md +9 -0
  54. package/docs/upstream-conformance.md +37 -0
  55. package/package.json +9 -1
  56. package/src/caddy/config-format-warning.test.ts +91 -0
  57. package/src/caddy/config-reconcile.ts +6 -0
  58. package/src/caddy/config.ts +8 -1
  59. package/src/caddy/format-warnings.test.ts +50 -0
  60. package/src/caddy/format-warnings.ts +39 -0
  61. package/src/caddy/format.test.ts +105 -0
  62. package/src/caddy/format.ts +121 -0
  63. package/src/caddy/index.ts +7 -0
  64. package/src/caddy/providers.test.ts +8 -0
  65. package/src/google-workspace/credentials.test.ts +40 -0
  66. package/src/google-workspace/credentials.ts +75 -0
  67. package/src/google-workspace/domain-alias.test.ts +69 -0
  68. package/src/google-workspace/domain-alias.ts +116 -0
  69. package/src/google-workspace/fake-google-workspace.ts +54 -0
  70. package/src/google-workspace/group-member.test.ts +91 -0
  71. package/src/google-workspace/group-member.ts +126 -0
  72. package/src/google-workspace/group.test.ts +93 -0
  73. package/src/google-workspace/group.ts +128 -0
  74. package/src/google-workspace/index.ts +54 -0
  75. package/src/google-workspace/org-unit.test.ts +81 -0
  76. package/src/google-workspace/org-unit.ts +143 -0
  77. package/src/google-workspace/providers.ts +26 -0
  78. package/src/google-workspace/resource.test.ts +186 -0
  79. package/src/google-workspace/resource.ts +162 -0
  80. package/src/google-workspace/values.ts +36 -0
  81. package/src/grafana/credentials.ts +65 -0
  82. package/src/grafana/datasource.test.ts +183 -0
  83. package/src/grafana/datasource.ts +171 -0
  84. package/src/grafana/fake-grafana.ts +64 -0
  85. package/src/grafana/index.ts +22 -0
  86. package/src/grafana/providers.ts +19 -0
  87. package/src/grafana/resource.ts +120 -0
@@ -0,0 +1,162 @@
1
+ /**
2
+ * One shape for every Google Workspace Directory object — the same four operations, different
3
+ * distilled call.
4
+ *
5
+ * ⛔ THE ALTERNATIVE IS COPYING THE SAME HANDLERS PER RESOURCE. Groups, group members, domain
6
+ * aliases and org units are all create / read / update / delete against
7
+ * `@distilled.cloud/google-workspace`'s typed `admin_directory_v1` operations. Hand-rolling the
8
+ * fourth copy is where someone drops the read-back or the `isResolved` guard and the plan lies.
9
+ *
10
+ * ★ SO THE FOUR OPERATIONS LIVE HERE, ONCE — mirroring `../forgejo/resource.ts`'s engine exactly.
11
+ * The Directory API addresses every object this family models by a stable key (an email, a
12
+ * domain alias name, an org unit path), so — unlike NetBox's page-then-disambiguate `locateOne`
13
+ * — `fetchLive` is always a plain get-by-key.
14
+ *
15
+ * ⚠️ `NotFound` IS FOLDED TO "ABSENT" INSIDE EACH RESOURCE FILE'S OWN `fetchLive`, NOT HERE — the
16
+ * same split forgejo's and netbox's engines use. `Effect.catchTag`'s tag-literal inference needs
17
+ * a concrete error union to resolve `"NotFound"` against; a spec's `E` is only concrete at each
18
+ * resource file's own call site.
19
+ *
20
+ * ★ ADOPT IS THE DEFAULT POSTURE, NOT A FLAG. `reconcile` LOCATES before it writes: a group or org
21
+ * unit the Workspace already has is bound, not duplicated — Tim's own domain (schenanigans.com)
22
+ * predates this code. `spec.attributes` always maps the FULL live object, so a bound row reads
23
+ * back byte-identical to what Workspace actually holds — the exact-as-live half of `adopt(true)`;
24
+ * the other half (refusing to bind without it) is upstream's `AdoptPolicy`, unchanged here.
25
+ *
26
+ * ⛔ CREDENTIALS ARE NEVER A PROP (S24/S25). See `credentials.ts` for what a stack passes instead.
27
+ */
28
+ import { isResolved } from 'alchemy/Diff';
29
+ import type { Input } from 'alchemy/Input';
30
+ import { CredentialsFromEnv } from '@distilled.cloud/google-workspace/Credentials';
31
+ import type { GoogleWorkspaceOpContext } from '@distilled.cloud/google-workspace/Protocol';
32
+ import * as Effect from 'effect/Effect';
33
+ import type * as HttpClient from 'effect/unstable/http/HttpClient';
34
+
35
+ /**
36
+ * ★ WHAT EVERY HANDLER NEEDS FROM THE CALLER'S RUNTIME, after Credentials are provided below.
37
+ * `GoogleWorkspaceOpContext` is `Credentials | HttpClient.HttpClient`; `googleWorkspaceHandlers`
38
+ * closes over `CredentialsFromEnv` itself (`GOOGLE_ACCESS_TOKEN` / `GOOGLE_PROJECT_ID` — see
39
+ * credentials.ts), so a consuming stack only has to provide `HttpClient.HttpClient`, the same
40
+ * shape every other distilled-backed family in this kit asks for.
41
+ */
42
+ export type GoogleWorkspaceRequirements = HttpClient.HttpClient;
43
+
44
+ /**
45
+ * One Directory object's distilled calls. `Live` is whatever the SDK decodes (a `Group`, a
46
+ * `Member`, a `DomainAlias`, an `OrgUnit`) — never an untyped response body.
47
+ *
48
+ * `E` is left to each resource file to declare (the union of the distilled operations it
49
+ * actually calls), so a call site's precise per-operation error union flows straight through
50
+ * instead of being widened by hand here.
51
+ */
52
+ export type GoogleWorkspaceSpec<Props extends object, Live, Attributes, E> = {
53
+ /** Already folds a real "absent" outcome — a `catchTag('NotFound', ...)` at the call site. */
54
+ readonly fetchLive: (
55
+ props: Props,
56
+ ) => Effect.Effect<Live | undefined, E, GoogleWorkspaceOpContext>;
57
+ readonly attributes: (live: Live, props: Props) => Attributes | undefined;
58
+ readonly matches: (attributes: Attributes, props: Props) => boolean;
59
+ /** Absent for an existence-only family (none here yet, but `GroupMember` could become one). */
60
+ readonly create?: (props: Props) => Effect.Effect<unknown, E, GoogleWorkspaceOpContext>;
61
+ readonly update?: (
62
+ props: Props,
63
+ live: Live,
64
+ ) => Effect.Effect<unknown, E, GoogleWorkspaceOpContext>;
65
+ readonly destroy: (
66
+ props: Props,
67
+ live: Live,
68
+ ) => Effect.Effect<unknown, E, GoogleWorkspaceOpContext>;
69
+ /** For the read-back-failure message. */
70
+ readonly describe: (props: Props) => string;
71
+ };
72
+
73
+ export const googleWorkspaceOperations = <Props extends object, Live, Attributes, E>(
74
+ spec: GoogleWorkspaceSpec<Props, Live, Attributes, E>,
75
+ ) => {
76
+ const read = (props: Props) =>
77
+ spec
78
+ .fetchLive(props)
79
+ .pipe(Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))));
80
+
81
+ return {
82
+ read,
83
+
84
+ diff: (news: Input<Props>, output: Attributes | undefined) =>
85
+ Effect.gen(function* () {
86
+ if (output === undefined || !isResolved(news)) return undefined;
87
+ const live = yield* read(news);
88
+ if (live === undefined) return { action: 'update' } as const;
89
+ if (spec.matches(live, news)) return { action: 'noop' } as const;
90
+ return spec.update === undefined
91
+ ? ({ action: 'replace' } as const)
92
+ : ({ action: 'update' } as const);
93
+ }),
94
+
95
+ reconcile: (news: Props) =>
96
+ Effect.gen(function* () {
97
+ const before = yield* spec.fetchLive(news);
98
+ if (before === undefined) {
99
+ if (spec.create === undefined) {
100
+ return yield* Effect.die(
101
+ new Error(`${spec.describe(news)}: spec declares no create for a missing object`),
102
+ );
103
+ }
104
+ yield* spec.create(news);
105
+ } else if (spec.update !== undefined) {
106
+ const mapped = spec.attributes(before, news);
107
+ // ⚠️ ADOPTION IS THE `mapped === undefined` CASE TOO: a row this spec cannot map is not
108
+ // a row to PATCH blindly.
109
+ if (mapped !== undefined && !spec.matches(mapped, news)) {
110
+ yield* spec.update(news, before);
111
+ }
112
+ }
113
+ const after = yield* read(news);
114
+ if (after === undefined) {
115
+ return yield* Effect.die(
116
+ new Error(
117
+ `${spec.describe(news)}: the write returned no error but the object is still ` +
118
+ 'absent. Directory returns the object it wrote — read back rather than trusting ' +
119
+ 'the status code.',
120
+ ),
121
+ );
122
+ }
123
+ return after;
124
+ }),
125
+
126
+ destroy: (olds: Props) =>
127
+ Effect.gen(function* () {
128
+ const live = yield* spec.fetchLive(olds);
129
+ if (live === undefined) return;
130
+ yield* spec.destroy(olds, live);
131
+ }),
132
+ };
133
+ };
134
+
135
+ /**
136
+ * The five provider handlers for a spec'd Directory object, wired once.
137
+ *
138
+ * ★ CREDENTIALS ARE PROVIDED HERE, ONCE, NOT BY EVERY CALLER. `CredentialsFromEnv` resolves
139
+ * `GOOGLE_ACCESS_TOKEN` on the calling fiber per request (credentials.ts's own note), so this
140
+ * closure keeps that laziness — nothing is captured at module load.
141
+ *
142
+ * ⛔ IT STOPS AT THE HANDLERS AND DOES NOT RETURN THE LAYER — same reasoning as `forgejoHandlers`.
143
+ *
144
+ * ⚠️ `list` answers empty. The Directory API's list endpoints page the whole domain — every group,
145
+ * every org unit — so adoption stays explicit rather than sweeping (same posture as NetBox and
146
+ * Forgejo; the census handoff script, not this provider, is where a full inventory belongs).
147
+ */
148
+ export const googleWorkspaceHandlers = <Props extends object, Live, Attributes extends object, E>(
149
+ spec: GoogleWorkspaceSpec<Props, Live, Attributes, E>,
150
+ ) => {
151
+ const ops = googleWorkspaceOperations(spec);
152
+ const withCredentials = <A>(effect: Effect.Effect<A, E, GoogleWorkspaceOpContext>) =>
153
+ Effect.provide(effect, CredentialsFromEnv);
154
+ return {
155
+ list: () => Effect.succeed([]),
156
+ read: ({ olds }: { olds: Props }) => withCredentials(ops.read(olds)),
157
+ diff: ({ news, output }: { news: Input<Props>; output: Attributes | undefined }) =>
158
+ withCredentials(ops.diff(news, output)),
159
+ reconcile: ({ news }: { news: Props }) => withCredentials(ops.reconcile(news)),
160
+ delete: ({ olds }: { olds: Props }) => withCredentials(ops.destroy(olds)),
161
+ };
162
+ };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Wire coercions shared across Google Workspace resources.
3
+ *
4
+ * ⚠️ EVERY FIELD ON A GENERATED DIRECTORY TYPE IS `S.optional(...)` (the SDK does not know which
5
+ * fields Google always sends), so a live read is `string | undefined` even for a field the API
6
+ * guarantees in practice. These helpers turn that into the settled value a `matches()` compares
7
+ * against, mirroring `../netbox/values.ts` and `../forgejo/values.ts` for the same reason: a
8
+ * `diff` that compared `undefined` against a declared default would see drift forever.
9
+ */
10
+
11
+ /** `''` for absent — Directory objects that omit a text field mean "no value", not `undefined`. */
12
+ export const text = (value: string | undefined): string => value ?? '';
13
+
14
+ export const bool = (value: boolean | undefined): boolean => value === true;
15
+
16
+ /**
17
+ * Wire arrays compared in sorted order. `StringList` (group aliases, org unit paths in a search)
18
+ * decodes to `string[] | undefined`; Directory does not promise an order.
19
+ */
20
+ export const stringArray = (value: readonly string[] | undefined): string[] =>
21
+ value === undefined ? [] : [...value].toSorted();
22
+
23
+ /**
24
+ * Every user- and group-facing key in this family is case-insensitive on the wire (Google lower-
25
+ * cases email addresses and org unit path segments internally) but a declaration may be typed in
26
+ * mixed case. Compare through this, never with `===`, or an adopted object drifts on every plan.
27
+ */
28
+ export const ciEqual = (left: string, right: string): boolean =>
29
+ left.toLowerCase() === right.toLowerCase();
30
+
31
+ /** An org unit path as the API wants it for a path segment: no leading `/`. */
32
+ export const stripLeadingSlash = (path: string): string => path.replace(/^\/+/, '');
33
+
34
+ /** An org unit path as attributes should read it back: exactly one leading `/`. */
35
+ export const withLeadingSlash = (path: string): string =>
36
+ path.startsWith('/') ? path : `/${path}`;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Grafana credentials for this family — a URL plus a TOKEN REFERENCE (an env var NAME), never a
3
+ * literal secret. Wraps `@distilled.cloud/grafana`'s own `Credentials` context directly (S23 —
4
+ * reuse the SDK's own client rather than inventing a second one); this file only adds the house's
5
+ * lazy, per-instance resolution on top of it.
6
+ *
7
+ * ★ ONE `GRAFANA_TOKEN`/`GRAFANA_URL` PAIR ISN'T ENOUGH FOR THIS HOUSE. Unlike Forgejo (one
8
+ * instance), the estate runs at least two Grafanas — `grafana.homeflare.dev` and
9
+ * `teslamate-grafana` on CT100, which the unit's own comment says has "Own Access SaaS OIDC
10
+ * client, never grafana.homeflare.dev's". The SDK's `CredentialsFromEnv` reads one fixed pair
11
+ * of names, so a stack declaring against more than one instance needs a target per instance —
12
+ * `grafanaCredentials(target)` below, parameterized the way `litellm/credentials.ts` and
13
+ * `proxmox/credentials.ts` already are in this kit.
14
+ *
15
+ * ⛔ THE TOKEN NEVER TOUCHES A PROP OR AN ATTRIBUTE (S25). `tokenEnv` is a NAME, resolved from
16
+ * `process.env` fresh inside the credentials effect on every request (S24) — a layer built once
17
+ * still re-reads the environment each time an operation runs, the same laziness
18
+ * `litellm/credentials.ts` and the SDK's own `forgejo`/`netbox` `CredentialsFromEnv` have.
19
+ *
20
+ * ⚠️ THE SDK's `Credentials` SERVICE VALUE TYPE IS `Effect.Effect<Config, never, never>` — the
21
+ * error channel is `never` (credentials.d.ts). A missing env var can only surface as a defect
22
+ * here, via `Effect.orDie`, the same trade-off `forgejo/resource.ts` and `netbox/resource.ts`
23
+ * already accept for their own `CredentialsFromEnv`; it is not a choice this file introduces.
24
+ */
25
+ import { type Config, Credentials } from '@distilled.cloud/grafana/Credentials';
26
+ import { ConfigError } from '@distilled.cloud/grafana/Errors';
27
+ import * as Effect from 'effect/Effect';
28
+ import * as Layer from 'effect/Layer';
29
+ import * as Redacted from 'effect/Redacted';
30
+
31
+ export interface GrafanaTarget {
32
+ /** Instance origin, e.g. `https://tesla.homeflare.dev/dash` — never `/api`; the SDK appends it. */
33
+ readonly baseUrl: string;
34
+ /** Env var NAME holding a Grafana service-account token. Never the value itself. */
35
+ readonly tokenEnv: string;
36
+ /** Selects the org a call applies to, via `X-Grafana-Org-Id`. Omit for the default org. */
37
+ readonly orgId?: number | string;
38
+ }
39
+
40
+ const resolve = (target: GrafanaTarget): Effect.Effect<Config, never, never> =>
41
+ Effect.sync(() => process.env[target.tokenEnv]).pipe(
42
+ Effect.flatMap((raw) =>
43
+ raw === undefined || raw.trim() === ''
44
+ ? Effect.fail(
45
+ new ConfigError({
46
+ message:
47
+ `${target.tokenEnv} is not set. Export the Grafana service-account token there ` +
48
+ `— never as an Alchemy prop — before running a stack that declares a resource ` +
49
+ `against ${target.baseUrl}.`,
50
+ }),
51
+ )
52
+ : Effect.succeed({
53
+ apiBaseUrl: target.baseUrl.replace(/\/+$/, ''),
54
+ token: Redacted.make(raw.trim()),
55
+ ...(target.orgId === undefined ? {} : { orgId: target.orgId }),
56
+ } satisfies Config),
57
+ ),
58
+ Effect.orDie,
59
+ );
60
+
61
+ /** The lazy, per-instance layer `grafanaProviders(target)` composes by default. */
62
+ export const grafanaCredentials = (target: GrafanaTarget): Layer.Layer<Credentials> =>
63
+ Layer.succeed(Credentials, resolve(target));
64
+
65
+ export type { Credentials };
@@ -0,0 +1,183 @@
1
+ /**
2
+ * `Grafana.Datasource` against a fake Grafana, proving the tagged-error paths, the secure-field
3
+ * reference seam (S25) and the observe-before-write "adopt" posture (S9/S10) — all through the
4
+ * REAL `@distilled.cloud/grafana` protocol (path assembly, JSON decode, status→typed-error
5
+ * matching), not re-implemented by hand. Mirrors `../forgejo/repository.test.ts`.
6
+ */
7
+ import { describe, expect, test } from 'bun:test';
8
+ import * as Effect from 'effect/Effect';
9
+ import { fakeFailure, fakeGrafana, fakeGrafanaLayer } from './fake-grafana.ts';
10
+ import { grafanaOperations } from './resource.ts';
11
+ import { GrafanaSecretRefUnsetError, spec } from './datasource.ts';
12
+
13
+ const DS_PATH = '/api/datasources/uid/teslamate';
14
+ const CREATE_PATH = '/api/datasources';
15
+
16
+ const liveJson = {
17
+ access: 'proxy',
18
+ id: 7,
19
+ isDefault: true,
20
+ name: 'TeslaMate',
21
+ orgId: 1,
22
+ readOnly: false,
23
+ type: 'postgres',
24
+ uid: 'teslamate',
25
+ url: '127.0.0.1:5432',
26
+ version: 1,
27
+ };
28
+
29
+ const props = { name: 'TeslaMate', type: 'postgres', uid: 'teslamate' };
30
+
31
+ describe('spec.fetchLive', () => {
32
+ test('a live datasource decodes into typed attributes', async () => {
33
+ const fake = fakeGrafana((method, url) =>
34
+ method === 'GET' && url.pathname === DS_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?.name).toBe('TeslaMate');
42
+ expect(live !== undefined && spec.attributes(live, props)).toEqual({
43
+ access: 'proxy',
44
+ id: 7,
45
+ isDefault: true,
46
+ name: 'TeslaMate',
47
+ orgId: 1,
48
+ readOnly: false,
49
+ type: 'postgres',
50
+ uid: 'teslamate',
51
+ url: '127.0.0.1:5432',
52
+ version: 1,
53
+ });
54
+ expect(fake.seen).toEqual([{ method: 'GET', path: DS_PATH }]);
55
+ });
56
+
57
+ test('a 404 folds to undefined — the exact idiom this family uses', async () => {
58
+ const fake = fakeGrafana(() => fakeFailure(404, 'Data source not found'));
59
+ const result = await Effect.runPromise(
60
+ spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
61
+ );
62
+ expect(result).toBeUndefined();
63
+ });
64
+
65
+ test('a 401 propagates — Unauthorized is a real failure, never folded to absent', async () => {
66
+ const fake = fakeGrafana(() => fakeFailure(401, 'bad token'));
67
+ const failure = await Effect.runPromise(
68
+ Effect.flip(spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch)))),
69
+ );
70
+ expect(failure._tag).toBe('Unauthorized');
71
+ });
72
+ });
73
+
74
+ describe('secure field references (S25)', () => {
75
+ const ENV_VAR = 'GRAFANA_FAMILY_TEST_DS_PASSWORD';
76
+
77
+ test('create resolves the env var fresh and sends it as secureJsonData, never as a prop', async () => {
78
+ process.env[ENV_VAR] = 's3cret';
79
+ try {
80
+ const fake = fakeGrafana((method, url) =>
81
+ method === 'POST' && url.pathname === CREATE_PATH
82
+ ? Response.json({ datasource: liveJson, id: 7, message: 'ok', name: 'TeslaMate' })
83
+ : fakeFailure(404, 'unexpected request'),
84
+ );
85
+ await Effect.runPromise(
86
+ spec
87
+ .create({ ...props, secureJsonDataRefs: { password: ENV_VAR } })
88
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
89
+ );
90
+ expect(fake.seen).toEqual([{ method: 'POST', path: CREATE_PATH }]);
91
+ // ⛔ THE POINT OF THIS TEST. Asserting only that a POST landed proves nothing about what it
92
+ // carried — this is where a dropped `secureJsonData` spread would actually be caught.
93
+ expect(fake.bodies[0]).toMatchObject({ secureJsonData: { password: 's3cret' } });
94
+ } finally {
95
+ delete process.env[ENV_VAR];
96
+ }
97
+ });
98
+
99
+ test('create refuses with a typed error when the ref is unset — and never sends the request', async () => {
100
+ const fake = fakeGrafana(() => fakeFailure(500, 'should not be reached'));
101
+ const failure = await Effect.runPromise(
102
+ Effect.flip(
103
+ spec
104
+ .create({ ...props, secureJsonDataRefs: { password: 'DEFINITELY_UNSET_VAR_XYZ' } })
105
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
106
+ ),
107
+ );
108
+ expect(failure).toBeInstanceOf(GrafanaSecretRefUnsetError);
109
+ expect(fake.seen).toEqual([]);
110
+ });
111
+ });
112
+
113
+ describe('reconcile — adopt semantics', () => {
114
+ test('declaring exactly what is live is a noop: no create or update is ever sent', async () => {
115
+ const fake = fakeGrafana((method, url) =>
116
+ method === 'GET' && url.pathname === DS_PATH
117
+ ? Response.json(liveJson)
118
+ : fakeFailure(500, 'reconcile should not write when nothing changed'),
119
+ );
120
+ const ops = grafanaOperations(spec);
121
+ const after = await Effect.runPromise(
122
+ ops
123
+ .reconcile({ ...props, access: 'proxy', isDefault: true })
124
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
125
+ );
126
+ expect(after.uid).toBe('teslamate');
127
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
128
+ });
129
+
130
+ test('a genuinely absent uid is created', async () => {
131
+ // ⚠️ STATEFUL ON PURPOSE. `reconcile` reads back AFTER writing (S9/S10) — a fake that always
132
+ // 404s the GET would make that read-back indistinguishable from the "write lied" defect
133
+ // `resource.ts` raises, so the second GET must see what the POST just created.
134
+ let created = false;
135
+ const newOne = { ...liveJson, uid: 'new-one' };
136
+ const fake = fakeGrafana((method, url) => {
137
+ if (method === 'GET' && url.pathname === '/api/datasources/uid/new-one') {
138
+ return created ? Response.json(newOne) : fakeFailure(404, 'not found');
139
+ }
140
+ if (method === 'POST' && url.pathname === CREATE_PATH) {
141
+ created = true;
142
+ return Response.json({ datasource: newOne, id: 7, message: 'ok', name: 'TeslaMate' });
143
+ }
144
+ return fakeFailure(500, 'unexpected request');
145
+ });
146
+ const ops = grafanaOperations(spec);
147
+ await Effect.runPromise(
148
+ ops
149
+ .reconcile({ ...props, uid: 'new-one' })
150
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
151
+ );
152
+ expect(fake.seen.some((s) => s.method === 'POST' && s.path === CREATE_PATH)).toBe(true);
153
+ });
154
+ });
155
+
156
+ describe('destroy', () => {
157
+ test('deletes a live datasource by uid', async () => {
158
+ const fake = fakeGrafana((method, url) => {
159
+ if (method === 'GET' && url.pathname === DS_PATH) return Response.json(liveJson);
160
+ if (method === 'DELETE' && url.pathname === DS_PATH) return Response.json({ message: 'ok' });
161
+ return fakeFailure(500, 'unexpected request');
162
+ });
163
+ await Effect.runPromise(
164
+ grafanaOperations(spec)
165
+ .destroy(props)
166
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
167
+ );
168
+ expect(fake.seen).toEqual([
169
+ { method: 'GET', path: DS_PATH },
170
+ { method: 'DELETE', path: DS_PATH },
171
+ ]);
172
+ });
173
+
174
+ test('destroying an absent uid sends no DELETE — idempotent (S11)', async () => {
175
+ const fake = fakeGrafana(() => fakeFailure(404, 'not found'));
176
+ await Effect.runPromise(
177
+ grafanaOperations(spec)
178
+ .destroy(props)
179
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
180
+ );
181
+ expect(fake.seen).toEqual([{ method: 'GET', path: DS_PATH }]);
182
+ });
183
+ });
@@ -0,0 +1,171 @@
1
+ /**
2
+ * `Grafana.Datasource` — one Grafana data source, keyed by `uid`.
3
+ *
4
+ * ★ WHY `uid` IS REQUIRED, NOT GENERATED. `getDataSourceByUID`/`updateDataSourceByUID`/
5
+ * `deleteDataSourceByUID` are all UID-keyed, and `getDataSources` (list) takes no filter — so
6
+ * locating a datasource this resource didn't create means listing the whole org and matching by
7
+ * name, the same ambiguity netbox/resource.ts's `soleMatch` exists to catch. Requiring `uid` up
8
+ * front (Grafana accepts a caller-chosen one on create — `AddDataSourceRequest.uid`) sidesteps
9
+ * it entirely: `read`/`reconcile`/`destroy` all key on the one field the API itself is keyed on.
10
+ * `teslamate-grafana`'s live datasource (provisioned from a file, not this resource) has a
11
+ * Grafana-generated uid; the census in the handoff script records it so a first declaration can
12
+ * name it explicitly.
13
+ *
14
+ * ⛔ `secureJsonData` (e.g. a datasource password) NEVER APPEARS AS A LITERAL PROP (S25).
15
+ * `secureJsonDataRefs` takes env var NAMES instead; `resolveSecureJsonData` reads them from
16
+ * `process.env` fresh inside `create`/`update`'s own effect, never at plan time and never
17
+ * returned in `attributes` — Grafana's own read API agrees: `DataSource.secureJsonFields` on a
18
+ * GET is a map of booleans (is-it-set), never the value.
19
+ */
20
+ import { Resource } from 'alchemy';
21
+ import * as Provider from 'alchemy/Provider';
22
+ import * as grafana from '@distilled.cloud/grafana';
23
+ import * as Data from 'effect/Data';
24
+ import * as Effect from 'effect/Effect';
25
+ import { type GrafanaSpec, grafanaHandlers } from './resource.ts';
26
+
27
+ export interface DatasourceProps {
28
+ uid: string;
29
+ name: string;
30
+ /** e.g. `postgres`, `prometheus`, `influxdb` — the Grafana plugin id. */
31
+ type: string;
32
+ url?: string;
33
+ access?: 'proxy' | 'direct';
34
+ database?: string;
35
+ user?: string;
36
+ basicAuth?: boolean;
37
+ basicAuthUser?: string;
38
+ isDefault?: boolean;
39
+ withCredentials?: boolean;
40
+ jsonData?: Record<string, unknown>;
41
+ /** Secure field name (e.g. `password`) -> env var NAME holding its value. Never the value. */
42
+ secureJsonDataRefs?: Record<string, string>;
43
+ }
44
+
45
+ export interface DatasourceAttributes {
46
+ uid: string;
47
+ id: number;
48
+ name: string;
49
+ type: string;
50
+ url: string;
51
+ access: string;
52
+ orgId: number;
53
+ isDefault: boolean;
54
+ readOnly: boolean;
55
+ version: number;
56
+ }
57
+
58
+ export interface GrafanaDatasource extends Resource<
59
+ 'Grafana.Datasource',
60
+ DatasourceProps,
61
+ DatasourceAttributes,
62
+ never
63
+ > {}
64
+
65
+ export const GrafanaDatasource = Resource<GrafanaDatasource>('Grafana.Datasource');
66
+
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
+ /** 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
+ });
94
+
95
+ /** `exactOptionalPropertyTypes` means an undeclared field must be OMITTED, never sent as `undefined`. */
96
+ const fields = (props: DatasourceProps) => ({
97
+ name: props.name,
98
+ type: props.type,
99
+ ...(props.access === undefined ? {} : { access: props.access }),
100
+ ...(props.basicAuth === undefined ? {} : { basicAuth: props.basicAuth }),
101
+ ...(props.basicAuthUser === undefined ? {} : { basicAuthUser: props.basicAuthUser }),
102
+ ...(props.database === undefined ? {} : { database: props.database }),
103
+ ...(props.isDefault === undefined ? {} : { isDefault: props.isDefault }),
104
+ ...(props.jsonData === undefined ? {} : { jsonData: props.jsonData }),
105
+ ...(props.url === undefined ? {} : { url: props.url }),
106
+ ...(props.user === undefined ? {} : { user: props.user }),
107
+ ...(props.withCredentials === undefined ? {} : { withCredentials: props.withCredentials }),
108
+ });
109
+
110
+ export const spec: GrafanaSpec<
111
+ DatasourceProps,
112
+ grafana.DataSource,
113
+ DatasourceAttributes,
114
+ | grafana.AddDataSourceError
115
+ | grafana.GetDataSourceByUIDError
116
+ | grafana.UpdateDataSourceByUIDError
117
+ | grafana.DeleteDataSourceByUIDError
118
+ | GrafanaSecretRefUnsetError
119
+ > = {
120
+ attributes: (live) => ({
121
+ access: live.access ?? '',
122
+ id: live.id ?? 0,
123
+ isDefault: live.isDefault ?? false,
124
+ name: live.name ?? '',
125
+ orgId: live.orgId ?? 0,
126
+ readOnly: live.readOnly ?? false,
127
+ type: live.type ?? '',
128
+ uid: live.uid ?? '',
129
+ url: live.url ?? '',
130
+ version: live.version ?? 0,
131
+ }),
132
+ create: (props) =>
133
+ Effect.flatMap(resolveSecureJsonData(props.secureJsonDataRefs), (secureJsonData) =>
134
+ grafana.addDataSource({
135
+ ...fields(props),
136
+ ...(secureJsonData === undefined ? {} : { secureJsonData }),
137
+ uid: props.uid,
138
+ }),
139
+ ),
140
+ destroy: (props) => grafana.deleteDataSourceByUID({ uid: props.uid }),
141
+ fetchLive: (props) =>
142
+ grafana
143
+ .getDataSourceByUID({ uid: props.uid })
144
+ .pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
145
+ /**
146
+ * ⛔ AN UNDECLARED FIELD IS NEITHER COMPARED NOR SENT — the same rule every family in this
147
+ * estate follows. `secureJsonDataRefs` is never compared: Grafana never returns the value to
148
+ * diff against, only whether a field is set (`secureJsonFields`), so this resource cannot
149
+ * detect drift in a secret's value — only in whether one is configured at all.
150
+ */
151
+ matches: (attributes, props) =>
152
+ (props.name === undefined || attributes.name === props.name) &&
153
+ (props.type === undefined || attributes.type === props.type) &&
154
+ (props.url === undefined || attributes.url === props.url) &&
155
+ (props.access === undefined || attributes.access === props.access) &&
156
+ (props.isDefault === undefined || attributes.isDefault === props.isDefault),
157
+ update: (props, live) =>
158
+ Effect.flatMap(resolveSecureJsonData(props.secureJsonDataRefs), (secureJsonData) =>
159
+ grafana.updateDataSourceByUID({
160
+ ...fields(props),
161
+ ...(secureJsonData === undefined ? {} : { secureJsonData }),
162
+ ...(live.version === undefined ? {} : { version: live.version }),
163
+ uid: props.uid,
164
+ }),
165
+ ),
166
+ };
167
+
168
+ export const handlers = grafanaHandlers(spec);
169
+
170
+ export const GrafanaDatasourceProvider = () =>
171
+ Provider.effect(GrafanaDatasource, Effect.succeed(GrafanaDatasource.Provider.of(handlers)));