@homeflare/alchemy 0.31.1 → 0.31.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.
@@ -0,0 +1,183 @@
1
+ /**
2
+ * The 0.31.1 regression (measured live 2026-09-24, homeflare-proxmox `bun run plan`): every
3
+ * `Proxmox.User`/`Proxmox.Group` row planned `update` with no warning, because their reads were
4
+ * silently folding EVERY failure — not only a refused credential — into "absent". Two fixes,
5
+ * both pinned here with REAL-SHAPED fixtures (live response bodies, nothing secret):
6
+ *
7
+ * 1. `access.getAccessUser`/`getAccessGroup` must be called with ONLY the schema's own label
8
+ * field, never the whole declared props object — the root cause: distilled's `buildRequest`
9
+ * treats every OTHER key as an "unknown key" and attaches it as a JSON body to what must stay
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" — only
12
+ * `read`/`reconcile` may, where a wrongful fold costs at most a redundant, loudly-refused
13
+ * create, never a silent wrong write.
14
+ */
15
+ import { describe, expect, test } from 'bun:test';
16
+ import * as Layer from 'effect/Layer';
17
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
18
+ import { engineOver } from '../verify/fake-engine.ts';
19
+ import { FAKE_TARGET, withoutBao } from './fake-pve.ts';
20
+ import { ProxmoxGroup, ProxmoxGroupProvider } from './group.ts';
21
+ import { ProxmoxUser, ProxmoxUserProvider } from './user.ts';
22
+
23
+ /**
24
+ * A fake cluster whose GET on `path` answers 200 with `live`, but REFUSES (500, mimicking PVE's
25
+ * real "InternalServerError" shape) once `transientFrom` is `true` — and asserts no request ever
26
+ * carries a body on a GET, the request-shape regression's own signature.
27
+ */
28
+ const clusterWithTransientFailure = (path: string, live: unknown) => {
29
+ let transient = false;
30
+ const stub = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
31
+ const request =
32
+ input instanceof Request ? new Request(input, init) : new Request(String(input), init);
33
+ const url = new URL(request.url);
34
+ if (url.pathname.includes('/creds/')) {
35
+ const data = { secret: 'fake-secret-not-real', token_id: 'hf-test@pve!fake' };
36
+ return Response.json({ data, lease_duration: 0 });
37
+ }
38
+ if (request.method === 'GET') {
39
+ const body = await request.text();
40
+ // ⛔ THE REQUEST-SHAPE REGRESSION'S OWN SIGNATURE: a real fetch client refuses this outright.
41
+ expect(body).toBe('');
42
+ }
43
+ // ⚠️ `decodeURIComponent` — user.ts's own ⚠️: distilled percent-encodes a `{userid}`/
44
+ // `{groupid}` label substitution (`tim@pve` -> `tim%40pve`), which a literal `path` compare
45
+ // would silently never match, turning every case below into a no-op stub.
46
+ const wire = decodeURIComponent(url.pathname.replace(/^\/api2\/json\//, ''));
47
+ if (wire !== path) return Response.json({ data: null });
48
+ if (transient) {
49
+ // MEASURED 2026-09-24, live TB4: a missing user/group answers exactly this shape.
50
+ return Response.json({ data: null, message: `no such object (${path})` }, { status: 500 });
51
+ }
52
+ return Response.json({ data: live });
53
+ };
54
+ const fetchStub = Object.assign(stub, { preconnect: globalThis.fetch.preconnect });
55
+ return {
56
+ breakReads: () => {
57
+ transient = true;
58
+ },
59
+ layer: FetchHttpClient.layer.pipe(
60
+ Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, fetchStub as typeof fetch)),
61
+ ),
62
+ };
63
+ };
64
+
65
+ describe('a transient read failure fails the plan loudly, never a silent false update', () => {
66
+ test('Proxmox.User: diff() propagates a transient read failure once a state row exists', async () => {
67
+ // MEASURED 2026-09-24, live TB4 `GET /access/users/tim@pve`.
68
+ const live = {
69
+ comment: 'Primary admin — PVE fallback',
70
+ email: 'tim@schenanigans.com',
71
+ enable: 1,
72
+ expire: 0,
73
+ firstname: 'Timothy',
74
+ groups: ['Schenanigans'],
75
+ lastname: 'Schneider',
76
+ tokens: null,
77
+ };
78
+ const declare = () =>
79
+ ProxmoxUser('tim', {
80
+ comment: 'Primary admin — PVE fallback',
81
+ email: 'tim@schenanigans.com',
82
+ firstname: 'Timothy',
83
+ groups: ['Schenanigans'],
84
+ lastname: 'Schneider',
85
+ target: FAKE_TARGET,
86
+ userid: 'tim@pve',
87
+ });
88
+ const fake = clusterWithTransientFailure('access/users/tim@pve', live);
89
+ await withoutBao(async () => {
90
+ const engine = engineOver(ProxmoxUserProvider().pipe(Layer.provideMerge(fake.layer)));
91
+ // First deploy: adopts the matching account, records a state row.
92
+ expect(await engine.deploy(declare())).toEqual({ tim: 'adopted' });
93
+ // Now every read of this account fails the way the live regression measured.
94
+ fake.breakReads();
95
+ // ⛔ THE FIX: this must REJECT (fail the plan), never resolve with a false 'update'.
96
+ await expect(engine.verify(declare(), { all: true })).rejects.toBeDefined();
97
+ });
98
+ });
99
+
100
+ test('Proxmox.Group: diff() propagates a transient read failure once a state row exists', async () => {
101
+ // MEASURED 2026-09-24, live TB4 `GET /access/groups/HomeAssistant`.
102
+ const live = { comment: 'Home Assistant service group', members: [] };
103
+ const declare = () =>
104
+ ProxmoxGroup('ha', {
105
+ comment: 'Home Assistant service group',
106
+ groupid: 'HomeAssistant',
107
+ target: FAKE_TARGET,
108
+ });
109
+ const fake = clusterWithTransientFailure('access/groups/HomeAssistant', live);
110
+ await withoutBao(async () => {
111
+ const engine = engineOver(ProxmoxGroupProvider().pipe(Layer.provideMerge(fake.layer)));
112
+ expect(await engine.deploy(declare())).toEqual({ ha: 'adopted' });
113
+ fake.breakReads();
114
+ await expect(engine.verify(declare(), { all: true })).rejects.toBeDefined();
115
+ });
116
+ });
117
+
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 — `readUser`/`readGroup` (used by `reconcile`) kept
120
+ // their fold on purpose, exactly so that path stays unaffected by this fix.
121
+ });
122
+
123
+ describe('the same fix, one command over: alchemy drift never reports a transient failure as missing', () => {
124
+ // ⛔ FOUND BY ADVERSARIAL REVIEW OF THIS VERY FIX, 2026-09-24 — NOT part of the live-measured
125
+ // regression above, but the SAME bug class one layer deeper. `alchemy/src/Drift.ts` (`alchemy
126
+ // drift`/`sync`/`deploy --detect-drift`) calls a provider's `read` hook DIRECTLY on an
127
+ // already-confirmed row (`output: old.attr`, defined — Drift.ts:239-292), the same situation
128
+ // `diff` handles above. Before `user.ts`/`group.ts`'s `read` hook learned to branch on
129
+ // `output` (this fix's second half), it ALWAYS called the folding `readUser`/`readGroup` —
130
+ // so a transient failure here folded to `undefined`, and Drift.ts's own dry-run branch
131
+ // reports that as `{action: 'missing'}` with NO error surfaced at all: a silent false "this
132
+ // account is gone" report, worse than the false `update` `diff` used to produce because
133
+ // nothing here even logs a warning. `engineOver`'s new `drift` capability
134
+ // (verify/fake-engine.ts) drives the real `Alchemy.Drift.detect` over the same fake cluster.
135
+ test('Proxmox.User: a confirmed row propagates instead of reporting missing', async () => {
136
+ const live = {
137
+ comment: 'Primary admin — PVE fallback',
138
+ email: 'tim@schenanigans.com',
139
+ enable: 1,
140
+ expire: 0,
141
+ firstname: 'Timothy',
142
+ groups: ['Schenanigans'],
143
+ lastname: 'Schneider',
144
+ tokens: null,
145
+ };
146
+ const declare = () =>
147
+ ProxmoxUser('tim', {
148
+ comment: 'Primary admin — PVE fallback',
149
+ email: 'tim@schenanigans.com',
150
+ firstname: 'Timothy',
151
+ groups: ['Schenanigans'],
152
+ lastname: 'Schneider',
153
+ target: FAKE_TARGET,
154
+ userid: 'tim@pve',
155
+ });
156
+ const fake = clusterWithTransientFailure('access/users/tim@pve', live);
157
+ await withoutBao(async () => {
158
+ const engine = engineOver(ProxmoxUserProvider().pipe(Layer.provideMerge(fake.layer)));
159
+ expect(await engine.deploy(declare())).toEqual({ tim: 'adopted' });
160
+ fake.breakReads();
161
+ // ⛔ THE FIX: this must REJECT. Before `read`'s `output` branch, it resolved with
162
+ // `{ tim: { action: 'missing' } }` and no error anywhere.
163
+ await expect(engine.drift(declare())).rejects.toBeDefined();
164
+ });
165
+ });
166
+
167
+ test('Proxmox.Group: a confirmed row propagates instead of reporting missing', async () => {
168
+ const live = { comment: 'Home Assistant service group', members: [] };
169
+ const declare = () =>
170
+ ProxmoxGroup('ha', {
171
+ comment: 'Home Assistant service group',
172
+ groupid: 'HomeAssistant',
173
+ target: FAKE_TARGET,
174
+ });
175
+ const fake = clusterWithTransientFailure('access/groups/HomeAssistant', live);
176
+ await withoutBao(async () => {
177
+ const engine = engineOver(ProxmoxGroupProvider().pipe(Layer.provideMerge(fake.layer)));
178
+ expect(await engine.deploy(declare())).toEqual({ ha: 'adopted' });
179
+ fake.breakReads();
180
+ await expect(engine.drift(declare())).rejects.toBeDefined();
181
+ });
182
+ });
183
+ });
@@ -107,7 +107,15 @@ export interface ProxmoxRole extends Resource<
107
107
 
