@homeflare/alchemy 0.37.2 → 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 (155) 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/distilled-pbs.d.ts +12 -0
  8. package/dist/proxmox/distilled-pbs.d.ts.map +1 -0
  9. package/dist/proxmox/fake-pbs-jobs.d.ts +23 -0
  10. package/dist/proxmox/fake-pbs-jobs.d.ts.map +1 -0
  11. package/dist/proxmox/fake-pbs-notify.d.ts.map +1 -1
  12. package/dist/proxmox/fake-pve.d.ts +1 -1
  13. package/dist/proxmox/fake-pve.d.ts.map +1 -1
  14. package/dist/proxmox/index.js +1501 -607
  15. package/dist/proxmox/index.js.map +48 -24
  16. package/dist/proxmox/live-core-fixtures.d.ts +40 -0
  17. package/dist/proxmox/live-core-fixtures.d.ts.map +1 -0
  18. package/dist/proxmox/metric-server-config.d.ts +49 -0
  19. package/dist/proxmox/metric-server-config.d.ts.map +1 -0
  20. package/dist/proxmox/metric-server-distilled.d.ts +43 -0
  21. package/dist/proxmox/metric-server-distilled.d.ts.map +1 -0
  22. package/dist/proxmox/metric-server.d.ts +2 -10
  23. package/dist/proxmox/metric-server.d.ts.map +1 -1
  24. package/dist/proxmox/notification-matcher-distilled.d.ts +9 -0
  25. package/dist/proxmox/notification-matcher-distilled.d.ts.map +1 -0
  26. package/dist/proxmox/notification-matcher-lifecycle.d.ts +27 -0
  27. package/dist/proxmox/notification-matcher-lifecycle.d.ts.map +1 -0
  28. package/dist/proxmox/notification-matcher.d.ts +5 -5
  29. package/dist/proxmox/notification-matcher.d.ts.map +1 -1
  30. package/dist/proxmox/notification-sdk-input.d.ts +22 -0
  31. package/dist/proxmox/notification-sdk-input.d.ts.map +1 -0
  32. package/dist/proxmox/notification-target-distilled.d.ts +12 -0
  33. package/dist/proxmox/notification-target-distilled.d.ts.map +1 -0
  34. package/dist/proxmox/notification-target-form.d.ts +2 -0
  35. package/dist/proxmox/notification-target-form.d.ts.map +1 -1
  36. package/dist/proxmox/notification-target-lifecycle.d.ts +29 -0
  37. package/dist/proxmox/notification-target-lifecycle.d.ts.map +1 -0
  38. package/dist/proxmox/notification-target-spec.d.ts +5 -0
  39. package/dist/proxmox/notification-target-spec.d.ts.map +1 -0
  40. package/dist/proxmox/notification-target.d.ts +4 -5
  41. package/dist/proxmox/notification-target.d.ts.map +1 -1
  42. package/dist/proxmox/pbs-datastore-endpoint.d.ts +1 -1
  43. package/dist/proxmox/pbs-datastore-form.d.ts +18 -42
  44. package/dist/proxmox/pbs-datastore-form.d.ts.map +1 -1
  45. package/dist/proxmox/pbs-datastore-guard.d.ts +7 -4
  46. package/dist/proxmox/pbs-datastore-guard.d.ts.map +1 -1
  47. package/dist/proxmox/pbs-datastore-lifecycle.d.ts +56 -0
  48. package/dist/proxmox/pbs-datastore-lifecycle.d.ts.map +1 -0
  49. package/dist/proxmox/pbs-datastore-wire.d.ts +21 -0
  50. package/dist/proxmox/pbs-datastore-wire.d.ts.map +1 -0
  51. package/dist/proxmox/pbs-datastore.d.ts +5 -6
  52. package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
  53. package/dist/proxmox/pbs-notification-input.d.ts +18 -0
  54. package/dist/proxmox/pbs-notification-input.d.ts.map +1 -0
  55. package/dist/proxmox/pbs-notification-matcher-distilled.d.ts +11 -0
  56. package/dist/proxmox/pbs-notification-matcher-distilled.d.ts.map +1 -0
  57. package/dist/proxmox/pbs-notification-matcher.d.ts +4 -3
  58. package/dist/proxmox/pbs-notification-matcher.d.ts.map +1 -1
  59. package/dist/proxmox/pbs-notification-target-distilled.d.ts +20 -0
  60. package/dist/proxmox/pbs-notification-target-distilled.d.ts.map +1 -0
  61. package/dist/proxmox/pbs-notification-target-lifecycle.d.ts +4 -4
  62. package/dist/proxmox/pbs-notification-target-lifecycle.d.ts.map +1 -1
  63. package/dist/proxmox/pbs-notification-target.d.ts +1 -1
  64. package/dist/proxmox/pbs-prune-job-lifecycle.d.ts +22 -0
  65. package/dist/proxmox/pbs-prune-job-lifecycle.d.ts.map +1 -0
  66. package/dist/proxmox/pbs-prune-job-spec.d.ts +5 -0
  67. package/dist/proxmox/pbs-prune-job-spec.d.ts.map +1 -0
  68. package/dist/proxmox/pbs-prune-job-wire.d.ts +11 -0
  69. package/dist/proxmox/pbs-prune-job-wire.d.ts.map +1 -0
  70. package/dist/proxmox/pbs-prune-job.d.ts +9 -8
  71. package/dist/proxmox/pbs-prune-job.d.ts.map +1 -1
  72. package/dist/proxmox/pbs-sync-job-attributes.d.ts +34 -0
  73. package/dist/proxmox/pbs-sync-job-attributes.d.ts.map +1 -0
  74. package/dist/proxmox/pbs-sync-job-form.d.ts +21 -22
  75. package/dist/proxmox/pbs-sync-job-form.d.ts.map +1 -1
  76. package/dist/proxmox/pbs-sync-job-lifecycle.d.ts +22 -0
  77. package/dist/proxmox/pbs-sync-job-lifecycle.d.ts.map +1 -0
  78. package/dist/proxmox/pbs-sync-job-wire.d.ts +11 -0
  79. package/dist/proxmox/pbs-sync-job-wire.d.ts.map +1 -0
  80. package/dist/proxmox/pbs-sync-job.d.ts +9 -39
  81. package/dist/proxmox/pbs-sync-job.d.ts.map +1 -1
  82. package/dist/proxmox/pbs-verify-job-lifecycle.d.ts +22 -0
  83. package/dist/proxmox/pbs-verify-job-lifecycle.d.ts.map +1 -0
  84. package/dist/proxmox/pbs-verify-job-spec.d.ts +16 -0
  85. package/dist/proxmox/pbs-verify-job-spec.d.ts.map +1 -0
  86. package/dist/proxmox/pbs-verify-job-wire.d.ts +11 -0
  87. package/dist/proxmox/pbs-verify-job-wire.d.ts.map +1 -0
  88. package/dist/proxmox/pbs-verify-job.d.ts +12 -12
  89. package/dist/proxmox/pbs-verify-job.d.ts.map +1 -1
  90. package/dist/proxmox/pool.d.ts +10 -1
  91. package/dist/proxmox/pool.d.ts.map +1 -1
  92. package/docs/pbs-distilled-jobs.md +31 -0
  93. package/docs/pbs-notifications.md +8 -2
  94. package/docs/proxmox-notification-sdk.md +28 -0
  95. package/docs/proxmox-sdk-migration.md +60 -0
  96. package/docs/upstream-conformance-families.md +6 -2
  97. package/package.json +1 -1
  98. package/src/proxmox/adopt-noop.test.ts +4 -5
  99. package/src/proxmox/backup-job-config.ts +95 -0
  100. package/src/proxmox/backup-job-distilled.ts +86 -0
  101. package/src/proxmox/backup-job.ts +78 -116
  102. package/src/proxmox/constraints.test.ts +9 -8
  103. package/src/proxmox/distilled-pbs.test.ts +87 -0
  104. package/src/proxmox/distilled-pbs.ts +50 -0
  105. package/src/proxmox/fake-pbs-jobs.ts +99 -0
  106. package/src/proxmox/fake-pbs-notify.ts +4 -1
  107. package/src/proxmox/fake-pve.ts +3 -2
  108. package/src/proxmox/live-core-fixtures.ts +126 -0
  109. package/src/proxmox/live-core-lifecycle.test.ts +115 -0
  110. package/src/proxmox/live-core-wire.test.ts +190 -0
  111. package/src/proxmox/metric-server-config.ts +87 -0
  112. package/src/proxmox/metric-server-distilled.ts +81 -0
  113. package/src/proxmox/metric-server.ts +77 -88
  114. package/src/proxmox/notification-distilled.test.ts +162 -0
  115. package/src/proxmox/notification-matcher-distilled.ts +60 -0
  116. package/src/proxmox/notification-matcher-lifecycle.ts +71 -0
  117. package/src/proxmox/notification-matcher.ts +9 -26
  118. package/src/proxmox/notification-replacement.test.ts +190 -0
  119. package/src/proxmox/notification-sdk-input.ts +39 -0
  120. package/src/proxmox/notification-target-distilled.ts +160 -0
  121. package/src/proxmox/notification-target-form.ts +2 -0
  122. package/src/proxmox/notification-target-lifecycle.ts +75 -0
  123. package/src/proxmox/notification-target-spec.ts +68 -0
  124. package/src/proxmox/notification-target.ts +6 -68
  125. package/src/proxmox/pbs-datastore-endpoint.ts +1 -1
  126. package/src/proxmox/pbs-datastore-form.ts +18 -49
  127. package/src/proxmox/pbs-datastore-guard.ts +7 -4
  128. package/src/proxmox/pbs-datastore-lifecycle.ts +114 -0
  129. package/src/proxmox/pbs-datastore-wire.ts +94 -0
  130. package/src/proxmox/pbs-datastore.ts +7 -133
  131. package/src/proxmox/pbs-jobs-delete.test.ts +40 -0
  132. package/src/proxmox/pbs-jobs-sdk.test.ts +215 -0
  133. package/src/proxmox/pbs-notification-errors.test.ts +93 -0
  134. package/src/proxmox/pbs-notification-input.ts +25 -0
  135. package/src/proxmox/pbs-notification-mail.test.ts +64 -0
  136. package/src/proxmox/pbs-notification-matcher-distilled.ts +65 -0
  137. package/src/proxmox/pbs-notification-matcher.ts +70 -17
  138. package/src/proxmox/pbs-notification-target-distilled.ts +124 -0
  139. package/src/proxmox/pbs-notification-target-lifecycle.ts +26 -17
  140. package/src/proxmox/pbs-notification-target.ts +1 -1
  141. package/src/proxmox/pbs-notifications-stack.test.ts +25 -0
  142. package/src/proxmox/pbs-prune-job-lifecycle.ts +52 -0
  143. package/src/proxmox/pbs-prune-job-spec.ts +53 -0
  144. package/src/proxmox/pbs-prune-job-wire.ts +68 -0
  145. package/src/proxmox/pbs-prune-job.ts +10 -58
  146. package/src/proxmox/pbs-sync-job-attributes.ts +33 -0
  147. package/src/proxmox/pbs-sync-job-form.ts +7 -6
  148. package/src/proxmox/pbs-sync-job-lifecycle.ts +46 -0
  149. package/src/proxmox/pbs-sync-job-wire.ts +71 -0
  150. package/src/proxmox/pbs-sync-job.ts +10 -41
  151. package/src/proxmox/pbs-verify-job-lifecycle.ts +52 -0
  152. package/src/proxmox/pbs-verify-job-spec.ts +76 -0
  153. package/src/proxmox/pbs-verify-job-wire.ts +58 -0
  154. package/src/proxmox/pbs-verify-job.ts +13 -85
  155. package/src/proxmox/pool.ts +92 -10
