@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.
@@ -28,13 +28,22 @@
28
28
  * IP assignments; the record of why it existed survives only in the change log.
29
29
  *
30
30
  * ⛔ NO CREDENTIAL IS A PROP. `NETBOX_URL` and `NETBOX_TOKEN` are read from the environment at
31
- * call time — the house convention, and the only shape that keeps a token out of Alchemy state.
31
+ * call time, through `@distilled.cloud/netbox`'s `CredentialsFromEnv` (resource.ts) — the house
32
+ * convention, and the only shape that keeps a token out of Alchemy state. Unchanged by this
33
+ * migration off `client.ts`'s hand-rolled `HttpClient` calls onto distilled's typed operations.
32
34
  */
33
35
  import { Resource } from 'alchemy';
34
36
  import * as Provider from 'alchemy/Provider';
37
+ import * as ipam from '@distilled.cloud/netbox/ipam';
35
38
  import * as Effect from 'effect/Effect';
36
39
  import { prefixBody, prefixMatches } from './prefix-form.ts';
37
- import { type NetboxRequirements, netboxHandlers } from './resource.ts';
40
+ import {
41
+ LOCATE_PAGE,
42
+ type NetboxRequirements,
43
+ type NetboxSpec,
44
+ locateOne,
45
+ netboxHandlers,
46
+ } from './resource.ts';
38
47
  import { choice, fk, text } from './values.ts';
39
48
 
40
49
  /**
@@ -92,45 +101,72 @@ export const NetboxPrefix = Resource<NetboxPrefix>('Netbox.Prefix', {
92
101
  defaultRemovalPolicy: 'retain',
93
102
  });
94
103
 
95
- const handlers = netboxHandlers<PrefixProps, PrefixAttributes>({
96
- attributes: (live, props) => {
97
- const id = live['id'];
98
- if (typeof id !== 'number') return undefined;
99
- return {
100
- comments: text(live['comments']),
101
- description: text(live['description']),
102
- isPool: live['is_pool'] === true,
103
- markUtilized: live['mark_utilized'] === true,
104
- prefix: text(live['prefix']) || props.prefix,
105
- prefixId: id,
106
- status: choice(live['status']),
107
- tenant: fk(live['tenant']),
108
- vlan: fk(live['vlan']),
109
- vrf: fk(live['vrf']),
110
- };
111
- },
112
- collection: 'ipam/prefixes',
104
+ const attributesOf = (live: ipam.Prefix, props: PrefixProps): PrefixAttributes => ({
105
+ comments: text(live.comments),
106
+ description: text(live.description),
107
+ isPool: live.is_pool === true,
108
+ markUtilized: live.mark_utilized === true,
109
+ prefix: live.prefix || props.prefix,
110
+ prefixId: live.id,
111
+ status: choice(live.status),
112
+ tenant: fk(live.tenant),
113
+ vlan: fk(live.vlan),
114
+ vrf: fk(live.vrf),
115
+ });
116
+
117
+ /**
118
+ * ★ EXPORTED for direct testing with an explicit fake `Credentials` layer — see
119
+ * `fake-netbox.ts`/`prefix.test.ts`, the same seam `../forgejo/repository.ts` uses.
120
+ */
121
+ export const spec: NetboxSpec<
122
+ PrefixProps,
123
+ ipam.Prefix,
124
+ PrefixAttributes,
125
+ | ipam.CreateIpamPrefixError
126
+ | ipam.ListIpamPrefixesError
127
+ | ipam.UpdateIpamPrefixesPartialError
128
+ | ipam.IpamPrefixesDestroyError
129
+ > = {
130
+ attributes: attributesOf,
131
+ create: (_props, body) =>
132
+ ipam.createIpamPrefix({ body: body as unknown as ipam.WritablePrefixRequest }),
113
133
  createBody: prefixBody,
134
+ destroy: (_props, live) => ipam.ipamPrefixesDestroy({ id: live.id }),
114
135
  describe: (props) => `ipam/prefixes ${props.prefix}`,
115
136
  endpoint: {
116
137
  create: 'netbox:POST /api/ipam/prefixes/',
117
138
  update: 'netbox:PATCH /api/ipam/prefixes/{id}/',
118
139
  },
119
140
  /**
120
- * 🔴 THE VRF IS **NOT** IN THIS FILTER, AND THE REASON IS A GUESS THAT WAS CAUGHT. This read
121
- * `vrf_id=null` for the global table — NetBox does define `FILTERS_NULL_CHOICE_VALUE = 'null'`
122
- * and does honour it on filters that opt in. ⛔ MEASURED IN THE VENDOR'S OWN SOURCE at
123
- * v4.7.0: `PrefixFilterSet.vrf_id` is a plain `django_filters.ModelMultipleChoiceFilter` with
124
- * no `null_value`, so `'null'` is validated against the VRF queryset, fails, and NetBox
125
- * answers 400 under strict filtering — on EVERY plan for EVERY global prefix.
126
- * ★ So the CIDR narrows server-side with a filter the document plainly declares, and `identifies`
127
- * picks the VRF in this process. One extra page of candidates, no vendor semantics guessed.
141
+ * 🔴 THE VRF IS **NOT** IN THIS SERVER-SIDE FILTER, AND THE REASON IS A GUESS THAT WAS CAUGHT.
142
+ * An earlier version filtered `vrf_id=null` for the global table — NetBox does define
143
+ * `FILTERS_NULL_CHOICE_VALUE = 'null'` and does honour it on filters that opt in. ⛔ MEASURED
144
+ * IN THE VENDOR'S OWN SOURCE at v4.7.0: `PrefixFilterSet.vrf_id` is a plain
145
+ * `django_filters.ModelMultipleChoiceFilter` with NO `null_value`, so `'null'` is validated
146
+ * against the VRF queryset, fails, and NetBox answers 400 under strict filtering. The
147
+ * sentinel is real but it is opt-in per filter, and this one did not opt in.
148
+ * ★ SO THE DISCRIMINATOR STAYS IN THIS PROCESS, VIA `locateOne`'s `identifies` — one extra page
149
+ * of candidates, no vendor semantics guessed. `listIpamPrefixes` never declares `NotFound`
150
+ * (a list call 200s with an empty page, it does not 404), so there is no status to fold here.
128
151
  */
129
- identifies: (live, props) => fk(live['vrf']) === props.vrf,
130
- locate: (props) => [['prefix', props.prefix]],
152
+ fetchLive: (props) =>
153
+ locateOne(
154
+ ipam.listIpamPrefixes({ limit: LOCATE_PAGE, prefix: [props.prefix] }),
155
+ `ipam/prefixes ${props.prefix}`,
156
+ (row) => fk(row.vrf) === props.vrf,
157
+ ),
131
158
  matches: prefixMatches,
132
- updateBody: prefixBody,
133
- });
159
+ update: {
160
+ body: prefixBody,
161
+ call: (_props, live, body) =>
162
+ ipam.updateIpamPrefixesPartial({
163
+ ...(body as unknown as Omit<ipam.UpdateIpamPrefixesPartialRequest, 'id'>),
164
+ id: live.id,
165
+ }),
166
+ },
167
+ };
168
+
169
+ export const handlers = netboxHandlers(spec);
134
170
 
