@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
@@ -0,0 +1,160 @@
1
+ /** PVE 9.2.11 target operations. Named SDK calls retain each endpoint's own request schema. */
2
+ import * as cluster from '@distilled.cloud/proxmox/cluster';
3
+ import type { ProxmoxOpContext, ProxmoxOpError } from '@distilled.cloud/proxmox/Protocol';
4
+ import * as Effect from 'effect/Effect';
5
+ import { runPve } from './distilled-pve.ts';
6
+ import type { NotificationTargetProps as Props } from './notification-target.ts';
7
+ import { addressList, createShape, shape } from './notification-target-form.ts';
8
+ import { notificationRequest, notificationResponse } from './notification-sdk-input.ts';
9
+ import { targetSpec } from './notification-target-spec.ts';
10
+
11
+ const read = (
12
+ props: Props,
13
+ ): Effect.Effect<Record<string, unknown>, ProxmoxOpError, ProxmoxOpContext> => {
14
+ const request = { name: props.name };
15
+ switch (props.type) {
16
+ case 'gotify':
17
+ return cluster.getClusterNotificationEndpointGotify(request).pipe(
18
+ Effect.flatMap(notificationResponse(cluster.GetClusterNotificationEndpointGotifyResponse)),
19
+ Effect.map((row) => ({ ...row })),
20
+ );
21
+ case 'sendmail':
22
+ return cluster.getClusterNotificationEndpointSendmail(request).pipe(
23
+ Effect.flatMap(
24
+ notificationResponse(cluster.GetClusterNotificationEndpointSendmailResponse),
25
+ ),
26
+ Effect.map((row) => ({
27
+ ...row,
28
+ 'from-address': row.from_address,
29
+ 'mailto-user': row.mailto_user,
30
+ })),
31
+ );
32
+ case 'smtp':
33
+ return cluster.getClusterNotificationEndpointSmtp(request).pipe(
34
+ Effect.flatMap(notificationResponse(cluster.GetClusterNotificationEndpointSmtpResponse)),
35
+ Effect.map((row) => ({
36
+ ...row,
37
+ 'from-address': row.from_address,
38
+ 'mailto-user': row.mailto_user,
39
+ })),
40
+ );
41
+ case 'webhook':
42
+ return cluster.getClusterNotificationEndpointWebhook(request).pipe(
43
+ Effect.flatMap(notificationResponse(cluster.GetClusterNotificationEndpointWebhookResponse)),
44
+ Effect.map((row) => ({ ...row })),
45
+ );
46
+ }
47
+ };
48
+
49
+ export const readTarget = (props: Props) =>
50
+ runPve(props.target, 'read', false, read(props)).pipe(
51
+ Effect.map((row) => targetSpec.attributes(row, props)),
52
+ Effect.catchTag('NotFound', () => Effect.succeed(undefined)),
53
+ );
54
+
55
+ /** The legacy forms spell scalar lists explicitly; SDK arrays preserve each logical value. */
56
+ const fields = (props: Props, create: boolean) => {
57
+ const form: Record<string, string | string[]> = {
58
+ ...(create ? createShape(props) : shape(props)),
59
+ name: props.name,
60
+ };
61
+ const cleared: string[] = [];
62
+ for (const [key, values] of [
63
+ ['header', props.header === undefined ? undefined : [...props.header]],
64
+ [
65
+ 'mailto',
66
+ props.mailto === undefined ? undefined : addressList(props.mailto).split(',').filter(Boolean),
67
+ ],
68
+ [
69
+ 'mailto-user',
70
+ props['mailto-user'] === undefined
71
+ ? undefined
72
+ : addressList(props['mailto-user']).split(',').filter(Boolean),
73
+ ],
74
+ ] as const) {
75
+ if (values === undefined) continue;
76
+ if (values.length > 0) form[key] = values;
77
+ else {
78
+ delete form[key];
79
+ if (!create) cleared.push(key);
80
+ }
81
+ }
82
+ // Empty SDK arrays emit no keys; a PUT must explicitly clear a declared empty list.
83
+ if (cleared.length > 0) form.delete = cleared;
84
+ return form;
85
+ };
86
+
87
+ /** No request: replacement planning must reject SDK-only input errors before any DELETE. */
88
+ export const validateTargetWrite = (props: Props, create: boolean) => {
89
+ const schema = create
90
+ ? {
91
+ sendmail: cluster.CreateClusterNotificationEndpointSendmailRequest,
92
+ smtp: cluster.CreateClusterNotificationEndpointSmtpRequest,
93
+ gotify: cluster.CreateClusterNotificationEndpointGotifyRequest,
94
+ webhook: cluster.CreateClusterNotificationEndpointWebhookRequest,
95
+ }[props.type]
96
+ : {
97
+ sendmail: cluster.PutClusterNotificationEndpointSendmailRequest,
98
+ smtp: cluster.PutClusterNotificationEndpointSmtpRequest,
99
+ gotify: cluster.PutClusterNotificationEndpointGotifyRequest,
100
+ webhook: cluster.PutClusterNotificationEndpointWebhookRequest,
101
+ }[props.type];
102
+ return notificationRequest(schema, fields(props, create)).pipe(Effect.asVoid);
103
+ };
104
+
105
+ export const createTarget = (props: Props) => {
106
+ const form = fields(props, true);
107
+ const operation =
108
+ props.type === 'sendmail'
109
+ ? notificationRequest(cluster.CreateClusterNotificationEndpointSendmailRequest, form).pipe(
110
+ Effect.flatMap(cluster.createClusterNotificationEndpointSendmail),
111
+ )
112
+ : props.type === 'smtp'
113
+ ? notificationRequest(cluster.CreateClusterNotificationEndpointSmtpRequest, form).pipe(
114
+ Effect.flatMap(cluster.createClusterNotificationEndpointSmtp),
115
+ )
116
+ : props.type === 'gotify'
117
+ ? notificationRequest(cluster.CreateClusterNotificationEndpointGotifyRequest, form).pipe(
118
+ Effect.flatMap(cluster.createClusterNotificationEndpointGotify),
119
+ )
120
+ : notificationRequest(cluster.CreateClusterNotificationEndpointWebhookRequest, form).pipe(
121
+ Effect.flatMap(cluster.createClusterNotificationEndpointWebhook),
122
+ );
123
+ return runPve(props.target, 'provision', true, operation);
124
+ };
125
+
126
+ export const updateTarget = (props: Props) => {
127
+ const form = fields(props, false);
128
+ const operation =
129
+ props.type === 'sendmail'
130
+ ? notificationRequest(cluster.PutClusterNotificationEndpointSendmailRequest, form).pipe(
131
+ Effect.flatMap(cluster.putClusterNotificationEndpointSendmail),
132
+ )
133
+ : props.type === 'smtp'
134
+ ? notificationRequest(cluster.PutClusterNotificationEndpointSmtpRequest, form).pipe(
135
+ Effect.flatMap(cluster.putClusterNotificationEndpointSmtp),
136
+ )
137
+ : props.type === 'gotify'
138
+ ? notificationRequest(cluster.PutClusterNotificationEndpointGotifyRequest, form).pipe(
139
+ Effect.flatMap(cluster.putClusterNotificationEndpointGotify),
140
+ )
141
+ : notificationRequest(cluster.PutClusterNotificationEndpointWebhookRequest, form).pipe(
142
+ Effect.flatMap(cluster.putClusterNotificationEndpointWebhook),
143
+ );
144
+ return runPve(props.target, 'provision', true, operation);
145
+ };
146
+
147
+ export const deleteTarget = (props: Props) => {
148
+ const request = { name: props.name };
149
+ const operation =
150
+ props.type === 'sendmail'
151
+ ? cluster.deleteClusterNotificationEndpointSendmail(request)
152
+ : props.type === 'smtp'
153
+ ? cluster.deleteClusterNotificationEndpointSmtp(request)
154
+ : props.type === 'gotify'
155
+ ? cluster.deleteClusterNotificationEndpointGotify(request)
156
+ : cluster.deleteClusterNotificationEndpointWebhook(request);
157
+ return runPve(props.target, 'provision', true, operation).pipe(
158
+ Effect.catchTag('NotFound', () => Effect.void),
159
+ );
160
+ };
@@ -6,6 +6,8 @@
6
6
  * one metric-server uses: this file turns a declaration into a form, and the resource file says
