@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,46 @@
1
+ /** PBS 4.2.6-1 sync 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 { SyncJobAttributes, SyncJobProps } from './pbs-sync-job.ts';
7
+ import { spec } from './pbs-sync-job-form.ts';
8
+ import { createOne, deleteOne, readOne, updateOne } from './pbs-sync-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: SyncJobProps }) => readOne(olds),
16
+ diff: ({ news, output }: { news: Input<SyncJobProps>; output: SyncJobAttributes | undefined }) =>
17
+ Effect.gen(function* () {
18
+ if (!isResolved(news)) return undefined;
19
+ yield* guardCreate(news, output === undefined);
20
+ yield* guardUpdate(news);
21
+ if (output === undefined) return undefined;
22
+ const live = yield* readOne(news);
23
+ if (live === undefined) {
24
+ yield* guardCreate(news, true);
25
+ return { action: 'update' } as const;
26
+ }
27
+ return { action: spec.matches(live, news) ? 'noop' : 'update' } as const;
28
+ }),
29
+ reconcile: ({ news }: { news: SyncJobProps }) =>
30
+ Effect.gen(function* () {
31
+ const live = yield* readOne(news);
32
+ yield* guardCreate(news, live === undefined);
33
+ yield* guardUpdate(news);
34
+ if (live === undefined) yield* createOne(news);
35
+ else if (!spec.matches(live, news)) yield* updateOne(news);
36
+ // ⛔ A successful write is not evidence of a job; read it back through the SDK.
37
+ const after = yield* readOne(news);
38
+ if (after === undefined) {
39
+ return yield* Effect.fail(
40
+ new Error(`${spec.path(news)}: write succeeded but job is absent.`),
41
+ );
42
+ }
43
+ return after;
44
+ }),
45
+ delete: ({ olds }: { olds: SyncJobProps }) => deleteOne(olds),
46
+ };
@@ -0,0 +1,71 @@
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 { SyncJobProps } from './pbs-sync-job.ts';
6
+ import { createBody, spec, updateBody } from './pbs-sync-job-form.ts';
7
+
8
+ /** SDK member names differ from the vendor form names; omission stays unmanaged. */
9
+ const members = (form: Record<string, string>) => ({
10
+ ...(form['remote-store'] === undefined ? {} : { remote_store: form['remote-store'] }),
11
+ ...(form['remove-vanished'] === undefined ? {} : { remove_vanished: form['remove-vanished'] }),
12
+ ...(form['store'] === undefined ? {} : { store: form['store'] }),
13
+ ...(form['comment'] === undefined ? {} : { comment: form['comment'] }),
14
+ ...(form['max-depth'] === undefined ? {} : { max_depth: form['max-depth'] }),
15
+ ...(form['ns'] === undefined ? {} : { ns: form['ns'] }),
16
+ ...(form['owner'] === undefined ? {} : { owner: form['owner'] }),
17
+ ...(form['rate-in'] === undefined ? {} : { rate_in: form['rate-in'] }),
18
+ ...(form['remote'] === undefined ? {} : { remote: form['remote'] }),
19
+ ...(form['remote-ns'] === undefined ? {} : { remote_ns: form['remote-ns'] }),
20
+ ...(form['schedule'] === undefined ? {} : { schedule: form['schedule'] }),
21
+ ...(form['verified-only'] === undefined ? {} : { verified_only: form['verified-only'] }),
22
+ ...(form['transfer-last'] === undefined ? {} : { transfer_last: form['transfer-last'] }),
23
+ ...(form['sync-direction'] === undefined ? {} : { sync_direction: form['sync-direction'] }),
24
+ });
25
+
26
+ const createRequest = (props: SyncJobProps): config.SyncConfigRequest => ({
27
+ ...members(createBody(props)),
28
+ id: props.id,
29
+ store: props.store,
30
+ remote_store: props['remote-store'],
31
+ });
32
+
33
+ const updateRequest = (props: SyncJobProps): config.PutConfigSyncRequest => ({
34
+ ...members(updateBody(props)),
35
+ id: props.id,
36
+ });
37
+
38
+ /** Preserve the state attribute shape while the SDK decodes its typed field names. */
39
+ const wire = (row: config.GetConfigSyncResponse): Record<string, unknown> => ({
40
+ ...row,
41
+ 'remote-store': row.remote_store,
42
+ 'remove-vanished': row.remove_vanished,
43
+ 'max-depth': row.max_depth,
44
+ 'rate-in': row.rate_in,
45
+ 'remote-ns': row.remote_ns,
46
+ 'verified-only': row.verified_only,
47
+ 'transfer-last': row.transfer_last,
48
+ 'sync-direction': row.sync_direction,
49
+ 'group-filter': row.group_filter,
50
+ });
51
+
52
+ /** ⛔ Only the SDK SyncJobNotFound is absence; permission, transport and malformed replies propagate. */
53
+ export const readOne = (props: SyncJobProps) =>
54
+ runPbs(props.target, 'read', config.getConfigSync({ id: props.id })).pipe(
55
+ Effect.map((row) => spec.attributes(wire(row), props)),
56
+ Effect.catchTag('SyncJobNotFound', () => Effect.succeed(undefined)),
57
+ );
58
+
59
+ export const createOne = (props: SyncJobProps) =>
60
+ runPbs(props.target, 'provision', config.syncConfig(createRequest(props)));
61
+
62
+ export const updateOne = (props: SyncJobProps) =>
63
+ runPbs(props.target, 'provision', config.putConfigSync(updateRequest(props)));
64
+
65
+ /** Deleting the schedule never deletes backups; already missing is a successful deletion. */
66
+ export const deleteOne = (props: SyncJobProps) =>
67
+ Effect.gen(function* () {
68
+ // ⛔ Missing-item tags are measured for GET only. Do not guess the DELETE error shape.
69
+ if ((yield* readOne(props)) === undefined) return;
70
+ return yield* runPbs(props.target, 'provision', config.deleteConfigSync({ id: props.id }));
71
+ });
@@ -35,8 +35,8 @@
35
35
  * `/api2/json/config/sync` is not even evidence that the endpoint exists. THE FIRST PLAN
