@homeflare/alchemy 0.37.1 → 0.37.3

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 (195) hide show
  1. package/dist/proxmox/backup-job-config.d.ts +95 -0
  2. package/dist/proxmox/backup-job-config.d.ts.map +1 -0
  3. package/dist/proxmox/backup-job-distilled.d.ts +46 -0
  4. package/dist/proxmox/backup-job-distilled.d.ts.map +1 -0
  5. package/dist/proxmox/backup-job.d.ts +9 -26
  6. package/dist/proxmox/backup-job.d.ts.map +1 -1
  7. package/dist/proxmox/ceph-fs-distilled.d.ts +17 -10
  8. package/dist/proxmox/ceph-fs-distilled.d.ts.map +1 -1
  9. package/dist/proxmox/ceph-fs-wire.d.ts +5 -22
  10. package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -1
  11. package/dist/proxmox/ceph-fs.d.ts +3 -3
  12. package/dist/proxmox/distilled-pbs.d.ts +12 -0
  13. package/dist/proxmox/distilled-pbs.d.ts.map +1 -0
  14. package/dist/proxmox/fake-pbs-jobs.d.ts +23 -0
  15. package/dist/proxmox/fake-pbs-jobs.d.ts.map +1 -0
  16. package/dist/proxmox/fake-pbs-notify.d.ts.map +1 -1
  17. package/dist/proxmox/fake-pve.d.ts +1 -1
  18. package/dist/proxmox/fake-pve.d.ts.map +1 -1
  19. package/dist/proxmox/group-wire.d.ts +2 -2
  20. package/dist/proxmox/group-wire.d.ts.map +1 -1
  21. package/dist/proxmox/index.js +1382 -506
  22. package/dist/proxmox/index.js.map +51 -27
  23. package/dist/proxmox/live-core-fixtures.d.ts +40 -0
  24. package/dist/proxmox/live-core-fixtures.d.ts.map +1 -0
  25. package/dist/proxmox/metric-server-config.d.ts +49 -0
  26. package/dist/proxmox/metric-server-config.d.ts.map +1 -0
  27. package/dist/proxmox/metric-server-distilled.d.ts +43 -0
  28. package/dist/proxmox/metric-server-distilled.d.ts.map +1 -0
  29. package/dist/proxmox/metric-server.d.ts +2 -10
  30. package/dist/proxmox/metric-server.d.ts.map +1 -1
  31. package/dist/proxmox/node-network-wire.d.ts +1 -1
  32. package/dist/proxmox/node-network-wire.d.ts.map +1 -1
  33. package/dist/proxmox/notification-matcher-distilled.d.ts +9 -0
  34. package/dist/proxmox/notification-matcher-distilled.d.ts.map +1 -0
  35. package/dist/proxmox/notification-matcher-lifecycle.d.ts +27 -0
  36. package/dist/proxmox/notification-matcher-lifecycle.d.ts.map +1 -0
  37. package/dist/proxmox/notification-matcher.d.ts +5 -5
  38. package/dist/proxmox/notification-matcher.d.ts.map +1 -1
  39. package/dist/proxmox/notification-sdk-input.d.ts +22 -0
  40. package/dist/proxmox/notification-sdk-input.d.ts.map +1 -0
  41. package/dist/proxmox/notification-target-distilled.d.ts +12 -0
  42. package/dist/proxmox/notification-target-distilled.d.ts.map +1 -0
  43. package/dist/proxmox/notification-target-form.d.ts +2 -0
  44. package/dist/proxmox/notification-target-form.d.ts.map +1 -1
  45. package/dist/proxmox/notification-target-lifecycle.d.ts +29 -0
  46. package/dist/proxmox/notification-target-lifecycle.d.ts.map +1 -0
  47. package/dist/proxmox/notification-target-spec.d.ts +5 -0
  48. package/dist/proxmox/notification-target-spec.d.ts.map +1 -0
  49. package/dist/proxmox/notification-target.d.ts +4 -5
  50. package/dist/proxmox/notification-target.d.ts.map +1 -1
  51. package/dist/proxmox/pbs-datastore-endpoint.d.ts +1 -1
  52. package/dist/proxmox/pbs-datastore-form.d.ts +18 -42
  53. package/dist/proxmox/pbs-datastore-form.d.ts.map +1 -1
  54. package/dist/proxmox/pbs-datastore-guard.d.ts +7 -4
  55. package/dist/proxmox/pbs-datastore-guard.d.ts.map +1 -1
  56. package/dist/proxmox/pbs-datastore-lifecycle.d.ts +56 -0
  57. package/dist/proxmox/pbs-datastore-lifecycle.d.ts.map +1 -0
  58. package/dist/proxmox/pbs-datastore-wire.d.ts +21 -0
  59. package/dist/proxmox/pbs-datastore-wire.d.ts.map +1 -0
  60. package/dist/proxmox/pbs-datastore.d.ts +5 -6
  61. package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
  62. package/dist/proxmox/pbs-notification-input.d.ts +18 -0
  63. package/dist/proxmox/pbs-notification-input.d.ts.map +1 -0
  64. package/dist/proxmox/pbs-notification-matcher-distilled.d.ts +11 -0
  65. package/dist/proxmox/pbs-notification-matcher-distilled.d.ts.map +1 -0
  66. package/dist/proxmox/pbs-notification-matcher.d.ts +4 -3
  67. package/dist/proxmox/pbs-notification-matcher.d.ts.map +1 -1
  68. package/dist/proxmox/pbs-notification-target-distilled.d.ts +20 -0
  69. package/dist/proxmox/pbs-notification-target-distilled.d.ts.map +1 -0
  70. package/dist/proxmox/pbs-notification-target-lifecycle.d.ts +4 -4
  71. package/dist/proxmox/pbs-notification-target-lifecycle.d.ts.map +1 -1
  72. package/dist/proxmox/pbs-notification-target.d.ts +1 -1
  73. package/dist/proxmox/pbs-prune-job-lifecycle.d.ts +22 -0
  74. package/dist/proxmox/pbs-prune-job-lifecycle.d.ts.map +1 -0
  75. package/dist/proxmox/pbs-prune-job-spec.d.ts +5 -0
  76. package/dist/proxmox/pbs-prune-job-spec.d.ts.map +1 -0
  77. package/dist/proxmox/pbs-prune-job-wire.d.ts +11 -0
  78. package/dist/proxmox/pbs-prune-job-wire.d.ts.map +1 -0
  79. package/dist/proxmox/pbs-prune-job.d.ts +9 -8
  80. package/dist/proxmox/pbs-prune-job.d.ts.map +1 -1
  81. package/dist/proxmox/pbs-sync-job-attributes.d.ts +34 -0
  82. package/dist/proxmox/pbs-sync-job-attributes.d.ts.map +1 -0
  83. package/dist/proxmox/pbs-sync-job-form.d.ts +21 -22
  84. package/dist/proxmox/pbs-sync-job-form.d.ts.map +1 -1
  85. package/dist/proxmox/pbs-sync-job-lifecycle.d.ts +22 -0
  86. package/dist/proxmox/pbs-sync-job-lifecycle.d.ts.map +1 -0
  87. package/dist/proxmox/pbs-sync-job-wire.d.ts +11 -0
  88. package/dist/proxmox/pbs-sync-job-wire.d.ts.map +1 -0
  89. package/dist/proxmox/pbs-sync-job.d.ts +9 -39
  90. package/dist/proxmox/pbs-sync-job.d.ts.map +1 -1
  91. package/dist/proxmox/pbs-verify-job-lifecycle.d.ts +22 -0
  92. package/dist/proxmox/pbs-verify-job-lifecycle.d.ts.map +1 -0
  93. package/dist/proxmox/pbs-verify-job-spec.d.ts +16 -0
  94. package/dist/proxmox/pbs-verify-job-spec.d.ts.map +1 -0
  95. package/dist/proxmox/pbs-verify-job-wire.d.ts +11 -0
  96. package/dist/proxmox/pbs-verify-job-wire.d.ts.map +1 -0
  97. package/dist/proxmox/pbs-verify-job.d.ts +12 -12
  98. package/dist/proxmox/pbs-verify-job.d.ts.map +1 -1
  99. package/dist/proxmox/pool.d.ts +10 -1
  100. package/dist/proxmox/pool.d.ts.map +1 -1
  101. package/dist/proxmox/storage-wire.d.ts +2 -2
  102. package/dist/proxmox/storage-wire.d.ts.map +1 -1
  103. package/dist/proxmox/user-wire.d.ts +2 -2
  104. package/dist/proxmox/user-wire.d.ts.map +1 -1
  105. package/dist/proxmox/zfs-pool-wire.d.ts +1 -1
  106. package/dist/proxmox/zfs-pool-wire.d.ts.map +1 -1
  107. package/docs/changelog/README.md +22 -0
  108. package/docs/changelog/archive-01.md +176 -0
  109. package/docs/changelog/archive-02.md +180 -0
  110. package/docs/changelog/archive-03.md +179 -0
  111. package/docs/changelog/archive-04.md +178 -0
  112. package/docs/changelog/archive-05.md +177 -0
  113. package/docs/changelog/archive-06.md +180 -0
  114. package/docs/changelog/archive-07.md +180 -0
  115. package/docs/changelog/archive-08.md +178 -0
  116. package/docs/changelog/archive-09.md +181 -0
  117. package/docs/changelog/archive-10.md +182 -0
  118. package/docs/changelog/archive-11.md +182 -0
  119. package/docs/changelog/archive-12.md +183 -0
  120. package/docs/changelog/archive-13.md +180 -0
  121. package/docs/changelog/archive-14.md +178 -0
  122. package/docs/changelog/archive-15.md +181 -0
  123. package/docs/changelog/archive-16.md +182 -0
  124. package/docs/changelog/archive-17.md +183 -0
  125. package/docs/changelog/archive-18.md +55 -0
  126. package/docs/pbs-distilled-jobs.md +31 -0
  127. package/docs/pbs-notifications.md +8 -2
  128. package/docs/proxmox-notification-sdk.md +28 -0
  129. package/docs/proxmox-sdk-migration.md +60 -0
  130. package/docs/proxmox.md +9 -3
  131. package/docs/upstream-conformance-families.md +6 -2
  132. package/package.json +3 -3
  133. package/src/proxmox/adopt-noop.test.ts +4 -5
  134. package/src/proxmox/backup-job-config.ts +95 -0
  135. package/src/proxmox/backup-job-distilled.ts +86 -0
  136. package/src/proxmox/backup-job.ts +78 -116
  137. package/src/proxmox/ceph-fs-delete.test.ts +5 -8
  138. package/src/proxmox/ceph-fs-destroy-sdk.test.ts +98 -0
  139. package/src/proxmox/ceph-fs-distilled.ts +41 -13
  140. package/src/proxmox/ceph-fs-wire.ts +5 -93
  141. package/src/proxmox/ceph-fs.ts +7 -7
  142. package/src/proxmox/constraints.test.ts +9 -8
  143. package/src/proxmox/distilled-pbs.test.ts +87 -0
  144. package/src/proxmox/distilled-pbs.ts +50 -0
  145. package/src/proxmox/fake-pbs-jobs.ts +99 -0
  146. package/src/proxmox/fake-pbs-notify.ts +4 -1
  147. package/src/proxmox/fake-pve.ts +3 -2
  148. package/src/proxmox/live-core-fixtures.ts +126 -0
  149. package/src/proxmox/live-core-lifecycle.test.ts +115 -0
  150. package/src/proxmox/live-core-wire.test.ts +190 -0
  151. package/src/proxmox/metric-server-config.ts +87 -0
  152. package/src/proxmox/metric-server-distilled.ts +81 -0
  153. package/src/proxmox/metric-server.ts +77 -88
  154. package/src/proxmox/notification-distilled.test.ts +162 -0
  155. package/src/proxmox/notification-matcher-distilled.ts +60 -0
  156. package/src/proxmox/notification-matcher-lifecycle.ts +71 -0
  157. package/src/proxmox/notification-matcher.ts +9 -26
  158. package/src/proxmox/notification-replacement.test.ts +190 -0
  159. package/src/proxmox/notification-sdk-input.ts +39 -0
  160. package/src/proxmox/notification-target-distilled.ts +160 -0
  161. package/src/proxmox/notification-target-form.ts +2 -0
  162. package/src/proxmox/notification-target-lifecycle.ts +75 -0
  163. package/src/proxmox/notification-target-spec.ts +68 -0
  164. package/src/proxmox/notification-target.ts +6 -68
  165. package/src/proxmox/pbs-datastore-endpoint.ts +1 -1
  166. package/src/proxmox/pbs-datastore-form.ts +18 -49
  167. package/src/proxmox/pbs-datastore-guard.ts +7 -4
  168. package/src/proxmox/pbs-datastore-lifecycle.ts +114 -0
  169. package/src/proxmox/pbs-datastore-wire.ts +94 -0
  170. package/src/proxmox/pbs-datastore.ts +7 -133
  171. package/src/proxmox/pbs-jobs-delete.test.ts +40 -0
  172. package/src/proxmox/pbs-jobs-sdk.test.ts +215 -0
  173. package/src/proxmox/pbs-notification-errors.test.ts +93 -0
  174. package/src/proxmox/pbs-notification-input.ts +25 -0
  175. package/src/proxmox/pbs-notification-mail.test.ts +64 -0
  176. package/src/proxmox/pbs-notification-matcher-distilled.ts +65 -0
  177. package/src/proxmox/pbs-notification-matcher.ts +70 -17
  178. package/src/proxmox/pbs-notification-target-distilled.ts +124 -0
  179. package/src/proxmox/pbs-notification-target-lifecycle.ts +26 -17
  180. package/src/proxmox/pbs-notification-target.ts +1 -1
  181. package/src/proxmox/pbs-notifications-stack.test.ts +25 -0
  182. package/src/proxmox/pbs-prune-job-lifecycle.ts +52 -0
  183. package/src/proxmox/pbs-prune-job-spec.ts +53 -0
  184. package/src/proxmox/pbs-prune-job-wire.ts +68 -0
  185. package/src/proxmox/pbs-prune-job.ts +10 -58
  186. package/src/proxmox/pbs-sync-job-attributes.ts +33 -0
  187. package/src/proxmox/pbs-sync-job-form.ts +7 -6
  188. package/src/proxmox/pbs-sync-job-lifecycle.ts +46 -0
  189. package/src/proxmox/pbs-sync-job-wire.ts +71 -0
  190. package/src/proxmox/pbs-sync-job.ts +10 -41
  191. package/src/proxmox/pbs-verify-job-lifecycle.ts +52 -0
  192. package/src/proxmox/pbs-verify-job-spec.ts +76 -0
  193. package/src/proxmox/pbs-verify-job-wire.ts +58 -0
  194. package/src/proxmox/pbs-verify-job.ts +13 -85
  195. package/src/proxmox/pool.ts +92 -10
