@homeflare/alchemy 0.38.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/README.md +22 -0
  2. package/dist/forgejo/index.js +4 -2
  3. package/dist/forgejo/index.js.map +3 -3
  4. package/dist/forgejo/team-member.d.ts +25 -0
  5. package/dist/forgejo/team-member.d.ts.map +1 -1
  6. package/dist/proxmox/generated/constraints/index.d.ts +1 -1
  7. package/dist/proxmox/generated/constraints/index.d.ts.map +1 -1
  8. package/dist/proxmox/generated/constraints/pve-access.d.ts +1 -1
  9. package/dist/proxmox/generated/constraints/pve-cluster-backup.d.ts +1 -1
  10. package/dist/proxmox/generated/constraints/pve-cluster-ceph.d.ts +1 -1
  11. package/dist/proxmox/generated/constraints/pve-cluster-firewall.d.ts +1 -1
  12. package/dist/proxmox/generated/constraints/pve-cluster-ha.d.ts +1 -1
  13. package/dist/proxmox/generated/constraints/pve-cluster-metrics.d.ts +1 -1
  14. package/dist/proxmox/generated/constraints/pve-cluster-notifications.d.ts +1 -1
  15. package/dist/proxmox/generated/constraints/pve-cluster-replication.d.ts +1 -1
  16. package/dist/proxmox/generated/constraints/pve-cluster-sdn.d.ts +1 -1
  17. package/dist/proxmox/generated/constraints/pve-nodes-ceph.d.ts +1 -1
  18. package/dist/proxmox/generated/constraints/pve-nodes-disks.d.ts +1 -1
  19. package/dist/proxmox/generated/constraints/pve-nodes-lxc.d.ts +1 -1
  20. package/dist/proxmox/generated/constraints/pve-nodes-network.d.ts +1 -1
  21. package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts +1 -1
  22. package/dist/proxmox/generated/constraints/pve-nodes-storage.d.ts +23 -0
  23. package/dist/proxmox/generated/constraints/pve-nodes-storage.d.ts.map +1 -0
  24. package/dist/proxmox/generated/constraints/pve-pools.d.ts +1 -1
  25. package/dist/proxmox/generated/constraints/pve-storage.d.ts +1 -1
  26. package/dist/proxmox/index.d.ts +14 -3
  27. package/dist/proxmox/index.d.ts.map +1 -1
  28. package/dist/proxmox/index.js +565 -133
  29. package/dist/proxmox/index.js.map +33 -23
  30. package/dist/proxmox/qemu-form.d.ts +27 -18
  31. package/dist/proxmox/qemu-form.d.ts.map +1 -1
  32. package/dist/proxmox/qemu-judge.d.ts +30 -0
  33. package/dist/proxmox/qemu-judge.d.ts.map +1 -0
  34. package/dist/proxmox/qemu-lifecycle.d.ts +16 -2
  35. package/dist/proxmox/qemu-lifecycle.d.ts.map +1 -1
  36. package/dist/proxmox/qemu-net.d.ts +9 -0
  37. package/dist/proxmox/qemu-net.d.ts.map +1 -0
  38. package/dist/proxmox/qemu-props.d.ts +112 -0
  39. package/dist/proxmox/qemu-props.d.ts.map +1 -0
  40. package/dist/proxmox/qemu-volume.d.ts +17 -0
  41. package/dist/proxmox/qemu-volume.d.ts.map +1 -0
  42. package/dist/proxmox/qemu.d.ts +14 -27
  43. package/dist/proxmox/qemu.d.ts.map +1 -1
  44. package/dist/proxmox/storage-download-errors.d.ts +5 -0
  45. package/dist/proxmox/storage-download-errors.d.ts.map +1 -0
  46. package/dist/proxmox/storage-download-identity.d.ts +27 -0
  47. package/dist/proxmox/storage-download-identity.d.ts.map +1 -0
  48. package/dist/proxmox/storage-download-lifecycle.d.ts +97 -0
  49. package/dist/proxmox/storage-download-lifecycle.d.ts.map +1 -0
  50. package/dist/proxmox/storage-download-props.d.ts +36 -0
  51. package/dist/proxmox/storage-download-props.d.ts.map +1 -0
  52. package/dist/proxmox/storage-download-task.d.ts +17 -0
  53. package/dist/proxmox/storage-download-task.d.ts.map +1 -0
  54. package/dist/proxmox/storage-download.d.ts +44 -0
  55. package/dist/proxmox/storage-download.d.ts.map +1 -0
  56. package/dist/telemetry/fake-collector.d.ts +36 -0
  57. package/dist/telemetry/fake-collector.d.ts.map +1 -0
  58. package/dist/telemetry/index.d.ts +11 -0
  59. package/dist/telemetry/index.d.ts.map +1 -0
  60. package/dist/telemetry/index.js +223 -0
  61. package/dist/telemetry/index.js.map +12 -0
  62. package/dist/telemetry/layer.d.ts +86 -0
  63. package/dist/telemetry/layer.d.ts.map +1 -0
  64. package/dist/telemetry/redact-serialization.d.ts +21 -0
  65. package/dist/telemetry/redact-serialization.d.ts.map +1 -0
  66. package/dist/telemetry/redact.d.ts +46 -0
  67. package/dist/telemetry/redact.d.ts.map +1 -0
  68. package/dist/unifi/drift.d.ts +27 -0
  69. package/dist/unifi/drift.d.ts.map +1 -0
  70. package/dist/unifi/fake-unifi.d.ts +5 -1
  71. package/dist/unifi/fake-unifi.d.ts.map +1 -1
  72. package/dist/unifi/firewall-zone-form.d.ts +6 -0
  73. package/dist/unifi/firewall-zone-form.d.ts.map +1 -1
  74. package/dist/unifi/firewall-zone.d.ts +1 -1
  75. package/dist/unifi/firewall-zone.d.ts.map +1 -1
  76. package/dist/unifi/index.d.ts +4 -3
  77. package/dist/unifi/index.d.ts.map +1 -1
  78. package/dist/unifi/index.js +138 -58
  79. package/dist/unifi/index.js.map +10 -8
  80. package/dist/unifi/network-drift.d.ts +26 -0
  81. package/dist/unifi/network-drift.d.ts.map +1 -0
  82. package/dist/unifi/network-form.d.ts +40 -7
  83. package/dist/unifi/network-form.d.ts.map +1 -1
  84. package/dist/unifi/network.d.ts +1 -0
  85. package/dist/unifi/network.d.ts.map +1 -1
  86. package/dist/unifi/paginate.d.ts +36 -0
  87. package/dist/unifi/paginate.d.ts.map +1 -0
  88. package/dist/unifi/resource.d.ts +36 -1
  89. package/dist/unifi/resource.d.ts.map +1 -1
  90. package/docs/telemetry-spike.md +94 -0
  91. package/docs/telemetry.md +109 -0
  92. package/docs/unifi.md +56 -0
  93. package/package.json +6 -2
  94. package/src/forgejo/team-member.test.ts +14 -0
  95. package/src/forgejo/team-member.ts +28 -1
  96. package/src/proxmox/constraints-required.test.ts +1 -0
  97. package/src/proxmox/generated/constraints/index.ts +3 -1
  98. package/src/proxmox/generated/constraints/pve-access.ts +1 -1
  99. package/src/proxmox/generated/constraints/pve-cluster-backup.ts +1 -1
  100. package/src/proxmox/generated/constraints/pve-cluster-ceph.ts +1 -1
  101. package/src/proxmox/generated/constraints/pve-cluster-firewall.ts +1 -1
  102. package/src/proxmox/generated/constraints/pve-cluster-ha.ts +1 -1
  103. package/src/proxmox/generated/constraints/pve-cluster-metrics.ts +1 -1
  104. package/src/proxmox/generated/constraints/pve-cluster-notifications.ts +1 -1
  105. package/src/proxmox/generated/constraints/pve-cluster-replication.ts +1 -1
  106. package/src/proxmox/generated/constraints/pve-cluster-sdn.ts +1 -1
  107. package/src/proxmox/generated/constraints/pve-nodes-ceph.ts +1 -1
  108. package/src/proxmox/generated/constraints/pve-nodes-disks.ts +1 -1
  109. package/src/proxmox/generated/constraints/pve-nodes-lxc.ts +1 -1
  110. package/src/proxmox/generated/constraints/pve-nodes-network.ts +1 -1
  111. package/src/proxmox/generated/constraints/pve-nodes-qemu.ts +1 -1
  112. package/src/proxmox/generated/constraints/pve-nodes-storage.ts +30 -0
  113. package/src/proxmox/generated/constraints/pve-pools.ts +1 -1
  114. package/src/proxmox/generated/constraints/pve-storage.ts +1 -1
  115. package/src/proxmox/index.test.ts +11 -6
  116. package/src/proxmox/index.ts +19 -3
  117. package/src/proxmox/qemu-form.ts +49 -13
  118. package/src/proxmox/qemu-identity.test.ts +30 -0
  119. package/src/proxmox/qemu-judge.test.ts +65 -0
  120. package/src/proxmox/qemu-judge.ts +84 -0
  121. package/src/proxmox/qemu-lifecycle.test.ts +78 -19
  122. package/src/proxmox/qemu-lifecycle.ts +131 -32
  123. package/src/proxmox/qemu-net.test.ts +48 -0
  124. package/src/proxmox/qemu-net.ts +78 -0
  125. package/src/proxmox/qemu-ownership.test.ts +44 -0
  126. package/src/proxmox/qemu-props.test.ts +87 -0
  127. package/src/proxmox/qemu-props.ts +157 -0
  128. package/src/proxmox/qemu-volume.test.ts +80 -0
  129. package/src/proxmox/qemu-volume.ts +121 -0
  130. package/src/proxmox/qemu.ts +15 -29
  131. package/src/proxmox/storage-download-errors.ts +7 -0
  132. package/src/proxmox/storage-download-identity.test.ts +54 -0
  133. package/src/proxmox/storage-download-identity.ts +50 -0
  134. package/src/proxmox/storage-download-lifecycle.ts +226 -0
  135. package/src/proxmox/storage-download-ownership.test.ts +50 -0
  136. package/src/proxmox/storage-download-props.ts +38 -0
  137. package/src/proxmox/storage-download-task.ts +72 -0
  138. package/src/proxmox/storage-download.test.ts +216 -0
  139. package/src/proxmox/storage-download.ts +58 -0
  140. package/src/telemetry/fake-collector.ts +59 -0
  141. package/src/telemetry/http-client-sealing.test.ts +82 -0
  142. package/src/telemetry/index.ts +10 -0
  143. package/src/telemetry/layer.test.ts +204 -0
  144. package/src/telemetry/layer.ts +152 -0
  145. package/src/telemetry/redact-serialization.ts +178 -0
  146. package/src/telemetry/redact.test.ts +124 -0
  147. package/src/telemetry/redact.ts +147 -0
  148. package/src/unifi/drift.test.ts +78 -0
  149. package/src/unifi/drift.ts +56 -0
  150. package/src/unifi/errors-and-secrets.test.ts +127 -0
  151. package/src/unifi/fake-unifi.ts +5 -1
  152. package/src/unifi/firewall-zone-form.ts +31 -2
  153. package/src/unifi/firewall-zone.test.ts +21 -1
  154. package/src/unifi/firewall-zone.ts +1 -1
  155. package/src/unifi/get-only-guard.test.ts +165 -0
  156. package/src/unifi/index.ts +15 -1
  157. package/src/unifi/network-drift.test.ts +207 -0
  158. package/src/unifi/network-drift.ts +80 -0
  159. package/src/unifi/network-form.ts +11 -39
  160. package/src/unifi/network.test.ts +7 -82
  161. package/src/unifi/network.ts +6 -6
  162. package/src/unifi/paginate.test.ts +228 -0
  163. package/src/unifi/paginate.ts +129 -0
  164. package/src/unifi/resource.ts +73 -3
  165. package/src/unifi/write-op-reference.test.ts +236 -0