135
171
  export const NetboxPrefixProvider = () =>
136
172
  Provider.effect(NetboxPrefix, Effect.succeed(NetboxPrefix.Provider.of(handlers)));
@@ -0,0 +1,67 @@
1
+ /**
2
+ * `soleMatch` and `locateOne` — identity resolution, and the two ways it must refuse. Pure/Effect
3
+ * only (no HTTP): `locateOne` takes an already-built list `Effect`, so these run against
4
+ * `Effect.succeed` fixtures rather than a fake server — `prefix.test.ts` exercises the real
5
+ * distilled wire path this feeds from.
6
+ *
7
+ * ⛔ THE FAILURE THIS GUARDS IS SILENT AND SELF-RENEWING. If an ambiguous match took the first
8
+ * row, adopt would bind to it, the next plan would bind to the other, and every plan after that
9
+ * would report drift that is not there — while "fixing" it PATCHed one object with the other's
10
+ * declaration. Failing loudly is the only outcome an operator can act on. Ported verbatim from
11
+ * the pre-migration `client.test.ts`, which tested the same `soleMatch` before it moved here.
12
+ */
13
+ import { describe, expect, test } from 'bun:test';
14
+ import * as Effect from 'effect/Effect';
15
+ import { LOCATE_PAGE, locateOne, soleMatch } from './resource.ts';
16
+
17
+ describe('soleMatch', () => {
18
+ test('no candidate is absent, not an error — that is what plans a create', () => {
19
+ expect(soleMatch([], 'ipam/prefixes 10.0.0.0/24')).toBeUndefined();
20
+ });
21
+
22
+ test('exactly one candidate is the object', () => {
23
+ expect(soleMatch([{ id: 7 }], 'ipam/prefixes 10.0.0.0/24')).toEqual({ id: 7 });
24
+ });
25
+
26
+ /** ⛔ Two rows means the identity rule is wrong, and the message says which object it was. */
27
+ test('more than one candidate throws and names the object', () => {
28
+ expect(() => soleMatch([{ id: 7 }, { id: 8 }], 'ipam/prefixes 10.0.0.0/24')).toThrow(
29
+ /ipam\/prefixes 10\.0\.0\.0\/24 matched 2 NetBox objects/,
30
+ );
31
+ });
32
+ });
33
+
34
+ describe('locateOne', () => {
35
+ const page = (count: number, results: readonly { id: number; vrf?: number }[]) =>
36
+ Effect.succeed({ count, results });
37
+
38
+ test('an empty page is absent', async () => {
39
+ const result = await Effect.runPromise(locateOne(page(0, []), 'ipam/prefixes 10.0.0.0/24'));
40
+ expect(result).toBeUndefined();
41
+ });
42
+
43
+ test('one row with no `identifies` narrowing is the object', async () => {
44
+ const result = await Effect.runPromise(
45
+ locateOne(page(1, [{ id: 7 }]), 'ipam/prefixes 10.0.0.0/24'),
46
+ );
47
+ expect(result).toEqual({ id: 7 });
48
+ });
49
+
50
+ test('`identifies` narrows several server-side matches to the declared one', async () => {
51
+ const rows = [
52
+ { id: 7, vrf: 1 },
53
+ { id: 8, vrf: 2 },
54
+ ];
55
+ const result = await Effect.runPromise(
56
+ locateOne(page(2, rows), 'ipam/prefixes 10.0.0.0/24', (row) => row.vrf === 2),
57
+ );
58
+ expect(result).toEqual({ id: 8, vrf: 2 });
59
+ });
60
+
61
+ test('more rows than the page width is a defect, not a truncation', async () => {
62
+ const rows = Array.from({ length: LOCATE_PAGE + 1 }, (_, i) => ({ id: i }));
63
+ await expect(
64
+ Effect.runPromise(locateOne(page(LOCATE_PAGE + 1, rows), 'ipam/prefixes 10.0.0.0/24')),
65
+ ).rejects.toThrow(/matched 21 rows, more than the 20-row page/);
66
+ });
67
+ });
@@ -1,134 +1,146 @@
1
1
  /**
2
- * One shape for every NetBox object — the same four operations, different path and JSON body.
2
+ * One shape for every NetBox object — the same four operations, different distilled call.
3
3
  *
4
4
  * ⛔ THE ALTERNATIVE IS COPYING THE SAME HANDLERS PER RESOURCE. Prefixes, VLANs, devices, tenants
5
- * and interfaces are all create / read / update / delete over `/api` with JSON bodies and a
6
- * numeric primary key. Hand-rolling the fourth copy is where someone drops the read-back or the
5
+ * and interfaces are all create / read / update / delete against `@distilled.cloud/netbox`'s
6
+ * typed operations. Hand-rolling the fourth copy is where someone drops the read-back or the
7
7
  * constraint guard and the plan lies.
8
8
  *
9
+ * ★ SO THE FOUR OPERATIONS LIVE HERE, ONCE — mirroring `../forgejo/resource.ts`'s engine exactly,
10
+ * plus the constraint-guard wiring every NetBox write already had (guardBody, unchanged).
11
+ *
12
+ * 🔴 WHY THIS REPLACED `client.ts`'s HAND-ROLLED `HttpClient` CALLS. Measured against Tim's
13
+ * distilled research page 2026-09-23: upstream Alchemy only accepts providers that call
14
+ * `@distilled.cloud/<vendor>` operations and `catchTag` their typed errors — not a raw
15
+ * `fetch`/`HttpClient` client over a hand-maintained path table. `@distilled.cloud/netbox`
16
+ * (published as `@homeflare/distilled-netbox@0.2.0`, aliased in — see
17
+ * `docs/distilled-interim.md`) covers every operation this family calls.
18
+ *
19
+ * ⚠️ `NotFound` IS FOLDED TO "ABSENT" INSIDE EACH RESOURCE FILE'S OWN `fetchLive`, NOT HERE — the
20
+ * direct successor to the old, centralized `absentOn404`, and the same split `../forgejo/resource.ts`
21
+ * uses. `Effect.catchTag`'s tag-literal inference needs a concrete error union to resolve
22
+ * `"NotFound"` against; a spec's `E` is only concrete at each resource file's own call site. A
23
+ * NetBox *list* call (what `locateOne` below wraps) never 404s — narrowing happens by an empty
24
+ * `results` page, not a status code — so nothing here folds a status at all.
25
+ *
9
26
  * ★ ADOPT IS THE DEFAULT POSTURE, NOT A FLAG. `reconcile` LOCATES before it writes: an object the
10
27
  * estate already has is bound, not duplicated. NetBox is a record that predates this code by
11
28
  * years; a provider that created a second `10.20.10.0/24` on first deploy would be worse than
12
29
  * no provider.
13
30
  *
14
- * ★ `defaultRemovalPolicy: 'retain'` ON EVERY FAMILY. NetBox holds the estate's record of what
15
- * the network was DECIDED to be, and deleting a row deletes history — child prefixes reparent,
16
- * IP assignments detach, and the change log is the only trace left. `retain` means the engine
17
- * skips `delete` when a resource is orphaned unless the caller opts into
18
- * `.pipe(RemovalPolicy.destroy())`. The `delete` handler is FULLY IMPLEMENTED anyway: a stub
19
- * that silently does nothing lies to whoever reads the plan.
31
+ * ★ `defaultRemovalPolicy: 'retain'` ON EVERY FAMILY — declared per resource file (prefix.ts),
32
+ * same as before this migration.
20
33
  */