@@ -0,0 +1,93 @@
1
+ /** Read failures must never turn an existing notification route into a planned create. */
2
+ import { expect, test } from 'bun:test';
3
+ import * as Effect from 'effect/Effect';
4
+ import * as Layer from 'effect/Layer';
5
+ import { engineOver } from '../verify/fake-engine.ts';
6
+ import type { PbsTarget } from './credentials.ts';
7
+ import { fakePve, withoutBao } from './fake-pve.ts';
8
+ import { deleteMatcher, readMatcher } from './pbs-notification-matcher-distilled.ts';
9
+ import {
10
+ PbsNotificationMatcher,
11
+ PbsNotificationMatcherProvider,
12
+ } from './pbs-notification-matcher.ts';
13
+ import { createTarget, deleteTarget, readTarget } from './pbs-notification-target-distilled.ts';
14
+ import { PbsNotificationTarget, PbsNotificationTargetProvider } from './pbs-notification-target.ts';
15
+
16
+ const PBS: PbsTarget = { api: 'https://pbs.test:8007', mount: 'pbs-test', scheme: 'pbs' };
17
+ const MATCHER = { name: 'pager', target: PBS, targets: ['mail-to-root'] };
18
+ const TARGET = { name: 'mail-to-root', target: PBS, type: 'sendmail' as const };
19
+
20
+ for (const status of [401, 403, 500, 503]) {
21
+ test(`PBS ${String(status)} fails cold adoption and cannot POST either notification family`, async () => {
22
+ const fake = fakePve(() => Response.json({ message: 'read refused' }, { status }));
23
+ const providers = Layer.mergeAll(
24
+ PbsNotificationMatcherProvider(),
25
+ PbsNotificationTargetProvider(),
26
+ );
27
+ const stack = Effect.gen(function* () {
28
+ yield* PbsNotificationMatcher('pager', MATCHER);
29
+ yield* PbsNotificationTarget('mail', TARGET);
30
+ });
31
+ await withoutBao(async () => {
32
+ const engine = engineOver(providers.pipe(Layer.provideMerge(fake.layer)));
33
+ await expect(engine.deploy(stack)).rejects.toBeDefined();
34
+ });
35
+ expect(fake.writes()).toEqual([]);
36
+ });
37
+ }
38
+
39
+ test('only the typed NotFound is absence, and deleting it is idempotent for both families', async () => {
40
+ // PBS 4.2 read-only missing-name probe, 2026-09-24: notification absence is plain-text 404.
41
+ const fake = fakePve(() => new Response("matcher 'pager' not found", { status: 404 }));
42
+ await withoutBao(async () => {
43
+ const outcome = await Effect.runPromise(
44
+ Effect.gen(function* () {
45
+ const matcher = yield* readMatcher(MATCHER);
46
+ const target = yield* readTarget(TARGET);
47
+ yield* deleteMatcher(MATCHER);
48
+ yield* deleteTarget(TARGET);
49
+ return { matcher, target };
50
+ }).pipe(Effect.provide(fake.layer)),
51
+ );
52
+ expect(outcome).toEqual({ matcher: undefined, target: undefined });
53
+ });
54
+ });
55
+
56
+ test('a malformed successful resource response is a failure, never absence', async () => {
57
+ const fake = fakePve(() => undefined);
58
+ await withoutBao(async () => {
59
+ for (const read of [
60
+ readMatcher(MATCHER).pipe(Effect.asVoid),
61
+ readTarget(TARGET).pipe(Effect.asVoid),
62
+ ]) {
63
+ await expect(Effect.runPromise(read.pipe(Effect.provide(fake.layer)))).rejects.toBeDefined();
64
+ }
65
+ });
66
+ expect(fake.writes()).toEqual([]);
67
+ });
68
+
69
+ test('typed write errors also propagate instead of being treated as completed deletes', async () => {
70
+ const fake = fakePve(() => Response.json({ message: 'permission denied' }, { status: 403 }));
71
+ await withoutBao(async () => {
72
+ for (const remove of [deleteMatcher(MATCHER), deleteTarget(TARGET)]) {
73
+ const error = await Effect.runPromise(remove.pipe(Effect.flip, Effect.provide(fake.layer)));
74
+ expect(error).toMatchObject({ _tag: 'Forbidden' });
75
+ }
76
+ });
77
+ expect(fake.writes()).toHaveLength(2);
78
+ });
79
+
80
+ test('invalid SDK input never echoes a write-only value or sends a vendor request', async () => {
81
+ const fake = fakePve(() => undefined);
82
+ await withoutBao(async () => {
83
+ const error = await Effect.runPromise(
84
+ createTarget(
85
+ { ...TARGET, type: 'smtp' },
86
+ { name: TARGET.name, password: 'invalid-input-secret-must-stay-private' },
87
+ ).pipe(Effect.flip, Effect.provide(fake.layer)),
88
+ );
89
+ expect(error).toMatchObject({ _tag: 'PbsNotificationInputRefusal' });
90
+ expect(JSON.stringify(error)).not.toContain('invalid-input-secret-must-stay-private');
91
+ });
92
+ expect(fake.calls).toEqual([]);
93
+ });
@@ -0,0 +1,25 @@
1
+ /** Validate translated fields with the SDK's own schema without echoing write-only values. */
2
+ import * as Data from 'effect/Data';
3
+ import * as Effect from 'effect/Effect';
4
+ import * as Schema from 'effect/Schema';
5
+
6
+ export class PbsNotificationInputRefusal extends Data.TaggedError('PbsNotificationInputRefusal')<{
7
+ readonly message: string;
8
+ }> {}
9
+
10
+ /**
11
+ * Fields already use SDK Type-side names; toType validates without any wire transforms.
12
+ * A schema diagnostic can reproduce the whole submitted form, including a webhook secret or
13
+ * SMTP password. Sanitize only our pre-request validation failure. SDK operation errors below
14
+ * this boundary retain their original typed tags and are never remapped.
15
+ */
16
+ export const notificationInput = <S extends Schema.Top>(schema: S, fields: unknown) =>
17
+ Schema.decodeUnknownEffect(Schema.toType(schema))(fields).pipe(
18
+ Effect.mapError(
19
+ () =>
20
+ new PbsNotificationInputRefusal({
21
+ message:
22
+ 'PBS notification fields do not match the SDK request schema; no PBS request was sent.',
23
+ }),
24
+ ),
25
+ );
@@ -0,0 +1,64 @@
1
+ /** Mail target CRUD uses the SDK's field spellings without losing lists or the password seal. */
2
+ import { afterEach, expect, test } from 'bun:test';
3
+ import * as Layer from 'effect/Layer';
4
+ import { engineOver } from '../verify/fake-engine.ts';
5
+ import type { PbsTarget } from './credentials.ts';
6
+ import { fakeNotify } from './fake-pbs-notify.ts';
7
+ import { withoutBao } from './fake-pve.ts';
8
+ import { PbsNotificationTarget, PbsNotificationTargetProvider } from './pbs-notification-target.ts';
9
+
10
+ const PBS: PbsTarget = { api: 'https://pbs.test:8007', mount: 'pbs-test', scheme: 'pbs' };
11
+ const PASSWORD = 'smtp-test-secret-not-real';
12
+ afterEach(() => {
13
+ delete process.env['HF_PBS_SMTP_TEST_PASSWORD'];
14
+ });
15
+
16
+ for (const type of ['sendmail', 'smtp'] as const) {
17
+ test(`${type} creates, reads its dashed fields, clears recipients and redeploys without a write`, async () => {
18
+ process.env['HF_PBS_SMTP_TEST_PASSWORD'] = PASSWORD;
19
+ const fake = fakeNotify();
20
+ const declare = (mailto: readonly string[]) =>
21
+ PbsNotificationTarget('pager', {
22
+ type,
23
+ name: 'pager',
24
+ target: PBS,
25
+ 'from-address': 'sender@example.com',
26
+ 'mailto-user': ['root@pam'],
27
+ mailto,
28
+ ...(type === 'smtp'
29
+ ? {
30
+ mode: 'starttls' as const,
31
+ password: { fromEnv: 'HF_PBS_SMTP_TEST_PASSWORD' },
32
+ port: 587,
33
+ server: 'smtp.example.com',
34
+ username: 'pager',
35
+ }
36
+ : {}),
37
+ });
38
+ await withoutBao(async () => {
39
+ const engine = engineOver(
40
+ PbsNotificationTargetProvider().pipe(Layer.provideMerge(fake.layer)),
41
+ );
42
+ await engine.deploy(declare(['ops@example.com', 'backup@example.com']));
43
+ expect(
44
+ (await engine.verify(declare(['ops@example.com', 'backup@example.com']), { all: true }))
45
+ .rows[0]?.diff,
46
+ ).toBe('noop');
47
+ expect(engine.stored()).not.toContain(PASSWORD);
48
+ await engine.deploy(declare([]));
49
+ expect((await engine.verify(declare([]), { all: true })).rows[0]?.diff).toBe('noop');
50
+ await engine.deploy(declare([]));
51
+ });
52
+ expect(fake.writes()).toEqual([
53
+ `POST config/notifications/endpoints/${type}`,
54
+ `PUT config/notifications/endpoints/${type}/pager`,
55
+ ]);
56
+ const post = fake.calls.find((call) => call.method === 'POST');
57
+ expect(post?.pairs).toContainEqual(['from-address', 'sender@example.com']);
58
+ expect(post?.pairs).toContainEqual(['mailto-user', 'root@pam']);
59
+ expect(post?.pairs.filter(([key]) => key === 'mailto')).toHaveLength(2);
60
+ const put = fake.calls.find((call) => call.method === 'PUT');
61
+ expect(put?.pairs).toContainEqual(['delete', 'mailto']);
62
+ expect(put?.pairs.map(([key]) => key)).not.toContain('password');
63
+ });
64
+ }
@@ -0,0 +1,65 @@
1
+ /** PBS 4.2.6-1 matcher operations through SDK 0.3.0; failed reads never become absence. */
2
+ import * as config from '@distilled.cloud/proxmox-backup/config';
3
+ import * as Effect from 'effect/Effect';
4
+ import { notificationInput } from './pbs-notification-input.ts';
5
+ import { runPbs } from './distilled-pbs.ts';
6
+ import {
7
+ type MatcherCreateForm,
8
+ type MatcherUpdateForm,
9
+ matcherAttributes,
10
+ } from './notification-matcher-form.ts';
11
+ import type { PbsNotificationMatcherProps as Props } from './pbs-notification-matcher.ts';
12
+
13
+ export const readMatcher = (props: Props) =>
14
+ runPbs(props.target, 'read', config.getConfigNotificationMatcher({ name: props.name })).pipe(
15
+ Effect.map((row) =>
16
+ matcherAttributes(
17
+ {
18
+ ...row,
19
+ 'invert-match': row.invert_match,
20
+ 'match-calendar': row.match_calendar,
21
+ 'match-field': row.match_field,
22
+ 'match-severity': row.match_severity,
23
+ },
24
+ props.name,
25
+ ),
26
+ ),
27
+ Effect.catchTag('NotFound', () => Effect.succeed(undefined)),
28
+ );
29
+
30
+ /** Validate with the SDK's request schema after spelling the shared PVE/PBS form's keys. */
31
+ const requestFields = (props: Props, form: MatcherCreateForm | MatcherUpdateForm) => ({
32
+ ...form,
33
+ invert_match: form['invert-match'],
34
+ match_calendar: form['match-calendar'],
35
+ match_field: form['match-field'],
36
+ match_severity: form['match-severity'],
37
+ name: props.name,
38
+ });
39
+
40
+ export const createMatcher = (props: Props, form: MatcherCreateForm) =>
41
+ runPbs(
42
+ props.target,
43
+ 'provision',
44
+ notificationInput(
45
+ config.CreateConfigNotificationMatcherRequest,
46
+ requestFields(props, form),
47
+ ).pipe(Effect.flatMap(config.createConfigNotificationMatcher)),
48
+ );
49
+
50
+ export const updateMatcher = (props: Props, form: MatcherUpdateForm) =>
51
+ runPbs(
52
+ props.target,
53
+ 'provision',
54
+ notificationInput(config.PutConfigNotificationMatcherRequest, requestFields(props, form)).pipe(
55
+ Effect.flatMap(config.putConfigNotificationMatcher),
56
+ ),
57
+ );
58
+
59
+ /** PBS reverts built-ins on deletion; it reports NotFound only for an absent user-created row. */
60
+ export const deleteMatcher = (props: Props) =>
61
+ runPbs(
62
+ props.target,
63
+ 'provision',
64
+ config.deleteConfigNotificationMatcher({ name: props.name }),
65
+ ).pipe(Effect.catchTag('NotFound', () => Effect.void));
@@ -36,21 +36,30 @@
36
36
  * ★ WHAT THIS NEEDS, READ OFF THE SERVER'S SCHEMA (generated/pbs.ts source):