@@ -2,13 +2,19 @@
2
2
  * `Unifi.Network`'s `spec` against a fake UniFi Network API, proving the real distilled wire
3
3
  * path (path assembly, JSON decode, `NotFound` folding), the declaration renderer, and — the
4
4
  * task's own requirement — that no handler this family exposes ever sends a non-`GET` request.
5
+ *
6
+ * ⚠️ `driftOf`/`matches` coverage (the DHCP/IPv6 unordered-array normalizers, the MEDIUM-4
7
+ * mutant-closing tests) lives in `network-drift.test.ts`, split out to keep this file under the
8
+ * house 250-line cap once that coverage grew — same split `network-drift.ts` itself is from
9
+ * `network-form.ts`, not a different family.
5
10
  */
6
11
  import { describe, expect, test } from 'bun:test';
7
12
  import type * as networks from '@distilled.cloud/unifi-network/networks';
8
13
  import * as Retry from '@distilled.cloud/unifi-network/Retry';
9
14
  import * as Effect from 'effect/Effect';
10
15
  import { fakeFailure, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
11
- import { attributesOf, matches } from './network-form.ts';
16
+ import { attributesOf } from './network-form.ts';
17
+ import { matches } from './network-drift.ts';
12
18
  import { type NetworkProps, declareNetwork, spec } from './network.ts';
13
19
  import { unifiOperations } from './resource.ts';
14
20
 
@@ -84,87 +90,6 @@ describe('declareNetwork -- the declaration renderer', () => {
84
90
  });
85
91
  });
