@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,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
+ });
@@ -0,0 +1,52 @@
1
+ /** PBS 4.2.6-1 prune lifecycle; the SDK owns serialization and typed API errors. */
2
+ import { isResolved } from 'alchemy/Diff';
3
+ import type { Input } from 'alchemy/Input';
4
+ import * as Effect from 'effect/Effect';
5
+ import { specGuards } from './resource-guard.ts';
6
+ import type { PruneJobAttributes, PruneJobProps } from './pbs-prune-job.ts';
7
+ import { spec } from './pbs-prune-job-spec.ts';
8
+ import { createOne, deleteOne, readOne, updateOne } from './pbs-prune-job-wire.ts';
9
+
10
+ const { guardCreate, guardUpdate } = specGuards(spec);
11
+
12
+ export const handlers = {
13
+ // ⛔ Explicit adoption only: a listed job may be somebody else's only backup policy.
14
+ list: () => Effect.succeed([]),
15
+ read: ({ olds }: { olds: PruneJobProps }) => readOne(olds),
16
+ diff: ({
17
+ news,
18
+ output,
19
+ }: {
20
+ news: Input<PruneJobProps>;
21
+ output: PruneJobAttributes | undefined;
22
+ }) =>
23
+ Effect.gen(function* () {
24
+ if (!isResolved(news)) return undefined;
25
+ yield* guardCreate(news, output === undefined);
26
+ yield* guardUpdate(news);
27
+ if (output === undefined) return undefined;
28
+ const live = yield* readOne(news);
29
+ if (live === undefined) {
30
+ yield* guardCreate(news, true);
31
+ return { action: 'update' } as const;
32
+ }
33
+ return { action: spec.matches(live, news) ? 'noop' : 'update' } as const;
34
+ }),
35
+ reconcile: ({ news }: { news: PruneJobProps }) =>
36
+ Effect.gen(function* () {
37
+ const live = yield* readOne(news);
38
+ yield* guardCreate(news, live === undefined);
39
+ yield* guardUpdate(news);
40
+ if (live === undefined) yield* createOne(news);
41
+ else if (!spec.matches(live, news)) yield* updateOne(news);
42
+ // ⛔ A successful write is not evidence of a job; read it back through the SDK.
43
+ const after = yield* readOne(news);
44
+ if (after === undefined) {
45
+ return yield* Effect.fail(
46
+ new Error(`${spec.path(news)}: write succeeded but job is absent.`),
47
+ );
48
+ }
49
+ return after;
50
+ }),
51
+ delete: ({ olds }: { olds: PruneJobProps }) => deleteOne(olds),
52
+ };
@@ -0,0 +1,53 @@
1
+ /** PBS 4.2.6-1 values and vendor form guards; SDK transport is in pbs-prune-job-wire.ts. */
2
+ import type { PruneJobAttributes, PruneJobProps } from './pbs-prune-job.ts';
3
+ import type { PveSpec } from './resource-spec.ts';
4
+ import { createBody, keepAttributes, keepsMatch, updateBody } from './pbs-prune-job-form.ts';
5
+ import { bool, int, text } from './values.ts';
6
+
7
+ /** "Not declared, or equal" — the single shape every unmanaged field is compared with. */
8
+ const same = <T>(declared: T | undefined, live: T) => declared === undefined || declared === live;
9
+
10
+ export const spec: PveSpec<PruneJobProps, PruneJobAttributes> = {
11
+ /**
12
+ * ⚠️ EVERY OPTIONAL FIELD FALLS BACK TO ITS ABSENT SENTINEL RATHER THAN TO PBS'S DEFAULT, because
13
+ * for this family the two are different questions. `keep-daily` absent does not mean "keep zero
14
+ * dailies", it means "this window is not part of the policy", and `matches` must not compare a
15
+ * window the declaration never mentioned against an invented value.
16
+ * ⛔ NO "IS IT REALLY THERE" GUARD, for backup-job.ts's reason: returning undefined for a job
17
+ * whose JSON is missing a key would be a guess about which keys PBS echoes, and a wrong guess
18
+ * sends reconcile down the POST branch — which creates ANOTHER prune job rather than failing.
19
+ */
20
+ attributes: (live, props) => ({
21
+ comment: text(live['comment'], ''),
22
+ disable: bool(live['disable'], false),
23
+ id: props.id,
24
+ ...keepAttributes(live),
25
+ 'max-depth': int(live['max-depth'], -1),
26
+ ns: text(live['ns'], ''),
27
+ schedule: text(live['schedule'], ''),
28
+ store: text(live['store'], props.store),
29
+ }),
30
+ collection: () => 'config/prune',
31
+ createForm: createBody,
32
+ /**
33
+ * ⚠️ THE UNDECLARED HALF IS NOT COMPARED, AND THAT IS THE WHOLE SAFETY MODEL. `same` reads "not
34
+ * declared, or equal", so a field set by hand on an adopted job survives adoption untouched and
35
+ * never appears as drift. `keepsMatch` applies the identical rule to the six windows, from the
36
+ * file that also writes and reads them — see the ★ at the top of pbs-prune-job-form.ts.
37
+ * ⚠️ `store` AND `schedule` HAVE NO UNDECLARED CASE because both are required props, so both are
38
+ * compared unconditionally. A changed `store` is an update, not a replace: PBS accepts `store`
39
+ * on PUT, so the job is re-pointed in place.
40
+ */
41
+ /** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
42
+ endpoint: { create: 'pbs:POST /config/prune', update: 'pbs:PUT /config/prune/{id}' },
43
+ matches: (attributes, props) =>
44
+ attributes.schedule === props.schedule &&
45
+ attributes.store === props.store &&
46
+ same(props.ns, attributes.ns) &&
47
+ same(props['max-depth'], attributes['max-depth']) &&
48
+ same(props.disable, attributes.disable) &&
49
+ same(props.comment, attributes.comment) &&
50
+ keepsMatch(attributes, props),
51
+ path: (props) => `config/prune/${props.id}`,
52
+ updateForm: updateBody,
53
+ };
@@ -0,0 +1,68 @@
1
+ /** PBS 4.2.6-1 operations through @distilled.cloud/proxmox-backup. */
2
+ import * as config from '@distilled.cloud/proxmox-backup/config';
3
+ import * as Effect from 'effect/Effect';
4
+ import { runPbs } from './distilled-pbs.ts';
5
+ import type { PruneJobProps } from './pbs-prune-job.ts';
6
+ import { createBody, updateBody } from './pbs-prune-job-form.ts';
7
+ import { spec } from './pbs-prune-job-spec.ts';
8
+
9
+ /** SDK member names differ from the vendor form names; omission stays unmanaged. */
10
+ const members = (form: Record<string, string>) => ({
11
+ ...(form['comment'] === undefined ? {} : { comment: form['comment'] }),
12
+ ...(form['disable'] === undefined ? {} : { disable: form['disable'] }),
13
+ ...(form['keep-daily'] === undefined ? {} : { keep_daily: form['keep-daily'] }),
14
+ ...(form['keep-hourly'] === undefined ? {} : { keep_hourly: form['keep-hourly'] }),
15
+ ...(form['keep-last'] === undefined ? {} : { keep_last: form['keep-last'] }),
16
+ ...(form['keep-monthly'] === undefined ? {} : { keep_monthly: form['keep-monthly'] }),
17
+ ...(form['keep-weekly'] === undefined ? {} : { keep_weekly: form['keep-weekly'] }),
18
+ ...(form['keep-yearly'] === undefined ? {} : { keep_yearly: form['keep-yearly'] }),
19
+ ...(form['max-depth'] === undefined ? {} : { max_depth: form['max-depth'] }),
20
+ ...(form['ns'] === undefined ? {} : { ns: form['ns'] }),
21
+ ...(form['schedule'] === undefined ? {} : { schedule: form['schedule'] }),
22
+ ...(form['store'] === undefined ? {} : { store: form['store'] }),
23
+ });
24
+
25
+ const createRequest = (props: PruneJobProps): config.CreateConfigPruneRequest => ({
26
+ ...members(createBody(props)),
27
+ id: props.id,
28
+ store: props.store,
29
+ schedule: props.schedule,
30
+ });
31
+
32
+ const updateRequest = (props: PruneJobProps): config.PutConfigPruneRequest => ({
33
+ ...members(updateBody(props)),
34
+ id: props.id,
35
+ });
36
+
37
+ /** Preserve the state attribute shape while the SDK decodes its typed field names. */
38
+ const wire = (row: config.GetConfigPruneResponse): Record<string, unknown> => ({
39
+ ...row,
40
+ 'keep-daily': row.keep_daily,
41
+ 'keep-hourly': row.keep_hourly,
42
+ 'keep-last': row.keep_last,
43
+ 'keep-monthly': row.keep_monthly,
44
+ 'keep-weekly': row.keep_weekly,
45
+ 'keep-yearly': row.keep_yearly,
46
+ 'max-depth': row.max_depth,
47
+ });
48
+
49
+ /** ⛔ Only the SDK PruneJobNotFound is absence; permission, transport and malformed replies propagate. */
50
+ export const readOne = (props: PruneJobProps) =>
51
+ runPbs(props.target, 'read', config.getConfigPrune({ id: props.id })).pipe(
52
+ Effect.map((row) => spec.attributes(wire(row), props)),
53
+ Effect.catchTag('PruneJobNotFound', () => Effect.succeed(undefined)),
54
+ );
55
+
56
+ export const createOne = (props: PruneJobProps) =>
57
+ runPbs(props.target, 'provision', config.createConfigPrune(createRequest(props)));
58
+
59
+ export const updateOne = (props: PruneJobProps) =>
60
+ runPbs(props.target, 'provision', config.putConfigPrune(updateRequest(props)));
61
+
62
+ /** Deleting the schedule never deletes backups; already missing is a successful deletion. */
63
+ export const deleteOne = (props: PruneJobProps) =>
64
+ Effect.gen(function* () {
65
+ // ⛔ Missing-item tags are measured for GET only. Do not guess the DELETE error shape.
66
+ if ((yield* readOne(props)) === undefined) return;
67
+ return yield* runPbs(props.target, 'provision', config.deleteConfigPrune({ id: props.id }));
68
+ });
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `Pbs.PruneJob` — a Proxmox Backup Server retention schedule, declared. PBS is a SIBLING of PVE,
3
- * so this family reuses `pveHandlers` wholesale rather than growing a second factory.
3
+ * and this family uses its own distilled SDK's typed config operations (PBS 4.2.6-1).
4
4
  *
5
5
  * ⛔ THIS IS THE ONE RESOURCE IN THIS PACKAGE THAT DESTROYS DATA ON AN ORDINARY *UPDATE*. CephPool,
6
6
  * ZfsPool and Storage can destroy data too — which is exactly why they default to `retain` and
@@ -26,8 +26,9 @@
26
26
  * `-1` in the attributes means "absent from prune.cfg", the sentinel storage.ts uses for
27
27
  * `maxfiles`, because `0` here is a real and very different setting.
28
28
  *
29
- * ⛔ THE CREDENTIAL DOES NOT EXIST YET, SO NOTHING BELOW HAS EVER MADE A CALL. PBS is a different
30
- * host and a different auth realm from PVE. MEASURED 2026-09-13: pbs.example.com:8007 answers
29
+ * ⚠️ HISTORICAL BOOTSTRAP BLOCKER, 2026-09-13: PBS was a different
30
+ * host and auth realm from PVE. The later PBS credential lane and SDK resolved this blocker.
31
+ * MEASURED 2026-09-13: pbs.example.com:8007 answers
31
32
  * HTTP 401 under STRICT TLS behind a real Let's Encrypt certificate (CN=pbs.example.com, issuer
32
33
  * YR1, valid 2026-09-02 to 2026-12-01) — so there is no `-k` to reach for and `client.ts` needs
