@homeflare/alchemy 0.37.2 → 0.37.4

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 (178) hide show
  1. package/dist/linux/directory-lifecycle.d.ts.map +1 -1
  2. package/dist/linux/directory.d.ts +5 -3
  3. package/dist/linux/directory.d.ts.map +1 -1
  4. package/dist/linux/index.js +4 -3
  5. package/dist/linux/index.js.map +4 -4
  6. package/dist/litellm/fake-stack.d.ts +7 -7
  7. package/dist/litellm/fake-stack.d.ts.map +1 -1
  8. package/dist/litellm/index.js +2 -2
  9. package/dist/litellm/index.js.map +4 -4
  10. package/dist/litellm/pass-through-endpoint.d.ts +9 -8
  11. package/dist/litellm/pass-through-endpoint.d.ts.map +1 -1
  12. package/dist/litellm/providers.d.ts +8 -2
  13. package/dist/litellm/providers.d.ts.map +1 -1
  14. package/dist/proxmox/backup-job-config.d.ts +95 -0
  15. package/dist/proxmox/backup-job-config.d.ts.map +1 -0
  16. package/dist/proxmox/backup-job-distilled.d.ts +46 -0
  17. package/dist/proxmox/backup-job-distilled.d.ts.map +1 -0
  18. package/dist/proxmox/backup-job.d.ts +9 -26
  19. package/dist/proxmox/backup-job.d.ts.map +1 -1
  20. package/dist/proxmox/distilled-pbs.d.ts +12 -0
  21. package/dist/proxmox/distilled-pbs.d.ts.map +1 -0
  22. package/dist/proxmox/fake-pbs-jobs.d.ts +23 -0
  23. package/dist/proxmox/fake-pbs-jobs.d.ts.map +1 -0
  24. package/dist/proxmox/fake-pbs-notify.d.ts.map +1 -1
  25. package/dist/proxmox/fake-pve.d.ts +1 -1
  26. package/dist/proxmox/fake-pve.d.ts.map +1 -1
  27. package/dist/proxmox/index.js +1501 -607
  28. package/dist/proxmox/index.js.map +48 -24
  29. package/dist/proxmox/live-core-fixtures.d.ts +40 -0
  30. package/dist/proxmox/live-core-fixtures.d.ts.map +1 -0
  31. package/dist/proxmox/metric-server-config.d.ts +49 -0
  32. package/dist/proxmox/metric-server-config.d.ts.map +1 -0
  33. package/dist/proxmox/metric-server-distilled.d.ts +43 -0
  34. package/dist/proxmox/metric-server-distilled.d.ts.map +1 -0
  35. package/dist/proxmox/metric-server.d.ts +2 -10
  36. package/dist/proxmox/metric-server.d.ts.map +1 -1
  37. package/dist/proxmox/notification-matcher-distilled.d.ts +9 -0
  38. package/dist/proxmox/notification-matcher-distilled.d.ts.map +1 -0
  39. package/dist/proxmox/notification-matcher-lifecycle.d.ts +27 -0
  40. package/dist/proxmox/notification-matcher-lifecycle.d.ts.map +1 -0
  41. package/dist/proxmox/notification-matcher.d.ts +5 -5
  42. package/dist/proxmox/notification-matcher.d.ts.map +1 -1
  43. package/dist/proxmox/notification-sdk-input.d.ts +22 -0
  44. package/dist/proxmox/notification-sdk-input.d.ts.map +1 -0
  45. package/dist/proxmox/notification-target-distilled.d.ts +12 -0
  46. package/dist/proxmox/notification-target-distilled.d.ts.map +1 -0
  47. package/dist/proxmox/notification-target-form.d.ts +2 -0
  48. package/dist/proxmox/notification-target-form.d.ts.map +1 -1
  49. package/dist/proxmox/notification-target-lifecycle.d.ts +29 -0
  50. package/dist/proxmox/notification-target-lifecycle.d.ts.map +1 -0
  51. package/dist/proxmox/notification-target-spec.d.ts +5 -0
  52. package/dist/proxmox/notification-target-spec.d.ts.map +1 -0
  53. package/dist/proxmox/notification-target.d.ts +4 -5
  54. package/dist/proxmox/notification-target.d.ts.map +1 -1
  55. package/dist/proxmox/pbs-datastore-endpoint.d.ts +1 -1
  56. package/dist/proxmox/pbs-datastore-form.d.ts +18 -42
  57. package/dist/proxmox/pbs-datastore-form.d.ts.map +1 -1
  58. package/dist/proxmox/pbs-datastore-guard.d.ts +7 -4
  59. package/dist/proxmox/pbs-datastore-guard.d.ts.map +1 -1
  60. package/dist/proxmox/pbs-datastore-lifecycle.d.ts +56 -0
  61. package/dist/proxmox/pbs-datastore-lifecycle.d.ts.map +1 -0
  62. package/dist/proxmox/pbs-datastore-wire.d.ts +21 -0
  63. package/dist/proxmox/pbs-datastore-wire.d.ts.map +1 -0
  64. package/dist/proxmox/pbs-datastore.d.ts +5 -6
  65. package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
  66. package/dist/proxmox/pbs-notification-input.d.ts +18 -0
  67. package/dist/proxmox/pbs-notification-input.d.ts.map +1 -0
  68. package/dist/proxmox/pbs-notification-matcher-distilled.d.ts +11 -0
  69. package/dist/proxmox/pbs-notification-matcher-distilled.d.ts.map +1 -0
  70. package/dist/proxmox/pbs-notification-matcher.d.ts +4 -3
  71. package/dist/proxmox/pbs-notification-matcher.d.ts.map +1 -1
  72. package/dist/proxmox/pbs-notification-target-distilled.d.ts +20 -0
  73. package/dist/proxmox/pbs-notification-target-distilled.d.ts.map +1 -0
  74. package/dist/proxmox/pbs-notification-target-lifecycle.d.ts +4 -4
  75. package/dist/proxmox/pbs-notification-target-lifecycle.d.ts.map +1 -1
  76. package/dist/proxmox/pbs-notification-target.d.ts +1 -1
  77. package/dist/proxmox/pbs-prune-job-lifecycle.d.ts +22 -0
  78. package/dist/proxmox/pbs-prune-job-lifecycle.d.ts.map +1 -0
  79. package/dist/proxmox/pbs-prune-job-spec.d.ts +5 -0
  80. package/dist/proxmox/pbs-prune-job-spec.d.ts.map +1 -0
  81. package/dist/proxmox/pbs-prune-job-wire.d.ts +11 -0
  82. package/dist/proxmox/pbs-prune-job-wire.d.ts.map +1 -0
  83. package/dist/proxmox/pbs-prune-job.d.ts +9 -8
  84. package/dist/proxmox/pbs-prune-job.d.ts.map +1 -1
  85. package/dist/proxmox/pbs-sync-job-attributes.d.ts +34 -0
  86. package/dist/proxmox/pbs-sync-job-attributes.d.ts.map +1 -0
  87. package/dist/proxmox/pbs-sync-job-form.d.ts +21 -22
  88. package/dist/proxmox/pbs-sync-job-form.d.ts.map +1 -1
  89. package/dist/proxmox/pbs-sync-job-lifecycle.d.ts +22 -0
  90. package/dist/proxmox/pbs-sync-job-lifecycle.d.ts.map +1 -0
  91. package/dist/proxmox/pbs-sync-job-wire.d.ts +11 -0
  92. package/dist/proxmox/pbs-sync-job-wire.d.ts.map +1 -0
  93. package/dist/proxmox/pbs-sync-job.d.ts +9 -39
  94. package/dist/proxmox/pbs-sync-job.d.ts.map +1 -1
  95. package/dist/proxmox/pbs-verify-job-lifecycle.d.ts +22 -0
  96. package/dist/proxmox/pbs-verify-job-lifecycle.d.ts.map +1 -0
  97. package/dist/proxmox/pbs-verify-job-spec.d.ts +16 -0
  98. package/dist/proxmox/pbs-verify-job-spec.d.ts.map +1 -0
  99. package/dist/proxmox/pbs-verify-job-wire.d.ts +11 -0
  100. package/dist/proxmox/pbs-verify-job-wire.d.ts.map +1 -0
  101. package/dist/proxmox/pbs-verify-job.d.ts +12 -12
  102. package/dist/proxmox/pbs-verify-job.d.ts.map +1 -1
  103. package/dist/proxmox/pool.d.ts +10 -1
  104. package/dist/proxmox/pool.d.ts.map +1 -1
  105. package/dist/release/archive.d.ts.map +1 -1
  106. package/dist/release/index.js.map +2 -2
  107. package/docs/pbs-distilled-jobs.md +31 -0
  108. package/docs/pbs-notifications.md +8 -2
  109. package/docs/proxmox-notification-sdk.md +28 -0
  110. package/docs/proxmox-sdk-migration.md +60 -0
  111. package/docs/upstream-conformance-families.md +6 -2
  112. package/package.json +1 -1
  113. package/src/linux/directory-lifecycle.ts +14 -2
  114. package/src/linux/directory.test.ts +24 -0
  115. package/src/linux/directory.ts +5 -3
  116. package/src/litellm/fake-stack.ts +13 -10
  117. package/src/litellm/pass-through-endpoint.ts +5 -6
  118. package/src/litellm/pass-through-stack.test.ts +54 -0
  119. package/src/litellm/providers.ts +8 -2
  120. package/src/proxmox/adopt-noop.test.ts +4 -5
  121. package/src/proxmox/backup-job-config.ts +95 -0
  122. package/src/proxmox/backup-job-distilled.ts +86 -0
  123. package/src/proxmox/backup-job.ts +78 -116
  124. package/src/proxmox/constraints.test.ts +9 -8
  125. package/src/proxmox/distilled-pbs.test.ts +87 -0
  126. package/src/proxmox/distilled-pbs.ts +50 -0
  127. package/src/proxmox/fake-pbs-jobs.ts +99 -0
  128. package/src/proxmox/fake-pbs-notify.ts +4 -1
  129. package/src/proxmox/fake-pve.ts +3 -2
  130. package/src/proxmox/live-core-fixtures.ts +126 -0
  131. package/src/proxmox/live-core-lifecycle.test.ts +115 -0
  132. package/src/proxmox/live-core-wire.test.ts +190 -0
  133. package/src/proxmox/metric-server-config.ts +87 -0
  134. package/src/proxmox/metric-server-distilled.ts +81 -0
  135. package/src/proxmox/metric-server.ts +77 -88
  136. package/src/proxmox/notification-distilled.test.ts +162 -0
  137. package/src/proxmox/notification-matcher-distilled.ts +60 -0
  138. package/src/proxmox/notification-matcher-lifecycle.ts +71 -0
  139. package/src/proxmox/notification-matcher.ts +9 -26
  140. package/src/proxmox/notification-replacement.test.ts +190 -0
  141. package/src/proxmox/notification-sdk-input.ts +39 -0
  142. package/src/proxmox/notification-target-distilled.ts +160 -0
  143. package/src/proxmox/notification-target-form.ts +2 -0
  144. package/src/proxmox/notification-target-lifecycle.ts +75 -0
  145. package/src/proxmox/notification-target-spec.ts +68 -0
  146. package/src/proxmox/notification-target.ts +6 -68
  147. package/src/proxmox/pbs-datastore-endpoint.ts +1 -1
  148. package/src/proxmox/pbs-datastore-form.ts +18 -49
  149. package/src/proxmox/pbs-datastore-guard.ts +7 -4
  150. package/src/proxmox/pbs-datastore-lifecycle.ts +114 -0
  151. package/src/proxmox/pbs-datastore-wire.ts +94 -0
  152. package/src/proxmox/pbs-datastore.ts +7 -133
  153. package/src/proxmox/pbs-jobs-delete.test.ts +40 -0
  154. package/src/proxmox/pbs-jobs-sdk.test.ts +215 -0
  155. package/src/proxmox/pbs-notification-errors.test.ts +93 -0
  156. package/src/proxmox/pbs-notification-input.ts +25 -0
  157. package/src/proxmox/pbs-notification-mail.test.ts +64 -0
  158. package/src/proxmox/pbs-notification-matcher-distilled.ts +65 -0
  159. package/src/proxmox/pbs-notification-matcher.ts +70 -17
  160. package/src/proxmox/pbs-notification-target-distilled.ts +124 -0
  161. package/src/proxmox/pbs-notification-target-lifecycle.ts +26 -17
  162. package/src/proxmox/pbs-notification-target.ts +1 -1
  163. package/src/proxmox/pbs-notifications-stack.test.ts +25 -0
  164. package/src/proxmox/pbs-prune-job-lifecycle.ts +52 -0
  165. package/src/proxmox/pbs-prune-job-spec.ts +53 -0
  166. package/src/proxmox/pbs-prune-job-wire.ts +68 -0
  167. package/src/proxmox/pbs-prune-job.ts +10 -58
  168. package/src/proxmox/pbs-sync-job-attributes.ts +33 -0
  169. package/src/proxmox/pbs-sync-job-form.ts +7 -6
  170. package/src/proxmox/pbs-sync-job-lifecycle.ts +46 -0
  171. package/src/proxmox/pbs-sync-job-wire.ts +71 -0
  172. package/src/proxmox/pbs-sync-job.ts +10 -41
  173. package/src/proxmox/pbs-verify-job-lifecycle.ts +52 -0
  174. package/src/proxmox/pbs-verify-job-spec.ts +76 -0
  175. package/src/proxmox/pbs-verify-job-wire.ts +58 -0
  176. package/src/proxmox/pbs-verify-job.ts +13 -85
  177. package/src/proxmox/pool.ts +92 -10
  178. package/src/release/archive.ts +5 -1
