@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,64 @@
1
+ /**
2
+ * A minimal in-memory Grafana HTTP API for this family's tests — an in-memory `fetch` handed to
3
+ * Effect's real `FetchHttpClient` through its `Fetch` reference, mirroring
4
+ * `../forgejo/fake-forgejo.ts` and `../netbox/fake-netbox.ts`. Tests exercise distilled's REAL
5
+ * path assembly, JSON encode/decode and status→typed-error matching (`404` → `NotFound`, `403` →
6
+ * `Forbidden`, …) — nothing about that matching is re-implemented here, only the wire responses a
7
+ * scenario needs.
8
+ *
9
+ * ⛔ TEST-ONLY, AND NO NETWORK. No provider imports this file. The base URL is RFC 2606
10
+ * `grafana.example.com`, and the token is a placeholder.
11
+ */
12
+ import { type Credentials, fromApiKey } from '@distilled.cloud/grafana/Credentials';
13
+ import * as Layer from 'effect/Layer';
14
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
15
+
16
+ export const FAKE_BASE = 'https://grafana.example.com';
17
+ export const FAKE_TOKEN = 'placeholder-grafana-token';
18
+
19
+ export type Seen = { readonly method: string; readonly path: string };
20
+
21
+ /** Grafana's `{ message?, status? }` failure envelope — matching the SDK's own protocol.ts note. */
22
+ export const fakeFailure = (status: number, message: string) =>
23
+ Response.json({ message }, { status });
24
+
25
+ /**
26
+ * `route` decides every response; this wrapper only records what was sent and adapts to
27
+ * `globalThis.fetch`'s signature. A test that expects no request at all asserts `seen` is empty.
28
+ *
29
+ * ★ `bodies` IS A SEPARATE, PARALLEL ARRAY (index-matched to `seen`), NOT FOLDED INTO IT — so
30
+ * every existing `expect(fake.seen).toEqual([{method, path}, …])` stays exact rather than
31
+ * growing an unrelated `body` key it would then have to repeat on every entry. A test that
32
+ * cares what was actually sent (not just that a request was) reads `bodies[n]` instead of only
33
+ * checking a request landed — the request landing is not proof of what it carried.
34
+ */
35
+ export const fakeGrafana = (route: (method: string, url: URL, body: unknown) => Response) => {
36
+ const seen: Seen[] = [];
37
+ const bodies: unknown[] = [];
38
+ const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
39
+ const request =
40
+ input instanceof Request ? new Request(input, init) : new Request(String(input), init);
41
+ const url = new URL(request.url);
42
+ const text = await request.text();
43
+ const body: unknown = text === '' ? undefined : JSON.parse(text);
44
+ seen.push({ method: request.method, path: `${url.pathname}${url.search}` });
45
+ bodies.push(body);
46
+ return route(request.method, url, body);
47
+ }) as typeof globalThis.fetch;
48
+ return { fetch, seen, bodies };
49
+ };
50
+
51
+ /**
52
+ * distilled `Credentials` (via the SDK's own `fromApiKey`) + the real `FetchHttpClient` over the
53
+ * fake — `GrafanaOpContext`, what every resource file's exported `spec.fetchLive`/`create`/
54
+ * `update`/`destroy` needs. Unlike `grafanaHandlers`'s production callers, this never touches
55
+ * `process.env` — `grafanaCredentials` reads `tokenEnv` fresh per call (credentials.ts's own
56
+ * note), which a test-time `process.env` mutation cannot reliably reach either (measured for the
57
+ * same reason in the forgejo/netbox migrations: Effect's default `Config` provider snapshots the
58
+ * environment). Passing an explicit layer exercises exactly the same production code without
59
+ * that seam.
60
+ */
61
+ export const fakeGrafanaLayer = (
62
+ fetchFn: typeof globalThis.fetch,
63
+ creds: Layer.Layer<Credentials> = fromApiKey({ apiBaseUrl: FAKE_BASE, apiKey: FAKE_TOKEN }),
64
+ ) => Layer.mergeAll(creds, FetchHttpClient.layer, Layer.succeed(FetchHttpClient.Fetch, fetchFn));
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Grafana providers for Alchemy.
3
+ *
4
+ * ⛔ THIS BARREL IS THE PUBLIC API, AND IT IS DELIBERATELY SMALLER THAN THE DIRECTORY. These are
5
+ * the symbols a real stack consumes; the rest of the files (resource.ts's engine, fake-grafana.ts)
6
+ * are internals a provider needs but a consumer should not depend on.
7
+ *
8
+ * ⛔ ONLY `Grafana.Datasource` SHIPS HERE. `@distilled.cloud/grafana@1.0.0-rc.12` has no folder,
9
+ * dashboard, alert-rule or contact-point CRUD operations — see docs/grafana.md and
10
+ * docs/upstream-conformance.md for the measured gap. Adding those resources now would mean
11
+ * hand-rolling an `HttpClient` client for them, which S23 forbids for a vendor distilled
12
+ * already covers in part; the right fix is upstream, in distilled, not a house workaround here.
13
+ */
14
+ export {
15
+ type DatasourceAttributes,
16
+ type DatasourceProps,
17
+ GrafanaDatasource,
18
+ GrafanaDatasourceProvider,
19
+ GrafanaSecretRefUnsetError,
20
+ } from './datasource.ts';
21
+ export { type GrafanaTarget, grafanaCredentials } from './credentials.ts';
22
+ export { grafanaProviders } from './providers.ts';
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The Grafana providers for one instance, with its credentials, as one layer for a stack.
3
+ *
4
+ * Layer.mergeAll(grafanaProviders({ baseUrl: '...', tokenEnv: 'GRAFANA_TOKEN' }), …)
5
+ *
6
+ * ★ ONE CALL PER INSTANCE. A stack declaring resources against both `grafana.homeflare.dev` and
7
+ * `teslamate-grafana` composes `grafanaProviders(targetA)` and `grafanaProviders(targetB)` —
8
+ * each resource type's `Resource` constructor is a single global registration, so the SAME
9
+ * `Grafana.Datasource` type serves both; only the credential layer differs per call.
10
+ * ⚠️ `HttpClient` is NOT provided here. It is ambient in every Alchemy runtime, the same as
11
+ * `litellmProviders`/`netboxProviders`-style families in this kit assume; a stack that already
12
+ * deploys anything over HTTP already has one.
13
+ */
14
+ import * as Layer from 'effect/Layer';
15
+ import { type GrafanaTarget, grafanaCredentials } from './credentials.ts';
16
+ import { GrafanaDatasourceProvider } from './datasource.ts';
17
+
18
+ export const grafanaProviders = (target: GrafanaTarget) =>
19
+ GrafanaDatasourceProvider().pipe(Layer.provide(grafanaCredentials(target)));
@@ -0,0 +1,120 @@
1
+ /**
2
+ * One shape for every Grafana object this family declares — mirroring `../forgejo/resource.ts`
3
+ * and `../netbox/resource.ts` exactly, minus the credentials wrapping those two bake in.
4
+ *
5
+ * ★ CREDENTIALS ARE NOT PROVIDED HERE. Forgejo and NetBox each manage exactly one instance for
6
+ * this house, so their engines close over a hardcoded `CredentialsFromEnv`. Grafana does not —
7
+ * see `credentials.ts`'s note on `grafana.homeflare.dev` vs `teslamate-grafana` — so `Credentials`
8
+ * stays a context requirement on every handler here, and `providers.ts`'s `grafanaProviders`
9
+ * supplies it per target with `Layer.provide`, the same composition `litellm/providers.ts` uses.
10
+ *
11
+ * ⚠️ `NotFound` IS FOLDED TO "ABSENT" INSIDE EACH RESOURCE FILE'S OWN `fetchLive`, NOT HERE — the
12
+ * same split forgejo's and netbox's engines use. `Effect.catchTag`'s tag-literal inference needs
13
+ * a concrete error union to resolve `"NotFound"` against; a spec's `E` is only concrete at each
14
+ * resource file's own call site.
15
+ */
16
+ import { isResolved } from 'alchemy/Diff';
17
+ import type { Input } from 'alchemy/Input';
18
+ import type { GrafanaOpContext } from '@distilled.cloud/grafana/Protocol';
19
+ import * as Effect from 'effect/Effect';
20
+
21
+ /**
22
+ * One Grafana object's distilled calls. `Live` is whatever the SDK decodes (a `DataSource`, …) —
23
+ * no untyped `Record<string, unknown>`. `E` is left to each resource file to declare (the union
24
+ * of the distilled operations it actually calls plus any domain refusal, e.g. an unset secret-ref
25
+ * env var), so a call site's precise error union flows straight through instead of being widened
26
+ * by hand here.
27
+ */
28
+ export type GrafanaSpec<Props extends object, Live, Attributes, E> = {
29
+ /** Already folds the SDK's `NotFound` to `undefined` via its own `catchTag` — see the note above. */
30
+ readonly fetchLive: (props: Props) => Effect.Effect<Live | undefined, E, GrafanaOpContext>;
31
+ readonly attributes: (live: Live, props: Props) => Attributes | undefined;
32
+ readonly matches: (attributes: Attributes, props: Props) => boolean;
33
+ readonly create: (props: Props) => Effect.Effect<unknown, E, GrafanaOpContext>;
34
+ readonly update?: (props: Props, live: Live) => Effect.Effect<unknown, E, GrafanaOpContext>;
35
+ readonly destroy: (props: Props, live: Live) => Effect.Effect<unknown, E, GrafanaOpContext>;
36
+ };
37
+
38
+ export const grafanaOperations = <Props extends object, Live, Attributes, E>(
39
+ spec: GrafanaSpec<Props, Live, Attributes, E>,
40
+ ) => {
41
+ const read = (props: Props) =>
42
+ spec
43
+ .fetchLive(props)
44
+ .pipe(Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))));
45
+
46
+ return {
47
+ read,
48
+
49
+ diff: (news: Input<Props>, output: Attributes | undefined) =>
50
+ Effect.gen(function* () {
51
+ if (output === undefined || !isResolved(news)) return undefined;
52
+ const live = yield* read(news);
53
+ if (live === undefined) return { action: 'update' } as const;
54
+ if (spec.matches(live, news)) return { action: 'noop' } as const;
55
+ return spec.update === undefined
56
+ ? ({ action: 'replace' } as const)
57
+ : ({ action: 'update' } as const);
58
+ }),
59
+
60
+ /**
61
+ * ★ OBSERVES BEFORE IT WRITES (S9, S10) — THE WHOLE OF "ADOPT" FOR THIS FAMILY. A `uid` this
62
+ * Grafana instance already has is bound and, when it already matches the declaration,
63
+ * left alone; only a genuinely absent `uid` is created. Declaring exactly what is live
64
+ * changes nothing on `reconcile`, the same posture netbox/resource.ts documents as
65
+ * "adopt is the default posture, not a flag".
66
+ */
67
+ reconcile: (news: Props) =>
68
+ Effect.gen(function* () {
69
+ const before = yield* spec.fetchLive(news);
70
+ if (before === undefined) {
71
+ yield* spec.create(news);
72
+ } else if (spec.update !== undefined) {
73
+ const mapped = spec.attributes(before, news);
74
+ if (mapped === undefined || spec.matches(mapped, news)) {
75
+ /* adoption or already converged — no write */
76
+ } else {
77
+ yield* spec.update(news, before);
78
+ }
79
+ }
80
+ const after = yield* read(news);
81
+ if (after === undefined) {
82
+ return yield* Effect.die(
83
+ new Error(
84
+ 'grafanaOperations: the write returned no error but the object is still absent. ' +
85
+ 'Read back rather than trusting the response status.',
86
+ ),
87
+ );
88
+ }
89
+ return after;
90
+ }),
91
+
92
+ destroy: (olds: Props) =>
93
+ Effect.gen(function* () {
94
+ const live = yield* spec.fetchLive(olds);
95
+ if (live === undefined) return;
96
+ yield* spec.destroy(olds, live);
97
+ }),
98
+ };
99
+ };
100
+
101
+ /**
102
+ * The five provider handlers for a spec'd Grafana object, wired once. `Credentials` and
103
+ * `HttpClient.HttpClient` stay ambient context requirements — see the file header.
104
+ *
105
+ * ⚠️ `list` answers empty — an org's full datasource list is not a page this family sweeps for
106
+ * adoption; adoption stays explicit, the same reasoning forgejo's and netbox's engines give.
107
+ */
108
+ export const grafanaHandlers = <Props extends object, Live, Attributes extends object, E>(
109
+ spec: GrafanaSpec<Props, Live, Attributes, E>,
110
+ ) => {
111
+ const ops = grafanaOperations(spec);
112
+ return {
113
+ list: () => Effect.succeed([]),
114
+ read: ({ olds }: { olds: Props }) => ops.read(olds),
115
+ diff: ({ news, output }: { news: Input<Props>; output: Attributes | undefined }) =>
116
+ ops.diff(news, output),
117
+ reconcile: ({ news }: { news: Props }) => ops.reconcile(news),
118
+ delete: ({ olds }: { olds: Props }) => ops.destroy(olds),
119
+ };
120
+ };