@homeflare/alchemy 0.37.0 → 0.37.2

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 (73) hide show
  1. package/dist/proxmox/ceph-daemon-wire.d.ts +5 -7
  2. package/dist/proxmox/ceph-daemon-wire.d.ts.map +1 -1
  3. package/dist/proxmox/ceph-daemon.d.ts +7 -8
  4. package/dist/proxmox/ceph-daemon.d.ts.map +1 -1
  5. package/dist/proxmox/ceph-fs-distilled.d.ts +31 -5
  6. package/dist/proxmox/ceph-fs-distilled.d.ts.map +1 -1
  7. package/dist/proxmox/ceph-fs-wire.d.ts +5 -22
  8. package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -1
  9. package/dist/proxmox/ceph-fs.d.ts +5 -6
  10. package/dist/proxmox/ceph-fs.d.ts.map +1 -1
  11. package/dist/proxmox/ceph-pool-settle.d.ts +9 -8
  12. package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -1
  13. package/dist/proxmox/ceph-pool-wire.d.ts +11 -20
  14. package/dist/proxmox/ceph-pool-wire.d.ts.map +1 -1
  15. package/dist/proxmox/group-wire.d.ts +8 -13
  16. package/dist/proxmox/group-wire.d.ts.map +1 -1
  17. package/dist/proxmox/index.js +296 -326
  18. package/dist/proxmox/index.js.map +14 -14
  19. package/dist/proxmox/node-network-wire.d.ts +1 -1
  20. package/dist/proxmox/node-network-wire.d.ts.map +1 -1
  21. package/dist/proxmox/storage-wire.d.ts +8 -11
  22. package/dist/proxmox/storage-wire.d.ts.map +1 -1
  23. package/dist/proxmox/unreadable-read.d.ts +7 -7
  24. package/dist/proxmox/user-wire.d.ts +8 -11
  25. package/dist/proxmox/user-wire.d.ts.map +1 -1
  26. package/dist/proxmox/user.d.ts +5 -5
  27. package/dist/proxmox/zfs-pool-wire.d.ts +1 -1
  28. package/dist/proxmox/zfs-pool-wire.d.ts.map +1 -1
  29. package/docs/changelog/README.md +22 -0
  30. package/docs/changelog/archive-01.md +176 -0
  31. package/docs/changelog/archive-02.md +180 -0
  32. package/docs/changelog/archive-03.md +179 -0
  33. package/docs/changelog/archive-04.md +178 -0
  34. package/docs/changelog/archive-05.md +177 -0
  35. package/docs/changelog/archive-06.md +180 -0
  36. package/docs/changelog/archive-07.md +180 -0
  37. package/docs/changelog/archive-08.md +178 -0
  38. package/docs/changelog/archive-09.md +181 -0
  39. package/docs/changelog/archive-10.md +182 -0
  40. package/docs/changelog/archive-11.md +182 -0
  41. package/docs/changelog/archive-12.md +183 -0
  42. package/docs/changelog/archive-13.md +180 -0
  43. package/docs/changelog/archive-14.md +178 -0
  44. package/docs/changelog/archive-15.md +181 -0
  45. package/docs/changelog/archive-16.md +182 -0
  46. package/docs/changelog/archive-17.md +183 -0
  47. package/docs/changelog/archive-18.md +55 -0
  48. package/docs/distilled-interim.md +15 -3
  49. package/docs/provider-standard.md +24 -6
  50. package/docs/proxmox.md +26 -0
  51. package/docs/upstream-conformance-bun.md +67 -0
  52. package/docs/upstream-conformance-conforms.md +55 -0
  53. package/docs/upstream-conformance-families.md +95 -0
  54. package/docs/upstream-conformance-grafana.md +106 -0
  55. package/docs/upstream-conformance.md +20 -298
  56. package/package.json +3 -3
  57. package/src/proxmox/ceph-daemon-wire.ts +4 -7
  58. package/src/proxmox/ceph-daemon.ts +7 -8
  59. package/src/proxmox/ceph-fs-delete.test.ts +5 -8
  60. package/src/proxmox/ceph-fs-destroy-sdk.test.ts +98 -0
  61. package/src/proxmox/ceph-fs-distilled.ts +45 -19
  62. package/src/proxmox/ceph-fs-wire.ts +5 -93
  63. package/src/proxmox/ceph-fs.ts +13 -15
  64. package/src/proxmox/ceph-pool-settle.ts +11 -10
  65. package/src/proxmox/ceph-pool-wire.ts +11 -20
  66. package/src/proxmox/ceph-pool-write.test.ts +9 -8
  67. package/src/proxmox/group-wire.ts +7 -12
  68. package/src/proxmox/read-failure-propagation.test.ts +5 -5
  69. package/src/proxmox/storage-wire.ts +9 -10
  70. package/src/proxmox/typed-absence.test.ts +146 -0
  71. package/src/proxmox/unreadable-read.ts +7 -7
  72. package/src/proxmox/user-wire.ts +7 -10
  73. package/src/proxmox/user.ts +5 -5