37
37
  * read GET Sys.Audit on /system/notifications
38
38
  * write POST, PUT, DELETE Sys.Modify on /system/notifications
39
- * ⚠️ NOT A DATASTORE PRIVILEGE. A PBS credential scoped to `/datastore/...` reads every matcher
40
- * as absent — the "absent, then already exists" signature notification-target.ts describes.
39
+ * ⚠️ NOT A DATASTORE PRIVILEGE. A credential scoped to `/datastore/...` cannot read matchers;
40
+ * the old client treated that refusal as absent — notification-target.ts describes the
41
+ * resulting "absent, then already exists" signature.
41
42
  */
42
43
  import { Resource } from 'alchemy';
43
44
  import * as Provider from 'alchemy/Provider';
45
+ import { isResolved } from 'alchemy/Diff';
46
+ import type { Input } from 'alchemy/Input';
44
47
  import * as Effect from 'effect/Effect';
45
48
  import {
46
49
  type NotificationMatcherAttributes,
47
50
  type NotificationMatcherFields,
48
- matcherAttributes,
49
51
  matcherCreateForm,
50
52
  matcherMatches,
51
53
  matcherUpdateForm,
52
54
  } from './notification-matcher-form.ts';
53
- import { type PveRequirements, type WithPbsTarget, pveHandlers } from './resource.ts';
55
+ import type { PveRequirements, WithPbsTarget } from './resource.ts';
56
+ import { guardForm } from './constraint-guard.ts';
57
+ import {
58
+ createMatcher,
59
+ deleteMatcher,
60
+ readMatcher,
61
+ updateMatcher,
62
+ } from './pbs-notification-matcher-distilled.ts';
54
63
 
