@homeflare/alchemy 0.35.0 → 0.37.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 (127) hide show
  1. package/README.md +14 -0
  2. package/dist/argocd/cluster.d.ts +102 -0
  3. package/dist/argocd/cluster.d.ts.map +1 -0
  4. package/dist/argocd/credentials.d.ts +37 -0
  5. package/dist/argocd/credentials.d.ts.map +1 -0
  6. package/dist/argocd/fake-argocd.d.ts +42 -0
  7. package/dist/argocd/fake-argocd.d.ts.map +1 -0
  8. package/dist/argocd/git-credentials.d.ts +29 -0
  9. package/dist/argocd/git-credentials.d.ts.map +1 -0
  10. package/dist/argocd/index.d.ts +19 -0
  11. package/dist/argocd/index.d.ts.map +1 -0
  12. package/dist/argocd/index.js +306 -0
  13. package/dist/argocd/index.js.map +17 -0
  14. package/dist/argocd/providers.d.ts +13 -0
  15. package/dist/argocd/providers.d.ts.map +1 -0
  16. package/dist/argocd/repo-creds.d.ts +67 -0
  17. package/dist/argocd/repo-creds.d.ts.map +1 -0
  18. package/dist/argocd/repository.d.ts +87 -0
  19. package/dist/argocd/repository.d.ts.map +1 -0
  20. package/dist/argocd/resource.d.ts +90 -0
  21. package/dist/argocd/resource.d.ts.map +1 -0
  22. package/dist/cloudflare/index.js +4 -4
  23. package/dist/cloudflare/index.js.map +3 -3
  24. package/dist/cloudflare/r2-bucket-lock.d.ts +14 -5
  25. package/dist/cloudflare/r2-bucket-lock.d.ts.map +1 -1
  26. package/dist/grafana/index.d.ts +5 -4
  27. package/dist/grafana/index.d.ts.map +1 -1
  28. package/dist/grafana/index.js +204 -90
  29. package/dist/grafana/index.js.map +7 -4
  30. package/dist/grafana/notification-policy-drop-warning.d.ts +27 -0
  31. package/dist/grafana/notification-policy-drop-warning.d.ts.map +1 -0
  32. package/dist/grafana/notification-policy-fixtures.d.ts +34 -0
  33. package/dist/grafana/notification-policy-fixtures.d.ts.map +1 -0
  34. package/dist/grafana/notification-policy-model.d.ts +53 -0
  35. package/dist/grafana/notification-policy-model.d.ts.map +1 -0
  36. package/dist/grafana/notification-policy.d.ts +57 -0
  37. package/dist/grafana/notification-policy.d.ts.map +1 -0
  38. package/dist/grafana/providers.d.ts +1 -1
  39. package/dist/grafana/providers.d.ts.map +1 -1
  40. package/dist/litellm/index.js.map +2 -2
  41. package/dist/litellm/operations.d.ts.map +1 -1
  42. package/dist/proxmox/ceph-daemon-form.d.ts +4 -5
  43. package/dist/proxmox/ceph-daemon-form.d.ts.map +1 -1
  44. package/dist/proxmox/ceph-daemon-wire.d.ts +33 -0
  45. package/dist/proxmox/ceph-daemon-wire.d.ts.map +1 -0
  46. package/dist/proxmox/ceph-daemon.d.ts +35 -0
  47. package/dist/proxmox/ceph-daemon.d.ts.map +1 -1
  48. package/dist/proxmox/ceph-fs-distilled.d.ts +14 -0
  49. package/dist/proxmox/ceph-fs-distilled.d.ts.map +1 -0
  50. package/dist/proxmox/ceph-fs-wire.d.ts +20 -11
  51. package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -1
  52. package/dist/proxmox/ceph-fs.d.ts +31 -6
  53. package/dist/proxmox/ceph-fs.d.ts.map +1 -1
  54. package/dist/proxmox/ceph-osd-tree.d.ts +12 -3
  55. package/dist/proxmox/ceph-osd-tree.d.ts.map +1 -1
  56. package/dist/proxmox/ceph-osd-write.d.ts +17 -2
  57. package/dist/proxmox/ceph-osd-write.d.ts.map +1 -1
  58. package/dist/proxmox/ceph-osd.d.ts +6 -0
  59. package/dist/proxmox/ceph-osd.d.ts.map +1 -1
  60. package/dist/proxmox/ceph-pool-settle.d.ts +10 -4
  61. package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -1
  62. package/dist/proxmox/ceph-pool-wire.d.ts +46 -0
  63. package/dist/proxmox/ceph-pool-wire.d.ts.map +1 -0
  64. package/dist/proxmox/ceph-pool.d.ts +10 -5
  65. package/dist/proxmox/ceph-pool.d.ts.map +1 -1
  66. package/dist/proxmox/group-wire.d.ts +1 -1
  67. package/dist/proxmox/group-wire.d.ts.map +1 -1
  68. package/dist/proxmox/index.js +611 -477
  69. package/dist/proxmox/index.js.map +15 -12
  70. package/dist/proxmox/storage-wire.d.ts +8 -1
  71. package/dist/proxmox/storage-wire.d.ts.map +1 -1
  72. package/dist/proxmox/user-wire.d.ts +1 -1
  73. package/dist/proxmox/user-wire.d.ts.map +1 -1
  74. package/docs/argocd-kubernetes.md +100 -0
  75. package/docs/argocd.md +139 -0
  76. package/docs/credentials.md +9 -0
  77. package/docs/grafana-alerting.md +3 -2
  78. package/docs/grafana-notification-policy.md +127 -0
  79. package/docs/grafana.md +12 -8
  80. package/docs/litellm.md +17 -0
  81. package/docs/peers.md +7 -0
  82. package/docs/unifi.md +45 -0
  83. package/docs/upstream-conformance.md +19 -5
  84. package/package.json +8 -3
  85. package/src/argocd/cluster.test.ts +120 -0
  86. package/src/argocd/cluster.ts +222 -0
  87. package/src/argocd/credentials.ts +38 -0
  88. package/src/argocd/fake-argocd.ts +60 -0
  89. package/src/argocd/git-credentials.ts +71 -0
  90. package/src/argocd/index.ts +40 -0
  91. package/src/argocd/providers.ts +17 -0
  92. package/src/argocd/repo-creds.test.ts +123 -0
  93. package/src/argocd/repo-creds.ts +115 -0
  94. package/src/argocd/repository.test.ts +167 -0
  95. package/src/argocd/repository.ts +165 -0
  96. package/src/argocd/resource.ts +150 -0
  97. package/src/cloudflare/r2-bucket-lock.test.ts +54 -18
  98. package/src/cloudflare/r2-bucket-lock.ts +25 -18
  99. package/src/grafana/index.ts +11 -4
  100. package/src/grafana/notification-policy-drop-warning.test.ts +163 -0
  101. package/src/grafana/notification-policy-drop-warning.ts +60 -0
  102. package/src/grafana/notification-policy-fixtures.ts +27 -0
  103. package/src/grafana/notification-policy-model.test.ts +104 -0
  104. package/src/grafana/notification-policy-model.ts +100 -0
  105. package/src/grafana/notification-policy-refusals.test.ts +119 -0
  106. package/src/grafana/notification-policy.test.ts +86 -0
  107. package/src/grafana/notification-policy.ts +191 -0
  108. package/src/grafana/providers.ts +2 -0
  109. package/src/litellm/operations.ts +8 -4
  110. package/src/proxmox/ceph-daemon-form.ts +7 -8
  111. package/src/proxmox/ceph-daemon-wire.ts +115 -0
  112. package/src/proxmox/ceph-daemon-write.test.ts +158 -0
  113. package/src/proxmox/ceph-daemon.ts +65 -47
  114. package/src/proxmox/ceph-fs-delete.test.ts +81 -0
  115. package/src/proxmox/ceph-fs-distilled.ts +102 -0
  116. package/src/proxmox/ceph-fs-wire.ts +17 -31
  117. package/src/proxmox/ceph-fs-write.test.ts +67 -0
  118. package/src/proxmox/ceph-fs.ts +66 -69
  119. package/src/proxmox/ceph-osd-tree.ts +14 -5
  120. package/src/proxmox/ceph-osd-write.test.ts +61 -0
  121. package/src/proxmox/ceph-osd-write.ts +33 -6
  122. package/src/proxmox/ceph-osd.ts +6 -0
  123. package/src/proxmox/ceph-pool-settle.ts +13 -7
  124. package/src/proxmox/ceph-pool-wire.ts +110 -0
  125. package/src/proxmox/ceph-pool-write.test.ts +145 -0
  126. package/src/proxmox/ceph-pool.ts +87 -98
  127. package/src/proxmox/distilled-task-await.test.ts +0 -124