33
34
  * no TLS escape hatch, exactly as on PVE. What it does need is a header it cannot build today.
@@ -47,7 +48,7 @@
47
48
  * ★ WHAT A `PbsTarget` NEEDS, AND WHY IT IS ALMOST `PveTarget`. Structurally `PveTarget` already
48
49
  * fits a PBS host: a credential mount name and an `/api2/json` base are the whole of it, and PBS
49
50
  * shares the `{"data": …}` envelope, the four-operation shape and the read-back discipline —
50
- * which is exactly why this file declares a spec and stops. The single thing `PveTarget` cannot
51
+ * which is why the pure spec survives the SDK transport migration. The single thing `PveTarget` cannot
51
52
  * carry is WHICH AUTHORIZATION SCHEME the host at the other end speaks, so `PbsTarget` below adds
52
53
  * that and nothing else rather than bending the PVE type in silence.
53
54
  *
@@ -55,9 +56,9 @@
55
56
  * below are MEASURED from the PBS API as published at pbs.proxmox.com/docs/api-viewer, fetched
56
57
  * 2026-09-13 — current PBS 4.x. Before PBS 2.2 there was no `/config/prune` at all: retention
57
58
  * lived on the datastore section itself, as `keep-*` plus `prune-schedule` under
58
- * `/config/datastore/{name}`. Against anything older every path here 404s, `pveOperations.read`
59
- * folds that into "absent", and the plan says create forever. Check `GET /api2/json/version`
60
- * before the first plan.
59
+ * `/config/datastore/{name}`. The old `pveOperations.read` folded those 404s into "absent",
60
+ * producing a create forever. The SDK path now propagates an unknown route's 404; only the
61
+ * vendor's typed missing-prune response means absent. Check the PBS version before planning.
61
62
  *