@@ -19,7 +19,7 @@ import { provideFreshArtifactStore } from 'alchemy/Artifacts';
19
19
  import * as Effect from 'effect/Effect';
20
20
  import * as Layer from 'effect/Layer';
21
21
  import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
22
- import { LiteLLMPassThroughEndpointProvider } from './pass-through-endpoint.ts';
22
+ import { litellmProviders } from './providers.ts';
23
23
 
24
24
  /** The planned action for each resource, deletions included, keyed by FQN. */
25
25
  export type Planned = Readonly<Record<string, string>>;
@@ -51,13 +51,13 @@ const actionsOf = (plan: PlanView): Planned => {
51
51
  };
52
52
 
53
53
  /**
54
- * ⚠️ CREDENTIALS/HTTPCLIENT ARE PROVIDED AROUND `deploy`, NOT BAKED INTO THE PROVIDER LAYER.
55
- * `Provider.effect`'s own construction effect is `Effect.succeed(...)` (pass-through-endpoint.ts)
56
- * — it never actually reads `Credentials`/`HttpClient`; only the HANDLERS it returns do, and
57
- * those run later, whenever `Apply.ts` calls `provider.reconcile`/`read`/`delete`. Effect
58
- * resolves a `yield*`'s service from whatever ambient context wraps the CALL SITE, so the layers
59
- * those handlers need must wrap the whole plan+apply pipeline below, exactly where openbao's
60
- * fake-stack.ts wraps `BaoEnv` around its own `deploy`.
54
+ * ⚠️ CREDENTIALS COME FROM `litellmProviders`, NOT AN OUTER `Effect.provide`. The handlers read
55
+ * `Credentials` when Apply calls them, not when the provider value is built. `provideMerge`
56
+ * keeps that service in the layer output, so it is in `compiled.services` — the context this
57
+ * harness gives Apply. An outer `Effect.provide(credentials)` would hide a sealed `Layer.provide`:
58
+ * the handlers would still see the key, and this harness would not catch the regression.
59
+ * ⚠️ `HttpClient` STAYS AMBIENT, wrapped around the pipeline below. `litellmProviders` does not
60
+ * bundle one; Alchemy's runtime provides it the same way. Do not add a second client here.
61
61
  * ★ `fetchFn` IS `startFakeLitellm(...).fetch` (fake-litellm.ts) — this harness never opens a real
62
62
  * socket; `FetchHttpClient.Fetch` is the seam the SDK's protocol layer calls through, same as
63
63
  * `../netbox/fake-netbox.ts`'s `fakeNetboxLayer`.
@@ -68,7 +68,11 @@ export const fakeStack = (
68
68
  name = 'PassThroughStack',
69
69
  ): FakeStack => {
70
70
  const state = Alchemy.inMemoryState();
71
- const providers = LiteLLMPassThroughEndpointProvider();
71
+ const providers = litellmProviders(credentials(creds));
72
+ // ⚠️ THE CAST STAYS. `StackBody` is `Effect<unknown, unknown, unknown>` so a test can pass any
73
+ // declaration, and that channel is not a legal `Alchemy.Stack` body. The uncast construction
74
+ // is pass-through-stack.test.ts. This cast used to also hide `Credentials` leaking out of the
75
+ // resource type; that leak is gone, and this harness still has to deploy.
72
76
  const stack = Alchemy.Stack as unknown as (
73
77
  stackName: string,
74
78
  options: { providers: typeof providers; state: typeof state },
@@ -90,7 +94,6 @@ export const fakeStack = (
90
94
  }).pipe(
91
95
  options.adopt === undefined ? (e) => e : Effect.provideService(AdoptPolicy, options.adopt),
92
96
  Effect.provideService(Alchemy.Stage, 'test'),
93
- Effect.provide(credentials(creds)),
94
97
  Effect.provide(FetchHttpClient.layer),
95
98
  Effect.provide(Layer.succeed(FetchHttpClient.Fetch, fetchFn)),
96
99
  Effect.scoped,
@@ -20,6 +20,10 @@
20
20
  * ⛔ NO CREDENTIAL IS A PROP (S25). `LITELLM_PROXY_URL`/`LITELLM_PROXY_API_KEY` are read at call
21
21
  * time by `@distilled.cloud/litellm`'s own `CredentialsFromEnv` (providers.ts) — the same two
22
22
  * variable names the retired hand-rolled `credentials.ts` read.
23
+ * ⛔ CREDENTIALS ARE NOT A RESOURCE REQUIREMENT. `LitellmOpContext` (`Credentials | HttpClient`)
24
+ * belongs to the handlers at Apply, on `litellmProviders`'s layer. The 5th `Resource` parameter
25
+ * used to be `LitellmRequirements | Stack | Stage`, so a stack body required `Credentials`, and
26
+ * `Alchemy.Stack` rejects that (`"LitellmCredentials"` is not `"Stack"`). Measured 2026-09-24.
23
27
  */