@@ -0,0 +1,150 @@
1
+ /**
2
+ * One shape for every Argo CD REST object — Repository, RepoCreds, Cluster — the same four
3
+ * operations, different distilled call.
4
+ *
5
+ * ⛔ THIS IS A NEAR-VERBATIM COPY OF `../forgejo/resource.ts`'S ENGINE, ON PURPOSE. Both SDKs speak
6
+ * plain bearer-authenticated REST with a real `NotFound` tag (see the family's own docs.md for
7
+ * why Argo CD, UNLIKE Discord, gets a genuine typed 404 — measured from `protocol.ts`), so the
8
+ * same read/diff/reconcile/destroy shape applies unchanged. A second hand-rolled engine here
9
+ * would just be forgejo's with the names swapped.
10
+ *
11
+ * ⚠️ NO `Unowned`/`adopt(true)` WRAPPING HERE — mirrors forgejo's own choice (not netbox's or
12
+ * discord's `Unowned` marker), not an oversight. A live match on a cold read (no persisted
13
+ * `output`) is treated as ours, same as every other forgejo-shaped family in this kit today.
14
+ *
15
+ * ⚠️ `NotFound` IS FOLDED TO "ABSENT" INSIDE EACH RESOURCE FILE'S OWN `fetchLive`, NOT HERE.
16
+ * `Effect.catchTag`'s tag-literal inference needs a concrete error union to resolve `"NotFound"`
17
+ * against; a spec's `E` is only concrete at each resource file's own call site. So every
18
+ * `fetchLive` ends `.pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined)))` itself.
19
+ */
20
+ import { isResolved } from 'alchemy/Diff';
21
+ import type { Input } from 'alchemy/Input';
22
+ import { CredentialsFromEnv } from '@distilled.cloud/argocd/Credentials';
23
+ import type { ArgocdOpContext } from '@distilled.cloud/argocd/Protocol';
24
+ import * as Effect from 'effect/Effect';
25
+ import type * as HttpClient from 'effect/unstable/http/HttpClient';
26
+
27
+ /** What every handler needs from the caller's runtime, once `argocdHandlers` bakes in credentials. */
28
+ export type ArgocdRequirements = HttpClient.HttpClient;
29
+
30
+ /**
31
+ * Strips undefined-valued keys, mapping each to a genuinely optional key rather than a present
32
+ * key typed `X | undefined`. `tsconfig.base.json` turns on `exactOptionalPropertyTypes`, and every
33
+ * generated Argo CD request type declares its optional fields the strict way (`foo?: X`, never
34
+ * `foo?: X | undefined`) — so `{ foo: props.foo }` where `props.foo?: X` does not typecheck against
35
+ * it even when `foo` is genuinely absent at runtime. Every resource file's request body goes
36
+ * through this once, mirroring `forgejo/repository.ts#editForm`'s conditional-spread idiom, just
37
+ * generalized instead of repeated per field.
38
+ */
39
+ export const present = <T extends object>(obj: T): { [K in keyof T]?: Exclude<T[K], undefined> } =>
40
+ Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) as never;
41
+
42
+ /**
43
+ * One Argo CD object's distilled calls. `Live` is whatever the SDK decodes (`V1alpha1Repository`,
44
+ * `V1alpha1Cluster`, …); `E` is left to each resource file to declare.
45
+ */
46
+ export type ArgocdSpec<Props extends object, Live, Attributes, E> = {
47
+ /**
48
+ * The identity key's value — `repo`/`url`/`server` depending on the resource — read off
49
+ * declared props and off persisted attributes respectively. `diff` compares the two to detect
50
+ * an identity change, which `upsert`'s single create-or-converge call cannot express as an
51
+ * in-place update: Argo CD indexes each of these objects BY this value, so a changed identity
52
+ * means a create at the new key, never a rename, and the object left at the old key must be
53
+ * deleted — exactly what a `replace` diff action tells Alchemy's own Plan/Apply to do (delete
54
+ * the old physical object, using `olds`, then reconcile `news`). Without this, a changed
55
+ * `repo`/`url`/`server` orphans the old, still-credentialed object with no state pointing at it
56
+ * — found in adversarial review 2026-09-24, fixed here.
57
+ */
58
+ readonly identityOfProps: (props: Props) => string;
59
+ readonly identityOfAttributes: (attributes: Attributes) => string;
60
+ /** Already folds the SDK's `NotFound` to `undefined` via its own `catchTag` — see the note above. */
61
+ readonly fetchLive: (props: Props) => Effect.Effect<Live | undefined, E, ArgocdOpContext>;
62
+ readonly attributes: (live: Live, props: Props) => Attributes | undefined;
63
+ readonly matches: (attributes: Attributes, props: Props) => boolean;
64
+ /**
65
+ * A single upsert call — every Argo CD create endpoint this family uses takes `upsert: true`
66
+ * and converges create-or-update in one request, so there is no separate update path to wire.
67
+ */
68
+ readonly upsert: (props: Props) => Effect.Effect<unknown, E, ArgocdOpContext>;
69
+ readonly destroy: (props: Props, live: Live) => Effect.Effect<unknown, E, ArgocdOpContext>;
70
+ };
71
+
72
+ export const argocdOperations = <Props extends object, Live, Attributes, E>(
73
+ spec: ArgocdSpec<Props, Live, Attributes, E>,
74
+ ) => {
75
+ const read = (props: Props) =>
76
+ spec
77
+ .fetchLive(props)
78
+ .pipe(Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))));
79
+
80
+ return {
81
+ read,
82
+
83
+ diff: (news: Input<Props>, output: Attributes | undefined) =>
84
+ Effect.gen(function* () {
85
+ if (output === undefined || !isResolved(news)) return undefined;
86
+ // The identity key changed (repo/url/server) — `upsert` cannot rename in place, it would
87
+ // create at the new key and leave the old, still-live object orphaned. See the spec type.
88
+ if (spec.identityOfProps(news) !== spec.identityOfAttributes(output)) {
89
+ return { action: 'replace' } as const;
90
+ }
91
+ const live = yield* read(news);
92
+ if (live === undefined) return { action: 'update' } as const;
93
+ return spec.matches(live, news)
94
+ ? ({ action: 'noop' } as const)
95
+ : ({ action: 'update' } as const);
96
+ }),
97
+
98
+ reconcile: (news: Props) =>
99
+ Effect.gen(function* () {
100
+ const before = yield* read(news);
101
+ // S10: no write when what's declared already matches what's live.
102
+ if (before === undefined || !spec.matches(before, news)) {
103
+ yield* spec.upsert(news);
104
+ }
105
+ const after = yield* read(news);
106
+ if (after === undefined) {
107
+ return yield* Effect.die(
108
+ new Error(
109
+ 'the write returned no error but the object is still absent — read back rather ' +
110
+ 'than trust the status code.',
111
+ ),
112
+ );
113
+ }
114
+ return after;
115
+ }),
116
+
117
+ destroy: (olds: Props) =>
118
+ Effect.gen(function* () {
119
+ const live = yield* spec.fetchLive(olds);
120
+ if (live === undefined) return;
121
+ yield* spec.destroy(olds, live);
122
+ }),
123
+ };
124
+ };
125
+
126
+ /**
127
+ * The four provider handlers for a spec'd Argo CD object, wired once.
128
+ *
129
+ * ★ CREDENTIALS ARE PROVIDED HERE, ONCE. `CredentialsFromEnv` resolves `ARGOCD_TOKEN` /
130
+ * `ARGOCD_SERVER` on the calling fiber per request (credentials.ts), never captured at load.
131
+ *
132
+ * ⚠️ `list` answers empty — Argo CD's list endpoints return every object of that kind on the whole
133
+ * instance; adoption stays explicit (a declared resource's own `read`), same reasoning as
134
+ * forgejo and discord.
135
+ */
136
+ export const argocdHandlers = <Props extends object, Live, Attributes extends object, E>(
137
+ spec: ArgocdSpec<Props, Live, Attributes, E>,
138
+ ) => {
139
+ const ops = argocdOperations(spec);
140
+ const withCredentials = <A>(effect: Effect.Effect<A, E, ArgocdOpContext>) =>
141
+ Effect.provide(effect, CredentialsFromEnv);
142
+ return {
143
+ list: () => Effect.succeed([]),
144
+ read: ({ olds }: { olds: Props }) => withCredentials(ops.read(olds)),
145
+ diff: ({ news, output }: { news: Input<Props>; output: Attributes | undefined }) =>
146
+ withCredentials(ops.diff(news, output)),
147
+ reconcile: ({ news }: { news: Props }) => withCredentials(ops.reconcile(news)),
148
+ delete: ({ olds }: { olds: Props }) => withCredentials(ops.destroy(olds)),
149
+ };
150
+ };
@@ -5,15 +5,15 @@
5
5
  *