21
34
  import { isResolved } from 'alchemy/Diff';
22
35
  import type { Input } from 'alchemy/Input';
36
+ import { CredentialsFromEnv } from '@distilled.cloud/netbox/Credentials';
37
+ import type { NetboxOpContext } from '@distilled.cloud/netbox/Protocol';
23
38
  import * as Effect from 'effect/Effect';
24
39
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
25
- import { NetboxError, type NetboxList, type NetboxRow, netbox, soleMatch } from './client.ts';
26
40
  import { guardBody } from './constraint-guard.ts';
27
41
  import type { EndpointKey, NetboxBody } from './constraints.ts';
28
42
 
29
43
  /**
30
- * ★ WHAT EVERY OPERATION HERE NEEDS FROM THE RUNTIME, AND NOTHING MORE. The token comes from
31
- * `NETBOX_TOKEN` in the environment, so there is no process to spawn and no `bao` to shell out
32
- * to. Declaring a service you never use is not harmless — it makes a stack provide a layer for
33
- * nothing, and it hides the one service you DO need.
44
+ * ★ WHAT EVERY HANDLER NEEDS FROM THE CALLER'S RUNTIME, after Credentials are provided below.
45
+ * `Netbox.NetboxOpContext` is `Credentials | HttpClient.HttpClient`; `netboxHandlers` closes
46
+ * over `CredentialsFromEnv` itself (`NETBOX_URL` / `NETBOX_TOKEN`, the names client.ts already
47
+ * read — see the SDK's own credentials.ts note), so a consuming stack only has to provide
48
+ * `HttpClient.HttpClient`, exactly as it did before this migration.
34
49
  */