@@ -0,0 +1,146 @@
1
+ /**
2
+ * A failed read is not evidence of absence. Exercise the real SDK's protocol and errors,
3
+ * including PVE's resource-specific HTTP 500s, before checking the engine's write boundary.
4
+ * Fixtures come from the vendor-source evidence in the SDK's generated error declarations.
5
+ * All HTTP is in-process, and `withoutBao` removes the shell's real credentials.
6
+ */
7
+ import { describe, expect, test } from 'bun:test';
8
+ import * as RemovalPolicy from 'alchemy/RemovalPolicy';
9
+ import * as Effect from 'effect/Effect';
10
+ import * as Layer from 'effect/Layer';
11
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
12
+ import type * as HttpClient from 'effect/unstable/http/HttpClient';
13
+ import { engineOver } from '../verify/fake-engine.ts';
14
+ import { readDaemon } from './ceph-daemon-wire.ts';
15
+ import { readFs } from './ceph-fs-distilled.ts';
16
+ import { ProxmoxCephFs, ProxmoxCephFsProvider } from './ceph-fs.ts';
17
+ import { readPoolStatus } from './ceph-pool-wire.ts';
18
+ import { FAKE_TARGET, withoutBao } from './fake-pve.ts';
19
+ import { readGroup } from './group-wire.ts';
20
+ import { readStorage } from './storage-wire.ts';
21
+ import { readUser } from './user-wire.ts';
22
+ import { ProxmoxUser, ProxmoxUserProvider } from './user.ts';
23
+
24
+ const target = FAKE_TARGET;
25
+ const ceph = { name: 'testfs', node: 'node-b', target };
26
+ const user = { userid: 'test@pve', target };
27
+
28
+ /** A mutable answer lets a formerly readable filesystem fail exactly when delete runs. */
29
+ const cluster = (initial: () => Response) => {
30
+ let answer = initial;
31
+ const writes: string[] = [];
32
+ const stub = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
33
+ const request =
34
+ input instanceof Request ? new Request(input, init) : new Request(String(input), init);
35
+ if (new URL(request.url).pathname.includes('/creds/')) {
36
+ const data = { secret: 'fake-secret-not-real', token_id: 'hf-test@pve!fake' };
37
+ return Response.json({ data, lease_duration: 0 });
38
+ }
39
+ if (request.method !== 'GET') writes.push(request.method);
40
+ return answer();
41
+ };
42
+ return {
43
+ answer: (next: () => Response) => {
44
+ answer = next;
45
+ },
46
+ layer: FetchHttpClient.layer.pipe(
47
+ Layer.provideMerge(
48
+ Layer.succeed(
49
+ FetchHttpClient.Fetch,
50
+ Object.assign(stub, { preconnect: globalThis.fetch.preconnect }) as typeof fetch,
51
+ ),
52
+ ),
53
+ ),
54
+ writes,
55
+ };
56
+ };
57
+
58
+ interface ReadCase {
59
+ name: string;
60
+ read: () => Effect.Effect<unknown, unknown, HttpClient.HttpClient>;
61
+ missing?: string;
62
+ }
63
+ const cases: ReadCase[] = [
64
+ { name: 'User', read: () => readUser(user), missing: "no such user ('test@pve')\n" },
65
+ {
66
+ name: 'Group',
67
+ read: () => readGroup({ groupid: 'test', target }),
68
+ missing: "group 'test' does not exist\n",
69
+ },
70
+ {
71
+ name: 'Storage',
72
+ read: () => readStorage({ storage: 'test', type: 'dir', target }),
73
+ missing: "storage 'test' does not exist\n",
74
+ },
75
+ {
76
+ name: 'CephPool',
77
+ read: () => readPoolStatus(ceph),
78
+ missing: "error with 'osd pool get': mon_cmd failed - unrecognized pool 'testfs'\n",
79
+ },
80
+ { name: 'CephFs', read: () => readFs(ceph) },
81
+ ...(['mon', 'mgr', 'mds'] as const).map((kind) => ({
82
+ name: `CephDaemon/${kind}`,
83
+ read: () => readDaemon({ ...ceph, kind }),
84
+ })),
85
+ ];
86
+ const errorResponse = (status: number, message: string) => () =>
87
+ Response.json({ data: null, message }, { status });
88
+
89
+ for (const entry of cases) {
90
+ describe(entry.name, () => {
91
+ test('only the measured absence signal returns undefined', async () => {
92
+ const fake = cluster(
93
+ entry.missing === undefined
94
+ ? () => Response.json({ data: [] })
95
+ : errorResponse(500, entry.missing),
96
+ );
97
+ await withoutBao(async () => {
98
+ expect(
99
+ await Effect.runPromise(entry.read().pipe(Effect.provide(fake.layer))),
100
+ ).toBeUndefined();
101
+ });
102
+ expect(fake.writes).toEqual([]);
103
+ });
104
+ for (const [status, tag] of [
105
+ [401, 'Unauthorized'],
106
+ [403, 'Forbidden'],
107
+ [500, 'InternalServerError'],
108
+ ] as const) {
109
+ test(`${status} unrelated failure propagates ${tag}`, async () => {
110
+ const fake = cluster(errorResponse(status, 'unrelated read failure'));
111
+ await withoutBao(async () => {
112
+ const error = await Effect.runPromise(
113
+ entry.read().pipe(Effect.flip, Effect.provide(fake.layer)),
114
+ );
115
+ expect(error).toMatchObject({ _tag: tag });
116
+ });
117
+ expect(fake.writes).toEqual([]);
118
+ });
119
+ }
120
+ });
121
+ }
122
+
123
+ test('a cold User adoption fails before any create when its read fails', async () => {
124
+ const fake = cluster(errorResponse(500, 'unrelated read failure'));
125
+ await withoutBao(async () => {
126
+ const engine = engineOver(ProxmoxUserProvider().pipe(Layer.provideMerge(fake.layer)));
127
+ await expect(engine.deploy(ProxmoxUser('test', user))).rejects.toBeDefined();
128
+ });
129
+ expect(fake.writes).toEqual([]);
130
+ });
131
+
132
+ test('CephFs delete fails on an unreadable index instead of falsely dropping state', async () => {
133
+ const fake = cluster(() =>
134
+ Response.json({
135
+ data: [{ name: ceph.name, data_pool: 'testfs_data', metadata_pool: 'testfs_metadata' }],
136
+ }),
137
+ );
138
+ await withoutBao(async () => {
139
+ const engine = engineOver(ProxmoxCephFsProvider().pipe(Layer.provideMerge(fake.layer)));
140
+ const declared = () => ProxmoxCephFs('fs', ceph).pipe(RemovalPolicy.destroy());
141
+ expect(await engine.deploy(declared())).toEqual({ fs: 'adopted' });
142
+ fake.answer(errorResponse(403, 'permission denied'));
143
+ await expect(engine.deploy(Effect.void)).rejects.toBeDefined();
144
+ });
145
+ expect(fake.writes).toEqual([]);
146
+ });
@@ -18,10 +18,10 @@
18
18
  * on a lane that cannot prove one is needed and (b) never takes the rest of the plan down with