6
6
  * ★ THE POINT OF THIS FILE IS THE TAGGED-ERROR PATHS, per the transport swap onto
7
7
  * `@distilled.cloud/cloudflare/r2`: `NoSuchBucket` is matched by Cloudflare's own error code
8
- * (10006), never by HTTP status, and only `readLock` treats it as "nothing to adopt" —
9
- * `reconcileLock`/`deleteLock` let it die like any other tagged error.
8
+ * (10006), never by HTTP status, and only `readLock`/`deleteLock` treat it as "nothing to do" —
9
+ * `reconcileLock` still fails on it, but as a typed SDK error, not the `Effect.orDie`
10
+ * defect this family used to raise (WI-7, decision 49 "upstream wins", 2026-09-24).
10
11
  */
12
+ import * as Retry from '@distilled.cloud/cloudflare/Retry';
11
13
  import type { CloudflareOpContext } from '@distilled.cloud/cloudflare/r2';
12
14
  import { Unowned } from 'alchemy/AdoptPolicy';
13
15
  import { describe, expect, test } from 'bun:test';
14
- import * as Cause from 'effect/Cause';
15
16
  import * as Effect from 'effect/Effect';
16
- import * as Exit from 'effect/Exit';
17
17
  import {
18
18
  FAKE_ACCOUNT,
19
19
  type FakeR2Lock,
@@ -28,8 +28,14 @@ import { type R2BucketLockProps, deleteLock, readLock, reconcileLock } from './r
28
28
  const run = <A, E>(fake: FakeR2Lock, effect: Effect.Effect<A, E, CloudflareOpContext>) =>
29
29
  Effect.runPromise(effect.pipe(Effect.provide(fakeR2LockLayer(fake))));
30
30
 
31
- const dies = <A, E>(fake: FakeR2Lock, effect: Effect.Effect<A, E, CloudflareOpContext>) =>
32
- Effect.runPromiseExit(effect.pipe(Effect.provide(fakeR2LockLayer(fake))));
31
+ /**
32
+ * The operation's typed refusal, never a defect — asserts S20/S21 (WI-7). `Retry.none` keeps a
33
+ * simulated 429 from actually waiting out distilled's real, unbounded-under-test throttling
34
+ * backoff (`applyRetry`'s `makeDefault` in `@distilled.cloud/core/api.ts`) — the house's own
35
+ * speed doctrine (S26, `testing-speed-doctrine`) never lets a test wait on a real retry schedule.
36
+ */
37
+ const refused = <A, E>(fake: FakeR2Lock, effect: Effect.Effect<A, E, CloudflareOpContext>) =>
38
+ Effect.runPromise(Effect.flip(effect).pipe(Retry.none, Effect.provide(fakeR2LockLayer(fake))));
33
39
 
34
40
  const rule: R2LockRule = {
35
41
  id: 'keep-30d',
@@ -83,11 +89,18 @@ describe('readLock', () => {
83
89
  test('a different tagged error at 404 is NOT swallowed as NoSuchBucket', async () => {
84
90
  // ⛔ code 7003 is `InvalidRoute`, not `NoSuchBucket` — even at the same 404 status a plain
85
91
  // status check would have treated as "no bucket". `catchTag('NoSuchBucket', …)` leaves it
86
- // alone, and `Effect.orDie` turns it into a defect.
92
+ // alone, and it now surfaces as a typed SDK error, never an `Effect.orDie` defect.
87
93
  const fake = fakeR2Lock({ onRoute: () => fakeFailure(404, 7003, 'Invalid route') });
88
- const exit = await dies(fake, readLock(props(), false));
89
- expect(Exit.isFailure(exit)).toBe(true);
90
- expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true);
94
+ const refusal = await refused(fake, readLock(props(), false));
95
+ expect(refusal._tag).toBe('InvalidRoute');
96
+ });
97
+
98
+ test('a rate-limited read surfaces as a typed refusal, not a crash', async () => {
99
+ // ⛔ 429 WITH NO MATCHED CODE maps to distilled's `TooManyRequests` (protocol.ts step 3,
100
+ // "Throttling"), one of the `CloudflareOpError` members this resource does not special-case.
101
+ const fake = fakeR2Lock({ onRoute: () => fakeFailure(429, 0, 'rate limited') });
102
+ const refusal = await refused(fake, readLock(props(), false));
103
+ expect(refusal._tag).toBe('TooManyRequests');
91
104
  });
92
105
  });
93
106
 
@@ -108,11 +121,10 @@ describe('reconcileLock', () => {
108
121
  expect(fake.locks.get('my-backups')).toEqual([rule]);
109
122
  });
110
123
 
111
- test('NoSuchBucket is NOT caught here: a genuinely missing bucket dies', async () => {
124
+ test('NoSuchBucket is NOT caught here: a genuinely missing bucket is a typed refusal', async () => {
112
125
  const fake = fakeR2Lock();
113
- const exit = await dies(fake, reconcileLock(props(), undefined));
114
- expect(Exit.isFailure(exit)).toBe(true);
115
- expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true);
126
+ const refusal = await refused(fake, reconcileLock(props(), undefined));
127
+ expect(refusal._tag).toBe('NoSuchBucket');
116
128
  });
117
129
  });