108
108
  export const ProxmoxRole = Resource<ProxmoxRole>('Proxmox.Role');
109
109
 
110
- /** ★ Default `read` role — see the header's ⚠️ on the list read's privilege requirement. */
110
+ /**
111
+ * ★ Default `read` role — see the header's ⚠️ on the list read's privilege requirement.
112
+ * ⛔ NO `orElseSucceed` — found 2026-09-24, the same bug class as the credential denial fix. A
113
+ * role's absence is ALWAYS a SUCCESSFUL read (the list comes back and the role is not in it —
114
+ * `access.listAccessRoles` never 404s/500s per-role the way `getAccessUser`/`getAccessGroup`
115
+ * do, MEASURED), so this family never needed a folding/non-folding split the way user.ts and
116
+ * group.ts do: any THROWN failure here is genuinely unexpected and must propagate and fail the
117
+ * whole plan loudly, in `diff`, `read` and `reconcile` alike — never fold to "absent → update".
118
+ */
111
119
  const readRole = (props: RoleProps) =>
112
120
  readOrUnreadable(runPve(props.target, 'read', false, access.listAccessRoles({}))).pipe(
113
121
  Effect.map((rows) => {
@@ -115,7 +123,6 @@ const readRole = (props: RoleProps) =>
115
123
  const row = find(rows, props.roleid);
116
124
  return row === undefined ? undefined : attributesOf(row, props);
117
125
  }),
118
- Effect.orElseSucceed(() => undefined),
119
126
  );
