@homeflare/alchemy 0.37.0 → 0.37.1
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.
- package/dist/proxmox/ceph-daemon-wire.d.ts +5 -7
- package/dist/proxmox/ceph-daemon-wire.d.ts.map +1 -1
- package/dist/proxmox/ceph-daemon.d.ts +7 -8
- package/dist/proxmox/ceph-daemon.d.ts.map +1 -1
- package/dist/proxmox/ceph-fs-distilled.d.ts +24 -5
- package/dist/proxmox/ceph-fs-distilled.d.ts.map +1 -1
- package/dist/proxmox/ceph-fs.d.ts +4 -5
- package/dist/proxmox/ceph-fs.d.ts.map +1 -1
- package/dist/proxmox/ceph-pool-settle.d.ts +9 -8
- package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -1
- package/dist/proxmox/ceph-pool-wire.d.ts +11 -20
- package/dist/proxmox/ceph-pool-wire.d.ts.map +1 -1
- package/dist/proxmox/group-wire.d.ts +7 -12
- package/dist/proxmox/group-wire.d.ts.map +1 -1
- package/dist/proxmox/index.js +7 -19
- package/dist/proxmox/index.js.map +13 -13
- package/dist/proxmox/storage-wire.d.ts +7 -10
- package/dist/proxmox/storage-wire.d.ts.map +1 -1
- package/dist/proxmox/unreadable-read.d.ts +7 -7
- package/dist/proxmox/user-wire.d.ts +7 -10
- package/dist/proxmox/user-wire.d.ts.map +1 -1
- package/dist/proxmox/user.d.ts +5 -5
- package/docs/distilled-interim.md +15 -3
- package/docs/provider-standard.md +24 -6
- package/docs/proxmox.md +20 -0
- package/docs/upstream-conformance-bun.md +67 -0
- package/docs/upstream-conformance-conforms.md +55 -0
- package/docs/upstream-conformance-families.md +95 -0
- package/docs/upstream-conformance-grafana.md +106 -0
- package/docs/upstream-conformance.md +20 -298
- package/package.json +1 -1
- package/src/proxmox/ceph-daemon-wire.ts +4 -7
- package/src/proxmox/ceph-daemon.ts +7 -8
- package/src/proxmox/ceph-fs-distilled.ts +7 -9
- package/src/proxmox/ceph-fs.ts +8 -10
- package/src/proxmox/ceph-pool-settle.ts +11 -10
- package/src/proxmox/ceph-pool-wire.ts +11 -20
- package/src/proxmox/ceph-pool-write.test.ts +9 -8
- package/src/proxmox/group-wire.ts +7 -12
- package/src/proxmox/read-failure-propagation.test.ts +5 -5
- package/src/proxmox/storage-wire.ts +9 -10
- package/src/proxmox/typed-absence.test.ts +146 -0
- package/src/proxmox/unreadable-read.ts +7 -7
- package/src/proxmox/user-wire.ts +7 -10
- package/src/proxmox/user.ts +5 -5
|
@@ -6,12 +6,10 @@
|
|
|
6
6
|
* ★ THE ABSENCE SIGNAL IS THE MEASURED ONE. `GET .../pool/{name}/status` on a name with no pool
|
|
7
7
|
* answers HTTP 500 (live TB4 `n2`, 2026-09-24, read-role, read-only probe:
|
|
8
8
|
* `{"data":null,"message":"error with 'osd pool get': mon_cmd failed - unrecognized pool
|
|
9
|
-
* '<name>'\n"}`) —
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* answer anything but HTTP 200, so this file builds its own stub the way
|
|
14
|
-
* zfs-pool-read-failure.test.ts does, to answer the measured 500 directly.
|
|
9
|
+
* '<name>'\n"}`) — SDK PR #265 now recognizes it as `CephPoolNotFound`. Only that tag folds
|
|
10
|
+
* to absence; unrelated 500s propagate. `confirmAbsent` (ceph-pool-settle.ts) still asks a
|
|
11
|
+
* SECOND, different question — does the index list this name — before create may run.
|
|
12
|
+
* `fakePve` only answers HTTP 200, so this file stubs the measured 500 directly.
|
|
15
13
|
*/
|
|
16
14
|
import { describe, expect, test } from 'bun:test';
|
|
17
15
|
import * as RemovalPolicy from 'alchemy/RemovalPolicy';
|
|
@@ -28,10 +26,13 @@ const INDEX = `nodes/${NODE}/ceph/pool`;
|
|
|
28
26
|
const OBJECT = `${INDEX}/${NAME}`;
|
|
29
27
|
const STATUS = `${OBJECT}/status`;
|
|
30
28
|
|
|
31
|
-
/**
|
|
29
|
+
/** The measured missing-pool 500, classified by the SDK as `CephPoolNotFound`. */
|
|
32
30
|
const absent = () =>
|
|
33
31
|
Response.json(
|
|
34
|
-
{
|
|
32
|
+
{
|
|
33
|
+
data: null,
|
|
34
|
+
message: `error with 'osd pool get': mon_cmd failed - unrecognized pool '${NAME}'\n`,
|
|
35
|
+
},
|
|
35
36
|
{ status: 500 },
|
|
36
37
|
);
|
|
37
38
|
|
|
@@ -97,20 +97,15 @@ export const readGroupOrFail = (props: GroupProps) =>
|
|
|
97
97
|
);
|
|
98
98
|
|
|
99
99
|
/**
|
|
100
|
-
* ⛔
|
|
101
|
-
* PVE
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
* ⛔ `group.ts`'s `diff` does **NOT** use this — found 2026-09-24, the SAME bug class as the
|
|
107
|
-
* credential denial fix: folding a TRANSIENT failure (a `PveClusterExhausted`, the encoding bug
|
|
108
|
-
* above) into "absent" at PLAN TIME is exactly what forced a false `update` with nothing
|
|
109
|
-
* compared. `diff` calls `readGroupOrFail` directly instead, so a genuine failure there
|
|
110
|
-
* propagates and fails the whole plan loudly — never silently misreports one row.
|
|
100
|
+
* ⛔ ONLY `GroupNotFound` MEANS ABSENT. The initial distilled transport swap preserved a
|
|
101
|
+
* catch-all fold because PVE reports missing objects as HTTP 500. SDK PR 265 now types that
|
|
102
|
+
* measured message, so permission errors, unrelated 500s and exhausted transports propagate
|
|
103
|
+
* instead of triggering a speculative create (decision 49 follow-up, 2026-09-24).
|
|
104
|
+
* ★ `readGroupOrFail` remains the strict path used for an already-confirmed state row. This
|
|
105
|
+
* change narrows cold reads/reconcile without changing that existing drift/diff contract.
|
|
111
106
|
*/
|
|
112
107
|
export const readGroup = (props: GroupProps) =>
|
|
113
|
-
readGroupOrFail(props).pipe(Effect.
|
|
108
|
+
readGroupOrFail(props).pipe(Effect.catchTag('GroupNotFound', () => Effect.succeed(undefined)));
|
|
114
109
|
|
|
115
110
|
/** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
|
|
116
111
|
export const dropUnreadable = (live: GroupAttributes | Unreadable | undefined) =>
|
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
* field, never the whole declared props object — the root cause: distilled's `buildRequest`
|
|
9
9
|
* treats every OTHER key as an "unknown key" and attaches it as a JSON body to what must stay
|
|
10
10
|
* a bodyless GET, which a stricter fetch client refuses outright.
|
|
11
|
-
* 2. `diff` must never fold a genuine (non-credential-denial) read failure into "absent"
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* 2. `diff` must never fold a genuine (non-credential-denial) read failure into "absent".
|
|
12
|
+
* The later typed-absence follow-up extends that protection to cold `read`/`reconcile`:
|
|
13
|
+
* only the SDK's resource-specific missing-object tags may trigger a create.
|
|
14
14
|
*/
|
|
15
15
|
import { describe, expect, test } from 'bun:test';
|
|
16
16
|
import * as Layer from 'effect/Layer';
|
|
@@ -116,8 +116,8 @@ describe('a transient read failure fails the plan loudly, never a silent false u
|
|
|
116
116
|
});
|
|
117
117
|
|
|
118
118
|
// ★ A brand-new declaration's own create-from-a-500 path is already covered end to end by
|
|
119
|
-
// user.test.ts's `missingAs500` fixture
|
|
120
|
-
//
|
|
119
|
+
// user.test.ts's `missingAs500` fixture. The typed-absence follow-up keeps that path by
|
|
120
|
+
// catching `UserNotFound`, while unrelated 500s now propagate on cold reads too.
|
|
121
121
|
});
|
|
122
122
|
|
|
123
123
|
describe('the same fix, one command over: alchemy drift never reports a transient failure as missing', () => {
|
|
@@ -93,18 +93,17 @@ export const readStorageOrFail = (props: StorageProps) =>
|
|
|
93
93
|
).pipe(Effect.map((live) => (live === UNREADABLE ? UNREADABLE : attributesOf(live, props))));
|
|
94
94
|
|
|
95
95
|
/**
|
|
96
|
-
* ⛔
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* nothing compared (the ORIGINAL cries-wolf bug this family shipped with, per unreadable-read.ts's
|
|
103
|
-
* own header). `diff` calls `readStorageOrFail` directly, so a genuine failure there propagates
|
|
104
|
-
* and fails the whole plan loudly instead.
|
|
96
|
+
* ⛔ ONLY `StorageNotFound` MEANS ABSENT. The initial distilled transport swap preserved a
|
|
97
|
+
* catch-all fold because PVE reports missing objects as HTTP 500. SDK PR 265 now types that
|
|
98
|
+
* measured message, so permission errors, unrelated 500s and exhausted transports propagate
|
|
99
|
+
* instead of triggering a speculative create (decision 49 follow-up, 2026-09-24).
|
|
100
|
+
* ★ `readStorageOrFail` remains the strict path used for an already-confirmed state row. This
|
|
101
|
+
* change narrows cold reads/reconcile without changing that existing drift/diff contract.
|
|
105
102
|
*/
|
|
106
103
|
export const readStorage = (props: StorageProps) =>
|
|
107
|
-
readStorageOrFail(props).pipe(
|
|
104
|
+
readStorageOrFail(props).pipe(
|
|
105
|
+
Effect.catchTag('StorageNotFound', () => Effect.succeed(undefined)),
|
|
106
|
+
);
|
|
108
107
|
|
|
109
108
|
/** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
|
|
110
109
|
export const dropUnreadable = (live: StorageAttributes | Unreadable | undefined) =>
|
|
@@ -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.
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
package/src/proxmox/user-wire.ts
CHANGED
|
@@ -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
|
-
* ⛔
|
|
134
|
-
* missing
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
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.
|
|
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) =>
|
package/src/proxmox/user.ts
CHANGED
|
@@ -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')".
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* `
|
|
41
|
-
* cries-wolf
|
|
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 —
|