118
130
 
@@ -126,14 +138,38 @@ describe('deleteLock', () => {
126
138
  expect(fake.locks.has('my-backups')).toBe(false);
127
139
  });
128
140
 
129
- test('NoSuchBucket is NOT caught here either: a genuinely missing bucket dies', async () => {
141
+ test('deleting when the bucket is already gone succeeds (S11, P10: delete is idempotent)', async () => {
142
+ // ⛔ THE ONE BEHAVIOUR CHANGE WI-7 MAKES: unlike `reconcileLock`, `deleteLock` now catches
143
+ // `NoSuchBucket` into success — a bucket that no longer exists has nothing left to unlock.
130
144
  const fake = fakeR2Lock();
131
145
  const output = await run(
132
146
  fakeR2Lock({ existingBuckets: ['my-backups'] }),
133
147
  reconcileLock(props(), undefined),
134
148
  );
135
- const exit = await dies(fake, deleteLock(output, FAKE_ACCOUNT));
136
- expect(Exit.isFailure(exit)).toBe(true);
137
- expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true);
149
+ await run(fake, deleteLock(output, FAKE_ACCOUNT));
150
+ expect(writes(fake)).toEqual(['PUT']);
151
+ });
152
+
153
+ test('delete does not swallow InvalidRoute at 404', async () => {
154
+ const fake = fakeR2Lock({ onRoute: () => fakeFailure(404, 7003, 'Invalid route') });
155
+ const refusal = await refused(
156
+ fake,
157
+ deleteLock(
158
+ { bucketName: 'my-backups', jurisdiction: 'default', rules: [rule] },
159
+ FAKE_ACCOUNT,
160
+ ),
161
+ );
162
+ expect(refusal._tag).toBe('InvalidRoute');
163
+ });
164
+
165
+ test('a rate-limited delete surfaces as a typed refusal, not a crash', async () => {
166
+ const setup = fakeR2Lock({ existingBuckets: ['my-backups'] });
167
+ const output = await run(setup, reconcileLock(props(), undefined));
168
+ const limited = fakeR2Lock({
169
+ existingBuckets: ['my-backups'],
170
+ onRoute: () => fakeFailure(429, 0, 'rate limited'),
171
+ });
172
+ const refusal = await refused(limited, deleteLock(output, FAKE_ACCOUNT));
173
+ expect(refusal._tag).toBe('TooManyRequests');
138
174
  });
139
175
  });