36
36
  * AGAINST A LIVE PBS IS THE MEASUREMENT. Read it; do not deploy it unseen.
37
37
  * ⚠️ AN ERROR BODY IS NOT WRAPPED: the 401 body is the bare text `authentication failed`, not
38
- * JSON. `pve()` checks `response.ok` before it parses, so a `pbs()` copied from it is safe —
39
- * provided the check stays in that order.
38
+ * JSON. The original `pve()` checked status before decoding success data. The distilled PBS
39
+ * protocol preserves this distinction and classifies error text through SDK patches.
40
40
  *
41
41
  * ⚠️ `Proxmox.BackupJob` AND `Pbs.SyncJob` ARE NOT THE SAME FAMILY AND THE NAMES NEARLY COLLIDE.
42
42
  * The first is a PVE vzdump schedule; this is a Proxmox Backup SERVER sync job, on a different
@@ -55,7 +55,9 @@
55
55
  import { Resource } from 'alchemy';
56
56
  import * as Provider from 'alchemy/Provider';
57
57
  import * as Effect from 'effect/Effect';
58
- import { handlers } from './pbs-sync-job-form.ts';
58
+ import { handlers } from './pbs-sync-job-lifecycle.ts';
59
+ import type { SyncJobAttributes } from './pbs-sync-job-attributes.ts';
60
+ export type { SyncJobAttributes } from './pbs-sync-job-attributes.ts';
59
61
  import type { PveRequirements } from './resource.ts';
60
62
  import type { WithPbsTarget } from './resource.ts';
61
63
 
