@homeflare/alchemy 0.27.3 → 0.27.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.
@@ -1,48 +1,66 @@
1
1
  import type { Input } from 'alchemy/Input';
2
+ import type { NetboxOpContext } from '@distilled.cloud/netbox/Protocol';
2
3
  import * as Effect from 'effect/Effect';
3
4
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
4
- import { NetboxError, type NetboxRow } from './client.ts';
5
5
  import type { EndpointKey, NetboxBody } from './constraints.ts';
6
6
  /**
7
- * ★ WHAT EVERY OPERATION HERE NEEDS FROM THE RUNTIME, AND NOTHING MORE. The token comes from
8
- * `NETBOX_TOKEN` in the environment, so there is no process to spawn and no `bao` to shell out
9
- * to. Declaring a service you never use is not harmless — it makes a stack provide a layer for
10
- * nothing, and it hides the one service you DO need.
7
+ * ★ WHAT EVERY HANDLER NEEDS FROM THE CALLER'S RUNTIME, after Credentials are provided below.
8
+ * `Netbox.NetboxOpContext` is `Credentials | HttpClient.HttpClient`; `netboxHandlers` closes
9
+ * over `CredentialsFromEnv` itself (`NETBOX_URL` / `NETBOX_TOKEN`, the names client.ts already
10
+ * read — see the SDK's own credentials.ts note), so a consuming stack only has to provide
11
+ * `HttpClient.HttpClient`, exactly as it did before this migration.
11
12
  */
12
13
  export type NetboxRequirements = HttpClient.HttpClient;