@@ -103,7 +103,9 @@ export const R2BucketLock: ResourceClass<R2BucketLock> = Resource<R2BucketLock>(
103
103
  * same case the status check used to cover — but by the vendor's own error identity, not by
104
104
  * reading a number off the response. Letting it propagate would make the resource impossible to
105
105
  * plan; swallowing every failure would also hide a 403 from a mis-scoped token, which is the
106
- * failure this estate spends the most time on. So: `NoSuchBucket` → absent, everything else dies.
106
+ * failure this estate spends the most time on. So: `NoSuchBucket` → absent, everything else
107
+ * propagates with its SDK tag. Upstream `Cloudflare/R2/BucketSippy.ts` at beta.79 uses this
108
+ * same catch-and-propagate shape; a blanket remap would hide those typed identities (S20/S21).
107
109
  *
108
110
  * ⛔ AN EMPTY LIVE RULE SET IS "NO LOCK", NOT "A LOCK WITH NOTHING IN IT", so it reads as absent
109
111
  * and the resource CREATES. The API answers `{}` for a bucket that has never been locked and
@@ -117,15 +119,16 @@ export const R2BucketLock: ResourceClass<R2BucketLock> = Resource<R2BucketLock>(
117
119
  export const readLock = (
118
120
  props: { accountId: string; bucketName: string; jurisdiction?: Jurisdiction },
119
121
  written: boolean,
120
- ): Effect.Effect<R2BucketLockAttributes | undefined, never, CloudflareOpContext> =>
122
+ ): Effect.Effect<
123
+ R2BucketLockAttributes | undefined,
124
+ Exclude<r2.GetBucketLockError, { _tag: 'NoSuchBucket' }>,
125
+ CloudflareOpContext
126
+ > =>
121
127
  Effect.gen(function* () {
122
128
  const jurisdiction = jurisdictionOf(props);
123
129
  const live = yield* r2
124
130
  .getBucketLock({ accountId: props.accountId, bucketName: props.bucketName, jurisdiction })
125
- .pipe(
126
- Effect.catchTag('NoSuchBucket', () => Effect.succeed(undefined)),
127
- Effect.orDie,
128
- );
131
+ .pipe(Effect.catchTag('NoSuchBucket', () => Effect.succeed(undefined)));
129
132
  if (live === undefined) return undefined;
130
133
  const rules = (live.rules ?? []) as readonly R2LockRule[];
131
134
  if (rules.length === 0) return undefined;
@@ -144,23 +147,21 @@ export const readLock = (
144
147
  * persisted (or what `read` just adopted), so the comparison costs nothing either.
145
148
  * ⛔ `NoSuchBucket` IS NOT CAUGHT HERE. Unlike `read`, a `reconcile` that hits it means the bucket
146
149
  * this declaration names is genuinely gone — that is a real failure to surface, not a case to
147
- * paper over, so it dies like every other tagged error `putBucketLock` can raise.
150
+ * paper over, so it propagates like every other typed SDK error `putBucketLock` can raise.
148
151
  */
149
152
  export const reconcileLock = (
150
153
  news: R2BucketLockProps,
151
154
  output: R2BucketLockAttributes | undefined,
152
- ): Effect.Effect<R2BucketLockAttributes, never, CloudflareOpContext> =>
155
+ ): Effect.Effect<R2BucketLockAttributes, r2.PutBucketLockError, CloudflareOpContext> =>
153
156
  Effect.gen(function* () {
154
157
  const jurisdiction = jurisdictionOf(news);
155
158
  if (output !== undefined && rulesEqual(output.rules, news.rules)) return output;
156
- yield* r2
157
- .putBucketLock({
158
- accountId: news.accountId,
159
- bucketName: news.bucketName,
160
- jurisdiction,
161
- ...toBody(news.rules),
162
- })
163
- .pipe(Effect.orDie);
159
+ yield* r2.putBucketLock({
160
+ accountId: news.accountId,
161
+ bucketName: news.bucketName,
162
+ jurisdiction,
163
+ ...toBody(news.rules),
164
+ });
164
165
  return { bucketName: news.bucketName, jurisdiction, rules: news.rules };
165
166
  });
166
167
 
@@ -170,11 +171,17 @@ export const reconcileLock = (
170
171
  * rule is configured, so retiring the repository has to start here — and it is behind
171
172
  * `defaultRemovalPolicy: 'retain'` and the estate CLI's refusal of `destroy` precisely because
172
173
  * no ordinary deploy should ever reach it.
174
+ * ⛔ IDEMPOTENT (S11): a missing bucket has nothing left to unlock. Catch only `NoSuchBucket`;
175
+ * every other SDK error propagates, like upstream `R2/BucketSippy.ts` at beta.79.
173
176
  */
174
177
  export const deleteLock = (
175
178
  output: R2BucketLockAttributes,
176
179
  accountId: string,
177
- ): Effect.Effect<void, never, CloudflareOpContext> =>
180
+ ): Effect.Effect<
181
+ void,
182
+ Exclude<r2.PutBucketLockError, { _tag: 'NoSuchBucket' }>,
183
+ CloudflareOpContext
184
+ > =>
178
185
  Effect.gen(function* () {
179
186
  yield* r2
180
187
  .putBucketLock({
@@ -183,7 +190,7 @@ export const deleteLock = (
183
190
  jurisdiction: output.jurisdiction,
184
191
  rules: [],
185
192
  })
186
- .pipe(Effect.orDie);
193
+ .pipe(Effect.catchTag('NoSuchBucket', () => Effect.void));
187
194
  });
188
195
 
189
196
  export const R2BucketLockProvider = () =>
@@ -7,10 +7,10 @@
7
7
  * depend on.
8
8
  *
9
9
  * `Grafana.Datasource`, `Grafana.Folder`, `Grafana.Dashboard`, `Grafana.ContactPoint`,
10
- * `Grafana.MuteTiming`, `Grafana.MessageTemplate` and `Grafana.AlertRuleGroup` ship here.
11
- * `Grafana.NotificationPolicy` — the singleton policy tree — is a stacked follow-up PR on the same
12
- * SDK operations — see docs/grafana.md, docs/grafana-folder-dashboard.md and
13
- * docs/grafana-alerting.md for what shipped and what is still open.
10
+ * `Grafana.MuteTiming`, `Grafana.MessageTemplate`, `Grafana.AlertRuleGroup` and
11
+ * `Grafana.NotificationPolicy` all ship here — see docs/grafana.md, docs/grafana-folder-dashboard.md,
12
+ * docs/grafana-alerting.md, docs/grafana-alerting-rules.md and
13
+ * docs/grafana-notification-policy.md for what shipped.
14
14
  */
15
15
  export {
16
16
  type AlertRuleGroupAttributes,
@@ -19,6 +19,13 @@ export {
19
19
  GrafanaAlertRuleGroup,
20
20
  GrafanaAlertRuleGroupProvider,
21
21
  } from './alert-rule-group.ts';
22
+ export {
23
+ type NotificationPolicyAttributes,
24
+ type NotificationPolicyProps,
25
+ type PolicyRoute,
26
+ GrafanaNotificationPolicy,
27
+ GrafanaNotificationPolicyProvider,
28
+ } from './notification-policy.ts';
22
29
  export {
23
30
  type DatasourceAttributes,
24
31
  type DatasourceProps,
@@ -0,0 +1,163 @@
1
+ /**
2
+ * `warnOnDroppedRoutes` — pure logic, then wired through `diff`/`update` on a fake Grafana. Mirrors
3
+ * `alert-rule-group-drop-warning.test.ts`: captures `Effect.logWarning` with a `Logger`, the house
4
+ * pattern `caddy/config-format-warning.test.ts` established.
5
+ */
6
+ import { describe, expect, test } from 'bun:test';
7
+ import * as Effect from 'effect/Effect';
8
+ import * as Logger from 'effect/Logger';
9
+ import { PATH, liveTree, props } from './notification-policy-fixtures.ts';
10
+ import { handlers, spec } from './notification-policy.ts';
11
+ import { warnOnDroppedRoutes } from './notification-policy-drop-warning.ts';
12
+ import { fakeFailure, fakeGrafana, fakeGrafanaLayer } from './fake-grafana.ts';
13
+ import { grafanaOperations } from './resource.ts';
14
+
15
+ const withWarnings = async <A>(effect: Effect.Effect<A, unknown, never>) => {
16
+ const warnings: string[] = [];
17
+ const collect = Logger.make((options) => {
18
+ if (options.logLevel === 'Warn') warnings.push(String(options.message));
19
+ });
20
+ const value = await Effect.runPromise(effect.pipe(Effect.provide(Logger.layer([collect]))));
21
+ return { value, warnings };
22
+ };
23
+
24
+ describe('warnOnDroppedRoutes — pure', () => {
25
+ test('logs nothing when the declared tree already has every live route', async () => {
26
+ const tree = { receiver: 'default', routes: [{ receiver: 'pagerduty' }] };
27
+ const { warnings } = await withWarnings(warnOnDroppedRoutes(tree, tree));
28
+ expect(warnings).toEqual([]);
29
+ });
30
+
31
+ test('names a dropped nested route by receiver and path', async () => {
32
+ const declared = { receiver: 'default', routes: [{ receiver: 'pagerduty' }] };
33
+ const live = {
34
+ receiver: 'default',
35
+ routes: [{ receiver: 'pagerduty' }, { matchers: ['team=x'], receiver: 'slack' }],
36
+ };
37
+ const { warnings } = await withWarnings(warnOnDroppedRoutes(declared, live));
38
+ expect(warnings).toHaveLength(1);
39
+ expect(warnings[0]).toContain('slack (root.1)');
40
+ });
41
+
42
+ test('a changed root receiver is never reported as "dropped" — root is replaced, not removed', async () => {
43
+ const { warnings } = await withWarnings(
44
+ warnOnDroppedRoutes({ receiver: 'new' }, { receiver: 'old' }),
45
+ );
46
+ expect(warnings).toEqual([]);
47
+ });
48
+
49
+ test('the same two routes reordered logs nothing — reordering is not dropping', async () => {
50
+ // ⛔ THE POINT: `matches()` (subset-match's position-significant array rule) still shows
51
+ // `update` for a reorder — this warning stays silent because nothing is actually REMOVED,
52
+ // complementing rather than duplicating that signal.
53
+ const a = { receiver: 'pagerduty' };
54
+ const b = { receiver: 'slack' };
55
+ const { warnings } = await withWarnings(
56
+ warnOnDroppedRoutes(
57
+ { receiver: 'default', routes: [b, a] },
58
+ { receiver: 'default', routes: [a, b] },
59
+ ),
60
+ );
61
+ expect(warnings).toEqual([]);
62
+ });
63
+
64
+ test('a duplicate route added live warns — the multiset fix, not the original Set collision', async () => {
65
+ // ⛔ THE REGRESSION AN ADVERSARIAL REVIEW OF THIS PR FOUND: the first version's `Set`-based
66
+ // identity collided this duplicate with the already-declared one and warned nothing, even
67
+ // though the PUT drops it. `severity=warning` -> `slack` is declared once; live has it TWICE.
68
+ const declared = {
69
+ receiver: 'default',
70
+ routes: [
71
+ { matchers: ['severity=warning'], receiver: 'slack' },
72
+ { matchers: ['severity=critical'], receiver: 'pagerduty' },
73
+ ],
74
+ };
75
+ const live = {
76
+ receiver: 'default',
77
+ routes: [
78
+ { matchers: ['severity=warning'], receiver: 'slack' },
79
+ { matchers: ['severity=critical'], receiver: 'pagerduty' },
80
+ { matchers: ['severity=warning'], receiver: 'slack' },
81
+ ],
82
+ };
83
+ const { warnings } = await withWarnings(warnOnDroppedRoutes(declared, live));
84
+ expect(warnings).toHaveLength(1);
85
+ expect(warnings[0]).toContain('REMOVES 1 route');
86
+ expect(warnings[0]).toContain('slack (root.2)');
87
+ });
88
+
89
+ test('an identical route under a DIFFERENT (undeclared) parent still warns — distinct lineage', async () => {
90
+ const declared = {
91
+ receiver: 'default',
92
+ routes: [{ receiver: 'p1', routes: [{ receiver: 'slack' }] }],
93
+ };
94
+ const live = {
95
+ receiver: 'default',
96
+ routes: [
97
+ { receiver: 'p1', routes: [{ receiver: 'slack' }] },
98
+ { receiver: 'p2', routes: [{ receiver: 'slack' }] },
99
+ ],
100
+ };
101
+ const { warnings } = await withWarnings(warnOnDroppedRoutes(declared, live));
102
+ expect(warnings).toHaveLength(1);
103
+ // ⛔ NOT root.0.0 (that one IS declared, under p1) — the dropped one is the p2 lineage's.
104
+ expect(warnings[0]).toContain('slack (root.1.0)');
105
+ });
106
+
107
+ test('two declared duplicates vs two live duplicates warns nothing — counts match', async () => {
108
+ const pair = {
109
+ receiver: 'default',
110
+ routes: [{ receiver: 'slack' }, { receiver: 'slack' }],
111
+ };
112
+ const { warnings } = await withWarnings(warnOnDroppedRoutes(pair, pair));
113
+ expect(warnings).toEqual([]);
114
+ });
115
+ });
116
+
117
+ describe('wired into diff and update', () => {
118
+ test('diff logs the dropped-route warning before anything is written', async () => {
119
+ const extra = { matchers: ['team=x'], receiver: 'slack' };
120
+ const withExtra = { ...liveTree, routes: [...liveTree.routes, extra] };
121
+ const fake = fakeGrafana((method, url) =>
122
+ method === 'GET' && url.pathname === PATH
123
+ ? Response.json(withExtra)
124
+ : fakeFailure(500, 'diff should not write'),
125
+ );
126
+ const { warnings, value } = await withWarnings(
127
+ handlers
128
+ .diff({ news: props, output: { provenance: 'api', route: props.route } })
129
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))) as Effect.Effect<
130
+ unknown,
131
+ unknown,
132
+ never
133
+ >,
134
+ );
135
+ // ⚠️ NOT 'noop': subset-match's array rule requires EXACT length, so live carrying one MORE
136
+ // route than declared already disagrees on its own — the warning and the mismatch are the
137
+ // same signal here, not independent (see the file header on `matches` vs this warning).
138
+ expect(value).toEqual({ action: 'update' });
139
+ expect(warnings).toHaveLength(1);
140
+ expect(warnings[0]).toContain('slack');
141
+ });
142
+
143
+ test('update logs the same warning right before the PUT that would drop the route', async () => {
144
+ const extra = { matchers: ['team=x'], receiver: 'slack' };
145
+ const withExtra = { ...liveTree, routes: [...liveTree.routes, extra] };
146
+ const fake = fakeGrafana((method, url) => {
147
+ if (method === 'GET' && url.pathname === PATH) return Response.json(withExtra);
148
+ if (method === 'PUT' && url.pathname === PATH) return Response.json(withExtra);
149
+ return fakeFailure(500, 'unexpected request');
150
+ });
151
+ const { warnings } = await withWarnings(
152
+ grafanaOperations(spec)
153
+ .reconcile({ route: { ...props.route, receiver: 'changed' } })
154
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))) as Effect.Effect<
155
+ unknown,
156
+ unknown,
157
+ never
158
+ >,
159
+ );
160
+ expect(warnings).toHaveLength(1);
161
+ expect(warnings[0]).toContain('slack');
162
+ });
163
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The warning `Grafana.NotificationPolicy` logs when a whole-tree `PUT` would silently drop a live
3
+ * sub-route the declaration doesn't account for — same shape as `alert-rule-group-drop-warning.ts`
4
+ * (kit PR 254's own fix, after an adversarial review found the identical risk for rule groups):
5
+ * `PUT /v1/provisioning/policies` REPLACES the entire tree, so a nested route someone added
6
+ * out-of-band is removed with no error and no distinct `Diff` action to report it under.
7
+ *
8
+ * ★ `Effect.logWarning`, NEVER A REFUSAL — same reasoning as `alert-rule-group-drop-warning.ts`'s
9
+ * own header, and further upstream `proxmox/unreadable-read.ts`'s `unreadableWarning`. A
10
+ * whole-tree write dropping an out-of-band route is EXPECTED for a resource whose whole point is
11
+ * to own the tree — only a silent one is worth surfacing loudly.
12
+ *
13
+ * ⛔ MULTISET COMPARISON, NOT SET MEMBERSHIP — fixed after an adversarial review of this PR found
14
+ * the first version's `Set`-based check collided ACROSS THE WHOLE TREE: any two nodes anywhere
15
+ * with identical own-fields shared one `Set` entry, so a duplicate route added out-of-band
16
+ * (e.g. a second `severity=warning -> slack` alongside an already-declared one) matched the
17
+ * existing entry and went unwarned even though the PUT would still drop it — worse than the
18
+ * documented "siblings only" limitation the original comment claimed. Every key from
19
+ * `FlatRoute.key` (ancestry-scoped — see that field's own doc) is now counted per side, and a
20
+ * `key` is only "dropped" for the amount its LIVE count exceeds its DECLARED count — N identical
21
+ * live occurrences under the same parent are dropped only past however many the declaration
22
+ * itself repeats, never all of them at once just because one is declared.
23
+ */
24
+ import * as Effect from 'effect/Effect';
25
+ import { type PolicyRoute, flattenRoutes, normalizeRoute } from './notification-policy-model.ts';
26
+
27
+ const countByKey = (routes: ReadonlyArray<{ readonly key: string }>): Map<string, number> => {
28
+ const counts = new Map<string, number>();
29
+ for (const route of routes) counts.set(route.key, (counts.get(route.key) ?? 0) + 1);
30
+ return counts;
31
+ };
32
+
33
+ export const warnOnDroppedRoutes = (
34
+ declared: PolicyRoute,
35
+ live: PolicyRoute,
36
+ ): Effect.Effect<void> =>
37
+ Effect.gen(function* () {
38
+ // Root is never "dropped" — it is always replaced, not removed (the file header's own note).
39
+ const declaredNodes = flattenRoutes(normalizeRoute(declared)).filter((r) => r.path !== 'root');
40
+ const liveNodes = flattenRoutes(normalizeRoute(live)).filter((r) => r.path !== 'root');
41
+ const declaredCounts = countByKey(declaredNodes);
42
+ const liveCounts = countByKey(liveNodes);
43
+
44
+ const dropped: Array<{ readonly path: string; readonly receiver: string }> = [];
45
+ for (const [key, liveCount] of liveCounts) {
46
+ const extra = liveCount - (declaredCounts.get(key) ?? 0);
47
+ if (extra <= 0) continue;
48
+ // Report the LAST `extra` live occurrences of this key, by path — an arbitrary but
49
+ // deterministic choice among indistinguishable duplicates (multiset identity, no position).
50
+ const occurrences = liveNodes.filter((route) => route.key === key);
51
+ dropped.push(...occurrences.slice(-extra));
52
+ }
53
+ if (dropped.length === 0) return;
54
+ const names = dropped.map((route) => `${route.receiver} (${route.path})`).join(', ');
55
+ yield* Effect.logWarning(
56
+ `Grafana.NotificationPolicy: this update REMOVES ${dropped.length} route(s)/matcher(s) not ` +
57
+ `in the declaration: ${names}. Whole-tree replacement is by design — add them to the ` +
58
+ `declared tree to keep them.`,
59
+ );
60
+ });
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Shared fixtures for the `notification-policy*.test.ts` files — split to keep each file under
3
+ * the house's 250-line cap. Deliberately NOT named `*.test.ts` — see `contact-point-fixtures.ts`'s
4
+ * own header for why.
5
+ */
6
+ export const PATH = '/api/v1/provisioning/policies';
7
+
8
+ export const liveTree = {
9
+ group_by: ['alertname'],
10
+ provenance: 'api',
11
+ receiver: 'default-receiver',
12
+ routes: [
13
+ { matchers: ['severity=critical'], receiver: 'pagerduty' },
14
+ { matchers: ['severity=warning'], receiver: 'slack' },
15
+ ],
16
+ };
17
+
18
+ export const declaredRoute = {
19
+ group_by: ['alertname'],
20
+ receiver: 'default-receiver',
21
+ routes: [
22
+ { matchers: ['severity=critical'], receiver: 'pagerduty' },
23
+ { matchers: ['severity=warning'], receiver: 'slack' },
24
+ ],
25
+ };
26
+
27
+ export const props = { route: declaredRoute };