7
7
  * what a target IS and when it has changed. The `import type` back is erased, so it is a cycle
8
8
  * on paper only.
9
+ * ★ These shapes still feed specGuards and preserve scalar/default/omission mapping. The
10
+ * distilled adapter replaces list fields with typed arrays; the SDK owns their wire encoding.
9
11
  */
10
12
  import type { NotificationTargetProps } from './notification-target.ts';
11
13
  import { text } from './values.ts';
@@ -0,0 +1,75 @@
1
+ /** PVE targets: observe before writes, retain secret omission and built-in delete behavior. */
2
+ import { isResolved } from 'alchemy/Diff';
3
+ import type { Input } from 'alchemy/Input';
4
+ import * as Effect from 'effect/Effect';
5
+ import type {
6
+ NotificationTargetAttributes as Attributes,
7
+ NotificationTargetProps as Props,
8
+ } from './notification-target.ts';
9
+ import { specGuards } from './resource-guard.ts';
10
+ import { targetSpec } from './notification-target-spec.ts';
11
+ import {
12
+ createTarget,
13
+ deleteTarget,
14
+ readTarget,
15
+ updateTarget,
16
+ validateTargetWrite,
17
+ } from './notification-target-distilled.ts';
18
+
19
+ const { guardCreate, guardUpdate } = specGuards(targetSpec);
20
+ const guard = (props: Props, create: boolean) =>
21
+ guardCreate(props, create).pipe(Effect.andThen(guardUpdate(props)));
22
+ export const targetHandlers = {
23
+ /** Targets include built-ins and human-owned endpoints; enrollment remains explicit. */
24
+ list: () => Effect.succeed([]),
25
+ read: ({ olds }: { olds: Props }) =>
26
+ Effect.gen(function* () {
27
+ const live = yield* readTarget(olds);
28
+ yield* guard(olds, live === undefined);
29
+ return live;
30
+ }),
31
+ diff: ({
32
+ news,
33
+ olds,
34
+ output,
35
+ }: {
36
+ news: Input<Props>;
37
+ olds: Props;
38
+ output: Attributes | undefined;
39
+ }) =>
40
+ Effect.gen(function* () {
41
+ if (!isResolved(news)) return undefined;
42
+ // Names are unique across endpoint types. A same-name type change must delete first.
43
+ if (olds.name !== news.name || olds.type !== news.type) {
44
+ // ⛔ Validate before delete-first can remove the working target. An existing destination
45
+ // is adoption: do not demand create-only secrets that the provider cannot declare.
46
+ const destination = yield* readTarget(news);
47
+ yield* guard(news, destination === undefined);
48
+ yield* validateTargetWrite(news, destination === undefined);
49
+ return { action: 'replace', deleteFirst: olds.name === news.name } as const;
50
+ }
51
+ yield* guard(news, output === undefined);
52
+ if (output === undefined) return undefined;
53
+ const live = yield* readTarget(news);
54
+ if (live === undefined) {
55
+ yield* guard(news, true);
56
+ return { action: 'update' } as const;
57
+ }
58
+ return { action: targetSpec.matches(live, news) ? 'noop' : 'update' } as const;
59
+ }),
60
+ reconcile: ({ news }: { news: Props }) =>
61
+ Effect.gen(function* () {
62
+ const live = yield* readTarget(news);
63
+ yield* guard(news, live === undefined);
64
+ if (live === undefined) yield* createTarget(news);
65
+ else if (!targetSpec.matches(live, news)) yield* updateTarget(news);
66
+ const after = yield* readTarget(news);
67
+ if (after === undefined)
68
+ return yield* Effect.die(
69
+ new Error(`Notification target ${news.name}: write returned but read-back is absent.`),
70
+ );
71
+ return after;
72
+ }),
73
+ /** Vendor DELETE reverts built-ins; SDK NotFound makes an absent custom target idempotent. */
74
+ delete: ({ olds }: { olds: Props }) => deleteTarget(olds),
75
+ };
@@ -0,0 +1,68 @@
1
+ /** Pure target state and vendor guards, kept separate from the SDK lifecycle. */
2
+ import type {
3
+ NotificationTargetAttributes,
4
+ NotificationTargetProps,
5
+ } from './notification-target.ts';
6
+ import type { PveSpec } from './resource-spec.ts';
7
+ import { targetEndpoint } from './notification-target-endpoint.ts';
8
+ import { addressList, createShape, headerList, portOf, shape } from './notification-target-form.ts';
9
+ import { text } from './values.ts';
10
+
11
+ export const targetSpec: PveSpec<NotificationTargetProps, NotificationTargetAttributes> = {
12
+ /**
13
+ * ⛔ NAMED FIELDS, NEVER A SPREAD OF `live`. gotify's `token` and smtp's `password` really are
14
+ * absent from their read schemas — but webhook's read schema DECLARES `secret`, so "PVE will
15
+ * not hand it to us" is not a defence. Listing what is kept is what keeps secret material out
16
+ * of a state row, whatever a future PVE decides to start returning.
17
+ */
18
+ attributes: (live, props) => ({
19
+ author: text(live['author']),
20
+ body: text(live['body']),
21
+ comment: text(live['comment']),
22
+ disable: live['disable'] === 1 || live['disable'] === true,
23
+ 'from-address': text(live['from-address']),
24
+ header: headerList(live['header']),
25
+ mailto: addressList(live['mailto']),
26
+ 'mailto-user': addressList(live['mailto-user']),
27
+ method: text(live['method']),
28
+ mode: text(live['mode']),
29
+ name: props.name,
30
+ port: portOf(live['port']),
31
+ server: text(live['server']),
32
+ type: props.type,
33
+ url: text(live['url']),
34
+ username: text(live['username']),
35
+ }),
36
+ collection: (props) => `cluster/notifications/endpoints/${props.type}`,
37
+ /** ⛔ `name` is create-only: PVE takes it in the body once, and in the path forever after. */
38
+ createForm: createShape,
39
+ /** ⚠️ A function, because the type picks the endpoint — see `targetEndpoint`. */
40
+ endpoint: targetEndpoint,
41
+ /**
42
+ * Each line reads "not declared, or equal"; `disable` is the one field with no undeclared case.
43
+ *
44
+ * ⚠️ IF A PLAN NEVER SETTLES, SUSPECT A SCHEMA DEFAULT COMING BACK AS A VALUE. PVE defaults smtp
45
+ * `mode` to `tls` and smtp `author` to `Proxmox VE`, and applies both when the mail is sent
46
+ * rather than writing them into the config — so an undeclared field reads back empty and its
47
+ * line is skipped. A version that STORED them would answer with a field nobody declared. The
48
+ * fix is to declare the field, never to stop comparing it.
49
+ */
50
+ matches: (attributes, props) =>
51
+ attributes.disable === (props.disable === true) &&
52
+ (props.comment === undefined || attributes.comment === props.comment) &&
53
+ (props.server === undefined || attributes.server === props.server) &&
54
+ (props.port === undefined || attributes.port === props.port) &&
55
+ (props.mode === undefined || attributes.mode === props.mode) &&
56
+ (props.username === undefined || attributes.username === props.username) &&
57
+ (props['from-address'] === undefined || attributes['from-address'] === props['from-address']) &&
58
+ (props.author === undefined || attributes.author === props.author) &&
59
+ (props.url === undefined || attributes.url === props.url) &&
60
+ (props.method === undefined || attributes.method === props.method) &&
61
+ (props.body === undefined || attributes.body === props.body) &&
62
+ (props.mailto === undefined || attributes.mailto === addressList(props.mailto)) &&
63
+ (props['mailto-user'] === undefined ||
64
+ attributes['mailto-user'] === addressList(props['mailto-user'])) &&
65
+ (props.header === undefined || attributes.header === headerList(props.header)),
66
+ path: (props) => `cluster/notifications/endpoints/${props.type}/${props.name}`,
67
+ updateForm: shape,
68
+ };
@@ -45,18 +45,15 @@
45
45
  * ⚠️ PREFER A SECOND ROLE GRANTED AT `/mapping/notifications` OVER WIDENING THE PROVISIONING ROLE