35
50
  export type NetboxRequirements = HttpClient.HttpClient;
36
51
 
37
- export interface NetboxSpec<Props extends object, Attributes> {
38
- /** `ipam/prefixes` — where a new one is POSTed and where `locate` filters. */
39
- readonly collection: string;
40
- /**
41
- * The query that narrows the candidates, as `URLSearchParams` pairs.
42
- *
43
- * ⛔ ONLY FILTERS THE VENDOR DOCUMENT DECLARES, AND ONLY IN THE FORM IT DECLARES THEM. A
44
- * filter NetBox rejects answers 400, which `absentOn404` correctly does NOT fold to "absent" —
45
- * so a guessed filter shape is a hard failure on every plan, not a soft one.
46
- * ★ IT DOES NOT HAVE TO BE UNIQUE. `identifies` finishes the job in this process, where the
47
- * rule is readable and testable offline.
48
- */
49
- readonly locate: (props: Props) => readonly (readonly [string, string])[];
50
- /**
51
- * Which of the returned rows IS this object.
52
- *
53
- * 🔴 THIS EXISTS BECAUSE THE OBVIOUS ALTERNATIVE WAS A GUESS. `Netbox.Prefix` first narrowed
54
- * server-side with `vrf_id=null`, the sentinel NetBox uses elsewhere for "no foreign key".
55
- * ⛔ MEASURED IN THE VENDOR'S OWN SOURCE at v4.7.0: `PrefixFilterSet.vrf_id` is a plain
56
- * `django_filters.ModelMultipleChoiceFilter` with NO `null_value`, so `'null'` is validated
57
- * against the VRF queryset, fails, and NetBox answers 400 under strict filtering. The
58
- * sentinel is real (`FILTERS_NULL_CHOICE_VALUE = 'null'`) but it is opt-in per filter, and
59
- * this one did not opt in.
60
- * ★ SO THE DISCRIMINATOR MOVED INTO THIS PROCESS. It costs one page of candidates and buys a
61
- * rule that can be read, tested without a server, and cannot be wrong about a vendor's
62
- * filter semantics. Omit it when `locate` really is unique on its own.
63
- */
64
- readonly identifies?: (live: NetboxRow, props: Props) => boolean;
65
- readonly attributes: (live: NetboxRow, props: Props) => Attributes | undefined;
66
- readonly createBody: (props: Props) => NetboxBody;
67
- /** ⚠️ PARTIAL BY DESIGN: NetBox updates are PATCH, so presence is not checked on this body. */
68
- readonly updateBody?: (props: Props) => NetboxBody;
69
- readonly matches: (attributes: Attributes, props: Props) => boolean;
70
- /** The generated constraint table to check each body against, by endpoint key. */
71
- readonly endpoint: { readonly create: EndpointKey; readonly update?: EndpointKey };
72
- /** For the message `soleMatch` raises and for read-back failures. */
73
- readonly describe: (props: Props) => string;
74
- }
75
-
76
- /** ⚠️ Only 404 folds to "absent". A 403 or a 502 is a real failure — folding it would plan a create over an existing object. */
77
- const absentOn404 = <A>(io: Effect.Effect<A, NetboxError, NetboxRequirements>) =>
78
- io.pipe(
79
- Effect.catchIf(
80
- (cause): cause is NetboxError => cause instanceof NetboxError && cause.status === 404,
81
- () => Effect.succeed(undefined as A),
82
- ),
83
- );
84
-
85
52
  /**
86
53
  * ⚠️ NOT `limit=1`, AND NOT `limit=2` EITHER. One row is all a caller wants, but the page has to
87
- * be wide enough for `identifies` to see every candidate `locate` matched — a prefix that
88
- * exists in several VRFs comes back several times, and a page of two would hide the third.
54
+ * be wide enough for an `identifies` narrowing to see every candidate a server-side filter
55
+ * matched — a prefix that exists in several VRFs comes back several times, and a page of two
56
+ * would hide the third.
89
57
  * ★ TWENTY, AND THE NUMBER IS AN ASSERTION: a natural key matching more than twenty rows is not
90
58
  * a natural key. `count` is checked against it, so the failure is "your filter is too broad"
91
- * rather than a silent truncation.
59
+ * rather than a silent truncation. Unchanged from client.ts's original value.
92
60
  */