13
- export interface NetboxSpec<Props extends object, Attributes> {
14
- /** `ipam/prefixes` — where a new one is POSTed and where `locate` filters. */
15
- readonly collection: string;
16
- /**
17
- * The query that narrows the candidates, as `URLSearchParams` pairs.
18
- *
19
- * ⛔ ONLY FILTERS THE VENDOR DOCUMENT DECLARES, AND ONLY IN THE FORM IT DECLARES THEM. A
20
- * filter NetBox rejects answers 400, which `absentOn404` correctly does NOT fold to "absent" —
21
- * so a guessed filter shape is a hard failure on every plan, not a soft one.
22
- * ★ IT DOES NOT HAVE TO BE UNIQUE. `identifies` finishes the job in this process, where the
23
- * rule is readable and testable offline.
24
- */
25
- readonly locate: (props: Props) => readonly (readonly [string, string])[];
26
- /**
27
- * Which of the returned rows IS this object.
28
- *
29
- * 🔴 THIS EXISTS BECAUSE THE OBVIOUS ALTERNATIVE WAS A GUESS. `Netbox.Prefix` first narrowed
30
- * server-side with `vrf_id=null`, the sentinel NetBox uses elsewhere for "no foreign key".
31
- * ⛔ MEASURED IN THE VENDOR'S OWN SOURCE at v4.7.0: `PrefixFilterSet.vrf_id` is a plain
32
- * `django_filters.ModelMultipleChoiceFilter` with NO `null_value`, so `'null'` is validated
33
- * against the VRF queryset, fails, and NetBox answers 400 under strict filtering. The
34
- * sentinel is real (`FILTERS_NULL_CHOICE_VALUE = 'null'`) but it is opt-in per filter, and
35
- * this one did not opt in.
36
- * ★ SO THE DISCRIMINATOR MOVED INTO THIS PROCESS. It costs one page of candidates and buys a
37
- * rule that can be read, tested without a server, and cannot be wrong about a vendor's
38
- * filter semantics. Omit it when `locate` really is unique on its own.
39
- */
40
- readonly identifies?: (live: NetboxRow, props: Props) => boolean;
41
- readonly attributes: (live: NetboxRow, props: Props) => Attributes | undefined;
42
- readonly createBody: (props: Props) => NetboxBody;
43
- /** ⚠️ PARTIAL BY DESIGN: NetBox updates are PATCH, so presence is not checked on this body. */
44
- readonly updateBody?: (props: Props) => NetboxBody;
14
+ /**
15
+ * ⚠️ NOT `limit=1`, AND NOT `limit=2` EITHER. One row is all a caller wants, but the page has to
16
+ * be wide enough for an `identifies` narrowing to see every candidate a server-side filter
17
+ * matched — a prefix that exists in several VRFs comes back several times, and a page of two
18
+ * would hide the third.
19
+ * ★ TWENTY, AND THE NUMBER IS AN ASSERTION: a natural key matching more than twenty rows is not
20
+ * a natural key. `count` is checked against it, so the failure is "your filter is too broad"
21
+ * rather than a silent truncation. Unchanged from client.ts's original value.
22
+ */
23
+ export declare const LOCATE_PAGE = 20;
24
+ /**
25
+ * The single row a filtered list returns, or `undefined`.
26
+ *
27
+ * ⛔ MORE THAN ONE MATCH IS A DEFECT, NOT A REASON TO TAKE THE FIRST. A `locate` filter that is
28
+ * not unique would let adopt bind to whichever row NetBox happened to order first, and the next
29
+ * plan would bind to the other one and report drift that is not there.
30
+ */
31
+ export declare const soleMatch: <T>(rows: readonly T[], describe: string) => T | undefined;
32
+ /**
33
+ * List, then narrow to at most one candidate — the same "which of these rows IS this object"
34
+ * problem a server-side filter cannot always answer (see prefix.ts's own note on `vrf_id`). Any
35
+ * NetBox resource whose locate is "filter server-side, then disambiguate in this process" reaches
36
+ * this once rather than reimplementing the page-width assertion and the ambiguity defect per
37
+ * resource — the direct successor to client.ts's `fetchLive` helper, generalized over the SDK's
38
+ * own paginated-list shape instead of a hand-decoded `NetboxList<NetboxRow>`.
39
+ */
40
+ export declare const locateOne: <Row, E, R>(list: Effect.Effect<{
41
+ readonly count: number;
42
+ readonly results: readonly Row[];
43
+ }, E, R>, describe: string, identifies?: (row: Row) => boolean) => Effect.Effect<Row | undefined, E, R>;
44
+ /**
45
+ * One NetBox object's distilled calls. `Live` is whatever the SDK decodes (a `Prefix`, a `VLAN`,
46
+ * …) — no more untyped `NetboxRow`. `E` is left to each resource file to declare (the union of the
47
+ * distilled operations it actually calls), so a call site's precise per-operation error union
48
+ * flows straight through instead of being widened by hand here.
49
+ */
50
+ export type NetboxSpec<Props extends object, Live, Attributes, E> = {
51
+ /** Already folds any real "absent" outcome (an empty list, or a `catchTag('NotFound', ...)`). */
52
+ readonly fetchLive: (props: Props) => Effect.Effect<Live | undefined, E, NetboxOpContext>;
53
+ readonly attributes: (live: Live, props: Props) => Attributes | undefined;
45
54
  readonly matches: (attributes: Attributes, props: Props) => boolean;
55
+ readonly createBody: (props: Props) => NetboxBody;
56
+ readonly create: (props: Props, body: NetboxBody) => Effect.Effect<unknown, E, NetboxOpContext>;
57
+ /** Absent for a create-only family — reconcile then never attempts a write against an adopted row. */
58
+ readonly update?: {
59
+ /** ⚠️ PARTIAL BY DESIGN: NetBox updates are PATCH, so presence is not checked on this body. */
60
+ readonly body: (props: Props) => NetboxBody;
61
+ readonly call: (props: Props, live: Live, body: NetboxBody) => Effect.Effect<unknown, E, NetboxOpContext>;
62
+ };
63
+ readonly destroy: (props: Props, live: Live) => Effect.Effect<unknown, E, NetboxOpContext>;
46
64
  /** The generated constraint table to check each body against, by endpoint key. */
47
65
  readonly endpoint: {
48
66
  readonly create: EndpointKey;
@@ -50,30 +68,37 @@ export interface NetboxSpec<Props extends object, Attributes> {
50
68
  };
51
69
  /** For the message `soleMatch` raises and for read-back failures. */
52
70
  readonly describe: (props: Props) => string;
53
- }
54
- export declare const netboxOperations: <Props extends object, Attributes>(spec: NetboxSpec<Props, Attributes>) => {
55
- read: (props: Props) => Effect.Effect<Attributes | undefined, never, HttpClient.HttpClient>;
71
+ };
72
+ export declare const netboxOperations: <Props extends object, Live, Attributes, E>(spec: NetboxSpec<Props, Live, Attributes, E>) => {
73
+ read: (props: Props) => Effect.Effect<Attributes | undefined, E, NetboxOpContext>;
56
74
  diff: (news: Input<Props>, output: Attributes | undefined) => Effect.Effect<{
57
75
  readonly action: 'update';
58
76
  } | {
59
77
  readonly action: 'noop';
60
78
  } | {
61
79
  readonly action: 'replace';
62
- } | undefined, never, HttpClient.HttpClient>;
63
- reconcile: (news: Props) => Effect.Effect<Attributes & ({} | null), NetboxError, HttpClient.HttpClient>;
64
- destroy: (olds: Props) => Effect.Effect<void, NetboxError, HttpClient.HttpClient>;
80
+ } | undefined, E, NetboxOpContext>;
81
+ reconcile: (news: Props) => Effect.Effect<Attributes & ({} | null), E, NetboxOpContext>;
82
+ destroy: (olds: Props) => Effect.Effect<void, E, NetboxOpContext>;
65
83
  };
66
84
  /**
67
85
  * The five provider handlers for a spec'd NetBox object, wired once.
68
86
  *
87
+ * ★ CREDENTIALS ARE PROVIDED HERE, ONCE, NOT BY EVERY CALLER. `CredentialsFromEnv` resolves
88
+ * `NETBOX_URL` / `NETBOX_TOKEN` on the calling fiber per request, so this closure keeps that
89
+ * laziness — nothing is captured at module load. ⛔ THE TOKEN IS NEVER A PROP: Alchemy persists
90
+ * attributes unencrypted, so nothing stored in a stack file may be a credential.
91
+ *
92
+ * ⛔ IT STOPS AT THE HANDLERS AND DOES NOT RETURN THE LAYER — same reasoning as `forgejoHandlers`.
93
+ *
69
94
  * ⚠️ `list` answers empty. NetBox index endpoints return the whole estate — tens of thousands of
70
95
  * rows on a real instance — so adoption stays explicit rather than sweeping.
71
96
  */
72
- export declare const netboxHandlers: <Props extends object, Attributes extends object>(spec: NetboxSpec<Props, Attributes>) => {
97
+ export declare const netboxHandlers: <Props extends object, Live, Attributes extends object, E>(spec: NetboxSpec<Props, Live, Attributes, E>) => {
73
98
  list: () => Effect.Effect<never[], never, never>;
74
99
  read: ({ olds }: {
75
100
  olds: Props;
76
- }) => Effect.Effect<Attributes | undefined, never, HttpClient.HttpClient>;
101
+ }) => Effect.Effect<Attributes | undefined, E, HttpClient.HttpClient>;
77
102
  diff: ({ news, output }: {
78
103
  news: Input<Props>;
79
104
  output: Attributes | undefined;
@@ -83,12 +108,12 @@ export declare const netboxHandlers: <Props extends object, Attributes extends o
83
108
  readonly action: 'noop';
84
109
  } | {
85
110
  readonly action: 'replace';
86
- } | undefined, never, HttpClient.HttpClient>;
111
+ } | undefined, E, HttpClient.HttpClient>;
87
112
  reconcile: ({ news }: {
88
113
  news: Props;
89
- }) => Effect.Effect<Attributes, NetboxError, HttpClient.HttpClient>;
114
+ }) => Effect.Effect<Attributes, E, HttpClient.HttpClient>;
90
115
  delete: ({ olds }: {
91
116
  olds: Props;
92
- }) => Effect.Effect<void, NetboxError, HttpClient.HttpClient>;
117
+ }) => Effect.Effect<void, E, HttpClient.HttpClient>;
93
118
  };