46
46
  * ON `/`. `/mapping` also holds the PCI and USB passthrough maps, so Mapping.Modify at the root
47
47
  * lets a credential scoped to make containers rewire somebody's hardware.
48
- * ⛔ A MISSING READ PRIVILEGE DOES NOT LOOK LIKE A 403. `pveOperations.read` ends in
49
- * `orElseSucceed(() => undefined)`, so a 403 on the GET is indistinguishable from "absent": the
50
- * plan says create, PVE answers "already exists", and the fault was an ACL all along. That pair
51
- * is the signature — grant Mapping.Audit to the READ role before changing anything else.
48
+ * ⛔ THE OLD HAND CLIENT FOLDED 403 INTO "ABSENT": the plan said create, PVE refused the
49
+ * duplicate, and the fault was an ACL all along. Distilled now propagates that failure.
50
+ * Grant Mapping.Audit to the READ role before adopting; only typed NotFound means absent.
52
51
  */
53
52
  import { Resource } from 'alchemy';
54
53
  import * as Provider from 'alchemy/Provider';
55
54
  import * as Effect from 'effect/Effect';
56
- import { targetEndpoint } from './notification-target-endpoint.ts';
57
- import { addressList, createShape, headerList, portOf, shape } from './notification-target-form.ts';
58
- import { type PveRequirements, type WithTarget, pveHandlers } from './resource.ts';
59
- import { text } from './values.ts';
55
+ import type { PveRequirements, WithTarget } from './resource-spec.ts';
56
+ import { targetHandlers } from './notification-target-lifecycle.ts';
60
57
 