24
28
  import { Resource } from 'alchemy';
25
29
  import { Unowned } from 'alchemy/AdoptPolicy';
@@ -27,13 +31,10 @@ import { isResolved } from 'alchemy/Diff';
27
31
  import type { Input } from 'alchemy/Input';
28
32
  import { createPhysicalName } from 'alchemy/PhysicalName';
29
33
  import * as Provider from 'alchemy/Provider';
30
- import type { Stack } from 'alchemy/Stack';
31
- import type { Stage } from 'alchemy/Stage';
32
34
  import type * as misc from '@distilled.cloud/litellm/misc';
33
35
  import * as Effect from 'effect/Effect';
34
36
  import * as Predicate from 'effect/Predicate';
35
37
  import {
36
- type LitellmRequirements,
37
38
  createPassThroughEndpoint,
38
39
  deletePassThroughEndpoint,
39
40
  listPassThroughEndpoints,
@@ -65,9 +66,7 @@ export {
65
66
  export interface LiteLLMPassThroughEndpoint extends Resource<
66
67
  'LiteLLM.PassThroughEndpoint',
67
68
  PassThroughEndpointProps,
68
- PassThroughEndpointAttributes,
69
- never,
70
- LitellmRequirements | Stack | Stage
69
+ PassThroughEndpointAttributes
71
70
  > {}
72
71
 
73
72
  export const LiteLLMPassThroughEndpoint = Resource<LiteLLMPassThroughEndpoint>(
@@ -0,0 +1,54 @@
1
+ /**
2
+ * A real `Alchemy.Stack` whose body `yield*`s `LiteLLM.PassThroughEndpoint`, with
3
+ * `litellmProviders` as the providers layer.
4
+ *
5
+ * ⛔ THE TYPE IS THE BUG. The resource used to put `LitellmRequirements`
6
+ * (`Credentials | HttpClient`) on its 5th type parameter, so this construction failed
7
+ * TS2345: `Credentials` is not `ProviderServices | StackServices`
8
+ * (`"LitellmCredentials"` is not `"Stack"`). No cast. `tsc` fails again if that leak
9
+ * returns. The stack value is not run — `Alchemy.Stack` returns a lazy effect
10
+ * (github/repo-policy-stack.test.ts measured the same).
11
+ *
12
+ * ★ THE DEPLOY IS THE OTHER HALF. `fakeStack` runs Alchemy's own Plan and Apply over the
13
+ * fake proxy, and its providers layer is `litellmProviders` (`fake-stack.ts`). A sealed
14
+ * `Layer.provide` builds the provider and then hides `Credentials`, so `reconcile` dies
15
+ * with Service not found before any request. A live row means the handler ran with the
16
+ * key present.
17
+ */
18
+ import { expect, test } from 'bun:test';
19
+ import { credentials } from '@distilled.cloud/litellm/Credentials';
20
+ import * as Alchemy from 'alchemy';
21
+ import * as Effect from 'effect/Effect';
22
+ import { FAKE_BASE, startFakeLitellm } from './fake-litellm.ts';
23
+ import { fakeStack } from './fake-stack.ts';
24
+ import { LiteLLMPassThroughEndpoint } from './pass-through-endpoint.ts';
25
+ import { litellmProviders } from './providers.ts';
26
+
27
+ const MASTER_KEY = 'sk-test-master';
28
+ const props = { path: '/bria', target: 'https://api.bria.ai' };
29
+
30
+ test('an Alchemy.Stack body can yield LiteLLMPassThroughEndpoint, and apply reaches the proxy', async () => {
31
+ const stack = Alchemy.Stack(
32
+ 'litellm-passthrough',
33
+ {
34
+ providers: litellmProviders(credentials({ apiKey: MASTER_KEY, baseUrl: FAKE_BASE })),
35
+ state: Alchemy.inMemoryState(),
36
+ },
37
+ Effect.gen(function* () {
38
+ return yield* LiteLLMPassThroughEndpoint('Endpoint', props);
39
+ }),
40
+ );
41
+ // Not run — see the file header. `tsc` is the assertion on this value.
42
+ expect(stack).toBeDefined();
43
+
44
+ const fake = startFakeLitellm({ masterKey: MASTER_KEY });
45
+ const planned = await fakeStack({ apiKey: MASTER_KEY, baseUrl: FAKE_BASE }, fake.fetch).deploy(
46
+ Effect.gen(function* () {
47
+ yield* LiteLLMPassThroughEndpoint('Endpoint', props);
48
+ }),
49
+ );
50
+
51
+ expect(planned).toEqual({ Endpoint: 'create' });
52
+ expect(fake.rows().some((row) => row.path === '/bria')).toBe(true);
53
+ expect(fake.requests().some((call) => call.method === 'POST')).toBe(true);
54
+ });
@@ -10,11 +10,17 @@
10
10
  * source them another way.
11
11
  * ⚠️ `HttpClient` is NOT provided here. It is ambient in every Alchemy runtime the same way it is
12
12
  * for `netboxProviders`-style families in this kit; a stack that already deploys anything over
13
- * HTTP already has one.
13
+ * HTTP already has one. Do not add a second client to this layer.
14
+ * ⚠️ `Layer.provideMerge`, NEVER PLAIN `Layer.provide`, for the credentials layer. The handlers
15
+ * still need `Credentials` when the engine calls `read`/`diff`/`reconcile`. `Provider.effect`
16
+ * puts that need on the layer. Plain `provide` satisfies it only while the `Provider` value is
17
+ * built, then hides `Credentials` from those later calls. Measured on Caddy, 2026-09-23:
18
+ * `caddy/providers.ts`. `provideMerge` feeds credentials in and keeps them in the layer the
19
+ * engine runs the handlers with.
14
20
  */
15
21
  import { type Credentials, CredentialsFromEnv } from '@distilled.cloud/litellm/Credentials';
16
22
  import * as Layer from 'effect/Layer';
17
23
  import { LiteLLMPassThroughEndpointProvider } from './pass-through-endpoint.ts';
18
24
 
19
25
  export const litellmProviders = (creds: Layer.Layer<Credentials> = CredentialsFromEnv) =>
20
- LiteLLMPassThroughEndpointProvider().pipe(Layer.provide(creds));
26
+ LiteLLMPassThroughEndpointProvider().pipe(Layer.provideMerge(creds));
@@ -2,10 +2,9 @@
2
2
  * Adopting an object that already matches must be read-only — measured per reconcile SHAPE, through
3
3
  * Alchemy's own Plan and Apply over a fake cluster (fake-pve.ts, ../verify/fake-engine.ts).
4
4
  *
5
- * ★ ONE ROW PER WAY A FAMILY REACHES ITS WRITE, not one per family. Every family built on
6
- * `pveHandlers` shares resource.ts's reconcile, so `Proxmox.Pool` stands for all of them; `Acl`
7
- * wraps that reconcile; `PbsDatastore` and `SdnApply` write their own. CephPool, the one that got
8
- * it wrong, has its own file. docs/adopt-verify.md carries the full per-family table.
5
+ * ★ ONE ROW PER WAY A FAMILY REACHES ITS WRITE, not one per family. Pool now has its own distilled
6
+ * lifecycle; `Acl` wraps resource.ts's shared reconcile; `PbsDatastore` and `SdnApply` write their
7
+ * own. CephPool, the one that got it wrong, has its own file. docs/adopt-verify.md carries the full per-family table.
9
8
  * ⚠️ A FIXTURE THAT DOES NOT MATCH TURNS A ROW INTO A DRIFT TEST, and the assertion on the
10
9
  * verifier's `noop` is what catches that: a wrong fixture fails loudly instead of passing for
11
10
  * the wrong reason. The Pool drift row proves the harness can see a write at all.
@@ -36,7 +35,7 @@ const cases: readonly Case[] = [
36
35
  {
37
36
  declare: () => ProxmoxPool('lab', { comment: 'x', poolid: 'lab', target: FAKE_TARGET }),
38
37
  live: { 'pools/lab': { comment: 'x', members: [] } },
39
- name: 'Proxmox.Pool (pveHandlers: every factory family)',
38
+ name: 'Proxmox.Pool (direct distilled lifecycle)',
40
39
  provider: ProxmoxPoolProvider as never,
41
40
  },
42
41
  {
@@ -0,0 +1,95 @@
1
+ /** Pure existing forms, normalization and equality, shared by plan and SDK lifecycle. */
2
+ import type { BackupJobAttributes, BackupJobProps } from './backup-job.ts';
3
+ import type { PveSpec } from './resource-spec.ts';
4
+ import { bool, guestList, propertyString, text } from './values.ts';
5
+
6
+ /**
7
+ * The form for both create and update.
8
+ *
9
+ * ⛔ A FIELD THE DECLARATION LEAVES OUT IS NOT SENT, AND THAT IS LOAD BEARING. PVE's PUT can only
10
+ * SET a key; clearing one needs `delete=<key>`, which this resource deliberately never sends.
11
+ * `updateForm` is handed the props and nothing else, so a `delete` list could only be "every
12
+ * field you did not declare" — and on a job adopted out of the UI that would silently strip
13
+ * settings this resource does not even model, `compress`, `mailto` and `bwlimit` among them, on
14
+ * the first deploy. Leaving undeclared fields alone is the smaller lie, and `matches` tells the
15
+ * same lie consistently: it does not compare what it cannot change.
16
+ *
17
+ * ⚠️ THE FIVE FIELDS PVE DOCUMENTS A DEFAULT FOR ARE ALWAYS SENT AND ALWAYS COMPARED — `enabled`
18
+ * (1), `all` (0), `mode` (snapshot), `notification-mode` (auto), `repeat-missed` (0). That is
19
+ * what makes a hand-flipped `enabled 0` show up as drift rather than as silence, and it is also
20
+ * what stops those five looping: a field compared against a default must be sent as that
21
+ * default, or every plan reports an update the PUT never performs. The cost is worth reading
22
+ * before the first deploy against an adopted job — an omitted `all` is a declaration that this
23
+ * job backs up NOTHING, and PVE accepts a job that selects nothing without a word. Read that
24
+ * first plan; do not deploy it unseen.
25
+ */
26
+ const shape = (props: BackupJobProps) => ({
27
+ all: props.all === true ? '1' : '0',
28
+ enabled: props.enabled === false ? '0' : '1',
29
+ mode: props.mode ?? 'snapshot',
30
+ 'notification-mode': props['notification-mode'] ?? 'auto',
31
+ 'repeat-missed': props['repeat-missed'] === true ? '1' : '0',
32
+ schedule: props.schedule,
33
+ ...(props.comment === undefined ? {} : { comment: props.comment }),
34
+ ...(props.exclude === undefined ? {} : { exclude: guestList(props.exclude) }),
35
+ ...(props.fleecing === undefined ? {} : { fleecing: props.fleecing }),
36
+ ...(props['notes-template'] === undefined ? {} : { 'notes-template': props['notes-template'] }),
37
+ ...(props.pool === undefined ? {} : { pool: props.pool }),
38
+ ...(props['prune-backups'] === undefined ? {} : { 'prune-backups': props['prune-backups'] }),
39
+ ...(props.storage === undefined ? {} : { storage: props.storage }),
40
+ ...(props.vmid === undefined ? {} : { vmid: guestList(props.vmid) }),
41
+ });
42
+
43
+ export const backupJobSpec = {
44
+ /**
45
+ * ⛔ NO "IS IT REALLY THERE" GUARD, ON PURPOSE. Returning undefined for a job whose JSON is
46
+ * missing some key would be a guess about which keys PVE echoes back, and a wrong guess here
47
+ * does not read as absent-and-harmless: `reconcile` would POST, and POST creates ANOTHER job.
48
+ * Absence is decided by the API declining to answer, which `pveOperations.read` already
49
+ * handles — not by a key being missing from an answer that did arrive.
50
+ */
51
+ attributes: (live, props) => ({
52
+ all: bool(live['all'], false),
53
+ comment: text(live['comment'], ''),
54
+ enabled: bool(live['enabled'], true),
55
+ exclude: guestList(live['exclude']),
56
+ fleecing: propertyString(live['fleecing'], 'enabled'),
57
+ id: props.id,
58
+ mode: text(live['mode'], 'snapshot'),
59
+ 'next-run': typeof live['next-run'] === 'number' ? live['next-run'] : 0,
60
+ 'notes-template': text(live['notes-template'], ''),
61
+ 'notification-mode': text(live['notification-mode'], 'auto'),
62
+ pool: text(live['pool'], ''),
63
+ 'prune-backups': propertyString(live['prune-backups']),
64
+ 'repeat-missed': bool(live['repeat-missed'], false),
65
+ schedule: text(live['schedule'], ''),
66
+ storage: text(live['storage'], ''),
67
+ vmid: guestList(live['vmid']),
68
+ }),
69
+ collection: () => 'cluster/backup',
70
+ /** ⛔ `id` IS SENT AND IS NOT OPTIONAL. Everything above depends on PVE not inventing one. */
71
+ createForm: (props) => ({ ...shape(props), id: props.id }),
72
+ /** Each line reads "not declared, or equal"; the five defaulted fields have no undeclared case. */
73
+ /** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
74
+ endpoint: { create: 'pve:POST /cluster/backup', update: 'pve:PUT /cluster/backup/{id}' },
75
+ matches: (attributes, props) =>
76
+ attributes.schedule === props.schedule &&
77
+ attributes.enabled === (props.enabled !== false) &&
78
+ attributes.all === (props.all === true) &&
79
+ attributes.mode === (props.mode ?? 'snapshot') &&
80
+ attributes['notification-mode'] === (props['notification-mode'] ?? 'auto') &&
81
+ attributes['repeat-missed'] === (props['repeat-missed'] === true) &&
82
+ (props.storage === undefined || attributes.storage === props.storage) &&
83
+ (props.pool === undefined || attributes.pool === props.pool) &&
84
+ (props.comment === undefined || attributes.comment === props.comment) &&
85
+ (props.vmid === undefined || attributes.vmid === guestList(props.vmid)) &&
86
+ (props.exclude === undefined || attributes.exclude === guestList(props.exclude)) &&
87
+ (props['notes-template'] === undefined ||
88
+ attributes['notes-template'] === props['notes-template']) &&
89
+ (props['prune-backups'] === undefined ||
90
+ attributes['prune-backups'] === propertyString(props['prune-backups'])) &&
91
+ (props.fleecing === undefined ||
92
+ attributes.fleecing === propertyString(props.fleecing, 'enabled')),
93
+ path: (props) => `cluster/backup/${props.id}`,
94
+ updateForm: shape,
95
+ } satisfies PveSpec<BackupJobProps, BackupJobAttributes>;
@@ -0,0 +1,86 @@
1
+ /** Backup-job operations from distilled-proxmox, walked against pve-manager 9.2.11. */
2
+ import * as cluster from '@distilled.cloud/proxmox/cluster';
3
+ import * as Effect from 'effect/Effect';
4
+ import * as Schema from 'effect/Schema';
5
+ import { ProxmoxParseError } from '@distilled.cloud/proxmox/Errors';
6
+ import { backupJobSpec } from './backup-job-config.ts';
7
+ import type { BackupJobProps } from './backup-job.ts';
8
+ import { runPve } from './distilled-pve.ts';
9
+
10
+ /** SDK member names differ from the vendor's hyphenated form keys; omission stays omission. */
11
+ export const backupJobRequest = (
12
+ props: BackupJobProps,
13
+ form = backupJobSpec.updateForm(props),
14
+ ): cluster.PutClusterBackupRequest => {
15
+ const {
16
+ 'notes-template': notes_template,
17
+ 'notification-mode': notification_mode,
18
+ 'prune-backups': prune_backups,
19
+ 'repeat-missed': repeat_missed,
20
+ ...fields
21
+ } = form;
22
+ return {
23
+ ...fields,
24
+ id: props.id,
25
+ notification_mode,
26
+ repeat_missed,
27
+ ...(notes_template === undefined ? {} : { notes_template }),
28
+ ...(prune_backups === undefined ? {} : { prune_backups }),
29
+ };
30
+ };
31
+
32
+ /**
33
+ * SDK decode also renames NESTED retention keys. Restore the vendor's property-string
34
+ * spelling before the existing canonical comparison, or keep_daily becomes perpetual drift.
35
+ * A property string from an older server remains a property string.
36
+ */
37
+ export const readBackupJob = (props: BackupJobProps) =>
38
+ runPve(props.target, 'read', false, cluster.getClusterBackup({ id: props.id })).pipe(
39
+ // Legacy PVE emits these two properties as strings; current PVE expands objects.
40
+ // Validate the generated response's remaining fields without narrowing that compatibility.
41
+ Effect.flatMap((live) =>
42
+ Schema.decodeUnknownEffect(Schema.toType(cluster.GetClusterBackupResponse))({
43
+ ...live,
44
+ prune_backups: undefined,
45
+ fleecing: undefined,
46
+ }).pipe(
47
+ Effect.mapError(
48
+ () =>
49
+ new ProxmoxParseError({
50
+ body: undefined,
51
+ cause: 'Backup job response does not match its vendor schema',
52
+ }),
53
+ ),
54
+ Effect.as(live),
55
+ ),
56
+ ),
57
+ Effect.map((live) =>
58
+ backupJobSpec.attributes(
59
+ {
60
+ ...live,
61
+ 'next-run': live.next_run,
62
+ 'notes-template': live.notes_template,
63
+ 'notification-mode': live.notification_mode,
64
+ 'prune-backups':
65
+ typeof live.prune_backups === 'object' && live.prune_backups !== null
66
+ ? Object.fromEntries(
67
+ Object.entries(live.prune_backups).map(([key, value]) => [
68
+ key.replaceAll('_', '-'),
69
+ value,
70
+ ]),
71
+ )
72
+ : live.prune_backups,
73
+ 'repeat-missed': live.repeat_missed,
74
+ },
75
+ props,
76
+ ),
77
+ ),
78
+ Effect.catchTag('BackupJobNotFound', () => Effect.succeed(undefined)),
79
+ Effect.catchTag('NotFound', () => Effect.succeed(undefined)),
80
+ );
81
+
82
+ export const deleteBackupJob = (props: BackupJobProps) =>
83
+ runPve(props.target, 'provision', true, cluster.deleteClusterBackup({ id: props.id })).pipe(
84
+ Effect.catchTag('BackupJobNotFound', () => Effect.void),
85
+ Effect.catchTag('NotFound', () => Effect.void),
86
+ );
@@ -13,14 +13,13 @@
13
13
  * COULD CARRY. PVE's own API schema says so in one parenthesis — `id`: "Job ID (will be
14
14
  * autogenerated)." — and a generated id looks like `backup-f56d8ab7-d524`. A resource that
15
15
  * POSTed without an id would create a SECOND job beside the first on EVERY reconcile, and the
16
- * read-back in `pveOperations` would still pass, because a job did appear: that check asks "is
17
- * the object there", not "is it the one I meant". So `id` is a REQUIRED prop and is always sent
16
+ * read-back could still pass, because a job did appear: that check asks "is the object there",
17
+ * not "is it the one I meant". So `id` is a REQUIRED prop and is always sent
18
18
  * on create. To adopt a job that already exists, declare its existing id; any other id is a new
19
19
  * job beside it, not an adoption.
20
- * ★ AN EXPLICIT id ALSO CLOSES A SECOND HOLE. `pveOperations.read` turns ANY failed GET into
21
- * "absent" — an expired lease, a 403, a node that did not answer — and reconcile then POSTs.
22
- * With an id, PVE refuses a duplicate section id and the deploy fails loudly; without one, that
23
- * same swallowed error would have quietly doubled the cluster's backup jobs.
20
+ * ★ FAILED READS NOW PROPAGATE THROUGH DISTILLED. Only a typed missing-job response means
21
+ * absent. An expired lease, a 403 or an unrelated server error stops reconciliation before
22
+ * any POST. The explicit id remains a second defense against accidentally duplicating jobs.
24
23
  *
25
24
  * ⚠️ `Sys.Modify` ON `/` IS THE GATE AND IT IS NOT A SMALL GRANT. PVE has no Backup.* privilege:
26
25
  * create, update and delete of a vzdump job are all checked against `Sys.Modify` at `/` — the
@@ -30,17 +29,21 @@
30
29
  * reaction to a 403.
31
30
  */
32
31
  import { Resource } from 'alchemy';
32
+ import { isResolved } from 'alchemy/Diff';
33
+ import * as cluster from '@distilled.cloud/proxmox/cluster';
34
+ import { runPve } from './distilled-pve.ts';
35
+ import { specGuards } from './resource-guard.ts';
36
+ import { formToSend } from './update-guard.ts';
37
+ import { backupJobSpec } from './backup-job-config.ts';
38
+ import { backupJobRequest, deleteBackupJob, readBackupJob } from './backup-job-distilled.ts';
39
+
33
40
  import * as Provider from 'alchemy/Provider';
34
41
  import * as Effect from 'effect/Effect';
35
- import { type PveRequirements, type WithTarget, pveHandlers } from './resource.ts';
36
- import { bool, guestList, propertyString, text } from './values.ts';
42
+ import type { PveRequirements, WithTarget } from './resource-spec.ts';
37
43
 
38
44
  /**
39
- * ⚠️ THE PROPS ARE PVE'S OWN KEY NAMES, DASHES INCLUDED. `notes-template` rather than
40
- * `notesTemplate` is deliberate: `createForm` is then a copy rather than a translation table,
41
- * and a translation table is one more place for a key to be renamed and silently never sent. A
42
- * field that is never sent reads back as whatever it already was, which is indistinguishable
43
- * from "PVE ignored me".
45
+ * ★ Public props retain PVE's own dashed field names. The distilled transport maps those
46
+ * existing forms to the SDK's generated identifiers; lifecycle tests check the actual wire.
44
47
  */
45
48
  export interface BackupJobProps extends WithTarget {
46
49
  /** ⛔ The primary key, REQUIRED. Never let PVE generate it — see the ⛔ above. */
@@ -107,109 +110,68 @@ export interface ProxmoxBackupJob extends Resource<
107
110
  export const ProxmoxBackupJob = Resource<ProxmoxBackupJob>('Proxmox.BackupJob');
108
111
 
109
112
  /**
110
- * The form for both create and update.
111
- *
112
- * ⛔ A FIELD THE DECLARATION LEAVES OUT IS NOT SENT, AND THAT IS LOAD BEARING. PVE's PUT can only
113
- * SET a key; clearing one needs `delete=<key>`, which this resource deliberately never sends.
114
- * `updateForm` is handed the props and nothing else, so a `delete` list could only be "every
115
- * field you did not declare" — and on a job adopted out of the UI that would silently strip
116
- * settings this resource does not even model, `compress`, `mailto` and `bwlimit` among them, on
117
- * the first deploy. Leaving undeclared fields alone is the smaller lie, and `matches` tells the
118
- * same lie consistently: it does not compare what it cannot change.
119
- *
120
- * ⚠️ THE FIVE FIELDS PVE DOCUMENTS A DEFAULT FOR ARE ALWAYS SENT AND ALWAYS COMPARED — `enabled`
121
- * (1), `all` (0), `mode` (snapshot), `notification-mode` (auto), `repeat-missed` (0). That is
122
- * what makes a hand-flipped `enabled 0` show up as drift rather than as silence, and it is also
123
- * what stops those five looping: a field compared against a default must be sent as that
124
- * default, or every plan reports an update the PUT never performs. The cost is worth reading
125
- * before the first deploy against an adopted job — an omitted `all` is a declaration that this
126
- * job backs up NOTHING, and PVE accepts a job that selects nothing without a word. Read that
127
- * first plan; do not deploy it unseen.
113
+ * ⛔ Adoption stays explicit: listing every backup job could grant Alchemy deletion rights over
114
+ * an undeclared schedule. Delete removes the schedule, not its stored archives; that still
115
+ * stops future backups, so the plan line is a consequential warning.
128
116
  */
129
- const shape = (props: BackupJobProps) => ({
130
- all: props.all === true ? '1' : '0',
131
- enabled: props.enabled === false ? '0' : '1',
132
- mode: props.mode ?? 'snapshot',
133
- 'notification-mode': props['notification-mode'] ?? 'auto',
134
- 'repeat-missed': props['repeat-missed'] === true ? '1' : '0',
135
- schedule: props.schedule,
136
- ...(props.comment === undefined ? {} : { comment: props.comment }),
137
- ...(props.exclude === undefined ? {} : { exclude: guestList(props.exclude) }),
138
- ...(props.fleecing === undefined ? {} : { fleecing: props.fleecing }),
139
- ...(props['notes-template'] === undefined ? {} : { 'notes-template': props['notes-template'] }),
140
- ...(props.pool === undefined ? {} : { pool: props.pool }),
141
- ...(props['prune-backups'] === undefined ? {} : { 'prune-backups': props['prune-backups'] }),
142
- ...(props.storage === undefined ? {} : { storage: props.storage }),
143
- ...(props.vmid === undefined ? {} : { vmid: guestList(props.vmid) }),
144
- });
117
+ const { guardCreate, guardUpdate } = specGuards(backupJobSpec);
145
118
 
146
- const handlers = pveHandlers<BackupJobProps, BackupJobAttributes>({
147
- /**
148
- * ⛔ NO "IS IT REALLY THERE" GUARD, ON PURPOSE. Returning undefined for a job whose JSON is
149
- * missing some key would be a guess about which keys PVE echoes back, and a wrong guess here
150
- * does not read as absent-and-harmless: `reconcile` would POST, and POST creates ANOTHER job.
151
- * Absence is decided by the API declining to answer, which `pveOperations.read` already
152
- * handles — not by a key being missing from an answer that did arrive.
153
- */
154
- attributes: (live, props) => ({
155
- all: bool(live['all'], false),
156
- comment: text(live['comment'], ''),
157
- enabled: bool(live['enabled'], true),
158
- exclude: guestList(live['exclude']),
159
- fleecing: propertyString(live['fleecing'], 'enabled'),
160
- id: props.id,
161
- mode: text(live['mode'], 'snapshot'),
162
- 'next-run': typeof live['next-run'] === 'number' ? live['next-run'] : 0,
163
- 'notes-template': text(live['notes-template'], ''),
164
- 'notification-mode': text(live['notification-mode'], 'auto'),
165
- pool: text(live['pool'], ''),
166
- 'prune-backups': propertyString(live['prune-backups']),
167
- 'repeat-missed': bool(live['repeat-missed'], false),
168
- schedule: text(live['schedule'], ''),
169
- storage: text(live['storage'], ''),
170
- vmid: guestList(live['vmid']),
171
- }),
172
- collection: () => 'cluster/backup',
173
- /** ⛔ `id` IS SENT AND IS NOT OPTIONAL. Everything above depends on PVE not inventing one. */
174
- createForm: (props) => ({ ...shape(props), id: props.id }),
175
- /** Each line reads "not declared, or equal"; the five defaulted fields have no undeclared case. */
176
- /** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
177
- endpoint: { create: 'pve:POST /cluster/backup', update: 'pve:PUT /cluster/backup/{id}' },
178
- matches: (attributes, props) =>
179
- attributes.schedule === props.schedule &&
180
- attributes.enabled === (props.enabled !== false) &&
181
- attributes.all === (props.all === true) &&
182
- attributes.mode === (props.mode ?? 'snapshot') &&
183
- attributes['notification-mode'] === (props['notification-mode'] ?? 'auto') &&
184
- attributes['repeat-missed'] === (props['repeat-missed'] === true) &&
185
- (props.storage === undefined || attributes.storage === props.storage) &&
186
- (props.pool === undefined || attributes.pool === props.pool) &&
187
- (props.comment === undefined || attributes.comment === props.comment) &&
188
- (props.vmid === undefined || attributes.vmid === guestList(props.vmid)) &&
189
- (props.exclude === undefined || attributes.exclude === guestList(props.exclude)) &&
190
- (props['notes-template'] === undefined ||
191
- attributes['notes-template'] === props['notes-template']) &&
192
- (props['prune-backups'] === undefined ||
193
- attributes['prune-backups'] === propertyString(props['prune-backups'])) &&
194
- (props.fleecing === undefined ||
195
- attributes.fleecing === propertyString(props.fleecing, 'enabled')),
196
- path: (props) => `cluster/backup/${props.id}`,
197
- updateForm: shape,
198
- });
199
-
200
- /**
201
- * ⛔ Empty for the same reason as every other resource here: adoption must be explicit.
202
- * `GET /cluster/backup` would hand back the job somebody made by hand, and adopting it
203
- * implicitly would give Alchemy the right to DELETE a cluster's only backup schedule the
204
- * first time a line left a stack file.
205
-
206
- *
207
- * ⚠️ DELETING THE JOB DOES NOT DELETE THE BACKUPS, AND THAT CUTS BOTH WAYS. The archives
208
- * stay on the storage, so this looks harmless today; it is destructive in six months,
209
- * when the schedule that should have replaced them has not run since. PVE will not
210
- * refuse this the way it refuses to delete a pool still holding guests — nothing depends
211
- * on a backup job — so the plan line is the only warning anyone gets.
212
-
213
- */
119
+ /** SDK lifecycle walked against pve-manager 9.2.11; failed reads never imply create. */
214
120
  export const ProxmoxBackupJobProvider = () =>
215
- Provider.effect(ProxmoxBackupJob, Effect.succeed(ProxmoxBackupJob.Provider.of(handlers)));
121
+ Provider.effect(
122
+ ProxmoxBackupJob,
123
+ Effect.succeed(
124
+ ProxmoxBackupJob.Provider.of({
125
+ list: () => Effect.succeed([]),
126
+ read: ({ olds }) => readBackupJob(olds),
127
+ diff: Effect.fn(function* ({ news, output }) {
128
+ if (!isResolved(news)) return undefined;
129
+ yield* guardCreate(news, output === undefined);
130
+ yield* guardUpdate(news);
131
+ if (output === undefined) return undefined;
132
+ const live = yield* readBackupJob(news);
133
+ if (live === undefined) {
134
+ yield* guardCreate(news, true);
135
+ return { action: 'update' } as const;
136
+ }
137
+ return { action: backupJobSpec.matches(live, news) ? 'noop' : 'update' } as const;
138
+ }),
139
+ reconcile: Effect.fn(function* ({ news }) {
140
+ const live = yield* readBackupJob(news);
141
+ yield* guardCreate(news, live === undefined);
142
+ yield* guardUpdate(news);
143
+ if (live === undefined) {
144
+ yield* runPve(
145
+ news.target,
146
+ 'provision',
147
+ true,
148
+ cluster.createClusterBackup(backupJobRequest(news)),
149
+ );
150
+ } else {
151
+ const form = formToSend(
152
+ backupJobSpec.matches,
153
+ live,
154
+ news,
155
+ backupJobSpec.updateForm(news),
156
+ );
157
+ if (form !== undefined)
158
+ yield* runPve(
159
+ news.target,
160
+ 'provision',
161
+ true,
162
+ cluster.putClusterBackup(backupJobRequest(news, form)),
163
+ );
164
+ }
165
+ const after = yield* readBackupJob(news);
166
+ if (after === undefined)
167
+ return yield* Effect.fail(
168
+ new Error(
169
+ `${backupJobSpec.path(news)}: write returned success but the resource is still absent`,
170
+ ),
171
+ );
172
+ return after;
173
+ }),
174
+ delete: ({ olds }) => deleteBackupJob(olds),
175
+ }),
176
+ ),
177
+ );
@@ -18,7 +18,8 @@ import { engineOver } from '../verify/fake-engine.ts';
18
18
  import { constraintsFor, formViolations } from './constraint-guard.ts';
19
19
  import { type EndpointConstraints, refusal, violations } from './constraints.ts';
20
20
  import type { PbsTarget } from './credentials.ts';
21
- import { fakePve, withoutBao } from './fake-pve.ts';
21
+ import { withoutBao } from './fake-pve.ts';
22
+ import { fakePbsJobs } from './fake-pbs-jobs.ts';
22
23
  import { PROXMOX_CONSTRAINTS, PROXMOX_CONSTRAINTS_DIGEST } from './generated/constraints/index.ts';
23
24
  import { PbsVerifyJob, PbsVerifyJobProvider } from './pbs-verify-job.ts';
24
25
 
@@ -46,12 +47,8 @@ describe('the v-r2-offsite failure, refused at plan instead of by the server', (
46
47
  * skip entirely (`output === undefined` returned before any check). It remembers the POST so
47
48
  * the read-back in `reconcile` can succeed, which is what the 128-character case needs.
48
49
  */
49
- const live = new Map<string, Record<string, unknown>>();
50
- const fake = fakePve((call) => {
51
- if (call.method === 'GET') return live.get(call.path);
52
- if (call.method === 'POST') live.set(`config/verify/${String(call.form['id'])}`, call.form);
53
- return undefined;
54
- });
50
+ // Missing PBS sections are measured plain-text HTTP400, not HTTP200 data:null.
51
+ const fake = fakePbsJobs();
55
52
  const engine = engineOver(PbsVerifyJobProvider().pipe(Layer.provideMerge(fake.layer)));
56
53
  const outcome = await withoutBao(() =>
57
54
  engine.deploy(job(comment)).then(
@@ -59,7 +56,11 @@ describe('the v-r2-offsite failure, refused at plan instead of by the server', (
59
56
  (error: unknown) => ({ error }),
60
57
  ),
61
58
  );
62
- return { ...outcome, calls: fake.calls, writes: fake.writes() };
59
+ return {
60
+ ...outcome,
61
+ calls: fake.calls,
62
+ writes: fake.writes.map((row) => `${row.method} ${row.path}`),
63
+ };
63
64
  };
64
65
 
65
66
  test('129 characters is refused, and NOTHING is written', async () => {