@@ -158,10 +160,10 @@ export interface SyncJobProps extends WithPbsTarget {
158
160
  'transfer-last'?: number;
159
161
  /**
160
162
  * ⛔ NOT DECLARABLE, AND A COMPILE ERROR RATHER THAN A PROP THAT HALF WORKS — the storage.ts
161
- * `password` idiom, for two independent reasons, either of which alone would be enough.
163
+ * `password` idiom. Two barriers originally applied; the second remains unresolved.
162
164
  * 1. THE WIRE FORM IS AN ARRAY. PBS takes `group-filter` as a repeated parameter
163
- * (`group-filter=type:ct&group-filter=regex:^web`), and `pve()`'s form is
164
- * `Record<string, string>` — one value per key — so a list cannot be expressed at all.
165
+ * (`group-filter=type:ct&group-filter=regex:^web`). The old single-value form could not
166
+ * express it; the distilled PBS protocol now supports repeated keys.
165
167
  * 2. NARROWING A FILTER MAY BE A DELETE. Whether `remove-vanished` skips the groups a filter
166
168
  * excludes is NOT something I could measure without a credential. If it does not, then
167
169
  * editing this field on a job with `remove-vanished` set removes every local group the new
@@ -169,8 +171,8 @@ export interface SyncJobProps extends WithPbsTarget {
169
171
  * half-working prop.
170
172
  * ★ IT IS STILL REPORTED in the attributes, so a plan shows the filters a hand-made job carries
171
173
  * and this resource never strips them: undeclared is unmanaged, and PBS's update assigns only
172
- * what it was sent. To make it declarable, fix both halves — a form type that can repeat a
173
- * key, and a MEASURED answer to (2) — in that order.
174
+ * what it was sent. Making it declarable still needs a MEASURED answer to (2), even though
175
+ * the SDK now supplies the necessary repeated-key transport.
174
176
  */
175
177
  'group-filter'?: never;
176
178
  /**
@@ -196,39 +198,6 @@ export interface SyncJobProps extends WithPbsTarget {
196
198
  'verified-only'?: boolean;
197
199
  }
198
200
 
199
- export interface SyncJobAttributes {
200
- /** From the props — it is the path this object was read by. */
201
- id: string;
202
- store: string;
203
- /** `''` is the root namespace, which is also how PBS spells it. */
204
- ns: string;
205
- /** `''` means a LOCAL sync — datastore to datastore on this PBS. */
206
- remote: string;
207
- 'remote-store': string;
208
- 'remote-ns': string;
209
- /** `''` means the job runs only when somebody starts it. */
210
- schedule: string;
211
- comment: string;
212
- 'remove-vanished': boolean;
213
- /** ⚠️ Absent on the wire means `false`, PBS's own default. */
214
- 'verified-only': boolean;
215
- /** `''` means no owner in the config, i.e. `root@pam` at runtime. An identifier, never a secret. */
216
- owner: string;
217
- /** Bytes per second, or `UNSET` (-1). See `bytes()`. */
218
- 'rate-in': number;
219
- /** `UNSET` (-1) means unset, which means full recursion. `0` is a real value meaning none. */
220
- 'max-depth': number;
221
- /** `UNSET` (-1) means all snapshots. */
222
- 'transfer-last': number;
223
- /**
224
- * ⚠️ REPORTED, NEVER SENT, NEVER COMPARED — the live filter list joined for display only. See the
225
- * ⛔ on the prop for why it is not declarable.
226
- */
227
- 'group-filter': string;
228
- /** `pull` or `push`. Reported, never compared — create-only here. */
229
- 'sync-direction': string;
230
- }
231
-
232
201
  export interface PbsSyncJob extends Resource<
233
202
  'Pbs.SyncJob',
234
203
  SyncJobProps,