@@ -0,0 +1,215 @@
1
+ /** Four PBS families through the real SDK and Alchemy's upstream test harness. */
2
+ import { afterEach, beforeEach, expect } from 'bun:test';
3
+ import * as Test from 'alchemy/Test/Bun';
4
+ import * as Effect from 'effect/Effect';
5
+ import * as Layer from 'effect/Layer';
6
+ import { PBS_JOBS_TARGET, fakePbsJobs } from './fake-pbs-jobs.ts';
7
+ import { PbsDatastore, PbsDatastoreProvider } from './pbs-datastore.ts';
8
+ import { deleteOne as deleteDatastore } from './pbs-datastore-wire.ts';
9
+ import { PbsPruneJob, PbsPruneJobProvider } from './pbs-prune-job.ts';
10
+ import { PbsSyncJob, PbsSyncJobProvider } from './pbs-sync-job.ts';
11
+ import { PbsVerifyJob, PbsVerifyJobProvider } from './pbs-verify-job.ts';
12
+
13
+ const fake = fakePbsJobs();
14
+ // ⛔ Test harness hooks take Effects, so isolate the shell before its per-test runtime starts.
15
+ let saved: [string, string | undefined][] = [];
16
+ beforeEach(() => {
17
+ saved = Object.entries(process.env).filter(([key]) => /^(BAO|VAULT)_/.test(key));
18
+ for (const [key] of saved) delete process.env[key];
19
+ process.env['BAO_ADDR'] = 'http://bao.invalid';
20
+ fake.reset();
21
+ });
22
+ afterEach(() => {
23
+ for (const key of Object.keys(process.env))
24
+ if (/^(BAO|VAULT)_/.test(key)) delete process.env[key];
25
+ for (const [key, value] of saved) process.env[key] = value;
26
+ });
27
+
28
+ const { test } = Test.make({
29
+ adopt: true,
30
+ dev: false,
31
+ sidecar: false,
32
+ stage: 'test',
33
+ providers: Layer.mergeAll(
34
+ PbsDatastoreProvider(),
35
+ PbsPruneJobProvider(),
36
+ PbsSyncJobProvider(),
37
+ PbsVerifyJobProvider(),
38
+ ).pipe(Layer.provideMerge(fake.layer)),
39
+ });
40
+
41
+ const stack = (comment = 'current') =>
42
+ Effect.gen(function* () {
43
+ yield* PbsDatastore('store', {
44
+ name: 'store',
45
+ path: '/mnt/store',
46
+ 'gc-schedule': 'daily',
47
+ 'verify-new': false,
48
+ comment,
49
+ target: PBS_JOBS_TARGET,
50
+ });
51
+ yield* PbsPruneJob('prune', {
52
+ id: 'prune',
53
+ store: 'store',
54
+ schedule: 'daily',
55
+ 'keep-daily': 30,
56
+ 'max-depth': 0,
57
+ comment,
58
+ target: PBS_JOBS_TARGET,
59
+ });
60
+ yield* PbsSyncJob('sync', {
61
+ id: 'sync',
62
+ store: 'store',
63
+ 'remote-store': 'other',
64
+ 'remote-ns': 'archive',
65
+ 'remove-vanished': false,
66
+ 'max-depth': 0,
67
+ 'verified-only': true,
68
+ comment,
69
+ target: PBS_JOBS_TARGET,
70
+ });
71
+ yield* PbsVerifyJob('verify', {
72
+ id: 'verify',
73
+ store: 'store',
74
+ schedule: null,
75
+ 'outdated-after': 30,
76
+ comment,
77
+ target: PBS_JOBS_TARGET,
78
+ });
79
+ });
80
+
81
+ const existing = () => {
82
+ fake.rows.set('config/datastore/store', {
83
+ name: 'store',
84
+ path: '/mnt/store',
85
+ 'gc-schedule': 'daily',
86
+ 'verify-new': false,
87
+ comment: 'current',
88
+ });
89
+ fake.rows.set('config/prune/prune', {
90
+ id: 'prune',
91
+ store: 'store',
92
+ schedule: 'daily',
93
+ 'keep-daily': 30,
94
+ 'max-depth': 0,
95
+ comment: 'current',
96
+ });
97
+ fake.rows.set('config/sync/sync', {
98
+ id: 'sync',
99
+ store: 'store',
100
+ 'remote-store': 'other',
101
+ 'remote-ns': 'archive',
102
+ 'remove-vanished': false,
103
+ 'max-depth': 0,
104
+ 'verified-only': true,
105
+ comment: 'current',
106
+ });
107
+ fake.rows.set('config/verify/verify', {
108
+ id: 'verify',
109
+ store: 'store',
110
+ 'ignore-verified': true,
111
+ 'outdated-after': 30,
112
+ comment: 'current',
113
+ });
114
+ };
115
+
116
+ const actions = (plan: { resources: Record<string, { action: string }> }) =>
117
+ Object.values(plan.resources)
118
+ .map((row) => row.action)
119
+ .sort();
120
+
121
+ test.provider(
122
+ 'matching datastore and three job families adopt without writes, then plan noop',
123
+ (scratch) =>
124
+ Effect.gen(function* () {
125
+ existing();
126
+ yield* scratch.deploy(stack());
127
+ expect(fake.writes).toEqual([]);
128
+ expect(actions(yield* scratch.plan(stack()))).toEqual(['noop', 'noop', 'noop', 'noop']);
129
+ }),
130
+ );
131
+
132
+ test.provider(
133
+ 'measured missing sections create through typed SDK forms and settle to noop',
134
+ (scratch) =>
135
+ Effect.gen(function* () {
136
+ yield* scratch.deploy(stack());
137
+ expect(fake.writes.map((row) => `${row.method} ${row.path}`).sort()).toEqual([
138
+ 'POST config/datastore',
139
+ 'POST config/prune',
140
+ 'POST config/sync',
141
+ 'POST config/verify',
142
+ ]);
143
+ expect(fake.writes.find((row) => row.path === 'config/prune')?.form).toMatchObject({
144
+ 'keep-daily': '30',
145
+ 'max-depth': '0',
146
+ });
147
+ expect(fake.writes.find((row) => row.path === 'config/sync')?.form).toMatchObject({
148
+ 'remote-store': 'other',
149
+ 'remote-ns': 'archive',
150
+ 'remove-vanished': '0',
151
+ 'verified-only': '1',
152
+ });
153
+ expect(fake.writes.find((row) => row.path === 'config/verify')?.form).not.toHaveProperty(
154
+ 'schedule',
155
+ );
156
+ expect(actions(yield* scratch.plan(stack()))).toEqual(['noop', 'noop', 'noop', 'noop']);
157
+ }),
158
+ );
159
+
160
+ test.provider(
161
+ 'drift updates only declared fields through SDK forms and the next plan is noop',
162
+ (scratch) =>
163
+ Effect.gen(function* () {
164
+ existing();
165
+ yield* scratch.deploy(stack());
166
+ yield* scratch.deploy(stack('changed'));
167
+ expect(fake.writes.map((row) => `${row.method} ${row.path}`).sort()).toEqual([
168
+ 'PUT config/datastore/store',
169
+ 'PUT config/prune/prune',
170
+ 'PUT config/sync/sync',
171
+ 'PUT config/verify/verify',
172
+ ]);
173
+ for (const write of fake.writes) expect(write.form).not.toHaveProperty('delete');
174
+ expect(actions(yield* scratch.plan(stack('changed')))).toEqual([
175
+ 'noop',
176
+ 'noop',
177
+ 'noop',
178
+ 'noop',
179
+ ]);
180
+ }),
181
+ );
182
+
183
+ for (const status of [200, 400, 401, 403, 500]) {
184
+ test.provider(
185
+ `HTTP ${status} malformed or refused read never becomes create or noop`,
186
+ (scratch) =>
187
+ Effect.gen(function* () {
188
+ existing();
189
+ yield* scratch.deploy(stack());
190
+ fake.failRead(status, { message: 'unrelated server refusal', data: null });
191
+ const result = yield* Effect.exit(scratch.plan(stack()));
192
+ expect(result._tag).toBe('Failure');
193
+ expect(fake.writes).toEqual([]);
194
+ // Clear the injected failure before Test.Bun's automatic fixture teardown.
195
+ fake.reset();
196
+ }),
197
+ );
198
+ }
199
+
200
+ test.provider(
201
+ 'a refused datastore delete read cannot confirm deletion; already absent needs no write',
202
+ (scratch) =>
203
+ Effect.gen(function* () {
204
+ existing();
205
+ yield* scratch.deploy(stack());
206
+ const props = { name: 'store', path: '/mnt/store', target: PBS_JOBS_TARGET };
207
+ fake.failRead(403, { message: 'permission denied', data: null });
208
+ expect((yield* Effect.exit(deleteDatastore(props)))._tag).toBe('Failure');
209
+ expect(fake.writes).toEqual([]);
210
+ fake.reset();
211
+ yield* deleteDatastore(props);
212
+ yield* scratch.destroy();
213
+ expect(fake.writes).toEqual([]);
214
+ }),
215
+ );
@@ -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(