61
58
  /** The four families PVE ships. `GET /cluster/notifications/endpoints` lists exactly these. */
62
59
  export type NotificationTargetType = 'gotify' | 'sendmail' | 'smtp' | 'webhook';
@@ -154,65 +151,6 @@ export const ProxmoxNotificationTarget = Resource<ProxmoxNotificationTarget>(
154
151
  'Proxmox.NotificationTarget',
155
152
  );
156
153
 
157
- const handlers = pveHandlers<NotificationTargetProps, NotificationTargetAttributes>({
158
- /**
159
- * ⛔ NAMED FIELDS, NEVER A SPREAD OF `live`. gotify's `token` and smtp's `password` really are
160
- * absent from their read schemas — but webhook's read schema DECLARES `secret`, so "PVE will
161
- * not hand it to us" is not a defence. Listing what is kept is what keeps secret material out
162
- * of a state row, whatever a future PVE decides to start returning.
163
- */
164
- attributes: (live, props) => ({
165
- author: text(live['author']),
166
- body: text(live['body']),
167
- comment: text(live['comment']),
168
- disable: live['disable'] === 1 || live['disable'] === true,
169
- 'from-address': text(live['from-address']),
170
- header: headerList(live['header']),
171
- mailto: addressList(live['mailto']),
172
- 'mailto-user': addressList(live['mailto-user']),
173
- method: text(live['method']),
174
- mode: text(live['mode']),
175
- name: props.name,
176
- port: portOf(live['port']),
177
- server: text(live['server']),
178
- type: props.type,
179
- url: text(live['url']),
180
- username: text(live['username']),
181
- }),
182
- collection: (props) => `cluster/notifications/endpoints/${props.type}`,
183
- /** ⛔ `name` is create-only: PVE takes it in the body once, and in the path forever after. */
184
- createForm: createShape,
185
- /** ⚠️ A function, because the type picks the endpoint — see `targetEndpoint`. */
186
- endpoint: targetEndpoint,
187
- /**
188
- * Each line reads "not declared, or equal"; `disable` is the one field with no undeclared case.
189
- *
190
- * ⚠️ IF A PLAN NEVER SETTLES, SUSPECT A SCHEMA DEFAULT COMING BACK AS A VALUE. PVE defaults smtp
191
- * `mode` to `tls` and smtp `author` to `Proxmox VE`, and applies both when the mail is sent
192
- * rather than writing them into the config — so an undeclared field reads back empty and its
193
- * line is skipped. A version that STORED them would answer with a field nobody declared. The
194
- * fix is to declare the field, never to stop comparing it.
195
- */
196
- matches: (attributes, props) =>
197
- attributes.disable === (props.disable === true) &&
198
- (props.comment === undefined || attributes.comment === props.comment) &&
199
- (props.server === undefined || attributes.server === props.server) &&
200
- (props.port === undefined || attributes.port === props.port) &&
201
- (props.mode === undefined || attributes.mode === props.mode) &&
202
- (props.username === undefined || attributes.username === props.username) &&
203
- (props['from-address'] === undefined || attributes['from-address'] === props['from-address']) &&
204
- (props.author === undefined || attributes.author === props.author) &&
205
- (props.url === undefined || attributes.url === props.url) &&
206
- (props.method === undefined || attributes.method === props.method) &&
207
- (props.body === undefined || attributes.body === props.body) &&
208
- (props.mailto === undefined || attributes.mailto === addressList(props.mailto)) &&
209
- (props['mailto-user'] === undefined ||
210
- attributes['mailto-user'] === addressList(props['mailto-user'])) &&
211
- (props.header === undefined || attributes.header === headerList(props.header)),
212
- path: (props) => `cluster/notifications/endpoints/${props.type}/${props.name}`,
213
- updateForm: shape,
214
- });
215
-
216
154
  /**
217
155
  * ⚠️ ON A BUILT-IN TARGET A DELETE REVERTS RATHER THAN REMOVES, and PVE reports success either way.
218
156
  * It is passed through unguarded because everywhere else in this package the cluster's answer is
@@ -222,5 +160,5 @@ const handlers = pveHandlers<NotificationTargetProps, NotificationTargetAttribut
222
160
  export const ProxmoxNotificationTargetProvider = () =>
223
161
  Provider.effect(
224
162
  ProxmoxNotificationTarget,
225
- Effect.succeed(ProxmoxNotificationTarget.Provider.of(handlers)),
163
+ Effect.succeed(ProxmoxNotificationTarget.Provider.of(targetHandlers)),
226
164
  );
@@ -2,7 +2,7 @@
2
2
  * `Pbs.Datastore`'s two vendor endpoints, and the plan-time check over its two forms.
3
3
  *
4
4
  * ★ A FILE OF ITS OWN BECAUSE THIS FAMILY DOES NOT USE `pveHandlers`. It writes its own `diff` and
5
- * `reconcile` (a create answers with a task id, so it settles rather than reading back once), so
5
+ * `reconcile` (bounded readback handles settling after a write; create returns no task id), so
6
6
  * it cannot inherit the guard from resource.ts — and a copy of the two calls inside that already
7
7
  * long file is exactly the per-resource copy-paste the shared handler exists to prevent.
8
8
  *
@@ -1,42 +1,41 @@
1
1
  /**
2
- * A PBS datastore on the wire: the transport that reaches PBS, the form it wants, and the coercion
3
- * back to something comparable.
2
+ * A PBS datastore's pure vendor forms and coercion back to comparable state. SDK transport is
3
+ * in pbs-datastore-wire.ts; lifecycle behavior is in pbs-datastore-lifecycle.ts.
4
4
  *
5
5
  * ★ SPLIT OUT OF pbs-datastore.ts FOR THE 250-LINE CAP, the seam notification-target-form.ts uses:
6
- * this file is everything that touches PBS's encoding, and the resource file is the declaration
7
- * and the five handlers. The `import type` back is erased, so it is a cycle on paper only.
6
+ * this file originally held PBS encoding beside forms, while the resource held its declaration
7
+ * and five handlers. The `import type` back is erased, so it is a cycle on paper only.
8
8
  *
9
- * ⛔ THIS FILE IS OVER THE 250-LINE CAP AND THE EXTRACTION IS NAMED RATHER THAN FUDGED. `PbsTarget`,
9
+ * ⛔ HISTORICAL EXTRACTION NOTE: THIS FILE EXCEEDED THE 250-LINE CAP IN THE ORIGINAL DRAFT. `PbsTarget`,
10
10
  * `PbsRole`, `PbsError`, `pbsAuthorization`, `PveForm`, `encode`, `pbs` and `settle` — about 150
11
11
  * lines — are a PBS CLIENT, not a datastore form, and belong in `pbs-client.ts`. Lifting them
12
12
  * leaves this file near 200 and costs nothing but a move. It was not done here only because the
13
13
  * family was commissioned as a two-file split; ceph-pool is the in-package precedent for three
14
14
  * (ceph-pool.ts + ceph-pool-form.ts + ceph-pool-settle.ts, 532 lines for one family). Everything
15
15
  * in that list is also what Pbs.SyncJob, Pbs.PruneJob and Pbs.Remote will each need unchanged,
16
- * so the second PBS family forces the move anyway. ⛔ DO NOT MEET THE CAP BY CUTTING COMMENTS.
16
+ * so the second PBS family forced that move. The SDK and focused siblings now own these seams.
17
+ * ⛔ DO NOT MEET THE CAP BY CUTTING COMMENTS.
17
18
  */