@@ -0,0 +1,52 @@
1
+ /** PBS 4.2.6-1 verify 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 { PbsVerifyJobAttributes, PbsVerifyJobProps } from './pbs-verify-job.ts';
7
+ import { spec } from './pbs-verify-job-spec.ts';
8
+ import { createOne, deleteOne, readOne, updateOne } from './pbs-verify-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: PbsVerifyJobProps }) => readOne(olds),
16
+ diff: ({
17
+ news,
18
+ output,
19
+ }: {
20
+ news: Input<PbsVerifyJobProps>;
21
+ output: PbsVerifyJobAttributes | 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: PbsVerifyJobProps }) =>
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: PbsVerifyJobProps }) => deleteOne(olds),
52
+ };
@@ -0,0 +1,76 @@
1
+ /** PBS 4.2.6-1 values and vendor form guards; SDK transport is in pbs-verify-job-wire.ts. */
2
+ import type { PbsVerifyJobAttributes, PbsVerifyJobProps } from './pbs-verify-job.ts';
3
+ import type { PveSpec } from './resource-spec.ts';
4
+ import { rechecks, shape } from './pbs-verify-job-form.ts';
5
+ import { bool, int, text } from './values.ts';
6
+
7
+ /**
8
+ * ⚠️ EVERY FIELD IS READ THROUGH `values.ts` THOUGH PBS RETURNS REAL JSON TYPES. Unlike PVE's
9
+ * SectionConfig round-trip, `GET /config/verify/{id}` serialises a Rust struct, so
10
+ * `ignore-verified` arrives as a JSON boolean and `max-depth` as a JSON number. `bool` and `int`
11
+ * accept both spellings, and using them costs nothing while covering the version that does not.
12
+ * ⚠️ `digest` IS NOT AN ATTRIBUTE AND CANNOT BECOME ONE BY ACCIDENT. PBS puts it on the rpcenv, and
13
+ * the JSON formatter adds rpcenv attributes as SIBLINGS of `data` — `{"data":{…},"digest":"…"}` —
14
+ * so both the old client and the SDK expose only the resource data. Attributes deliberately
15
+ * exclude it: the digest covers verification.cfg as a FILE, so keeping it would churn state
16
+ * whenever an unrelated verify job was edited.
17
+ */
18
+ export const spec: PveSpec<PbsVerifyJobProps, PbsVerifyJobAttributes> = {
19
+ attributes: (live, props) => {
20
+ const ignoreVerified = bool(live['ignore-verified'], true);
21
+ const outdatedAfter = int(live['outdated-after'], -1);
22
+ return {
23
+ comment: text(live['comment'], ''),
24
+ id: props.id,
25
+ 'ignore-verified': ignoreVerified,
26
+ 'max-depth': int(live['max-depth'], -1),
27
+ ns: text(live['ns'], ''),
28
+ 'outdated-after': outdatedAfter,
29
+ rechecks: rechecks(ignoreVerified, outdatedAfter),
30
+ schedule: text(live['schedule'], ''),
31
+ store: text(live['store'], props.store),
32
+ };
33
+ },
34
+ /**
35
+ * 🔴 THIS WAS `config/verification` AND PBS ANSWERS 404 FOR IT. MEASURED on the live 4.2 server:
36
+ * `GET /api2/json/config/verification` -> 404 "Path not found"; `GET /api2/json/config/verify`
37
+ * -> 200 with both jobs. The struct is `VerificationJobConfig` and the CLI subcommand is
38
+ * `verify-job`, so the long spelling reads right and is simply not the route.
39
+ * ⛔ THE OLD FAILURE MODE: `read` folded every failure into `undefined`, so a 404 from a
40
+ * WRONG PATH was indistinguishable from an object that was not
41
+ * there. The first plan against the live estate said `create` for two verification jobs that
42
+ * have existed for weeks — and a create would then have POSTed to a 404 as well, so it fails
43
+ * loudly rather than duplicating anything. Nothing caught it earlier because no verification
44
+ * job had ever been declared; registering the provider exercises no path at all.
45
+ */
46
+ collection: () => 'config/verify',
47
+ /** ⛔ `id` IS SENT. PBS refuses a duplicate id, and that refusal is this family's safety net. */
48
+ createForm: (props) => ({ ...shape(props), id: props.id }),
49
+ /**
50
+ * ⚠️ AN UNDECLARED FIELD IS NEITHER SENT NOR COMPARED — the `Proxmox.BackupJob` trade, for the
51
+ * same reason and with one extra: this form carries NO `delete` list, so there is nothing it
52
+ * could clear even if it wanted to. See the ⛔ on `withClears` in pbs-verify-job-form.ts.
53
+ * ⚠️ `store` IS COMPARED AND IS MUTABLE, which is unusual and worth reading twice. PBS's update
54
+ * handler assigns it and re-checks privileges on both the old and the new path, so changing
55
+ * `store` MOVES the job — and the datastore it left is then verified by nothing. That is a
56
+ * one-word edit with the same effect as deleting the job, and it plans as a quiet `update`.
57
+ * ⚠️ `rechecks` IS ABSENT HERE ON PURPOSE. It is a rendering of the two fields on the lines
58
+ * above; comparing it too would report the same drift twice.
59
+ */
60
+ /** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
61
+ endpoint: { create: 'pbs:POST /config/verify', update: 'pbs:PUT /config/verify/{id}' },
62
+ matches: (attributes, props) =>
63
+ attributes.store === props.store &&
64
+ // ⚠️ `null` IS COMPARED AGAINST `''`, NOT SKIPPED. A parked job is a declaration like any
65
+ // other, so declaring `null` against a job that HAS a schedule must plan an update — the
66
+ // form then omits `schedule`, which is a set-only form, so see the ⛔ on clearing below.
67
+ attributes.schedule === (props.schedule ?? '') &&
68
+ attributes['ignore-verified'] === (props['ignore-verified'] !== false) &&
69
+ (props['outdated-after'] === undefined ||
70
+ attributes['outdated-after'] === props['outdated-after']) &&
71
+ (props['max-depth'] === undefined || attributes['max-depth'] === props['max-depth']) &&
72
+ (props.ns === undefined || attributes.ns === props.ns) &&
73
+ (props.comment === undefined || attributes.comment === props.comment),
74
+ path: (props) => `config/verify/${props.id}`,
75
+ updateForm: shape,
76
+ };
@@ -0,0 +1,58 @@
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 { PbsVerifyJobProps } from './pbs-verify-job.ts';
6
+ import { shape } from './pbs-verify-job-form.ts';
7
+ import { spec } from './pbs-verify-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['ignore-verified'] === undefined ? {} : { ignore_verified: form['ignore-verified'] }),
12
+ ...(form['store'] === undefined ? {} : { store: form['store'] }),
13
+ ...(form['schedule'] === undefined ? {} : { schedule: form['schedule'] }),
14
+ ...(form['comment'] === undefined ? {} : { comment: form['comment'] }),
15
+ ...(form['max-depth'] === undefined ? {} : { max_depth: form['max-depth'] }),
16
+ ...(form['ns'] === undefined ? {} : { ns: form['ns'] }),
17
+ ...(form['outdated-after'] === undefined ? {} : { outdated_after: form['outdated-after'] }),
18
+ });
19
+
20
+ const createRequest = (props: PbsVerifyJobProps): config.VerifyConfigRequest => ({
21
+ ...members({ ...shape(props), id: props.id }),
22
+ id: props.id,
23
+ store: props.store,
24
+ });
25
+
26
+ const updateRequest = (props: PbsVerifyJobProps): config.PutConfigVerifyRequest => ({
27
+ ...members(shape(props)),
28
+ id: props.id,
29
+ });
30
+
31
+ /** Preserve the state attribute shape while the SDK decodes its typed field names. */
32
+ const wire = (row: config.GetConfigVerifyResponse): Record<string, unknown> => ({
33
+ ...row,
34
+ 'ignore-verified': row.ignore_verified,
35
+ 'max-depth': row.max_depth,
36
+ 'outdated-after': row.outdated_after,
37
+ });
38
+
39
+ /** ⛔ Only the SDK VerifyJobNotFound is absence; permission, transport and malformed replies propagate. */
40
+ export const readOne = (props: PbsVerifyJobProps) =>
41
+ runPbs(props.target, 'read', config.getConfigVerify({ id: props.id })).pipe(
42
+ Effect.map((row) => spec.attributes(wire(row), props)),
43
+ Effect.catchTag('VerifyJobNotFound', () => Effect.succeed(undefined)),
44
+ );
45
+
46
+ export const createOne = (props: PbsVerifyJobProps) =>
47
+ runPbs(props.target, 'provision', config.verifyConfig(createRequest(props)));
48
+
49
+ export const updateOne = (props: PbsVerifyJobProps) =>
50
+ runPbs(props.target, 'provision', config.putConfigVerify(updateRequest(props)));
51
+
52
+ /** Deleting the schedule never deletes backups; already missing is a successful deletion. */
53
+ export const deleteOne = (props: PbsVerifyJobProps) =>
54
+ Effect.gen(function* () {
55
+ // ⛔ Missing-item tags are measured for GET only. Do not guess the DELETE error shape.
56
+ if ((yield* readOne(props)) === undefined) return;
57
+ return yield* runPbs(props.target, 'provision', config.deleteConfigVerify({ id: props.id }));
58
+ });
@@ -9,22 +9,22 @@
9
9
  * green `Proxmox.BackupJob` makes it worse rather than better, because a vzdump job that says
10
10
  * "OK" every night is exactly the evidence people stop at.
11
11
  *
12
- * ⛔ THIS FILE CANNOT AUTHENTICATE UNTIL TWO THINGS OUTSIDE IT CHANGE, AND IT IS SHIPPED SAYING SO
13
- * RATHER THAN SHIPPED LOOKING FINISHED. Both are recorded on `PbsTarget` below:
12
+ * ⚠️ HISTORICAL BOOTSTRAP BLOCKER, 2026-09-13: TWO THINGS OUTSIDE THIS FILE WERE MISSING
13
+ * before PBS could authenticate. Both are now resolved by the PBS lane and SDK:
14
14
  * 1. `credentials.ts` has no PBS mint. MEASURED 2026-09-13 from this Mac: the agent's
15
15
  * approle is refused `sys/mounts`, `kv/infra/proxmox` and `proxmox-pbs/creds/read` alike —
16
16
  * 403 permission denied on all three — so there is no mount to name and none is invented.
17
17
  * 2. `client.ts`'s `authorization()` builds a PVE header, and PBS will not accept it. See the
18
18
  * ⛔ on `PbsTarget`.
19
- * Until both land, every call 401s, `pveOperations.read` folds that into "absent", and the plan
20
- * reports `create` for a job that is plainly there. The deploy then FAILS at the POST rather
19
+ * Before both landed, every call 401'd, `pveOperations.read` folded that into "absent", and the plan
20
+ * reported `create` for a job that was plainly there. The deploy then FAILED at the POST rather
21
21
  * than clobbering anything — an unauthenticated POST 401s, and an authenticated one is refused
22
22
  * because PBS will not take a duplicate section id — so the cost is a LYING PLAN and a broken
23
23
  * run, not a lost job. That is still reason enough not to wire it into a stack yet: a plan
24
24
  * nobody can trust is its own outage.
25
25
  *
26
- * ⚠️ THE SHAPES BELOW ARE READ FROM PUBLISHED SOURCE, NOT MEASURED ON THIS ESTATE. Everything is
27
- * taken from `pbs-api-types/src/jobs.rs`, `src/api2/config/verify.rs`, `src/server/verify_job.rs`
26
+ * ⚠️ THE ORIGINAL SHAPES WERE READ FROM PUBLISHED SOURCE, NOT MEASURED ON THIS ESTATE. Everything is
27
+ * originally taken from `pbs-api-types/src/jobs.rs`, `src/api2/config/verify.rs`, `src/server/verify_job.rs`
28
28
  * and `src/backup/verify.rs` at HEAD of the Proxmox git mirrors on 2026-09-13. What WAS measured
29
29
  * here: `pbs.example.com:8007` and `pbs.mgmt.example.com:8007` both answer HTTP 401 under
30
30
  * STRICT TLS (curl `ssl_verify_result=0`), so no `-k` is needed and `client.ts`'s plain `fetch`
@@ -34,10 +34,9 @@
34
34
  import { Resource } from 'alchemy';
35
35
  import * as Provider from 'alchemy/Provider';
36
36
  import * as Effect from 'effect/Effect';
37
+ import { handlers } from './pbs-verify-job-lifecycle.ts';
37
38
  import type { PbsTarget } from './credentials.ts';
38
- import { rechecks, shape } from './pbs-verify-job-form.ts';
39
- import { type PveRequirements, pveHandlers } from './resource.ts';
40
- import { bool, int, text } from './values.ts';
39
+ import type { PveRequirements } from './resource-spec.ts';
41
40
 
42
41
  export interface PbsVerifyJobProps {
43
42
  /** ⛔ A PBS host, never a PVE cluster. See `PbsTarget`. */
@@ -141,78 +140,7 @@ export interface PbsVerifyJob extends Resource<
141
140
  export const PbsVerifyJob = Resource<PbsVerifyJob>('Pbs.VerifyJob');
142
141
 
143
142
  /**
144
- * ⚠️ EVERY FIELD IS READ THROUGH `values.ts` THOUGH PBS RETURNS REAL JSON TYPES. Unlike PVE's
145
- * SectionConfig round-trip, `GET /config/verify/{id}` serialises a Rust struct, so
146
- * `ignore-verified` arrives as a JSON boolean and `max-depth` as a JSON number. `bool` and `int`
147
- * accept both spellings, and using them costs nothing while covering the version that does not.
148
- * ⚠️ `digest` IS NOT AN ATTRIBUTE AND CANNOT BECOME ONE BY ACCIDENT. PBS puts it on the rpcenv, and
149
- * the JSON formatter adds rpcenv attributes as SIBLINGS of `data` — `{"data":{…},"digest":"…"}` —
150
- * so `client.ts`, which returns `body.data`, never sees it. That is lucky rather than designed:
151
- * the digest covers verification.cfg as a FILE, so keeping it would churn this resource's state
152
- * whenever an unrelated verify job was edited.
153
- */
154
- const handlers = pveHandlers<PbsVerifyJobProps, PbsVerifyJobAttributes>({
155
- attributes: (live, props) => {
156
- const ignoreVerified = bool(live['ignore-verified'], true);
157
- const outdatedAfter = int(live['outdated-after'], -1);
158
- return {
159
- comment: text(live['comment'], ''),
160
- id: props.id,
161
- 'ignore-verified': ignoreVerified,
162
- 'max-depth': int(live['max-depth'], -1),
163
- ns: text(live['ns'], ''),
164
- 'outdated-after': outdatedAfter,
165
- rechecks: rechecks(ignoreVerified, outdatedAfter),
166
- schedule: text(live['schedule'], ''),
167
- store: text(live['store'], props.store),
168
- };
169
- },
170
- /**
171
- * 🔴 THIS WAS `config/verification` AND PBS ANSWERS 404 FOR IT. MEASURED on the live 4.2 server:
172
- * `GET /api2/json/config/verification` -> 404 "Path not found"; `GET /api2/json/config/verify`
173
- * -> 200 with both jobs. The struct is `VerificationJobConfig` and the CLI subcommand is
174
- * `verify-job`, so the long spelling reads right and is simply not the route.
175
- * ⛔ THE FAILURE MODE IS THE ONE THIS PACKAGE KEEPS FINDING: `read` folds every failure into
176
- * `undefined`, so a 404 from a WRONG PATH is indistinguishable from an object that is not
177
- * there. The first plan against the live estate said `create` for two verification jobs that
178
- * have existed for weeks — and a create would then have POSTed to a 404 as well, so it fails
179
- * loudly rather than duplicating anything. Nothing caught it earlier because no verification
180
- * job had ever been declared; registering the provider exercises no path at all.
181
- */
182
- collection: () => 'config/verify',
183
- /** ⛔ `id` IS SENT. PBS refuses a duplicate id, and that refusal is this family's safety net. */
184
- createForm: (props) => ({ ...shape(props), id: props.id }),
185
- /**
186
- * ⚠️ AN UNDECLARED FIELD IS NEITHER SENT NOR COMPARED — the `Proxmox.BackupJob` trade, for the
187
- * same reason and with one extra: this form carries NO `delete` list, so there is nothing it
188
- * could clear even if it wanted to. See the ⛔ on `withClears` in pbs-verify-job-form.ts.
189
- * ⚠️ `store` IS COMPARED AND IS MUTABLE, which is unusual and worth reading twice. PBS's update
190
- * handler assigns it and re-checks privileges on both the old and the new path, so changing
191
- * `store` MOVES the job — and the datastore it left is then verified by nothing. That is a
192
- * one-word edit with the same effect as deleting the job, and it plans as a quiet `update`.
193
- * ⚠️ `rechecks` IS ABSENT HERE ON PURPOSE. It is a rendering of the two fields on the lines
194
- * above; comparing it too would report the same drift twice.
195
- */
196
- /** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
197
- endpoint: { create: 'pbs:POST /config/verify', update: 'pbs:PUT /config/verify/{id}' },
198
- matches: (attributes, props) =>
199
- attributes.store === props.store &&
200
- // ⚠️ `null` IS COMPARED AGAINST `''`, NOT SKIPPED. A parked job is a declaration like any
201
- // other, so declaring `null` against a job that HAS a schedule must plan an update — the
202
- // form then omits `schedule`, which is a set-only form, so see the ⛔ on clearing below.
203
- attributes.schedule === (props.schedule ?? '') &&
204
- attributes['ignore-verified'] === (props['ignore-verified'] !== false) &&
205
- (props['outdated-after'] === undefined ||
206
- attributes['outdated-after'] === props['outdated-after']) &&
207
- (props['max-depth'] === undefined || attributes['max-depth'] === props['max-depth']) &&
208
- (props.ns === undefined || attributes.ns === props.ns) &&
209
- (props.comment === undefined || attributes.comment === props.comment),
210
- path: (props) => `config/verify/${props.id}`,
211
- updateForm: shape,
212
- });
213
-
214
- /**
215
- * ⛔ `list` IS EMPTY, INHERITED FROM `pveHandlers`, AND THE REASON IS SHARPER HERE THAN ELSEWHERE.
143
+ * ⛔ `list` STAYS EXPLICITLY EMPTY, AND THE REASON IS SHARPER HERE THAN ELSEWHERE.
216
144
  * `GET /config/verify` returns every verification job the caller can see, including the
217
145
  * ones the PBS installer and a human made. Adopting those would put Alchemy one dropped line
218
146
  * away from DELETING a datastore's only verification schedule — and deleting it destroys nothing
@@ -220,10 +148,10 @@ const handlers = pveHandlers<PbsVerifyJobProps, PbsVerifyJobAttributes>({
220
148
  * to say when the rot started. Adoption stays explicit: declare the existing id.
221
149
  *
222
150
  * ⚠️ NO `digest` IS SENT ON UPDATE OR DELETE, SO THIS IS LAST-WRITER-WINS. PBS accepts an optional
223
- * `digest` on both and would refuse a write made against a stale read. Sending one would need a
224
- * read-then-write window this provider does not have — `pveOperations` reads and writes in two
225
- * separate calls with two separate mints — so a concurrent edit from the PBS UI is overwritten
226
- * rather than refused. Same behaviour as every PVE family here; stated because PBS offers better.
151
+ * `digest` on both and would refuse a write made against a stale read. This migration preserves
152
+ * the existing forms, which omit it; the SDK runner reuses leased credentials but does not add
153
+ * an optimistic-lock check. A concurrent edit from the PBS UI may therefore be overwritten
154
+ * rather than refused. PBS offers that stronger guarantee as a separate provider change.
227
155
  */
228
156
  export const PbsVerifyJobProvider = () =>
229
157
  Provider.effect(PbsVerifyJob, Effect.succeed(PbsVerifyJob.Provider.of(handlers)));