19
19
  * it — the "noop-with-warning" reading of the fix, over the alternative of a typed plan error.
20
20
  *
21
- * ⛔ ONLY THE CREDENTIAL DENIAL IS CAUGHT HERE. Every other read failure (a genuine
22
- * `Cause.TimeoutError`, `PveClusterExhausted`, a network error) keeps folding into `undefined`
23
- * exactly as it always has — this narrows ONE typed tag out of that fold, via `catchTag`, and
24
- * changes nothing else about it. See credential-errors.ts's own ⛔ for why 403 alone qualifies.
21
+ * ⛔ ONLY THE CREDENTIAL DENIAL IS CAUGHT HERE. Other errors propagate to the caller.
22
+ * Legacy callers may still fold them, but the typed-absence follow-up makes User/Group/
23
+ * Storage catch only their SDK missing-object tag. Never infer a resource is absent from
24
+ * this sentinel. See credential-errors.ts's own ⛔ for why the OpenBao mint's 403 qualifies.
25
25
  */
26
26
  import * as Effect from 'effect/Effect';
27
27
  import type { PveCredentialDenied } from './credential-errors.ts';
@@ -31,9 +31,9 @@ export const UNREADABLE = Symbol('pve-unreadable');
31
31
  export type Unreadable = typeof UNREADABLE;