94
119
  //# sourceMappingURL=resource.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"resource.d.ts","sourceRoot":"","sources":["../../src/netbox/resource.ts"],"names":[],"mappings":"AAqBA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AAC3C,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AACnE,OAAO,EAAE,WAAW,EAAmB,KAAK,SAAS,EAAqB,MAAM,aAAa,CAAC;AAE9F,OAAO,KAAK,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEhE;;;;;GAKG;AACH,MAAM,MAAM,kBAAkB,GAAG,UAAU,CAAC,UAAU,CAAC;AAEvD,MAAM,WAAW,UAAU,CAAC,KAAK,SAAS,MAAM,EAAE,UAAU;IAC1D,8EAA8E;IAC9E,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B;;;;;;;;OAQG;IACH,QAAQ,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,SAAS,CAAC,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,EAAE,CAAC;IAC1E;;;;;;;;;;;;;OAaG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,KAAK,KAAK,OAAO,CAAC;IACjE,QAAQ,CAAC,UAAU,EAAE,CAAC,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,KAAK,KAAK,UAAU,GAAG,SAAS,CAAC;IAC/E,QAAQ,CAAC,UAAU,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,UAAU,CAAC;IAClD,+FAA+F;IAC/F,QAAQ,CAAC,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,UAAU,CAAC;IACnD,QAAQ,CAAC,OAAO,EAAE,CAAC,UAAU,EAAE,UAAU,EAAE,KAAK,EAAE,KAAK,KAAK,OAAO,CAAC;IACpE,kFAAkF;IAClF,QAAQ,CAAC,QAAQ,EAAE;QAAE,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC;QAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAA;KAAE,CAAC;IACnF,qEAAqE;IACrE,QAAQ,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,MAAM,CAAC;CAC7C;AA2BD,eAAO,MAAM,gBAAgB,GAAI,KAAK,SAAS,MAAM,EAAE,UAAU,QACzD,UAAU,CAAC,KAAK,EAAE,UAAU,CAAC;kBA0Bd,KAAK;iBAQX,KAAK,CAAC,KAAK,CAAC,UAAU,UAAU,GAAG,SAAS;yBAIZ,QAAQ;;yBACF,MAAM;;yBAEtC,SAAS;;sBAIV,KAAK;oBAkCP,KAAK;CAOxB,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,cAAc,GAAI,KAAK,SAAS,MAAM,EAAE,UAAU,SAAS,MAAM,QACtE,UAAU,CAAC,KAAK,EAAE,UAAU,CAAC;IAIjC,IAAI;IACJ,IAAI,aAAa;QAAE,IAAI,EAAE,KAAK,CAAA;KAAE;IAChC,IAAI,qBAAqB;QAAE,IAAI,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC;QAAC,MAAM,EAAE,UAAU,GAAG,SAAS,CAAA;KAAE;yBA/DlC,QAAQ;;yBACF,MAAM;;yBAEtC,SAAS;;IA8D5B,SAAS,aAAa;QAAE,IAAI,EAAE,KAAK,CAAA;KAAE;IACrC,MAAM,aAAa;QAAE,IAAI,EAAE,KAAK,CAAA;KAAE;CAErC,CAAC"}