120
127
 
121
128
  /** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
@@ -1,10 +1,13 @@
1
1
  /**
2
- * `Proxmox.User`'s wire shape: PVE's create/update forms and how a live item becomes
3
- * attributes. Split out of user.ts (2026-09-24, the distilled migration) to keep that file
4
- * under the 250-line cap — the api-token.ts/api-token-form.ts seam.
2
+ * `Proxmox.User`'s wire shape: PVE's create/update forms, how a live item becomes attributes,
3
+ * and the read itself. Split out of user.ts (2026-09-24, the distilled migration) to keep that
4
+ * file under the 250-line cap — the api-token.ts/api-token-form.ts seam.
5
5
  */
6
- import type * as access from '@distilled.cloud/proxmox/access';
6
+ import * as access from '@distilled.cloud/proxmox/access';
7
+ import * as Effect from 'effect/Effect';
8
+ import { runPve } from './distilled-pve.ts';
7
9
  import type { UserAttributes, UserProps } from './user.ts';
10
+ import { UNREADABLE, type Unreadable, readOrUnreadable } from './unreadable-read.ts';
8
11
  import { bool, text } from './values.ts';
9
12
 
10
13
  export const USER_CREATE = 'pve:POST /access/users';
@@ -77,10 +80,7 @@ export const updateForm = (props: UserProps): access.PutAccessUserRequest => ({
77
80
  userid: props.userid,
78
81
  });