62
63
  * ★ THE READ LANE NEEDS NOTHING EXTRA, UNLIKE THREE PVE FAMILIES — so there is no `readRole:
63
64
  * 'provision'` below and there should not be one. MEASURED: `GET /config/prune/{id}` requires
@@ -79,10 +80,9 @@
79
80
  import { Resource } from 'alchemy';
80
81
  import * as Provider from 'alchemy/Provider';
81
82
  import * as Effect from 'effect/Effect';
83
+ import { handlers } from './pbs-prune-job-lifecycle.ts';
82
84
  import type { PbsTarget } from './credentials.ts';
83
- import { createBody, keepAttributes, keepsMatch, updateBody } from './pbs-prune-job-form.ts';
84
- import { type PveRequirements, type WithPbsTarget, pveHandlers } from './resource.ts';
85
- import { bool, int, text } from './values.ts';
85
+ import type { PveRequirements, WithPbsTarget } from './resource-spec.ts';
86
86
 
87
87
  /**
88
88
  * ⚠️ THE PROPS ARE PBS'S OWN KEY NAMES, HYPHENS INCLUDED, for backup-job.ts's reason: the form
@@ -160,54 +160,6 @@ export interface PbsPruneJob extends Resource<
160
160
  */
161
161
  export const PbsPruneJob = Resource<PbsPruneJob>('Pbs.PruneJob');