1
+ {"version":3,"file":"resource.d.ts","sourceRoot":"","sources":["../../src/netbox/resource.ts"],"names":[],"mappings":"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"}
@@ -19,3 +19,13 @@ through the package's own `CredentialsFromEnv` layer, which reads `FORGEJO_URL`
19
19
  — the same two variable names the retired hand-rolled client read, still resolved on the calling
20
20
  fiber per request, never captured at module scope. `Authorization: token <value>` (Forgejo's
21
21
  access-token scheme, not `Bearer`) is built by the package's protocol layer, not by this package.
22
+
23
+ The NetBox subpath (2026-09-23, moved onto `@distilled.cloud/netbox` — see
24
+ [distilled-interim.md](./distilled-interim.md)) resolves credentials the same way, through the
25
+ SDK's own `CredentialsFromEnv` layer, which reads `NETBOX_URL` / `NETBOX_TOKEN` — the same two
26
+ variable names the retired hand-rolled `client.ts` read, still resolved on the calling fiber per
27
+ request, never captured at module scope. `Authorization: Token <value>` (NetBox's Django REST
28
+ Framework `TokenAuthentication` scheme — NOT `Bearer`, measured against this estate's own
29
+ instance) is built by the package's protocol layer, not by this package. ⛔ The token is never a
30
+ prop: Alchemy persists attributes unencrypted, so nothing stored in a stack file may be a
31
+ credential.
package/docs/peers.md CHANGED
@@ -53,3 +53,14 @@ whole install, not one per subpath — `peers.test.ts`'s "no peer is marked opti
53
53
  enforces it. `@effect/sql-pg` is optional on `alchemy`'s own manifest (its module graph
54
54
  does not reach `SQL/Postgres` unless a stack imports it); here it stays a plain peer like
55
55
  every other one.
56
+
57
+ ★ **`@distilled.cloud/netbox` (added 2026-09-23, moving `/netbox` off a hand-rolled
58
+ `HttpClient` client the same way `/forgejo` did) is NOT on this install line, and that is
59
+ deliberate — see [distilled-interim.md](./distilled-interim.md).** The real
60
+ `@distilled.cloud/netbox` is not published upstream yet, so this package aliases it onto
61
+ `@homeflare/distilled-netbox@0.2.0`, a copy built the distilled way and shipped from this
62
+ monorepo, as a plain **`dependencies`** entry (not a peer). A `dependencies` entry resolves
63
+ automatically for every consumer that installs `@homeflare/alchemy` — nothing to add to a
64
+ peer install line, and nothing for `peers.test.ts` to check. When the real package ships
65
+ upstream, the alias's target changes (or the entry becomes a normal peer, matching
66
+ `@distilled.cloud/forgejo` above) and this line still does not change for it.
@@ -104,8 +104,23 @@ then tidiness.
104
104
  kubeconfig it writes is meant to be consumed through upstream's
105
105
  `Kubernetes.KubeConfig({ path })`.
106
106
  9. **`netbox/*` conforms mostly.** Its constraints are generated from NetBox 4.7.0's