18
- import * as Effect from 'effect/Effect';
19
- import { type PveForm, pve } from './client.ts';
20
19
  import type { PbsTarget as ApiPbsTarget } from './credentials.ts';
21
20
  import type { PbsDatastoreProps } from './pbs-datastore.ts';
22
21
  import { bool, flag, int, propertyString, text } from './values.ts';
23
22
 
24
23
  /**
25
- * ★ PBS REUSES THE PVE CLIENT RATHER THAN OWNING ONE. An earlier draft of this file carried its
26
- * own `PbsTarget`, `PbsError`, `pbsAuthorization`, form encoder and `pve()` — a near-copy of
27
- * client.ts differing in one header. They are gone: `PveTarget` now carries a `scheme`, and
28
- * `authorization()` spells the header `PBSAPIToken=<id>:<secret>` when it is `'pbs'`. The
29
- * insight that survived is the FORM TYPE — PBS decodes a multi-valued field from repeated keys
30
- * where PVE wants a comma string — and that now lives on `PveForm` in client.ts, so both
31
- * products share one encoder and neither can reach for the other's convention by accident.
24
+ * ★ PBS ORIGINALLY REUSED THE PVE CLIENT RATHER THAN OWNING ANOTHER. An earlier draft carried
25
+ * its own `PbsTarget`, `PbsError`, `pbsAuthorization`, form encoder and `pve()` — a near-copy of
26
+ * client.ts differing in one header. The shared target's `scheme` replaced that copy. This
27
+ * family now uses the distilled PBS SDK through `distilled-pbs.ts`, retaining the same leased
28
+ * credentials and PBS authorization scheme. The important encoding distinction survives:
29
+ * PBS decodes a multi-valued field from repeated keys where PVE wants a comma string. The SDK
30
+ * protocol owns that behavior; these pure forms describe only this datastore's scalar fields.
32
31
  *
33
32
  * 🔴 THIS ALIAS SAID `= PveTarget` AND IT WAS WRONG — Pbs.Datastore REQUIRED A PVE TARGET. When
34
33
  * `credentials.ts` grew a real `PbsTarget` with `scheme: 'pbs'`, this line was left pointing at
35
34
  * the PVE type, which pins `scheme: 'pve'`. So the one family that most needs the discriminant
36
35
  * was the one family that refused the correct value: declaring a datastore against the PBS host
37
36
  * failed to compile with `Type '"pbs"' is not assignable to type 'Input<"pve">'`, and passing a
38
- * PVE target compiled fine and would have 401'd every call — which `read` folds into "absent",
39
- * so the plan would say CREATE for a datastore holding the estate's backups.
37
+ * PVE target compiled fine and would have 401'd every call — which the old `read` folded into
38
+ * "absent", so the plan would say CREATE for a datastore holding the estate's backups.
40
39
  * ⛔ IT SURVIVED BECAUSE NOTHING EVER DECLARED ONE. The provider was registered in
41
40
  * alchemy.run.ts from the day it was written and `pveHandlers`' `list` answers empty, so
42
41
  * registration exercises no call site at all. Three sibling families import `PbsTarget` from
@@ -183,7 +182,7 @@ const mutable = (props: PbsDatastoreProps): Record<string, string> => ({
183
182
  * slot, not of the declaration; one that could re-point it is one that can send backups to the
184
183
  * wrong drive.
185
184
  */