86
92
 
87
- describe('matches -- unordered arrays (dhcpGuarding, ipv6Configuration)', () => {
88
- // ⛔ `deepEqual` (alchemy/Diff) sorts object keys but not array elements: without normalizing
89
- // these three fields (see network-form.ts's header), a console returning the same set in a
90
- // different order would plan a spurious update.
91
- test('the same trusted DHCP servers in a different order is a noop', () => {
92
- const live = liveNetwork({
93
- dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.2', '10.0.0.1'] },
94
- });
95
- const props: NetworkProps = {
96
- ...PROPS,
97
- dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.1', '10.0.0.2'] },
98
- };
99
- expect(matches(attributesOf(live, props), props)).toBe(true);
100
- });
101
-
102
- test('a different set of trusted DHCP servers is an update', () => {
103
- const live = liveNetwork({
104
- dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.1', '10.0.0.2'] },
105
- });
106
- const props: NetworkProps = {
107
- ...PROPS,
108
- dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.1', '10.0.0.3'] },
109
- };
110
- expect(matches(attributesOf(live, props), props)).toBe(false);
111
- });
112
-
113
- test('the same IPv6 host subnets and DNS overrides in a different order is a noop', () => {
114
- const live = liveNetwork({
115
- ipv6Configuration: {
116
- clientAddressAssignment: { slaacEnabled: true },
117
- interfaceType: 'ETHERNET',
118
- additionalHostIpSubnets: ['2001:db8::/64', '2001:db8:1::/64'],
119
- dnsServerIpAddressesOverride: ['2001:4860:4860::8888', '2001:4860:4860::8844'],
120
- },
121
- });
122
- const props: NetworkProps = {
123
- ...PROPS,
124
- ipv6Configuration: {
125
- clientAddressAssignment: { slaacEnabled: true },
126
- interfaceType: 'ETHERNET',
127
- additionalHostIpSubnets: ['2001:db8:1::/64', '2001:db8::/64'],
128
- dnsServerIpAddressesOverride: ['2001:4860:4860::8844', '2001:4860:4860::8888'],
129
- },
130
- };
131
- expect(matches(attributesOf(live, props), props)).toBe(true);
132
- });
133
-
134
- test('a different set of IPv6 host subnets is an update', () => {
135
- const live = liveNetwork({
136
- ipv6Configuration: {
137
- clientAddressAssignment: { slaacEnabled: true },
138
- interfaceType: 'ETHERNET',
139
- additionalHostIpSubnets: ['2001:db8::/64', '2001:db8:1::/64'],
140
- },
141
- });
142
- const props: NetworkProps = {
143
- ...PROPS,
144
- ipv6Configuration: {
145
- clientAddressAssignment: { slaacEnabled: true },
146
- interfaceType: 'ETHERNET',
147
- additionalHostIpSubnets: ['2001:db8::/64', '2001:db8:2::/64'],
148
- },
149
- };
150
- expect(matches(attributesOf(live, props), props)).toBe(false);
151
- });
152
-
153
- test('a live `null` (not `undefined`) for either field does not throw', () => {
154
- // UniFi's JSON answers `null` for an unset optional field (see network-form.ts's header) —
155
- // a reality the SDK's `T | undefined` types don't model. The double cast lands `null` at
156
- // runtime the same way a real JSON decode would, past what the type system alone allows.
157
- const live = {
158
- ...liveNetwork(),
159
- dhcpGuarding: null,
160
- ipv6Configuration: null,
161
- } as unknown as networks.NetworkDetails;
162
- const declared = declareNetwork(live, 'site-1');
163
- expect(() => attributesOf(live, declared)).not.toThrow();
164
- expect(matches(attributesOf(live, declared), declared)).toBe(true);
165
- });
166
- });
167
-
168
93
  describe('Unifi.Network write paths never reach the vendor API', () => {
169
94
  const ops = unifiOperations(spec);
170
95
 
@@ -23,17 +23,17 @@ import { adopt } from 'alchemy/AdoptPolicy';
23
23
  import * as Provider from 'alchemy/Provider';
24
24
  import * as networks from '@distilled.cloud/unifi-network/networks';
25
25
  import * as Effect from 'effect/Effect';
26
- import {
27
- type NetworkAttributes,
28
- type NetworkProps,
29
- attributesOf,
30
- matches,
31
- } from './network-form.ts';
26
+ import { type NetworkAttributes, type NetworkProps, attributesOf } from './network-form.ts';
27
+ import { matches } from './network-drift.ts';
32
28
  import type { UnifiRequirements, UnifiSpec } from './resource.ts';
33
29
  import { unifiHandlers } from './resource.ts';
34
30
 
35
31
  export type { NetworkAttributes, NetworkProps } from './network-form.ts';
36
32
  export { declareNetwork } from './network-form.ts';
33
+ // MEDIUM-4: `matches` moved here from `network-form.ts` — see network-drift.ts's own header. Not
34
+ // re-exported from this barrel file either, same as before the move: `spec.matches` below is the
35
+ // only consumer a provider needs.
36
+ export { driftOf } from './network-drift.ts';
37
37
 
38
38
  export interface UnifiNetwork extends Resource<
39
39
  'Unifi.Network',
@@ -0,0 +1,228 @@
1
+ /**
2
+ * `pageAll` (B0b/T9) — pure walk-logic tests against a synthetic fetcher, the three IMPORTANT-3
3
+ * consistency checks (red team, 2026-09-26) against fetchers that lie the exact ways a real vendor
4
+ * response could, and one integration test proving it type-checks and decodes against a REAL SDK
5
+ * list operation (`getNetworksOverviewPage`) through the fake UniFi server. `M2`: this file does
6
+ * not repeat the 404/500 decode tests `network.test.ts` already covers — those are about ONE
7
+ * page's failure, not the walk this file exists to prove.
8
+ */
9
+ import { describe, expect, test } from 'bun:test';
10
+ import * as Effect from 'effect/Effect';
11
+ import * as networks from '@distilled.cloud/unifi-network/networks';
12
+ import { fakeFailure, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
13
+ import { type OffsetPage, UnifiPaginationInconsistent, pageAll } from './paginate.ts';
14
+
15
+ interface Row {
16
+ readonly id: number;
17
+ }
18
+
19
+ /** A fake dataset served `pageSize` rows at a time, the same envelope every real list op uses —
20
+ * including `offset` echoed back exactly as requested, the honest case every OTHER test in this
21
+ * file deviates from on purpose. */
22
+ const fakePages = (rows: readonly Row[], pageSize: number) => {
23
+ const calls: { offset: number; limit: number | undefined }[] = [];
24
+ const fetchPage = (request: { offset?: number; limit?: number }) => {
25
+ const offset = request.offset ?? 0;
26
+ calls.push({ offset, limit: request.limit });
27
+ return Effect.succeed<OffsetPage<Row>>({
28
+ data: rows.slice(offset, offset + pageSize),
29
+ offset,
30
+ totalCount: rows.length,
31
+ });
32
+ };
33
+ return { calls, fetchPage };
34
+ };
35
+
36
+ const flip = <A>(effect: Effect.Effect<A, UnifiPaginationInconsistent>) =>
37
+ Effect.runPromise(Effect.flip(effect));
38
+
39
+ describe('pageAll -- walk logic', () => {
40
+ test('walks every page in order and returns every row exactly once', async () => {
41
+ const rows = Array.from({ length: 7 }, (_, id) => ({ id }));
42
+ const { calls, fetchPage } = fakePages(rows, 3);
43
+
44
+ const result = await Effect.runPromise(pageAll(fetchPage, { limit: 3 }));
45
+
46
+ expect(result).toEqual(rows);
47
+ expect(calls).toEqual([
48
+ { offset: 0, limit: 3 },
49
+ { offset: 3, limit: 3 },
50
+ { offset: 6, limit: 3 },
51
+ ]);
52
+ });
53
+
54
+ test('a single page under the limit stops after one call', async () => {
55
+ const rows = [{ id: 1 }, { id: 2 }];
56
+ const { calls, fetchPage } = fakePages(rows, 10);
57
+
58
+ const result = await Effect.runPromise(pageAll(fetchPage, { limit: 10 }));
59
+
60
+ expect(result).toEqual(rows);
61
+ expect(calls).toHaveLength(1);
62
+ });
63
+
64
+ test('zero rows total makes exactly one call and returns empty', async () => {
65
+ const { calls, fetchPage } = fakePages([], 5);
66
+
67
+ const result = await Effect.runPromise(pageAll(fetchPage, { limit: 5 }));
68
+
69
+ expect(result).toEqual([]);
70
+ expect(calls).toHaveLength(1);
71
+ });
72
+
73
+ test('T9: keeps paging on totalCount even when every page is short of the limit', async () => {
74
+ // A page shorter than `limit` is NOT by itself "last page" -- only `totalCount` is. This
75
+ // fetcher returns exactly one row per call (as if capped server-side, independent of `limit`)
76
+ // while `totalCount` says three exist; a pager that stopped on `data.length < limit` would
77
+ // truncate after the FIRST call and silently drop rows 2 and 3 -- the exact T9 failure mode.
78
+ const offsetsSeen: number[] = [];
79
+ const fetchPage = (request: { offset?: number; limit?: number }) => {
80
+ const offset = request.offset ?? 0;
81
+ offsetsSeen.push(offset);
82
+ return Effect.succeed<OffsetPage<Row>>({ data: [{ id: offset }], offset, totalCount: 3 });
83
+ };
84
+
85
+ const result = await Effect.runPromise(pageAll(fetchPage, { limit: 10 }));
86
+
87
+ expect(result).toEqual([{ id: 0 }, { id: 1 }, { id: 2 }]);
88
+ expect(offsetsSeen).toEqual([0, 1, 2]);
89
+ });
90
+
91
+ test('resumes from a caller-given offset instead of always starting at 0', async () => {
92
+ const rows = Array.from({ length: 5 }, (_, id) => ({ id }));
93
+ const { calls, fetchPage } = fakePages(rows, 2);
94
+
95
+ const result = await Effect.runPromise(pageAll(fetchPage, { offset: 2, limit: 2 }));
96
+
97
+ expect(result).toEqual(rows.slice(2));
98
+ expect(calls[0]).toEqual({ offset: 2, limit: 2 });
99
+ });
100
+
101
+ test('a failure from one page propagates instead of being swallowed', async () => {
102
+ const boom = new Error('boom');
103
+ const fetchPage = () => Effect.fail(boom);
104
+
105
+ const result = await Effect.runPromise(Effect.flip(pageAll(fetchPage, { limit: 5 })));
106
+
107
+ expect(result).toBe(boom);
108
+ });
109
+ });
110
+
111
+ describe('pageAll -- IMPORTANT-3: typed failures on a self-contradicting response', () => {
112
+ test('the server ignoring offset (always echoing 0) fails as offset-mismatch, not an infinite loop', async () => {
113
+ let calls = 0;
114
+ const fetchPage = () => {
115
+ calls += 1;
116
+ // Always answers page 1, regardless of the requested offset -- the "offset ignored" trap.
117
+ return Effect.succeed<OffsetPage<Row>>({
118
+ data: [{ id: 1 }, { id: 2 }],
119
+ offset: 0,
120
+ totalCount: 5,
121
+ });
122
+ };
123
+
124
+ const failure = await flip(pageAll(fetchPage, { limit: 2 }));
125
+
126
+ expect(failure).toBeInstanceOf(UnifiPaginationInconsistent);
127
+ expect(failure.reason).toBe('offset-mismatch');
128
+ // The FIRST page (offset 0 requested, 0 echoed) is fine; the SECOND (offset 2 requested, 0
129
+ // echoed) is what trips it -- proving this stops the walk instead of cycling forever.
130
+ expect(calls).toBe(2);
131
+ });
132
+
133
+ test('totalCount drifting between calls fails as total-count-changed', async () => {
134
+ let call = 0;
135
+ const fetchPage = (request: { offset?: number; limit?: number }) => {
136
+ call += 1;
137
+ const offset = request.offset ?? 0;
138
+ // A row is removed after the first page is read -- totalCount drops from 3 to 2.
139
+ return Effect.succeed<OffsetPage<Row>>({
140
+ data: [{ id: offset }],
141
+ offset,
142
+ totalCount: call === 1 ? 3 : 2,
143
+ });
144
+ };
145
+
146
+ const failure = await flip(pageAll(fetchPage, { limit: 1 }));
147
+
148
+ expect(failure.reason).toBe('total-count-changed');
149
+ });
150
+
151
+ test('an undercounted totalCount fails loudly instead of silently truncating (T9)', async () => {
152
+ // The exact scenario named in review: totalCount says 0 but the vendor still returns real
153
+ // rows on the one page fetched before the loop (correctly) stops on totalCount -- the OLD
154
+ // behavior returned those 2 rows as if they were the complete, correct answer.
155
+ const fetchPage = () =>
156
+ Effect.succeed<OffsetPage<Row>>({ data: [{ id: 1 }, { id: 2 }], offset: 0, totalCount: 0 });
157
+
158
+ const failure = await flip(pageAll(fetchPage, { limit: 10 }));
159
+
160
+ expect(failure.reason).toBe('row-count-mismatch');
161
+ });
162
+
163
+ test('a walk that never reaches totalCount fails at the page ceiling instead of looping forever', async () => {
164
+ // Offset IS honored (so this never trips offset-mismatch first) but totalCount is inflated far
165
+ // past what MAX_PAGES worth of single-row pages could ever reach -- the walk is internally
166
+ // consistent page-to-page, just never converges, which only the ceiling can catch.
167
+ const fetchPage = (request: { offset?: number; limit?: number }) =>
168
+ Effect.succeed<OffsetPage<Row>>({
169
+ data: [{ id: request.offset ?? 0 }],
170
+ offset: request.offset ?? 0,
171
+ totalCount: 1_000_000,
172
+ });
173
+
174
+ const failure = await flip(pageAll(fetchPage, { limit: 1 }));
175
+
176
+ expect(failure.reason).toBe('page-ceiling');
177
+ });
178
+
179
+ test('an honest, well-behaved server never trips any of the three checks', async () => {
180
+ const rows = Array.from({ length: 9 }, (_, id) => ({ id }));
181
+ const { fetchPage } = fakePages(rows, 4);
182
+
183
+ const result = await Effect.runPromise(pageAll(fetchPage, { limit: 4 }));
184
+
185
+ expect(result).toEqual(rows);
186
+ });
187
+ });
188
+
189
+ describe('pageAll -- against the real SDK (decode proof)', () => {
190
+ test('walks two real pages of getNetworksOverviewPage through the fake server', async () => {
191
+ const allNetworks = Array.from({ length: 3 }, (_, i) => ({
192
+ default: i === 0,
193
+ enabled: true,
194
+ id: `net-${i}`,
195
+ management: 'ADVANCED',
196
+ metadata: { origin: 'USER' as const },
197
+ name: `Network ${i}`,
198
+ vlanId: i + 1,
199
+ }));
200
+ const fake = fakeUnifi((method, url) => {
201
+ if (
202
+ method !== 'GET' ||
203
+ url.pathname !== '/proxy/network/integration/v1/sites/site-1/networks'
204
+ ) {
205
+ return fakeFailure(400, 'unexpected request');
206
+ }
207
+ const offset = Number(url.searchParams.get('offset') ?? '0');
208
+ const limit = Number(url.searchParams.get('limit') ?? '2');
209
+ return Response.json({
210
+ count: allNetworks.slice(offset, offset + limit).length,
211
+ data: allNetworks.slice(offset, offset + limit),
212
+ limit,
213
+ offset,
214
+ totalCount: allNetworks.length,
215
+ });
216
+ });
217
+
218
+ const result = await Effect.runPromise(
219
+ pageAll(networks.getNetworksOverviewPage, { siteId: 'site-1', limit: 2 }).pipe(
220
+ Effect.provide(fakeUnifiLayer(fake.fetch)),
221
+ ),
222
+ );
223
+
224
+ expect(result.map((n) => n.id)).toEqual(['net-0', 'net-1', 'net-2']);
225
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
226
+ expect(fake.seen).toHaveLength(2);
227
+ });
228
+ });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * B0b / T9: a CONSUMER-SIDE sequential offset-pager for the SDK's list operations. None of them
3
+ * paginate on their own — `getNetworksOverviewPage`, `getFirewallZones`, `getAclRules`, … all take
4
+ * `{ ..., offset?, limit? }` and answer `{ count, data, limit, offset, totalCount }` (the shape
5
+ * `protocol.ts`'s own header names); there is no `smithy.api#paginated` trait in the pinned spec
6
+ * to generate a helper from.
7
+ *
8
+ * ⛔ NO SDK OR `@distilled.cloud/core` CHANGE (M1 — upstream's own convention: pagination stays
9
+ * out of the 59 packages walked down for `distilled-doctrine`). This is a plain function over
10
+ * whatever `Effect.Effect<OffsetPage<T>, E, R>` a resource file's own `fetchPage` already is.
11
+ * ⛔ STOPS ON `totalCount`, NEVER ON `data.length < limit` (T9's own wording). A page can come back
12
+ * short of the requested `limit` for reasons unrelated to "this was the last page" (a vendor-side
13
+ * per-call cap, a narrow filter) — `totalCount` is the one number the response commits to, so it
14
+ * is the only thing this loop trusts to stop early. An empty page (`data.length === 0`) is the
15
+ * other legitimate stop, and the only one checked before `totalCount` is read.
16
+ * ⚠️ NOT SEQUENCED AGAINST A LIVE CONSOLE THAT MUTATES BETWEEN CALLS. `totalCount` is whatever the
17
+ * MOST RECENT page reported; a page walk racing a live edit could see a row twice, miss one, or
18
+ * just see a moving target. `homeflare-network`'s own import already treats one family's fetch
19
+ * as one sequenced run for this reason (T27's neighboring trap) — nothing here can fix a
20
+ * concurrent mutation, only DETECT the common ways one shows up (below) instead of returning a
21
+ * silently wrong row set.
22
+ *
23
+ * ⚠️ RED TEAM (2026-09-26, IMPORTANT-3): the first version of this file trusted the vendor's
24
+ * paged response completely — a lying or drifting `totalCount`, an ignored `offset`, or a row
25
+ * added/removed mid-walk all produced a WRONG row set with no signal at all (silent truncation,
26
+ * an infinite loop, or a subtly incomplete list an importer would commit as if it were correct).
27
+ * Every list op's response already carries `offset` alongside `totalCount` (`networks.ts`'s
28
+ * `NetworkOverviewPage`, and `protocol.ts`'s header for every OTHER list op too); the three
29
+ * checks below are consistency proofs built entirely from fields the vendor already sends, plus
30
+ * a hard ceiling as a backstop against a walk that can never converge. These are typed failures,
31
+ * not `Effect.die`: an inconsistent paged response is a VENDOR-DATA condition a caller may
32
+ * legitimately want to retry or report on (distilled-doctrine: a defect is for "structurally
33
+ * impossible" code paths, not for data the wire actually sent).
34
+ */
35
+ import * as Data from 'effect/Data';
36
+ import * as Effect from 'effect/Effect';
37
+
38
+ /** The one response shape every UniFi Network list operation already has (`protocol.ts`). */
39
+ export interface OffsetPage<T> {
40
+ readonly data: readonly T[];
41
+ readonly offset: number;
42
+ readonly totalCount: number;
43
+ }
44
+
45
+ /** The one request shape every UniFi Network list operation already accepts, plus its own fields. */
46
+ export type OffsetPageRequest = { readonly offset?: number; readonly limit?: number };
47
+
48
+ /** Well past any family's real row count, even at a one-row page size (`docs/unifi.md`: a real
49
+ * console's largest family is in the hundreds, not thousands) — a walk that reaches this many
50
+ * pages is not looking at a real UniFi site, it is looking at a response that never lets `offset`
51
+ * converge on `totalCount`. */
52
+ const MAX_PAGES = 1000;
53
+
54
+ export class UnifiPaginationInconsistent extends Data.TaggedError('UnifiPaginationInconsistent')<{
55
+ readonly reason:
56
+ | 'offset-mismatch'
57
+ | 'total-count-changed'
58
+ | 'row-count-mismatch'
59
+ | 'page-ceiling';
60
+ readonly detail: string;
61
+ }> {
62
+ override get message(): string {
63
+ return (
64
+ `UniFi pager: ${this.reason} -- ${this.detail}. The paged response contradicted itself (or ` +
65
+ 'the walk ran implausibly long); the rows collected so far cannot be trusted and are not returned.'
66
+ );
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Walks every page of `fetchPage`, starting at `request.offset ?? 0`, and returns every row in
72
+ * order. `request.limit`, if given, is kept as the page size for every call; the vendor's own
73
+ * default applies otherwise — this loop never invents one (T18: "pass ops only their own fields").
74
+ *
75
+ * Three self-consistency checks run against fields the vendor already sends with every page —
76
+ * detection, not a fix, for the "live console mutates mid-walk" case the header above accepts as
77
+ * unfixable from the client alone: a row added AND removed in the same window can still net out to
78
+ * a `totalCount` and final row count that agree by coincidence. What this closes is the SILENT
79
+ * version of every case actually observed in review: an ignored `offset`, a `totalCount` that
80
+ * drifts between calls, and an undercounted `totalCount` that truncates the walk early.
81
+ */
82
+ export const pageAll = <Req extends OffsetPageRequest, T, E, R>(
83
+ fetchPage: (request: Req) => Effect.Effect<OffsetPage<T>, E, R>,
84
+ request: Req,
85
+ ): Effect.Effect<T[], E | UnifiPaginationInconsistent, R> =>
86
+ Effect.gen(function* () {
87
+ const rows: T[] = [];
88
+ const startOffset = request.offset ?? 0;
89
+ let offset = startOffset;
90
+ let totalCount: number | undefined;
91
+ for (let pageCount = 1; ; pageCount++) {
92
+ if (pageCount > MAX_PAGES) {
93
+ return yield* new UnifiPaginationInconsistent({
94
+ reason: 'page-ceiling',
95
+ detail: `stopped after ${MAX_PAGES} pages at offset ${offset} without reaching totalCount`,
96
+ });
97
+ }
98
+ // ⚠️ THE CAST IS SOUND, NOT A SHORTCUT: `Req` is a generic parameter, so TS cannot see that
99
+ // spreading a value already typed `Req` and overriding one of `OffsetPageRequest`'s own
100
+ // fields reconstructs a `Req` — but every field except `offset` is untouched from the
101
+ // caller's own `request`, so it does.
102
+ const page = yield* fetchPage({ ...request, offset } as Req);
103
+ if (page.offset !== offset) {
104
+ return yield* new UnifiPaginationInconsistent({
105
+ reason: 'offset-mismatch',
106
+ detail: `requested offset ${offset}, page echoed ${page.offset} -- rows may be duplicated or skipped`,
107
+ });
108
+ }
109
+ if (totalCount !== undefined && page.totalCount !== totalCount) {
110
+ return yield* new UnifiPaginationInconsistent({
111
+ reason: 'total-count-changed',
112
+ detail: `totalCount changed from ${totalCount} to ${page.totalCount} between page calls`,
113
+ });
114
+ }
115
+ totalCount = page.totalCount;
116
+ rows.push(...page.data);
117
+ if (page.data.length === 0) break;
118
+ offset += page.data.length;
119
+ if (offset >= totalCount) break;
120
+ }
121
+ const expected = totalCount - startOffset;
122
+ if (rows.length !== expected) {
123
+ return yield* new UnifiPaginationInconsistent({
124
+ reason: 'row-count-mismatch',
125
+ detail: `collected ${rows.length} rows but totalCount(${totalCount}) - startOffset(${startOffset}) = ${expected}`,
126
+ });
127
+ }
128
+ return rows;
129
+ });
@@ -37,8 +37,8 @@ import type { Input } from 'alchemy/Input';
37
37
  import { CredentialsFromEnv } from '@distilled.cloud/unifi-network/Credentials';
38
38
  import type { UnifiNetworkOpContext } from '@distilled.cloud/unifi-network/Protocol';
39
39
  import * as Effect from 'effect/Effect';
40
- import type * as HttpClient from 'effect/unstable/http/HttpClient';
41
- import { refuseWrite } from './policy.ts';
40
+ import * as HttpClient from 'effect/unstable/http/HttpClient';
41
+ import { UNIFI_READ_ONLY_POLICY, refuseWrite } from './policy.ts';
42
42
 
43
43
  /** What every handler needs from the caller's runtime once `CredentialsFromEnv` is baked in. */
44
44
  export type UnifiRequirements = HttpClient.HttpClient;
@@ -107,6 +107,76 @@ export const unifiOperations = <Props extends object, Live, Attributes extends o
107
107
  };
108
108
  };
109
109
 
110
+ /**
111
+ * B0a / T12 DEFENSE IN DEPTH. `policy.ts`'s `UnifiWriteRefused` stops write INTENT at
112
+ * `reconcile`/`destroy` — every path this engine exposes already returns that refusal instead of
113
+ * calling an SDK write op. This guard stops the same thing one layer lower, AT THE WIRE: it wraps
114
+ * whatever `HttpClient` the caller already provides (the stack's `FetchHttpClient.layer` in
115
+ * production, `fakeUnifiLayer` in tests — `openbao/bao-http.ts`'s header names this same
116
+ * "wrap, don't replace" seam) so a future resource file that, by mistake, called an SDK write
117
+ * operation directly — bypassing `unifiOperations` entirely — would still never reach the vendor
118
+ * API. `write-op-reference.test.ts` proves no such call exists today; this is what stops one from
119
+ * ever taking effect if one is ever added.
120
+ *
121
+ * ⚠️ `Effect.die`, NOT A TYPED FAILURE — classified per distilled-doctrine's "classify every
122
+ * die/orDie". Every generated SDK operation (`networks.getNetworkDetails`, …) declares its OWN
123
+ * closed error union from the pinned OpenAPI spec, and generated files are never hand-edited to
124
+ * add a member to it. A typed `Effect.fail` here would be a failure mode absent from every op's
125
+ * declared type the moment it occurred — a type-system lie. A defect is the sound way to add a
126
+ * NEW "this must be structurally impossible" failure underneath types this package does not
127
+ * own, exactly like `openbao`'s `refuse()` helpers' `Effect.die(new Error(...))` for a
128
+ * configuration move that should never be reachable.
129
+ */
130
+ export class UnifiNonGetRequest extends Error {
131
+ constructor(
132
+ readonly method: string,
133
+ /** PATH ONLY, never the full URL — see `requestPath` below. */
134
+ readonly path: string,
135
+ ) {
136
+ super(
137
+ `UniFi HttpClient guard: refused ${method} ${path} -- ${UNIFI_READ_ONLY_POLICY}. This is a ` +
138
+ 'defect, not a recoverable condition: something under src/unifi tried to send a non-GET ' +
139
+ "request, which policy.ts's per-operation refusal should already have made impossible. " +
140
+ 'Fix the kit code that produced this request.',
141
+ );
142
+ this.name = 'UnifiNonGetRequest';
143
+ }
144
+ }
145
+
146
+ /**
147
+ * ⛔ PATH ONLY, NEVER THE HOST. A cloud connector's base URL embeds the account's Console ID
148
+ * (`docs/unifi.md`'s Credentials section, T3) — a defect message is still a message that can
149
+ * reach a log, a CI failure body or a pasted error report, so it gets the same treatment as
150
+ * `redactUrls` gives the import layer's own error text. Falls back to a fixed placeholder rather
151
+ * than the raw string on a parse failure, so a malformed URL can never leak through unredacted.
152
+ */
153
+ const requestPath = (url: string): string => {
154
+ try {
155
+ return new URL(url).pathname;
156
+ } catch {
157
+ return '<unparseable request url>';
158
+ }
159
+ };
160
+
161
+ const guardGetOnly = (client: HttpClient.HttpClient): HttpClient.HttpClient =>
162
+ client.pipe(
163
+ HttpClient.mapRequestEffect((request) =>
164
+ request.method === 'GET'
165
+ ? Effect.succeed(request)
166
+ : Effect.die(new UnifiNonGetRequest(request.method, requestPath(request.url))),
167
+ ),
168
+ );
169
+
170
+ /**
171
+ * A `Layer` that reads whatever `HttpClient` is already in the calling context and replaces it,
172
+ * for everything downstream, with `guardGetOnly`'s wrapped version (`HttpClient.layerMergedContext`
173
+ * — see the guard above). Exported so `get-only-guard.test.ts` can prove the mechanism directly,
174
+ * against a bare `HttpClient` service, without needing `CredentialsFromEnv` or any env var at all.
175
+ */
176
+ export const GetOnlyHttpClient = HttpClient.layerMergedContext(
177
+ Effect.map(HttpClient.HttpClient, guardGetOnly),
178
+ );
179
+
110
180
  /**
111
181
  * The four provider handlers for a spec'd read-only UniFi object, wired once.
112
182
  *
@@ -118,7 +188,7 @@ export const unifiHandlers = <Props extends object, Live, Attributes extends obj
118
188
  ) => {
119
189
  const ops = unifiOperations(spec);
120
190
  const withCredentials = <A, Err>(effect: Effect.Effect<A, Err, UnifiNetworkOpContext>) =>
121
- Effect.provide(effect, CredentialsFromEnv);
191
+ effect.pipe(Effect.provide(GetOnlyHttpClient), Effect.provide(CredentialsFromEnv));
122
192
  return {
123
193
  list: () => Effect.succeed([]),
124
194
  read: (args: { olds: Props; output: Attributes | undefined }) =>