32
32
 
33
33
  /**
34
- * Catches ONLY `PveCredentialDenied` and turns it into the sentinel, so a caller that also
35
- * `.pipe(Effect.orElseSucceed(() => undefined))` for every OTHER failure never conflates a
36
- * refusal with a genuine absence. Compose this INSIDE that fold, never after it.
34
+ * Catches ONLY `PveCredentialDenied` and returns the sentinel. Compose this BEFORE the
35
+ * caller's absence handling so a credential refusal cannot become a genuine absence.
36
+ * This helper itself classifies neither SDK missing-object tags nor transport failures.
37
37
  *
38
38
  * ⚠️ `E | PveCredentialDenied` IN, `E` OUT — not a generic `E` narrowed by `Exclude`. `catchTag`
39
39
  * needs the tag to be STATICALLY present in the error union to typecheck at all; a bare
@@ -130,18 +130,15 @@ export const readUserOrFail = (props: UserProps) =>
130
130
  ).pipe(Effect.map((live) => (live === UNREADABLE ? UNREADABLE : attributesOf(live, props))));
131
131
 
132
132
  /**
133
- * ⛔ FOLDS A GENUINE READ FAILURE TO `undefined` TOO, AND ONLY `read`/`reconcile` MAY USE IT. A
134
- * missing user is a thrown 500 (user.ts's own header), so `reconcile`'s create branch NEEDS
135
- * "absent" from a failure to ever run — without this fold, a brand-new account could never be
136
- * created. Safe here because a wrongful fold costs at most a redundant `createAccessUser` POST,
137
- * which PVE refuses loudly rather than silently corrupting anything.
138
- * ⛔ `user.ts`'s `diff` does **NOT** use this — the SAME bug class as the credential denial fix:
139
- * folding a TRANSIENT failure into "absent" at PLAN TIME is what forced a false `update` with
140
- * nothing compared. `diff` calls `readUserOrFail` directly, so a genuine failure there
141
- * propagates and fails the whole plan loudly instead.
133
+ * ⛔ ONLY `UserNotFound` MEANS ABSENT. The initial distilled transport swap preserved a
134
+ * catch-all fold because PVE reports missing objects as HTTP 500. SDK PR 265 now types that
135
+ * measured message, so permission errors, unrelated 500s and exhausted transports propagate
136
+ * instead of triggering a speculative create (decision 49 follow-up, 2026-09-24).
137
+ * ★ `readUserOrFail` remains the strict path used for an already-confirmed state row. This
138
+ * change narrows cold reads/reconcile without changing that existing drift/diff contract.
142
139
  */
143
140
  export const readUser = (props: UserProps) =>
144
- readUserOrFail(props).pipe(Effect.orElseSucceed(() => undefined));
141
+ readUserOrFail(props).pipe(Effect.catchTag('UserNotFound', () => Effect.succeed(undefined)));
145
142
 
146
143
  /** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
147
144
  export const dropUnreadable = (live: UserAttributes | Unreadable | undefined) =>
@@ -34,11 +34,11 @@
34
34
  * · `Sys.Audit` (or `User.Modify`) for the read, which the `read` role's auditor already has.
35
35
  *
36
36
  * ⚠️ A MISSING USER IS A 500, NOT A 404 — MEASURED against the live cluster, "no such user
37
- * ('x@pve')". distilled turns that into an `InternalServerError`, with no distinct "not found"
38
- * tag to catch. `user-wire.ts`'s `readUser` (used by `read`/`reconcile`, where the create
39
- * workflow needs "absent" from exactly this failure) still folds it; `readUserOrFail` (used by
40
- * `diff` below) does not — see that function's own header for why folding it there was the
41
- * cries-wolf bug all over again, just for a genuine PVE quirk instead of a permission denial.
37
+ * ('x@pve')". The first SDK migration exposed it as `InternalServerError`, so its create
38
+ * workflow needed a catch-all fold. SDK PR #265 now recognizes `UserNotFound`; `readUser`
39
+ * catches only that tag, letting other cold-read/reconcile failures propagate. The strict
40
+ * `readUserOrFail` path for confirmed rows remains unchanged: its original transient-failure
41
+ * fix stopped the cries-wolf update with nothing compared (user-wire.ts).
42
42
  *
43
43
  * ⚠️ THE WIRE URL FOR THIS FAMILY CHANGED, THOUGH NOTHING A CALLER SEES DID. MEASURED
44
44
  * (user.test.ts): distilled's `{userid}` label substitution percent-encodes it —