93
- const LOCATE_PAGE = 20;
61
+ export const LOCATE_PAGE = 20;
94
62
 
95
- const query = (pairs: readonly (readonly [string, string])[]): string =>
96
- new URLSearchParams([
97
- ...pairs.map(([k, v]) => [k, v] as [string, string]),
98
- ['limit', String(LOCATE_PAGE)],
99
- ]).toString();
63
+ /**
64
+ * The single row a filtered list returns, or `undefined`.
65
+ *
66
+ * ⛔ MORE THAN ONE MATCH IS A DEFECT, NOT A REASON TO TAKE THE FIRST. A `locate` filter that is
67
+ * not unique would let adopt bind to whichever row NetBox happened to order first, and the next
68
+ * plan would bind to the other one and report drift that is not there.
69
+ */
70
+ export const soleMatch = <T>(rows: readonly T[], describe: string): T | undefined => {
71
+ if (rows.length > 1) {
72
+ throw new Error(
73
+ `${describe} matched ${String(rows.length)} NetBox objects. An object's identity must be ` +
74
+ 'unique; narrow it (the VRF, the site, the tenant) rather than taking the first row.',
75
+ );
76
+ }
77
+ return rows[0];
78
+ };
100
79
 
101
- export const netboxOperations = <Props extends object, Attributes>(
102
- spec: NetboxSpec<Props, Attributes>,
103
- ) => {
104
- const fetchLive = (props: Props) =>
105
- absentOn404(
106
- netbox<NetboxList<NetboxRow>>('GET', `${spec.collection}/?${query(spec.locate(props))}`),
107
- ).pipe(
108
- Effect.map((list) => {
109
- if (list !== undefined && list.count > LOCATE_PAGE) {
110
- throw new Error(
111
- `${spec.describe(props)}: the locate filter matched ${String(list.count)} rows, more ` +
112
- `than the ${String(LOCATE_PAGE)}-row page this reads. That is not a natural key — ` +
113
- 'narrow the filter rather than paging through candidates.',
114
- );
115
- }
116
- const rows = list?.results ?? [];
117
- const narrow = spec.identifies;
118
- return soleMatch(
119
- narrow === undefined ? rows : rows.filter((row) => narrow(row, props)),
120
- spec.describe(props),
80
+ /**
81
+ * List, then narrow to at most one candidate — the same "which of these rows IS this object"
82
+ * problem a server-side filter cannot always answer (see prefix.ts's own note on `vrf_id`). Any
83
+ * NetBox resource whose locate is "filter server-side, then disambiguate in this process" reaches
84
+ * this once rather than reimplementing the page-width assertion and the ambiguity defect per
85
+ * resource — the direct successor to client.ts's `fetchLive` helper, generalized over the SDK's
86
+ * own paginated-list shape instead of a hand-decoded `NetboxList<NetboxRow>`.
87
+ */
88
+ export const locateOne = <Row, E, R>(
89
+ list: Effect.Effect<{ readonly count: number; readonly results: readonly Row[] }, E, R>,
90
+ describe: string,
91
+ identifies?: (row: Row) => boolean,
92
+ ): Effect.Effect<Row | undefined, E, R> =>
93
+ list.pipe(
94
+ Effect.map((page) => {
95
+ if (page.count > LOCATE_PAGE) {
96
+ throw new Error(
97
+ `${describe}: the locate filter matched ${String(page.count)} rows, more than the ` +
98
+ `${String(LOCATE_PAGE)}-row page this reads. That is not a natural key — narrow the ` +
99
+ 'filter rather than paging through candidates.',
121
100
  );
122
- }),
123
- );
101
+ }
102
+ const rows = identifies === undefined ? page.results : page.results.filter(identifies);
103
+ return soleMatch(rows, describe);
104
+ }),
105
+ );
124
106
 
125
- /** ⛔ The numeric `id` is the wire path. NetBox has no update-by-natural-key. */
126
- const objectPath = (live: NetboxRow): string => `${spec.collection}/${String(live['id'])}`;
107
+ /**
108
+ * One NetBox object's distilled calls. `Live` is whatever the SDK decodes (a `Prefix`, a `VLAN`,
109
+ * …) — no more untyped `NetboxRow`. `E` is left to each resource file to declare (the union of the
110
+ * distilled operations it actually calls), so a call site's precise per-operation error union
111
+ * flows straight through instead of being widened by hand here.
112
+ */
113
+ export type NetboxSpec<Props extends object, Live, Attributes, E> = {
114
+ /** Already folds any real "absent" outcome (an empty list, or a `catchTag('NotFound', ...)`). */
115
+ readonly fetchLive: (props: Props) => Effect.Effect<Live | undefined, E, NetboxOpContext>;
116
+ readonly attributes: (live: Live, props: Props) => Attributes | undefined;
117
+ readonly matches: (attributes: Attributes, props: Props) => boolean;
118
+ readonly createBody: (props: Props) => NetboxBody;
119
+ readonly create: (props: Props, body: NetboxBody) => Effect.Effect<unknown, E, NetboxOpContext>;
120
+ /** Absent for a create-only family — reconcile then never attempts a write against an adopted row. */
121
+ readonly update?: {
122
+ /** ⚠️ PARTIAL BY DESIGN: NetBox updates are PATCH, so presence is not checked on this body. */
123
+ readonly body: (props: Props) => NetboxBody;
124
+ readonly call: (
125
+ props: Props,
126
+ live: Live,
127
+ body: NetboxBody,
128
+ ) => Effect.Effect<unknown, E, NetboxOpContext>;
129
+ };
130
+ readonly destroy: (props: Props, live: Live) => Effect.Effect<unknown, E, NetboxOpContext>;
131
+ /** The generated constraint table to check each body against, by endpoint key. */
132
+ readonly endpoint: { readonly create: EndpointKey; readonly update?: EndpointKey };
133
+ /** For the message `soleMatch` raises and for read-back failures. */
134
+ readonly describe: (props: Props) => string;
135
+ };
127
136
 
137
+ export const netboxOperations = <Props extends object, Live, Attributes, E>(
138
+ spec: NetboxSpec<Props, Live, Attributes, E>,
139
+ ) => {
128
140
  const read = (props: Props) =>
129
- fetchLive(props).pipe(
130
- Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))),
131
- );
141
+ spec
142
+ .fetchLive(props)
143
+ .pipe(Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))));
132
144
 