162
162
 
163
- /** "Not declared, or equal" — the single shape every unmanaged field is compared with. */
164
- const same = <T>(declared: T | undefined, live: T) => declared === undefined || declared === live;
165
-
166
- const handlers = pveHandlers<PruneJobProps, PruneJobAttributes>({
167
- /**
168
- * ⚠️ EVERY OPTIONAL FIELD FALLS BACK TO ITS ABSENT SENTINEL RATHER THAN TO PBS'S DEFAULT, because
169
- * for this family the two are different questions. `keep-daily` absent does not mean "keep zero
170
- * dailies", it means "this window is not part of the policy", and `matches` must not compare a
171
- * window the declaration never mentioned against an invented value.
172
- * ⛔ NO "IS IT REALLY THERE" GUARD, for backup-job.ts's reason: returning undefined for a job
173
- * whose JSON is missing a key would be a guess about which keys PBS echoes, and a wrong guess
174
- * sends reconcile down the POST branch — which creates ANOTHER prune job rather than failing.
175
- */
176
- attributes: (live, props) => ({
177
- comment: text(live['comment'], ''),
178
- disable: bool(live['disable'], false),
179
- id: props.id,
180
- ...keepAttributes(live),
181
- 'max-depth': int(live['max-depth'], -1),
182
- ns: text(live['ns'], ''),
183
- schedule: text(live['schedule'], ''),
184
- store: text(live['store'], props.store),
185
- }),
186
- collection: () => 'config/prune',
187
- createForm: createBody,
188
- /**
189
- * ⚠️ THE UNDECLARED HALF IS NOT COMPARED, AND THAT IS THE WHOLE SAFETY MODEL. `same` reads "not
190
- * declared, or equal", so a field set by hand on an adopted job survives adoption untouched and
191
- * never appears as drift. `keepsMatch` applies the identical rule to the six windows, from the
192
- * file that also writes and reads them — see the ★ at the top of pbs-prune-job-form.ts.
193
- * ⚠️ `store` AND `schedule` HAVE NO UNDECLARED CASE because both are required props, so both are
194
- * compared unconditionally. A changed `store` is an update, not a replace: PBS accepts `store`
195
- * on PUT, so the job is re-pointed in place.
196
- */
197
- /** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
198
- endpoint: { create: 'pbs:POST /config/prune', update: 'pbs:PUT /config/prune/{id}' },
199
- matches: (attributes, props) =>
200
- attributes.schedule === props.schedule &&
201
- attributes.store === props.store &&
202
- same(props.ns, attributes.ns) &&
203
- same(props['max-depth'], attributes['max-depth']) &&
204
- same(props.disable, attributes.disable) &&
205
- same(props.comment, attributes.comment) &&
206
- keepsMatch(attributes, props),
207
- path: (props) => `config/prune/${props.id}`,
208
- updateForm: updateBody,
209
- });
210
-
211
163
  /**
212
164
  * ⛔ `list` IS EMPTY like every other family here, and the stakes are the usual ones read backwards.
213
165
  * `GET /config/prune` hands back every prune job on the server, the ones an operator tuned by
@@ -0,0 +1,33 @@
1
+ /** Stable PBS sync-job state; only vendor configuration, never runtime task status. */
2
+ export interface SyncJobAttributes {
3
+ /** From the props — it is the path this object was read by. */
4
+ id: string;
5
+ store: string;
6
+ /** `''` is the root namespace, which is also how PBS spells it. */
7
+ ns: string;
8
+ /** `''` means a LOCAL sync — datastore to datastore on this PBS. */
9
+ remote: string;
10
+ 'remote-store': string;
11
+ 'remote-ns': string;
12
+ /** `''` means the job runs only when somebody starts it. */
13
+ schedule: string;
14
+ comment: string;
15
+ 'remove-vanished': boolean;
16
+ /** ⚠️ Absent on the wire means `false`, PBS's own default. */
17
+ 'verified-only': boolean;
18
+ /** `''` means no owner in the config, i.e. `root@pam` at runtime. An identifier, never a secret. */
19
+ owner: string;
20
+ /** Bytes per second, or `UNSET` (-1). See `bytes()`. */
21
+ 'rate-in': number;
22
+ /** `UNSET` (-1) means unset, which means full recursion. `0` is a real value meaning none. */
23
+ 'max-depth': number;
24
+ /** `UNSET` (-1) means all snapshots. */
25
+ 'transfer-last': number;
26
+ /**
27
+ * ⚠️ REPORTED, NEVER SENT, NEVER COMPARED — the live filter list joined for display only. See the
28
+ * ⛔ on the prop for why it is not declarable.
29
+ */
30
+ 'group-filter': string;
31
+ /** `pull` or `push`. Reported, never compared — create-only here. */
32
+ 'sync-direction': string;
33
+ }
@@ -7,17 +7,18 @@ import type { SyncJobAttributes, SyncJobProps } from './pbs-sync-job.ts';
7
7
  * with the wire instead of staying with the props. The `import type` back is a cycle on paper
8
8
  * only — erased before anything runs, so the runtime arrow points one way, resource to wire.
9
9
  *
10
- * ★ IT USES THE SHARED PVE CLIENT, NOT A PBS ONE. An earlier draft of this file was written
10
+ * ★ HISTORICALLY IT USED THE SHARED PVE CLIENT, NOT A PBS ONE. An earlier draft of this file was written
11
11
  * against a `./pbs-client.ts` that never existed, and spelled out what it would have to export.
12
12
  * That module is not needed: PBS speaks the same `/api2/json` paths and the same `{"data": …}`
13
13
  * envelope, its OpenBao mount vends the same `{token_id, secret}`, and the only difference is
14
14
  * the authorization header — so `PveTarget` carries a `scheme` and `authorization()` spells
15
- * `PBSAPIToken=<id>:<secret>` for `'pbs'`. `pveHandlers` and `pveOperations` work unchanged.
15
+ * `PBSAPIToken=<id>:<secret>` for `'pbs'`. The SDK now owns that header; the pure spec below
16
+ * preserves the existing field and retention semantics.
16
17
  * The one PBS-specific thing that survived is the FORM TYPE: PBS decodes a multi-valued field
17
- * from repeated keys, and `PveForm` in client.ts admits a list for exactly that reason.
18
+ * from repeated keys, and the distilled PBS protocol must preserve that encoding.
18
19
 
19
20
  */
20
- import { pveHandlers } from './resource.ts';
21
+ import type { PveSpec } from './resource-spec.ts';
21
22
  import { bool, int, text } from './values.ts';
22
23
 
23
24
  /**
@@ -150,7 +151,7 @@ export const createBody = (props: SyncJobProps): Record<string, string> => ({
150
151
  */
151
152
  export const updateBody = (props: SyncJobProps): Record<string, string> => shape(props);
152
153
 
153
- export const handlers = pveHandlers<SyncJobProps, SyncJobAttributes>({
154
+ export const spec: PveSpec<SyncJobProps, SyncJobAttributes> = {
154
155
  /**
155
156
  * ⛔ NO "IS IT REALLY THERE" GUARD, for backup-job.ts's reason: absence is the API declining to
156
157
  * answer, which the factory's `read` already handles, not a key missing from an answer that did
@@ -233,4 +234,4 @@ export const handlers = pveHandlers<SyncJobProps, SyncJobAttributes>({
233
234
  /** ⚠️ A Proxmox safe id has no `/` or `:` in it, so there is nothing here to encode. */
234
235
  path: (props) => `config/sync/${props.id}`,
235
236
  updateForm: updateBody,
236
- });
237
+ };