55
64
  export interface PbsNotificationMatcherProps extends NotificationMatcherFields, WithPbsTarget {}
56
65
 
@@ -65,19 +74,63 @@ export interface PbsNotificationMatcher extends Resource<
65
74
  /** ⚠️ No `retain`, for notification-matcher.ts's reason; a built-in REVERTS on delete. */
66
75
  export const PbsNotificationMatcher = Resource<PbsNotificationMatcher>('Pbs.NotificationMatcher');
67
76
 
68
- const handlers = pveHandlers<PbsNotificationMatcherProps, NotificationMatcherAttributes>({
69
- attributes: (live, props) => matcherAttributes(live, props.name),
70
- collection: () => 'config/notifications/matchers',
71
- createForm: matcherCreateForm,
72
- /** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
73
- endpoint: {
74
- create: 'pbs:POST /config/notifications/matchers',
75
- update: 'pbs:PUT /config/notifications/matchers/{name}',
76
- },
77
- matches: matcherMatches,
78
- path: (props) => `config/notifications/matchers/${props.name}`,
79
- updateForm: matcherUpdateForm,
80
- });
77
+ type Props = PbsNotificationMatcherProps;
78
+ type Attributes = NotificationMatcherAttributes;
79
+
80
+ const guardCreate = (props: Props, requirePresence: boolean) =>
81
+ guardForm('pbs:POST /config/notifications/matchers', matcherCreateForm(props), requirePresence);
82
+ const guardUpdate = (props: Props) =>
83
+ guardForm('pbs:PUT /config/notifications/matchers/{name}', matcherUpdateForm(props), false);
84
+
85
+ /**
86
+ * SDK-backed lifecycle: read before writing and compare the entire rule so adoption is free.
87
+ * ⛔ A 401, 403, malformed response or failed credential mint must fail the plan. Only the
88
+ * SDK's typed NotFound means absent; the old factory turned all of these into false creates.
89
+ */
90
+ const handlers = {
91
+ /** Built-ins belong to the host; adopting them must stay an explicit declaration. */
92
+ list: () => Effect.succeed([]),
93
+ read: ({ olds }: { olds: Props }) => readMatcher(olds),
94
+ diff: ({
95
+ news,
96
+ olds,
97
+ output,
98
+ }: {
99
+ news: Input<Props>;
100
+ olds: Props;
101
+ output: Attributes | undefined;
102
+ }) =>
103
+ Effect.gen(function* () {
104
+ if (!isResolved(news)) return undefined;
105
+ yield* guardCreate(news, output === undefined);
106
+ yield* guardUpdate(news);
107
+ if (output === undefined) return undefined;
108
+ if (olds.name !== news.name) return { action: 'replace' } as const;
109
+ const live = yield* readMatcher(news);
110
+ if (live === undefined) {
111
+ yield* guardCreate(news, true);
112
+ return { action: 'update' } as const;
113
+ }
114
+ return { action: matcherMatches(live, news) ? 'noop' : 'update' } as const;
115
+ }),
116
+ reconcile: ({ news }: { news: Props }) =>
117
+ Effect.gen(function* () {
118
+ const live = yield* readMatcher(news);
119
+ yield* guardCreate(news, live === undefined);
120
+ yield* guardUpdate(news);
121
+ if (live === undefined) yield* createMatcher(news, matcherCreateForm(news));
122
+ else if (!matcherMatches(live, news)) yield* updateMatcher(news, matcherUpdateForm(news));
123
+ const after = yield* readMatcher(news);
124
+ if (after === undefined)
125
+ return yield* Effect.fail(
126
+ new Error(
127
+ `config/notifications/matchers/${news.name}: the write returned no error but the matcher is still absent.`,
128
+ ),
129
+ );
130
+ return after;
131
+ }),
132
+ delete: ({ olds }: { olds: Props }) => deleteMatcher(olds),
133
+ };
81
134
 
82
135
  export const PbsNotificationMatcherProvider = () =>
83
136
  Provider.effect(
@@ -0,0 +1,124 @@
1
+ /**
2
+ * PBS 4.2.6-1 notification operations, generated from its vendor schema in SDK 0.3.0.
3
+ *
4
+ * ★ Keep targetForm's selective secret groups and deletion semantics. The SDK schemas validate
5
+ * the spelling adapter before their typed operations encode it; no request shape is cast.
6
+ * Only the SDK's NotFound is absence. An unreadable target must stop adoption, not create it.
7
+ */
8
+ import * as config from '@distilled.cloud/proxmox-backup/config';
9
+ import type {
10
+ ProxmoxBackupOpContext,
11
+ ProxmoxBackupOpError,
12
+ } from '@distilled.cloud/proxmox-backup/Protocol';
13
+ import * as Effect from 'effect/Effect';
14
+ import { notificationInput } from './pbs-notification-input.ts';
15
+ import type { PveForm } from './client.ts';
16
+ import { runPbs } from './distilled-pbs.ts';
17
+ import type { PbsNotificationTargetProps as Props } from './pbs-notification-target.ts';
18
+ import { targetAttributes } from './pbs-notification-target-wire.ts';
19
+
20
+ /** SDK property names differ from the vendor's form keys only at these two fields. */
21
+ const requestFields = (props: Props, form: PveForm) => ({
22
+ ...form,
23
+ from_address: form['from-address'],
24
+ mailto_user: form['mailto-user'],
25
+ name: props.name,
26
+ });
27
+
28
+ const read = (
29
+ props: Props,
30
+ ): Effect.Effect<Record<string, unknown>, ProxmoxBackupOpError, ProxmoxBackupOpContext> => {
31
+ switch (props.type) {
32
+ case 'sendmail':
33
+ return config.getConfigNotificationEndpointSendmail({ name: props.name }).pipe(
34
+ Effect.map((row) => ({
35
+ ...row,
36
+ 'from-address': row.from_address,
37
+ 'mailto-user': row.mailto_user,
38
+ })),
39
+ );
40
+ case 'smtp':
41
+ return config.getConfigNotificationEndpointSmtp({ name: props.name }).pipe(
42
+ Effect.map((row) => ({
43
+ ...row,
44
+ 'from-address': row.from_address,
45
+ 'mailto-user': row.mailto_user,
46
+ })),
47
+ );
48
+ case 'webhook':
49
+ return config
50
+ .getConfigNotificationEndpointWebhook({ name: props.name })
51
+ .pipe(Effect.map((row) => ({ ...row })));
52
+ }
53
+ };
54
+
55
+ export const readTarget = (props: Props) =>
56
+ runPbs(props.target, 'read', read(props)).pipe(
57
+ Effect.map((row) => targetAttributes(row, props)),
58
+ Effect.catchTag('NotFound', () => Effect.succeed(undefined)),
59
+ );
60
+
61
+ /** No request: run the same translated-input validation before replacement can delete anything. */
62
+ export const validateTargetWrite = (props: Props, form: PveForm, mode: 'create' | 'update') => {
63
+ const schema =
64
+ mode === 'create'
65
+ ? {
66
+ sendmail: config.CreateConfigNotificationEndpointSendmailRequest,
67
+ smtp: config.CreateConfigNotificationEndpointSmtpRequest,
68
+ webhook: config.CreateConfigNotificationEndpointWebhookRequest,
69
+ }[props.type]
70
+ : {
71
+ sendmail: config.PutConfigNotificationEndpointSendmailRequest,
72
+ smtp: config.PutConfigNotificationEndpointSmtpRequest,
73
+ webhook: config.PutConfigNotificationEndpointWebhookRequest,
74
+ }[props.type];
75
+ return notificationInput(schema, requestFields(props, form)).pipe(Effect.asVoid);
76
+ };
77
+
78
+ export const createTarget = (props: Props, form: PveForm) => {
79
+ const fields = requestFields(props, form);
80
+ const operation =
81
+ props.type === 'sendmail'
82
+ ? notificationInput(config.CreateConfigNotificationEndpointSendmailRequest, fields).pipe(
83
+ Effect.flatMap(config.createConfigNotificationEndpointSendmail),
84
+ )
85
+ : props.type === 'smtp'
86
+ ? notificationInput(config.CreateConfigNotificationEndpointSmtpRequest, fields).pipe(
87
+ Effect.flatMap(config.createConfigNotificationEndpointSmtp),
88
+ )
89
+ : notificationInput(config.CreateConfigNotificationEndpointWebhookRequest, fields).pipe(
90
+ Effect.flatMap(config.createConfigNotificationEndpointWebhook),
91
+ );
92
+ return runPbs(props.target, 'provision', operation);
93
+ };
94
+
95
+ export const updateTarget = (props: Props, form: PveForm) => {
96
+ const fields = requestFields(props, form);
97
+ const operation =
98
+ props.type === 'sendmail'
99
+ ? notificationInput(config.PutConfigNotificationEndpointSendmailRequest, fields).pipe(
100
+ Effect.flatMap(config.putConfigNotificationEndpointSendmail),
101
+ )
102
+ : props.type === 'smtp'
103
+ ? notificationInput(config.PutConfigNotificationEndpointSmtpRequest, fields).pipe(
104
+ Effect.flatMap(config.putConfigNotificationEndpointSmtp),
105
+ )
106
+ : notificationInput(config.PutConfigNotificationEndpointWebhookRequest, fields).pipe(
107
+ Effect.flatMap(config.putConfigNotificationEndpointWebhook),
108
+ );
109
+ return runPbs(props.target, 'provision', operation);
110
+ };
111
+
112
+ /** Built-ins still follow PBS's revert-on-delete semantics; missing objects are already deleted. */
113
+ export const deleteTarget = (props: Props) => {
114
+ const request = { name: props.name };
115
+ const operation =
116
+ props.type === 'sendmail'
117
+ ? config.deleteConfigNotificationEndpointSendmail(request)
118
+ : props.type === 'smtp'
119
+ ? config.deleteConfigNotificationEndpointSmtp(request)
120
+ : config.deleteConfigNotificationEndpointWebhook(request);
121
+ return runPbs(props.target, 'provision', operation).pipe(
122
+ Effect.catchTag('NotFound', () => Effect.void),
123
+ );
124
+ };
@@ -14,7 +14,13 @@
14
14
  import { isResolved } from 'alchemy/Diff';
15
15
  import type { Input } from 'alchemy/Input';
16
16
  import * as Effect from 'effect/Effect';
17
- import { pve } from './client.ts';
17
+ import {
18
+ createTarget,
19
+ deleteTarget,
20
+ readTarget as readLive,
21
+ updateTarget,
22
+ validateTargetWrite,
23
+ } from './pbs-notification-target-distilled.ts';
18
24
  import { guardForm } from './constraint-guard.ts';
19
25
  import { pbsTargetEndpoint } from './pbs-notification-target-endpoint.ts';
20
26
  import type {
@@ -31,7 +37,6 @@ import {
31
37
  sealedState,
32
38
  targetForm,
33
39
  } from './pbs-notification-target-form.ts';
34
- import { targetAttributes } from './pbs-notification-target-wire.ts';
35
40
  import { seal } from '../secrets/write-only.ts';
36
41
 
37
42
  type Props = PbsNotificationTargetProps;
@@ -39,16 +44,6 @@ type Attributes = PbsNotificationTargetAttributes;
39
44
 
40
45
  const path = (props: Props) => `config/notifications/endpoints/${props.type}/${props.name}`;
41
46
 
42
- /**
43
- * ⚠️ A 404 and `{"data": null}` are answers here — and so, resource.ts's ⛔, is a 403. `pve()`
44
- * returns `null` for the second, not `undefined`, so the test is `== null`.
45
- */
46
- const readLive = (props: Props) =>
47
- pve<Record<string, unknown>>(props.target, 'read', 'GET', path(props)).pipe(
48
- Effect.map((data) => (data == null ? undefined : targetAttributes(data, props))),
49
- Effect.orElseSucceed(() => undefined),
50
- );
51
-
52
47
  const refuse = (props: Props) => {
53
48
  const reasons = refusals(props);
54
49
  return reasons.length === 0
@@ -124,10 +119,25 @@ export const handlers = {
124
119
  }) =>
125
120
  Effect.gen(function* () {
126
121
  if (output === undefined || !isResolved(news)) return undefined;
122
+ yield* refuse(news);
127
123
  if (olds !== undefined && (olds.type !== news.type || olds.name !== news.name)) {
124
+ // ⛔ Preflight the destination before delete-first can remove a working target. A rename
125
+ // may adopt an existing endpoint; its write-only values have no seal from this resource.
126
+ const destination = yield* readLive(news);
127
+ const groups = resolveGroups(news);
128
+ const carry =
129
+ destination === undefined
130
+ ? { header: true, sealed: true }
131
+ : toCarry(destination, news, groups, '');
132
+ if (carry !== undefined) {
133
+ yield* requireValues(news, groups, carry);
134
+ const mode = destination === undefined ? 'create' : 'update';
135
+ const form = targetForm(news, groups, carry, mode);
136
+ yield* guardForm(pbsTargetEndpoint(news)[mode], form, destination === undefined);
137
+ yield* validateTargetWrite(news, form, mode);
138
+ }
128
139
  return { action: 'replace', deleteFirst: olds.name === news.name } as const;
129
140
  }
130
- yield* refuse(news);
131
141
  const live = yield* readLive(news);
132
142
  if (live === undefined) return { action: 'update' } as const;
133
143
  return toCarry(live, news, resolveGroups(news), output.sealed) === undefined
@@ -143,7 +153,6 @@ export const handlers = {
143
153
  if (live === undefined) {
144
154
  const carry = { header: true, sealed: true } as const;
145
155
  yield* requireValues(news, groups, carry);
146
- const collection = `config/notifications/endpoints/${news.type}`;
147
156
  const form = targetForm(news, groups, carry, 'create');
148
157
  /**
149
158
  * ⛔ WITH PRESENCE, BECAUSE THIS BRANCH IS UNCONDITIONALLY THE CREATE. PBS marks `name` on
@@ -153,7 +162,7 @@ export const handlers = {
153
162
  * parameter NAME into its message (constraints.ts), so a refusal never quotes one.
154
163
  */
155
164
  yield* guardForm(pbsTargetEndpoint(news).create, form, true);
156
- yield* pve(news.target, 'provision', 'POST', collection, form);
165
+ yield* createTarget(news, form);
157
166
  sealed = seal(groups.sealed.values);
158
167
  } else {
159
168
  const carry = toCarry(live, news, groups, sealed);
@@ -162,7 +171,7 @@ export const handlers = {
162
171
  const form = targetForm(news, groups, carry, 'update');
163
172
  /** ⚠️ NO PRESENCE: an update form is partial by design — it carries what `toCarry` said. */
164
173
  yield* guardForm(pbsTargetEndpoint(news).update, form, false);
165
- yield* pve(news.target, 'provision', 'PUT', path(news), form);
174
+ yield* updateTarget(news, form);
166
175
  if (carry.sealed) sealed = seal(groups.sealed.values);
167
176
  }
168
177
  }
@@ -175,5 +184,5 @@ export const handlers = {
175
184
  }
176
185
  return { ...after, sealed } satisfies Attributes;
177
186
  }),
178
- delete: ({ olds }: { olds: Props }) => pve(olds.target, 'provision', 'DELETE', path(olds)),
187
+ delete: ({ olds }: { olds: Props }) => deleteTarget(olds),
179
188
  };
@@ -24,7 +24,7 @@
24
24
  * the consequence. Read `origin` before declaring a name you did not create.
25
25
  *
26
26
  * ★ WHAT THIS NEEDS (generated/pbs.ts source): Sys.Audit on /system/notifications to read,
27
- * Sys.Modify there to write. A datastore-scoped credential reads every target as absent.
27
+ * Sys.Modify there to write. A datastore-scoped credential cannot read targets; its typed refusal now fails the plan.
28
28
  */
29
29
  import { Resource } from 'alchemy';
30
30
  import * as Provider from 'alchemy/Provider';
@@ -92,3 +92,28 @@ test('a new type under the same name replaces delete-first — names are unique
92
92
  'POST config/notifications/endpoints/sendmail',
93
93
  ]);
94
94
  });
95
+
96
+ test('renaming a matcher replaces it instead of leaving the old route behind', async () => {
97
+ const fake = fakeNotify();
98
+ const declare = (name: string) =>
99
+ PbsNotificationMatcher('route', {
100
+ name,
101
+ target: PBS,
102
+ targets: ['mail-to-root'],
103
+ });
104
+ await withoutBao(async () => {
105
+ const engine = engineOver(
106
+ PbsNotificationMatcherProvider().pipe(Layer.provideMerge(fake.layer)),
107
+ );
108
+ await engine.deploy(declare('route-old'));
109
+ await engine.deploy(declare('route-new'));
110
+ expect((await engine.verify(declare('route-new'), { all: true })).rows[0]?.diff).toBe('noop');
111
+ });
112
+ expect(fake.objects.has('config/notifications/matchers/route-old')).toBe(false);
113
+ expect(fake.objects.has('config/notifications/matchers/route-new')).toBe(true);
114
+ expect(fake.writes()).toEqual([
115
+ 'POST config/notifications/matchers',
116
+ 'POST config/notifications/matchers',
117
+ 'DELETE config/notifications/matchers/route-old',
118
+ ]);
119
+ });