133
145
  return {
134
146
  read,
@@ -139,29 +151,29 @@ export const netboxOperations = <Props extends object, Attributes>(
139
151
  const live = yield* read(news);
140
152
  if (live === undefined) return { action: 'update' } as const;
141
153
  if (spec.matches(live, news)) return { action: 'noop' } as const;
142
- return spec.updateBody === undefined
154
+ return spec.update === undefined
143
155
  ? ({ action: 'replace' } as const)
144
156
  : ({ action: 'update' } as const);
145
157
  }),
146
158
 
147
159
  reconcile: (news: Props) =>
148
160
  Effect.gen(function* () {
149
- const before = yield* fetchLive(news);
161
+ const before = yield* spec.fetchLive(news);
150
162
  if (before === undefined) {
151
163
  const body = spec.createBody(news);
152
164
  // ⛔ GUARD BEFORE THE REQUEST, WITH PRESENCE ON. This is the only place a missing
153
165
  // required property can still be caught locally.
154
166
  yield* guardBody(spec.endpoint.create, body, true);
155
- yield* netbox('POST', `${spec.collection}/`, body);
156
- } else if (spec.updateBody !== undefined) {
167
+ yield* spec.create(news, body);
168
+ } else if (spec.update !== undefined) {
157
169
  const mapped = spec.attributes(before, news);
158
170
  // ⚠️ ADOPTION IS THE `mapped === undefined` CASE TOO: a row this spec cannot map is not
159
171
  // a row to PATCH blindly.
160
172
  if (mapped !== undefined && !spec.matches(mapped, news)) {
161
- const body = spec.updateBody(news);
173
+ const body = spec.update.body(news);
162
174
  yield* guardBody(spec.endpoint.update, body, false);
163
175
  if (Object.keys(body).length > 0) {
164
- yield* netbox('PATCH', `${objectPath(before)}/`, body);
176
+ yield* spec.update.call(news, before, body);
165
177
  }
166
178
  }
167
179
  }
@@ -180,9 +192,9 @@ export const netboxOperations = <Props extends object, Attributes>(
180
192
 
181
193
  destroy: (olds: Props) =>
182
194
  Effect.gen(function* () {
183
- const live = yield* fetchLive(olds);
195
+ const live = yield* spec.fetchLive(olds);
184
196
  if (live === undefined) return;
185
- yield* netbox('DELETE', `${objectPath(live)}/`);
197
+ yield* spec.destroy(olds, live);
186
198
  }),
187
199
  };
188
200
  };
@@ -190,19 +202,28 @@ export const netboxOperations = <Props extends object, Attributes>(
190
202
  /**
191
203
  * The five provider handlers for a spec'd NetBox object, wired once.
192
204
  *
205
+ * ★ CREDENTIALS ARE PROVIDED HERE, ONCE, NOT BY EVERY CALLER. `CredentialsFromEnv` resolves
206
+ * `NETBOX_URL` / `NETBOX_TOKEN` on the calling fiber per request, so this closure keeps that
207
+ * laziness — nothing is captured at module load. ⛔ THE TOKEN IS NEVER A PROP: Alchemy persists
208
+ * attributes unencrypted, so nothing stored in a stack file may be a credential.
209
+ *
210
+ * ⛔ IT STOPS AT THE HANDLERS AND DOES NOT RETURN THE LAYER — same reasoning as `forgejoHandlers`.
211
+ *
193
212
  * ⚠️ `list` answers empty. NetBox index endpoints return the whole estate — tens of thousands of
194
213
  * rows on a real instance — so adoption stays explicit rather than sweeping.
195
214
  */
196
- export const netboxHandlers = <Props extends object, Attributes extends object>(
197
- spec: NetboxSpec<Props, Attributes>,
215
+ export const netboxHandlers = <Props extends object, Live, Attributes extends object, E>(
216
+ spec: NetboxSpec<Props, Live, Attributes, E>,
198
217
  ) => {
199
218
  const ops = netboxOperations(spec);
219
+ const withCredentials = <A>(effect: Effect.Effect<A, E, NetboxOpContext>) =>
220
+ Effect.provide(effect, CredentialsFromEnv);
200
221
  return {
201
222
  list: () => Effect.succeed([]),
202
- read: ({ olds }: { olds: Props }) => ops.read(olds),
223
+ read: ({ olds }: { olds: Props }) => withCredentials(ops.read(olds)),
203
224
  diff: ({ news, output }: { news: Input<Props>; output: Attributes | undefined }) =>
204
- ops.diff(news, output),
205
- reconcile: ({ news }: { news: Props }) => ops.reconcile(news),
206
- delete: ({ olds }: { olds: Props }) => ops.destroy(olds),
225
+ withCredentials(ops.diff(news, output)),
226
+ reconcile: ({ news }: { news: Props }) => withCredentials(ops.reconcile(news)),
227
+ delete: ({ olds }: { olds: Props }) => withCredentials(ops.destroy(olds)),
207
228
  };
208
229
  };
@@ -1,52 +0,0 @@
1
- /**
2
- * One authenticated call to the NetBox REST API at `/api`.
3
- *
4
- * ⚠️ `Authorization: Token <value>`, NOT `Bearer`. NetBox is Django REST Framework using DRF's own
5
- * `TokenAuthentication`. MEASURED on this estate's own instance while it was up: `Bearer`
6
- * returns 401 and `Token` returns 200. ⛔ A wrong scheme and a wrong credential are
7
- * indistinguishable in the response, which is why this is written down rather than inferred.
8
- *
9
- * ⚠️ NETBOX DOES NOT WRAP A DETAIL RESPONSE THE WAY PVE DOES — a GET of one object returns the
10
- * object. A LIST, however, IS wrapped: `{count, next, previous, results: [...]}`. A caller that
11
- * treats a list like a detail sees `undefined` and calls a healthy object absent, which plans a
12
- * create over something that already exists.
13
- *
14
- * ⛔ THE TOKEN IS NEVER A PROP. It is read from `NETBOX_TOKEN` at call time only. Alchemy persists
15
- * attributes unencrypted; nothing stored here may be a credential.
16
- */
17
- import * as Effect from 'effect/Effect';
18
- import * as HttpClient from 'effect/unstable/http/HttpClient';
19
- /**
20
- * The instance, from `NETBOX_URL`. Trailing slash stripped.
21
- *
22
- * ⛔ NO DEFAULT HOSTNAME AND NO DEFAULT PORT. A published package that fell back to a loopback
23
- * address would let a consumer who forgot the variable watch every call fail against a machine
24
- * that is not theirs, instead of being told which variable is missing.
25
- * ⚠️ Read at CALL time, never at module scope — a value captured at import is pinned to whatever
26
- * the first isolate saw.
27
- */
28
- export declare const apiBase: () => string;
29
- export declare class NetboxError extends Error {
30
- readonly status: number;
31
- readonly method: string;
32
- readonly path: string;
33
- constructor(status: number, method: string, path: string, detail: string);
34
- }
35
- /** A NetBox list response. ⚠️ Paginated: `next` is a URL, not a cursor to guess at. */
36
- export interface NetboxList<T> {
37
- readonly count: number;
38
- readonly next: string | null;
39
- readonly results: readonly T[];
40
- }
41
- export type NetboxRow = Record<string, unknown>;
42
- /** One JSON call. Returns the parsed body, or `undefined` when the response is empty (DELETE). */
43
- export declare const netbox: <T>(method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', path: string, body?: Record<string, unknown>) => Effect.Effect<T, NetboxError, HttpClient.HttpClient>;
44
- /**
45
- * The single row a filtered list returns, or `undefined`.
46
- *
47
- * ⛔ MORE THAN ONE MATCH IS A DEFECT, NOT A REASON TO TAKE THE FIRST. A `locate` filter that is
48
- * not unique would let adopt bind to whichever row NetBox happened to order first, and the next
49
- * plan would bind to the other one and report drift that is not there.
50
- */
51
- export declare const soleMatch: <T>(rows: readonly T[], describe: string) => T | undefined;
52
- //# sourceMappingURL=client.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/netbox/client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAK9D;;;;;;;;GAQG;AACH,eAAO,MAAM,OAAO,QAAO,MAQ1B,CAAC;AAEF,qBAAa,WAAY,SAAQ,KAAK;IAElC,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM;IAHvB,YACW,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACrB,MAAM,EAAE,MAAM,EAIf;CACF;AAkBD,uFAAuF;AACvF,MAAM,WAAW,UAAU,CAAC,CAAC;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,QAAQ,CAAC,OAAO,EAAE,SAAS,CAAC,EAAE,CAAC;CAChC;AAED,MAAM,MAAM,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAEhD,kGAAkG;AAClG,eAAO,MAAM,MAAM,GAAI,CAAC,UACd,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,OAAO,GAAG,QAAQ,QAC7C,MAAM,SACL,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,yDA8C5B,CAAC;AAEL;;;;;;GAMG;AACH,eAAO,MAAM,SAAS,GAAI,CAAC,QAAQ,SAAS,CAAC,EAAE,YAAY,MAAM,KAAG,CAAC,GAAG,SAQvE,CAAC"}