107
- OpenAPI, and it uses Effect `HttpClient`. Adopting first is documented as deliberate.
108
- It diverges in its credentials: `NETBOX_TOKEN` is read at call time (S24).
107
+ OpenAPI. Adopting first is documented as deliberate.
108
+ - ✅ **S23 fixed 2026-09-23** (branch `claude2/distilled-netbox-family`, mirroring the
109
+ Forgejo migration above, decision 42). `Netbox.Prefix` now calls
110
+ `@distilled.cloud/netbox`'s typed `ipam` operations instead of a hand-rolled `Effect
111
+ HttpClient` client. The old status-carrying `NetboxError` and its `cause.status ===
112
+ 404` check are gone entirely — `Netbox.Prefix` locates by a server-side list filter,
113
+ which never 404s (an empty page is a normal 200), so nothing here checks a status
114
+ code at all, a stronger form of the same rule `catchTag('NotFound', …)` enforces for
115
+ a family that reads by direct key. `client.ts` is deleted. Not published upstream
116
+ yet, so aliased onto `@homeflare/distilled-netbox@0.2.0` as a plain `dependencies`
117
+ entry, not a peer — [distilled-interim.md](./distilled-interim.md). State did not
118
+ move: props/attributes stay byte-identical, proven by the family's unchanged
119
+ existing tests plus new tests against a fake NetBox exercising the real distilled
120
+ protocol.
121
+ - ⛔ **Still diverges on credentials (S24):** `NETBOX_TOKEN` / `NETBOX_URL` are read at
122
+ call time, now through the SDK's `CredentialsFromEnv` rather than a hand-rolled
123
+ `token()` — same divergence, unchanged by the transport swap.
109
124
  10. **Every family repeats `list: () => Effect.succeed([])`**, 30 times, and only
110
125
  `R2BucketLock` and `MeshNode` declare `nuke`. The constructor already defaults `list`
111
126
  (S12). **Fix:** write the reason where it differs, and declare `nuke: { skip: true }`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.27.3",
3
+ "version": "0.27.4",
4
4
  "description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
5
5
  "homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
6
6
  "bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
@@ -94,6 +94,7 @@
94
94
  "access": "public"
95
95
  },
96
96
  "dependencies": {
97
+ "@distilled.cloud/netbox": "npm:@homeflare/distilled-netbox@0.3.0",
97
98
  "@distilled.cloud/proxmox": "npm:@homeflare/distilled-proxmox@0.2.0"
98
99
  },
99
100
  "peerDependencies": {
@@ -0,0 +1,56 @@
1
+ /**
2
+ * A minimal in-memory NetBox REST API for this family's tests — an in-memory `fetch` handed to
3
+ * Effect's real `FetchHttpClient` through its `Fetch` reference, mirroring
4
+ * `../forgejo/fake-forgejo.ts` (itself mirroring `cloudflare/fake-mesh.ts`). Tests exercise
5
+ * distilled's REAL path assembly, JSON encode/decode and status→typed-error matching (`404` →
6
+ * `NotFound`, `403` → `Forbidden`, …) — nothing about that matching is re-implemented here, only
7
+ * the wire responses a scenario needs.
8
+ *
9
+ * ⛔ TEST-ONLY, AND NO NETWORK. No provider imports this file. The base URL is RFC 2606
10
+ * `netbox.example.com`, and the token is a placeholder.
11
+ */
12
+ import { type Credentials, credentials } from '@distilled.cloud/netbox/Credentials';
13
+ import * as Layer from 'effect/Layer';
14
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
15
+
16
+ export const FAKE_BASE = 'https://netbox.example.com';
17
+ export const FAKE_TOKEN = 'placeholder-netbox-token';
18
+
19
+ export type Seen = { readonly method: string; readonly path: string };
20
+
21
+ /** NetBox's `{ detail }` failure envelope (403/404) — matching protocol.ts's `errorEnvelope`. */
22
+ export const fakeFailure = (status: number, detail: string) =>
23
+ Response.json({ detail }, { status });
24
+
25
+ /**
26
+ * `route` decides every response; this wrapper only records what was sent and adapts to
27
+ * `globalThis.fetch`'s signature. A test that expects no request at all asserts `seen` is empty.
28
+ */
29
+ export const fakeNetbox = (route: (method: string, url: URL) => Response) => {
30
+ const seen: Seen[] = [];
31
+ const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
32
+ const request =
33
+ input instanceof Request ? new Request(input, init) : new Request(String(input), init);
34
+ const url = new URL(request.url);
35
+ seen.push({ method: request.method, path: `${url.pathname}${url.search}` });
36
+ return route(request.method, url);
37
+ }) as typeof globalThis.fetch;
38
+ return { fetch, seen };
39
+ };
40
+
41
+ /**
42
+ * distilled Credentials + the real FetchHttpClient over the fake — `NetboxOpContext`, what every
43
+ * resource file's exported `spec.fetchLive` needs.
44
+ *
45
+ * ⚠️ NOT PAIRED WITH `handlers` (each resource file's `netboxHandlers(spec)` export). `handlers`
46
+ * bakes in `CredentialsFromEnv`, which resolves `NETBOX_URL` / `NETBOX_TOKEN` through Effect's
47
+ * `Config` — whose default provider snapshots `process.env` once and never re-reads it (measured
48
+ * in the forgejo migration, kit PR 180: mutating `process.env` mid-test-run had no effect). So a
49
+ * test cannot point `handlers` at a fake server; it calls `spec.fetchLive`/`spec.attributes`
50
+ * directly instead, with this explicit layer, exercising exactly the same production code
51
+ * without that env seam.
52
+ */
53
+ export const fakeNetboxLayer = (
54
+ fetchFn: typeof globalThis.fetch,
55
+ creds: Layer.Layer<Credentials> = credentials({ baseUrl: FAKE_BASE, token: FAKE_TOKEN }),
56
+ ) => Layer.mergeAll(creds, FetchHttpClient.layer, Layer.succeed(FetchHttpClient.Fetch, fetchFn));
@@ -8,7 +8,6 @@
8
8
  * ★ The constraint reader IS exported, because a consumer writing its own family needs the same
9
9
  * guard — and because a table nobody can reach is a table nobody uses.
10
10
  */
11
- export { NetboxError, apiBase } from './client.ts';
12
11
  export {
13
12
  NETBOX_CONSTRAINTS,
14
13
  bodyViolations,
@@ -31,4 +30,11 @@ export {
31
30
  type PrefixProps,
32
31
  type PrefixStatus,
33
32
  } from './prefix.ts';
34
- export { type NetboxRequirements, type NetboxSpec, netboxHandlers } from './resource.ts';
33
+ export {
34
+ LOCATE_PAGE,
35
+ locateOne,
36
+ type NetboxRequirements,
37
+ type NetboxSpec,
38
+ netboxHandlers,
39
+ soleMatch,
40
+ } from './resource.ts';
@@ -0,0 +1,223 @@
1
+ /**
2
+ * `Netbox.Prefix`'s `spec` against a fake NetBox, proving the real distilled wire path that
3
+ * replaced `client.ts`'s hand-rolled `HttpClient` calls — path assembly, JSON encode/decode and
4
+ * status→typed-error matching, exercised through `@distilled.cloud/netbox/ipam`'s real operations,
5
+ * never re-implemented here. Mirrors `../forgejo/org-label.test.ts` and `repository.test.ts`'s
6
+ * "call the exported production functions" shape (kit PR 180).
7
+ *
8
+ * ⛔ THE POINT OF THIS FILE. `resource.test.ts` proves `locateOne`/`soleMatch` in isolation with a
9
+ * fixture list; this file proves prefix.ts's own `spec.fetchLive`, `spec.attributes`,
10
+ * `spec.create`, `spec.update.call` and `spec.destroy` — the exact object `netboxHandlers(spec)`
11
+ * wraps into `NetboxPrefixProvider` — against a real HTTP round trip.
12
+ */
13
+ import { describe, expect, test } from 'bun:test';
14
+ import type * as ipam from '@distilled.cloud/netbox/ipam';
15
+ import * as Effect from 'effect/Effect';
16
+ import { FAKE_BASE, fakeFailure, fakeNetbox, fakeNetboxLayer } from './fake-netbox.ts';
17
+ import type { PrefixProps } from './prefix.ts';
18
+ import { spec } from './prefix.ts';
19
+
20
+ const PREFIXES_PATH = '/api/ipam/prefixes/';
21
+ const PREFIX_ID_PATH = '/api/ipam/prefixes/7/';
22
+
23
+ /** A decodable `Prefix` — every field the SDK's schema requires present, per ipam.ts's interface. */
24
+ const livePrefix = (overrides: Record<string, unknown> = {}): ipam.Prefix =>
25
+ ({
26
+ _depth: 0,
27
+ children: 0,
28
+ comments: '',
29
+ created: '2026-01-01T00:00:00Z',
30
+ description: '',
31
+ display: '10.0.0.0/24',
32
+ display_url: `${FAKE_BASE}${PREFIX_ID_PATH}`,
33
+ family: { label: 'IPv4', value: 4 },
34
+ id: 7,
35
+ is_pool: false,
36
+ last_updated: '2026-01-01T00:00:00Z',
37
+ mark_utilized: false,
38
+ prefix: '10.0.0.0/24',
39
+ scope: null,
40
+ status: { label: 'Active', value: 'active' },
41
+ url: `${FAKE_BASE}${PREFIX_ID_PATH}`,
42
+ vrf: null,
43
+ ...overrides,
44
+ }) as unknown as ipam.Prefix;
45
+
46
+ /** `BriefVRF`/`BriefRIR`/`BriefVLAN` all require `id`, `url`, `display`, `name` — no `S.optional`. */
47
+ const briefVrf = (id: number, name: string) => ({
48
+ display: name,
49
+ id,
50
+ name,
51
+ url: `${FAKE_BASE}/api/ipam/vrfs/${String(id)}/`,
52
+ });
53
+ const briefTenant = (id: number, name: string) => ({
54
+ display: name,
55
+ id,
56
+ name,
57
+ slug: name,
58
+ url: `${FAKE_BASE}/api/tenancy/tenants/${String(id)}/`,
59
+ });
60
+ const briefVlan = (id: number, name: string, vid: number) => ({
61
+ display: name,
62
+ id,
63
+ name,
64
+ url: `${FAKE_BASE}/api/ipam/vlans/${String(id)}/`,
65
+ vid,
66
+ });
67
+
68
+ const readThrough = (fetchFn: typeof globalThis.fetch, props: PrefixProps) =>
69
+ Effect.runPromise(
70
+ spec.fetchLive(props).pipe(
71
+ Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))),
72
+ Effect.provide(fakeNetboxLayer(fetchFn)),
73
+ ),
74
+ );
75
+
76
+ describe('Netbox.Prefix spec.fetchLive', () => {
77
+ test('one server-side match, no VRF declared, decodes into the typed row', async () => {
78
+ const fake = fakeNetbox((method, url) =>
79
+ method === 'GET' && url.pathname === PREFIXES_PATH
80
+ ? Response.json({ count: 1, next: null, previous: null, results: [livePrefix()] })
81
+ : fakeFailure(400, 'unexpected request'),
82
+ );
83
+ const live = await Effect.runPromise(
84
+ spec.fetchLive({ prefix: '10.0.0.0/24' }).pipe(Effect.provide(fakeNetboxLayer(fake.fetch))),
85
+ );
86
+ expect(live?.id).toBe(7);
87
+ expect(fake.seen[0]?.method).toBe('GET');
88
+ // ⚠️ Not just the path — the server-side filter this resource relies on to narrow
89
+ // candidates before `identifies` runs. A future distilled bump that changed how an
90
+ // array param serializes would silently stop narrowing on the wire; asserting only
91
+ // `pathname` would never catch that.
92
+ const sent = new URL(`${FAKE_BASE}${fake.seen[0]?.path ?? ''}`);
93
+ expect(sent.searchParams.getAll('prefix')).toEqual(['10.0.0.0/24']);
94
+ });
95
+
96
+ /**
97
+ * 🔴 THE CASE `identifies` EXISTS FOR — the same VRF disambiguation prefix.ts's own docstring
98
+ * explains. `prefix` alone matched two rows (the same CIDR in two VRFs); the declared `vrf`
99
+ * picks the right one in this process, never by re-filtering server-side.
100
+ */
101
+ test('several rows for the same CIDR narrow to the declared VRF', async () => {
102
+ const rows = [
103
+ livePrefix({ id: 7, vrf: briefVrf(1, 'blue') }),
104
+ livePrefix({ id: 8, vrf: briefVrf(2, 'red') }),
105
+ ];
106
+ const fake = fakeNetbox((method, url) =>
107
+ method === 'GET' && url.pathname === PREFIXES_PATH
108
+ ? Response.json({ count: 2, next: null, previous: null, results: rows })
109
+ : fakeFailure(400, 'unexpected request'),
110
+ );
111
+ const live = await Effect.runPromise(
112
+ spec
113
+ .fetchLive({ prefix: '10.0.0.0/24', vrf: 2 })
114
+ .pipe(Effect.provide(fakeNetboxLayer(fake.fetch))),
115
+ );
116
+ expect(live?.id).toBe(8);
117
+ });
118
+
119
+ test('no candidate rows is absent, not an error', async () => {
120
+ const fake = fakeNetbox(() =>
121
+ Response.json({ count: 0, next: null, previous: null, results: [] }),
122
+ );
123
+ const live = await Effect.runPromise(
124
+ spec.fetchLive({ prefix: '10.20.30.0/24' }).pipe(Effect.provide(fakeNetboxLayer(fake.fetch))),
125
+ );
126
+ expect(live).toBeUndefined();
127
+ });
128
+
129
+ test('a 400 on the list propagates — never folded to absent', async () => {
130
+ const fake = fakeNetbox(() => fakeFailure(400, 'prefix: invalid CIDR'));
131
+ const failure = await Effect.runPromise(
132
+ Effect.flip(
133
+ spec.fetchLive({ prefix: 'not-a-cidr' }).pipe(Effect.provide(fakeNetboxLayer(fake.fetch))),
134
+ ),
135
+ );
136
+ expect(failure._tag).toBe('BadRequest');
137
+ });
138
+ });
139
+
140
+ describe('Netbox.Prefix spec.attributes — the actual exported production mapping', () => {
141
+ test('status, foreign keys and free text all decode the way client.ts used to read them', async () => {
142
+ const fake = fakeNetbox((method, url) =>
143
+ method === 'GET' && url.pathname === PREFIXES_PATH
144
+ ? Response.json({
145
+ count: 1,
146
+ next: null,
147
+ previous: null,
148
+ results: [
149
+ livePrefix({
150
+ comments: 'management range',
151
+ description: 'core VLAN',
152
+ is_pool: true,
153
+ mark_utilized: true,
154
+ status: { label: 'Deprecated', value: 'deprecated' },
155
+ tenant: briefTenant(3, 'ops'),
156
+ vlan: briefVlan(5, 'core', 10),
157
+ vrf: briefVrf(1, 'blue'),
158
+ }),
159
+ ],
160
+ })
161
+ : fakeFailure(400, 'unexpected request'),
162
+ );
163
+ expect(await readThrough(fake.fetch, { prefix: '10.0.0.0/24', vrf: 1 })).toEqual({
164
+ comments: 'management range',
165
+ description: 'core VLAN',
166
+ isPool: true,
167
+ markUtilized: true,
168
+ prefix: '10.0.0.0/24',
169
+ prefixId: 7,
170
+ status: 'deprecated',
171
+ tenant: 3,
172
+ vlan: 5,
173
+ vrf: 1,
174
+ });
175
+ });
176
+ });
177
+
178
+ describe('Netbox.Prefix spec.create / spec.update.call / spec.destroy — the real wire calls', () => {
179
+ const props: PrefixProps = { description: 'core VLAN', prefix: '10.0.0.0/24', status: 'active' };
180
+ const update = spec.update;
181
+ if (update === undefined) throw new Error('Netbox.Prefix must declare spec.update');
182
+
183
+ test('create POSTs the constraint-checked body to /api/ipam/prefixes/', async () => {
184
+ const fake = fakeNetbox((method, url) =>
185
+ method === 'POST' && url.pathname === PREFIXES_PATH
186
+ ? Response.json(livePrefix({ description: 'core VLAN' }), { status: 201 })
187
+ : fakeFailure(400, 'unexpected request'),
188
+ );
189
+ const body = spec.createBody(props);
190
+ const live = await Effect.runPromise(
191
+ spec.create(props, body).pipe(Effect.provide(fakeNetboxLayer(fake.fetch))),
192
+ );
193
+ expect((live as ipam.Prefix).id).toBe(7);
194
+ expect(fake.seen).toEqual([{ method: 'POST', path: PREFIXES_PATH }]);
195
+ });
196
+
197
+ test('update PATCHes /api/ipam/prefixes/{id}/, addressed by the live row numeric id', async () => {
198
+ const fake = fakeNetbox((method, url) =>
199
+ method === 'PATCH' && url.pathname === PREFIX_ID_PATH
200
+ ? Response.json(livePrefix({ status: { label: 'Deprecated', value: 'deprecated' } }))
201
+ : fakeFailure(400, 'unexpected request'),
202
+ );
203
+ const declared = { ...props, status: 'deprecated' as const };
204
+ const body = update.body(declared);
205
+ const after = await Effect.runPromise(
206
+ update.call(declared, livePrefix(), body).pipe(Effect.provide(fakeNetboxLayer(fake.fetch))),
207
+ );
208
+ expect((after as ipam.Prefix).status?.value).toBe('deprecated');
209
+ expect(fake.seen).toEqual([{ method: 'PATCH', path: PREFIX_ID_PATH }]);
210
+ });
211
+
212
+ test('destroy DELETEs /api/ipam/prefixes/{id}/', async () => {
213
+ const fake = fakeNetbox((method, url) =>
214
+ method === 'DELETE' && url.pathname === PREFIX_ID_PATH
215
+ ? new Response(null, { status: 204 })
216
+ : fakeFailure(400, 'unexpected request'),
217
+ );
218
+ await Effect.runPromise(
219
+ spec.destroy(props, livePrefix()).pipe(Effect.provide(fakeNetboxLayer(fake.fetch))),
220
+ );
221
+ expect(fake.seen).toEqual([{ method: 'DELETE', path: PREFIX_ID_PATH }]);
222
+ });
223
+ });