186
- export const createForm = (props: PbsDatastoreProps): PveForm => ({
185
+ export const createForm = (props: PbsDatastoreProps): Record<string, string> => ({
187
186
  ...mutable(props),
188
187
  name: props.name,
189
188
  path: props.path,
@@ -213,36 +212,6 @@ export const createForm = (props: PbsDatastoreProps): PveForm => ({
213
212
  * element that fails the enum check. `PveForm` and `encode` already carry arrays for exactly
214
213
  * this. ⚠️ REASONED FROM THE PUBLISHED SCHEMA, NOT PROVEN ON A WIRE.
215
214
  */
216
- export const updateForm = (props: PbsDatastoreProps): PveForm => mutable(props);
215
+ export const updateForm = (props: PbsDatastoreProps): Record<string, string> => mutable(props);
217
216
 
218
217
  export const object = (props: PbsDatastoreProps) => `config/datastore/${props.name}`;
219
-
220
- /**
221
- * The live datastore, or undefined.
222
- *
223
- * ⛔ `name` IS THE PRESENCE TEST, NOT THE STATUS CODE — PBS does not answer 404 for a section that
224
- * is missing from `datastore.cfg`, it answers a generic error, so there is no code to key off.
225
- * ⛔ A MISSING READ PRIVILEGE THEREFORE LOOKS EXACTLY LIKE ABSENCE, because the fold below cannot
226
- * tell them apart. The signature is a plan that says CREATE followed by PBS answering
227
- * "datastore '<name>' already exists." — that pair means Datastore.Audit, not a missing
228
- * datastore. It is loud and non-destructive, which is the only reason the fold is tolerable here;
229
- * see the ⛔ on `readRole` in resource.ts for the PVE families where it is not.
230
- * ⛔ ON THE DELETE PATH THE SAME FOLD IS A LIE, AND THIS IS THE ONE HAZARD IN THIS FAMILY I COULD
231
- * NOT CLOSE. `delete` settles by polling this read until it answers `undefined` — and a `read`
232
- * role that lacks `Datastore.Audit` answers `undefined` from the very first poll, so EVERY delete
233
- * would report success while the datastore stood. Worse, the two lanes are different leases: the
234
- * DELETE goes out under `provision` and may genuinely fail, and this read cannot see that it did.
235
- * ★ THE SHAPE OF THE FIX IS ALREADY IN THIS PACKAGE: `confirmAbsent` in ceph-pool-settle.ts asks
236
- * a SECOND, DIFFERENT question — does the collection LIST this object? — and lets a failure
237
- * PROPAGATE rather than folding it, so "I could not ask" fails the deploy instead of passing
238
- * it. Add the same over `GET /config/datastore` before anyone relies on a reported deletion.
239
- * ⚠️ UNTIL THEN THE PRECONDITION IS EXPLICIT: the `read` role MUST hold `Datastore.Audit` on the
240
- * datastore, or this provider cannot tell a deletion from a refusal.
241
- */
242
- export const readOne = (props: PbsDatastoreProps) =>
243
- pve<Record<string, unknown>>(props.target, 'read', 'GET', object(props)).pipe(
244
- Effect.map((live) =>
245
- live === undefined || typeof live['name'] !== 'string' ? undefined : attributes(live, props),
246
- ),
247
- Effect.orElseSucceed(() => undefined),
248
- );
@@ -76,13 +76,15 @@ export const guardBackend = (live: PbsDatastoreAttributes, props: PbsDatastorePr
76
76
  /**
77
77
  * Wait for PBS to finish, then answer the last thing it said.
78
78
  *
79
- * ⛔ IT EXISTS BECAUSE A PBS CREATE IS A FORKED WORKER AND THE CONFIG SECTION IS WRITTEN LAST.
79
+ * ⛔ THE ORIGINAL CREATE-WORKER ASSUMPTION BELOW WAS REASONED, NOT MEASURED.
80
80
  * `POST /config/datastore` answers with a UPID while a `create-datastore` task builds the chunk
81
81
  * store's 65536 directories; only when that finishes does the section reach `datastore.cfg`. An
82
82
  * immediate read-back therefore sees a datastore that is not there yet, and the factory's "the
83
83
  * write returned no error but the object is still absent" would fire on a create going perfectly.
84
84
  * The DELETE forks a worker too. ⚠️ DOCUMENTED FROM THE PUBLISHED SCHEMA, NOT MEASURED — I hold
85
- * no PBS token. If a create turns out to be synchronous, the first poll simply succeeds.
85
+ * no PBS token. The pinned PBS 4.2.6-1 schema actually declares a NULL create response,
86
+ * while DELETE declares a UPID. We preserve bounded readback for both; a synchronous create
87
+ * succeeds at the first poll rather than depending on the old UPID assumption.
86
88
  * ★ IT TAKES THE READ AS AN EFFECT RATHER THAN A FUNCTION — the one simplification over
87
89
  * ceph-pool-settle.ts: an Effect is a description, so re-running it re-reads and no dependency
88
90
  * has to be threaded back in to avoid a cycle.
@@ -91,8 +93,9 @@ export const guardBackend = (live: PbsDatastoreAttributes, props: PbsDatastorePr
91
93
  * Anything still unsettled after two minutes is a fault to surface, not a wait to lengthen, and
92
94
  * the caller's message points at the task log, which is where the real answer is. `Effect.sleep`
93
95
  * yields between polls; nothing here parks a call on one long wait.
94
- * ⚠️ POLLING THE TASK STATUS INSTEAD WOULD BE WORSE, for ceph-pool-settle.ts's reason: every call
95
- * mints a FRESH token, so the token asking about the task is not the one that started it.
96
+ * ⚠️ THE ORIGINAL TRANSPORT MINTED A TOKEN PER CALL, making task-owner polling unreliable.
97
+ * The SDK runner now reuses leases, but create has no task id in its measured response schema.
98
+ * This migration therefore preserves bounded readback rather than inventing a task handle.
96
99
  */
97
100
  export const settle = <A, E, R>(read: Effect.Effect<A, E, R>, done: (live: A) => boolean) =>
98
101
  Effect.gen(function* () {