79
82
 
80
- export const attributesOf = (
81
- live: access.GetAccessUserResponse,
82
- props: UserProps,
83
- ): UserAttributes => ({
83
+ const attributesOf = (live: access.GetAccessUserResponse, props: UserProps): UserAttributes => ({
84
84
  comment: text(live.comment),
85
85
  email: text(live.email),
86
86
  /**
@@ -110,3 +110,39 @@ export const matches = (attributes: UserAttributes, props: UserProps) =>
110
110
  attributes.firstname === (props.firstname ?? '') &&
111
111
  attributes.lastname === (props.lastname ?? '') &&
112
112
  attributes.groups.join(',') === groupSet(props.groups).join(',');
113
+
114
+ /**
115
+ * ★ Default `read` role for this family — only the write side needs `provision`.
116
+ * ⛔ `{ userid: props.userid }`, NEVER THE WHOLE `props` — MEASURED 2026-09-24, the regression in
117
+ * kit 0.31.1. `GetAccessUserRequest`'s schema declares only `userid` (a path label); any OTHER
118
+ * key on the object passed to `access.getAccessUser` — `target`, `comment`, every field this
119
+ * family's own props carry — is treated by distilled's `buildRequest` as an "unknown key" and
120
+ * JSON-encoded onto the request as a BODY, on every one of these bodyless GETs. A stricter
121
+ * fetch client than a bare `bun run` of this file refuses a GET carrying any body at all
122
+ * (`TypeError [ERR_INVALID_ARG_VALUE]: fetch() request with GET/HEAD method cannot have body`),
123
+ * which `runPveWith` retries across every member and exhausts identically —
124
+ * `PveClusterExhausted`, then silently folded to "absent" by the old `orElseSucceed`, forcing a
125
+ * false `update` on every account.
126
+ */
127
+ export const readUserOrFail = (props: UserProps) =>
128
+ readOrUnreadable(
129
+ runPve(props.target, 'read', false, access.getAccessUser({ userid: props.userid })),
130
+ ).pipe(Effect.map((live) => (live === UNREADABLE ? UNREADABLE : attributesOf(live, props))));
131
+
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.
142
+ */
143
+ export const readUser = (props: UserProps) =>
144
+ readUserOrFail(props).pipe(Effect.orElseSucceed(() => undefined));
145
+
146
+ /** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
147
+ export const dropUnreadable = (live: UserAttributes | Unreadable | undefined) =>
148
+ live === UNREADABLE ? undefined : live;
@@ -33,12 +33,12 @@
33
33
  * user, so a role wide enough to edit the account can still be refused for one group.
34
34
  * · `Sys.Audit` (or `User.Modify`) for the read, which the `read` role's auditor already has.
35
35
  *
36
- * ⚠️ A MISSING USER IS A 500, NOT A 404 — "no such user ('x@pve')". distilled turns that into an
37
- * `UnknownProxmoxError` (protocol.ts), and `readOrUnreadable`'s own `orElseSucceed` folds it
38
- * into "absent" same as the old hand-rolled client did. A 403 therefore also reads as absent
39
- * UNLESS it is the one refusal `readOrUnreadable` catches (unreadable-read.ts): a REFUSED
40
- * `provision` mint, the cries-wolf case. Any other refusal still reads as absent, and the
41
- * honest error arrives one step later, from the create.
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.
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 —
@@ -60,18 +60,15 @@ import * as Effect from 'effect/Effect';
60
60
  import { guardWrite } from './distilled-guard.ts';
61
61
  import type { PveRequirements, WithTarget } from './resource-spec.ts';
62
62
  import { runPve } from './distilled-pve.ts';
63
- import {
64
- UNREADABLE,
65
- type Unreadable,
66
- readOrUnreadable,
67
- unreadableWarning,
68
- } from './unreadable-read.ts';
63
+ import { UNREADABLE, unreadableWarning } from './unreadable-read.ts';
69
64
  import {
70
65
  USER_CREATE,
71
66
  USER_UPDATE,
72
- attributesOf,
73
67
  createForm,
68
+ dropUnreadable,
74
69
  matches,
70
+ readUser,
71
+ readUserOrFail,
75
72
  updateForm,
76
73
  } from './user-wire.ts';
77
74
 
@@ -126,17 +123,6 @@ export interface ProxmoxUser extends Resource<
126
123
 
127
124
  export const ProxmoxUser = Resource<ProxmoxUser>('Proxmox.User');
128
125
 
129
- /** ★ Default `read` role for this family — only the write side needs `provision`. */
130
- const readUser = (props: UserProps) =>
131
- readOrUnreadable(runPve(props.target, 'read', false, access.getAccessUser(props))).pipe(
132
- Effect.map((live) => (live === UNREADABLE ? UNREADABLE : attributesOf(live, props))),
133
- Effect.orElseSucceed(() => undefined),
134
- );
135
-
136
- /** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
137
- const dropUnreadable = (live: UserAttributes | Unreadable | undefined) =>
138
- live === UNREADABLE ? undefined : live;
139
-
140
126
  export const ProxmoxUserProvider = () =>
141
127
  Provider.effect(
142
128
  ProxmoxUser,
@@ -148,15 +134,31 @@ export const ProxmoxUserProvider = () =>
148
134
  * identity someone made years ago. Adoption is an explicit act.
149
135
  */
150
136
  list: () => Effect.succeed([]),
151
- read: Effect.fn(function* ({ olds }) {
152
- return dropUnreadable(yield* readUser(olds));
137
+ // ⚠️ FOLDS ONLY WHEN `output` IS `undefined` — MEASURED 2026-09-24, an adversarial review
138
+ // of this very fix caught the gap. The engine calls this ONE hook from FOUR places
139
+ // (alchemy/src/{Plan,Apply,Drift}.ts), and only `output` tells them apart: Plan.ts's
140
+ // cold-start adoption probe and interrupted-create recovery, and Apply.ts's
141
+ // delete-recovery, all pass `output: undefined` — nothing is confirmed to exist yet, so
142
+ // `readUser`'s fold is still needed there or a brand-new account could never be adopted
143
+ // or created. `Drift.ts` (`alchemy drift`/`sync`/`deploy --detect-drift`) is different:
144
+ // it calls this on an ALREADY-CONFIRMED row (`output: old.attr`, defined), exactly
145
+ // `diff`'s own situation below — a transient failure folded to `undefined` there is
146
+ // reported as `{action: 'missing'}` with NO error at all, the cries-wolf bug again, one
147
+ // command over. `readUserOrFail` on that branch propagates it instead.
148
+ read: Effect.fn(function* ({ olds, output }) {
149
+ return dropUnreadable(
150
+ yield* output === undefined ? readUser(olds) : readUserOrFail(olds),
151
+ );
153
152
  }),
154
153
  diff: Effect.fn(function* ({ news, output }) {
155
154
  if (!isResolved(news)) return undefined;
156
155
  yield* guardWrite(USER_CREATE, createForm(news), output === undefined);
157
156
  yield* guardWrite(USER_UPDATE, updateForm(news), false);
158
157
  if (output === undefined) return undefined;
159
- const live = yield* readUser(news);
158
+ // ⚠️ `readUserOrFail`, NOT `readUser` — see that function's own header (user-wire.ts).
159
+ // A genuine failure here propagates and fails the whole plan loudly instead of
160
+ // folding to "absent" and forcing a false update, the cries-wolf class of bug.
161
+ const live = yield* readUserOrFail(news);
160
162
  // ⛔ THE CRIES-WOLF FIX: a refused read used to fall into `undefined` below and force
161
163
  // `update` on an account that was plainly there — see unreadable-read.ts.
162
164
  if (live === UNREADABLE) {
@@ -125,11 +125,24 @@ export type Body = Effect.Effect<unknown, unknown, unknown>;
125
125
  type Compiled = { readonly name: string; readonly stage: string; readonly services: never };
126
126
  type PlanView = { readonly resources: Readonly<Record<string, { readonly action: string }>> };
127
127
 
128
+ /** One row of `Alchemy.Drift.detect`'s dry-run report — the fields `drift` below asserts on. */
129
+ export type DriftRow = { readonly action: string };
130
+
128
131
  export interface FakeEngine {
129
132
  /** Plan and apply `body`, as `alchemy deploy --adopt` would; resolves to the planned actions. */
130
133
  readonly deploy: (body: Body) => Promise<Record<string, string>>;
131
134
  /** Verify `body` against what the earlier deploys recorded. */
132
135
  readonly verify: (body: Body, options?: VerifyOptions) => Promise<AdoptReport>;
136
+ /**
137
+ * `alchemy drift`'s own dry-run (`Alchemy.Drift.detect`) over whatever `deploy` already
138
+ * persisted — `body` is used only to resolve the stack's name/stage/providers, exactly as
139
+ * `verify` does; drift itself reads the state store, not `body`'s declarations. ⚠️ A DIFFERENT
140
+ * ENGINE PATH FROM `verify`'s `--all` READ, proxmox/read-failure-propagation.test.ts's own ⛔:
141
+ * `Drift.ts` calls a provider's `read` with `output` set to the PERSISTED attributes, and folds
142
+ * a `read` that resolves `undefined` into `{action: 'missing'}` with no error — silent, unlike
143
+ * `verify.ts`'s `readWithState`, which already wraps that same call in `Effect.catchCause`.
144
+ */
145
+ readonly drift: (body: Body) => Promise<Record<string, DriftRow>>;
133
146
  /**
134
147
  * Every state row the store holds, as JSON — what a Postgres store would persist. ★ For tests
135
148
  * that must prove a value NEVER reaches state (proxmox/pbs-notification-target-state.test.ts).
@@ -152,6 +165,10 @@ export const engineOver = <ROut, E, RIn>(layer: Layer.Layer<ROut, E, RIn>): Fake
152
165
  ) => Effect.Effect<Compiled, unknown, never>;
153
166
  const plan = Alchemy.Plan.make as unknown as (c: Compiled) => Effect.Effect<PlanView, unknown>;
154
167
  const apply = Alchemy.apply as unknown as (planned: PlanView) => Effect.Effect<unknown, unknown>;
168
+ const detectDrift = Alchemy.Drift.detect as unknown as (stack: {
169
+ name: string;
170
+ stage: string;
171
+ }) => Effect.Effect<{ resources: Record<string, DriftRow> }, unknown>;
155
172
 
156
173
  const run = <A>(body: Body, next: (compiled: Compiled) => Effect.Effect<A, unknown, unknown>) =>
157
174
  Effect.runPromise(
@@ -182,6 +199,12 @@ export const engineOver = <ROut, E, RIn>(layer: Layer.Layer<ROut, E, RIn>): Fake
182
199
  run(body, (compiled) =>
183
200
  verifySession({ context: compiled.services, stack: compiled as never }, options),
184
201
  ),
202
+ drift: (body) =>
203
+ run(body, (compiled) =>
204
+ detectDrift({ name: compiled.name, stage: compiled.stage }).pipe(
205
+ Effect.map((detected) => detected.resources),
206
+ ),
207
+ ),
185
208
  };
186
209
  };
187
210