@alchemy-unifi/network 0.0.1 → 0.1.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.
@@ -0,0 +1,250 @@
1
+ import * as Sdk from "@alchemy-unifi/network-sdk";
2
+ import { Unowned } from "alchemy/AdoptPolicy";
3
+ import type { Diff } from "alchemy/Diff";
4
+ import * as Provider from "alchemy/Provider";
5
+ import { Resource } from "alchemy/Resource";
6
+ import { UserFacingError } from "alchemy/UserFacingError";
7
+ import * as Data from "effect/Data";
8
+ import * as Effect from "effect/Effect";
9
+ import * as Stream from "effect/Stream";
10
+ import { withConnection } from "./Connection.ts";
11
+ import { type DistributiveOmit, isCurrent, omitKeys } from "./internal/Fields.ts";
12
+ import { plannedString, UNKNOWN } from "./internal/Plan.ts";
13
+ import { captureServices } from "./internal/Services.ts";
14
+ import { describeQuery, LookupNotFound } from "./Lookup.ts";
15
+ import type { Providers } from "./Providers.ts";
16
+ import * as ReadCache from "./ReadCache.ts";
17
+ import { checkSameSite, resolveSite, type Site } from "./Site.ts";
18
+
19
+ /**
20
+ * Props are the Integration API's list body plus `site`. `type` picks what
21
+ * it holds: `"IPV4_ADDRESSES"`, `"IPV6_ADDRESSES"` or `"PORTS"` (no MACs).
22
+ */
23
+ export type TrafficMatchingListProps = Sdk.CreateOrUpdateTrafficMatchingList & {
24
+ /** Site to create the list in. @default the profile's site, else the console's only site */
25
+ site?: string;
26
+ };
27
+
28
+ /** A list: a `TrafficMatchingList` resource, a {@link TrafficMatchingList.lookup} result, or `{ trafficMatchingListId }`. */
29
+ export interface TrafficMatchingListReference {
30
+ readonly trafficMatchingListId: string;
31
+ readonly siteId?: string | undefined;
32
+ }
33
+
34
+ export interface TrafficMatchingListAttributes {
35
+ siteId: string;
36
+ trafficMatchingListId: string;
37
+ name: string;
38
+ type: TrafficMatchingListProps["type"];
39
+ /** The whole list as the console reports it, so `alchemy drift` sees any change. */
40
+ config: DistributiveOmit<Sdk.TrafficMatchingList, "id">;
41
+ }
42
+
43
+ export interface TrafficMatchingList extends Resource<
44
+ "UnifiNetwork.TrafficMatchingList",
45
+ TrafficMatchingListProps,
46
+ TrafficMatchingListAttributes,
47
+ never,
48
+ Providers
49
+ > {}
50
+
51
+ const loadLists = (siteId: string) => withConnection(Stream.runCollect(Sdk.getTrafficMatchingLists.items({ siteId })));
52
+ const listsKey = ReadCache.key<ReadonlyArray<Sdk.TrafficMatchingList>, Effect.Error<ReturnType<typeof loadLists>>>(
53
+ "trafficMatchingLists",
54
+ );
55
+
56
+ /** A site's traffic-matching lists, listed once per run. */
57
+ export const listTrafficMatchingLists = (siteId: string) => ReadCache.cached(listsKey, siteId, loadLists(siteId));
58
+
59
+ const toAttributes = (siteId: string, list: Sdk.TrafficMatchingList): TrafficMatchingListAttributes => {
60
+ const { id, ...config } = list;
61
+ return { siteId, trafficMatchingListId: id, name: list.name, type: list.type, config };
62
+ };
63
+
64
+ /** Fields the console reports but rejects in request bodies. */
65
+ const READ_ONLY = ["id"];
66
+
67
+ /** A list can't be deleted while something references it. */
68
+ export class TrafficMatchingListInUse extends Data.TaggedError("TrafficMatchingListInUse")<{
69
+ readonly listName: string;
70
+ /** Names of the firewall policies referencing it, as far as they could be found. */
71
+ readonly referencedBy: ReadonlyArray<string>;
72
+ readonly detail: string;
73
+ }> {
74
+ readonly [UserFacingError]: true = true;
75
+ override get message() {
76
+ const by =
77
+ this.referencedBy.length > 0
78
+ ? `firewall policies ${this.referencedBy.map((name) => `"${name}"`).join(", ")}`
79
+ : `something the provider couldn't identify (${this.detail})`;
80
+ return `Can't delete traffic-matching list "${this.listName}": it's still referenced by ${by}. Those references aren't managed by this stack (e.g. made in the UI); remove them, then deploy again.`;
81
+ }
82
+ }
83
+
84
+ /** User-defined firewall policies that mention `trafficMatchingListId` in a filter. */
85
+ const policiesReferencing = (siteId: string, trafficMatchingListId: string) =>
86
+ withConnection(Stream.runCollect(Sdk.getFirewallPolicies.items({ siteId }))).pipe(
87
+ Effect.map((policies) =>
88
+ policies
89
+ .filter(
90
+ (p) =>
91
+ p.metadata.origin === "USER_DEFINED" &&
92
+ JSON.stringify([p.source, p.destination]).includes(trafficMatchingListId),
93
+ )
94
+ .map((p) => p.name),
95
+ ),
96
+ Effect.orElseSucceed((): ReadonlyArray<string> => []),
97
+ );
98
+
99
+ /** The list ID a reference means in `site`. */
100
+ export const resolveTrafficMatchingList = (reference: TrafficMatchingListReference, site: Site) =>
101
+ checkSameSite("traffic-matching list", reference.siteId, site).pipe(Effect.as(reference.trafficMatchingListId));
102
+
103
+ /** A list by ID, from the run's cached lists, which come whole (one request for every list in a plan). */
104
+ const getList = (siteId: string, trafficMatchingListId: string) =>
105
+ listTrafficMatchingLists(siteId).pipe(Effect.map((lists) => lists.find((l) => l.id === trafficMatchingListId)));
106
+
107
+ /**
108
+ * A named list of IPv4 addresses, IPv6 addresses or ports, which firewall
109
+ * policies reference instead of repeating values inline. Names are unique
110
+ * per site, so existing lists are adopted by name with `--adopt`. Changing
111
+ * `type` updates the list in place.
112
+ *
113
+ * @example
114
+ * ```ts
115
+ * import * as UnifiNetwork from "@alchemy-unifi/network";
116
+ * import * as Alchemy from "alchemy";
117
+ * import * as Effect from "effect/Effect";
118
+ *
119
+ * export default Alchemy.Stack(
120
+ * "HomeNetwork",
121
+ * { providers: UnifiNetwork.providers(), state: UnifiNetwork.state() },
122
+ * Effect.gen(function* () {
123
+ * const haHosts = yield* UnifiNetwork.TrafficMatchingList("ha-hosts", {
124
+ * type: "IPV4_ADDRESSES",
125
+ * name: "Home Assistant hosts",
126
+ * items: [{ type: "IP_ADDRESS", value: "192.168.2.20" }],
127
+ * });
128
+ * // Reference it from a policy's filter: { type: "TRAFFIC_MATCHING_LIST", matchOpposite: false, trafficMatchingList: haHosts }
129
+ * }),
130
+ * );
131
+ * ```
132
+ */
133
+ export const TrafficMatchingList = Resource<TrafficMatchingList>("UnifiNetwork.TrafficMatchingList")({
134
+ /**
135
+ * A list in the site by name. Dies with a user-facing `LookupNotFound`:
136
+ * stack bodies can't fail with typed errors.
137
+ */
138
+ lookup: Effect.fn("UnifiNetwork.TrafficMatchingList.lookup")(function* (query: {
139
+ readonly name: string;
140
+ readonly site?: string;
141
+ }) {
142
+ const site = yield* resolveSite(query.site);
143
+ const list = (yield* listTrafficMatchingLists(site.id)).find((l) => l.name === query.name);
144
+ if (list === undefined) {
145
+ return yield* new LookupNotFound({ resource: "traffic-matching list", query: describeQuery(query), site });
146
+ }
147
+ return toAttributes(site.id, list);
148
+ }, Effect.orDie),
149
+ });
150
+
151
+ export const TrafficMatchingListProvider = () =>
152
+ Provider.effect(
153
+ TrafficMatchingList,
154
+ Effect.gen(function* () {
155
+ const run = yield* captureServices;
156
+
157
+ return TrafficMatchingList.Provider.of({
158
+ list: () =>
159
+ run(
160
+ Effect.gen(function* () {
161
+ const site = yield* resolveSite();
162
+ return (yield* listTrafficMatchingLists(site.id)).map((list) =>
163
+ toAttributes(site.id, list),
164
+ );
165
+ }),
166
+ ),
167
+
168
+ read: ({ olds, output }) =>
169
+ run(
170
+ Effect.gen(function* () {
171
+ if (output !== undefined) {
172
+ const list = yield* getList(output.siteId, output.trafficMatchingListId);
173
+ return list === undefined ? undefined : toAttributes(output.siteId, list);
174
+ }
175
+ const site = yield* resolveSite(olds.site);
176
+ const list = (yield* listTrafficMatchingLists(site.id)).find((l) => l.name === olds.name);
177
+ return list === undefined ? undefined : Unowned(toAttributes(site.id, list));
178
+ }),
179
+ ),
180
+
181
+ diff: ({ news, output }) =>
182
+ run(
183
+ Effect.gen(function* () {
184
+ const siteProp = plannedString(news, "site");
185
+ if (output === undefined || siteProp === UNKNOWN) return undefined;
186
+ const site = yield* resolveSite(siteProp);
187
+ if (site.id !== output.siteId) return { action: "replace" } satisfies Diff;
188
+ return undefined;
189
+ }),
190
+ ),
191
+
192
+ reconcile: ({ news, output }) =>
193
+ run(
194
+ Effect.gen(function* () {
195
+ const site = yield* resolveSite(news.site);
196
+ const { site: _site, ...body } = news;
197
+ const observed =
198
+ output !== undefined && output.siteId === site.id
199
+ ? yield* getList(site.id, output.trafficMatchingListId)
200
+ : undefined;
201
+ const write = ReadCache.invalidating(listsKey, site.id);
202
+ if (observed === undefined) {
203
+ const created = yield* withConnection(
204
+ Sdk.createTrafficMatchingList({ siteId: site.id, body }),
205
+ ).pipe(write);
206
+ return toAttributes(site.id, created);
207
+ }
208
+ if (isCurrent(body, omitKeys(observed, READ_ONLY))) return toAttributes(site.id, observed);
209
+ const updated = yield* withConnection(
210
+ Sdk.updateTrafficMatchingList({
211
+ siteId: site.id,
212
+ trafficMatchingListId: observed.id,
213
+ body,
214
+ }),
215
+ ).pipe(write);
216
+ return toAttributes(site.id, updated);
217
+ }),
218
+ ),
219
+
220
+ delete: ({ output }) =>
221
+ run(
222
+ withConnection(
223
+ Sdk.deleteTrafficMatchingList({
224
+ siteId: output.siteId,
225
+ trafficMatchingListId: output.trafficMatchingListId,
226
+ }),
227
+ ).pipe(
228
+ Effect.asVoid,
229
+ Effect.catchTags({
230
+ NotFound: () => Effect.void,
231
+ // 422 "Selected traffic matching list is referenced by other resources".
232
+ UnprocessableEntity: (error) =>
233
+ policiesReferencing(output.siteId, output.trafficMatchingListId).pipe(
234
+ Effect.flatMap((referencedBy) =>
235
+ Effect.fail(
236
+ new TrafficMatchingListInUse({
237
+ listName: output.name,
238
+ referencedBy,
239
+ detail: error.message,
240
+ }),
241
+ ),
242
+ ),
243
+ ),
244
+ }),
245
+ ReadCache.invalidating(listsKey, output.siteId),
246
+ ),
247
+ ),
248
+ });
249
+ }),
250
+ );
@@ -0,0 +1,318 @@
1
+ import * as Sdk from "@alchemy-unifi/network-sdk";
2
+ import { Unowned } from "alchemy/AdoptPolicy";
3
+ import type { Diff } from "alchemy/Diff";
4
+ import * as Provider from "alchemy/Provider";
5
+ import { Resource } from "alchemy/Resource";
6
+ import * as Effect from "effect/Effect";
7
+ import * as Stream from "effect/Stream";
8
+ import { withConnection } from "./Connection.ts";
9
+ import { type DistributiveOmit, isCurrent, omitKeys } from "./internal/Fields.ts";
10
+ import { planned, plannedString, UNKNOWN } from "./internal/Plan.ts";
11
+ import { captureServices } from "./internal/Services.ts";
12
+ import { describeQuery, LookupAmbiguous, LookupNotFound } from "./Lookup.ts";
13
+ import { type NetworkReference, resolveNetwork } from "./Network.ts";
14
+ import type { Providers } from "./Providers.ts";
15
+ import * as ReadCache from "./ReadCache.ts";
16
+ import { resolveSite, type Site } from "./Site.ts";
17
+
18
+ /** The site's native (untagged) network. */
19
+ export interface NativeNetwork {
20
+ readonly type: "NATIVE";
21
+ }
22
+
23
+ interface CommonProps {
24
+ /** Site to create the broadcast in. @default the profile's site, else the console's only site */
25
+ site?: string;
26
+ /**
27
+ * The network clients join: a network reference, or
28
+ * `WifiBroadcast.NATIVE_NETWORK` for the site's default network (a
29
+ * `Network` resource or lookup for the default network works too).
30
+ */
31
+ network?: NetworkReference | NativeNetwork;
32
+ }
33
+
34
+ /** WPA security on a standard broadcast must set `fastRoamingEnabled` (the console rejects it otherwise). */
35
+ type WithFastRoaming<S> = S extends { fastRoamingEnabled?: boolean } ? S & { fastRoamingEnabled: boolean } : S;
36
+
37
+ export type StandardWifiSecurityConfiguration = WithFastRoaming<Sdk.WifiSecurityConfigurationDetailObject>;
38
+
39
+ /**
40
+ * An ordinary SSID. Pass passphrases as `Redacted` (e.g. from
41
+ * `Config.Redacted`) to keep them out of logs.
42
+ */
43
+ export interface StandardWifiBroadcastProps
44
+ extends
45
+ Omit<Sdk.IntegrationStandardWifiBroadcastCreateUpdateDto, "network" | "securityConfiguration">,
46
+ CommonProps {
47
+ securityConfiguration: StandardWifiSecurityConfiguration;
48
+ }
49
+
50
+ /** An SSID tuned for IoT devices (2.4 GHz only, longer DTIM). */
51
+ export interface IotWifiBroadcastProps
52
+ extends Omit<Sdk.IntegrationIotOptimizedWifiBroadcastCreateUpdateDto, "network">, CommonProps {}
53
+
54
+ /** Props are the Integration API's Wi-Fi body, plus `site` and references; `type` picks the kind. */
55
+ export type WifiBroadcastProps = StandardWifiBroadcastProps | IotWifiBroadcastProps;
56
+
57
+ export interface WifiBroadcastAttributes {
58
+ siteId: string;
59
+ wifiBroadcastId: string;
60
+ name: string;
61
+ type: WifiBroadcastProps["type"];
62
+ /** The network clients join, when it isn't the native one. */
63
+ networkId: string | undefined;
64
+ /** The whole broadcast as the console reports it (passphrases `Redacted`), so `alchemy drift` sees any change. */
65
+ config: DistributiveOmit<Sdk.WifiBroadcastDetails, "id" | "metadata">;
66
+ }
67
+
68
+ export interface WifiBroadcast extends Resource<
69
+ "UnifiNetwork.WifiBroadcast",
70
+ WifiBroadcastProps,
71
+ WifiBroadcastAttributes,
72
+ never,
73
+ Providers
74
+ > {}
75
+
76
+ const loadBroadcasts = (siteId: string) =>
77
+ withConnection(Stream.runCollect(Sdk.getWifiBroadcastPage.items({ siteId })));
78
+ const broadcastsKey = ReadCache.key<
79
+ ReadonlyArray<Sdk.WifiBroadcastOverview>,
80
+ Effect.Error<ReturnType<typeof loadBroadcasts>>
81
+ >("wifiBroadcasts");
82
+
83
+ /** A site's Wi-Fi broadcasts, listed once per run. */
84
+ export const listWifiBroadcasts = (siteId: string) => ReadCache.cached(broadcastsKey, siteId, loadBroadcasts(siteId));
85
+
86
+ const toAttributes = (siteId: string, broadcast: Sdk.WifiBroadcastDetails): WifiBroadcastAttributes => {
87
+ const { id, metadata: _metadata, ...config } = broadcast;
88
+ return {
89
+ siteId,
90
+ wifiBroadcastId: id,
91
+ name: broadcast.name,
92
+ type: broadcast.type,
93
+ networkId: broadcast.network?.type === "SPECIFIC" ? broadcast.network.networkId : undefined,
94
+ config,
95
+ };
96
+ };
97
+
98
+ const byFrequency = (bands: ReadonlyArray<number>) => bands.toSorted((a, b) => a - b);
99
+
100
+ /**
101
+ * `body` with its bands sorted (their order means nothing; see
102
+ * {@link comparable}), and an explicit value for the one unset field that
103
+ * can't simply be left out (verified): a standard broadcast's
104
+ * `bandSteeringEnabled`, which the console reports as `false` when unset,
105
+ * so without the default the body would never match and every deploy would
106
+ * update it (and drift would report it) forever.
107
+ */
108
+ const withDefaults = (body: Sdk.WifiBroadcastCreateOrUpdate): Sdk.WifiBroadcastCreateOrUpdate =>
109
+ body.type === "STANDARD"
110
+ ? {
111
+ ...body,
112
+ bandSteeringEnabled: body.bandSteeringEnabled ?? false,
113
+ broadcastingFrequenciesGHz: byFrequency(body.broadcastingFrequenciesGHz),
114
+ }
115
+ : body;
116
+
117
+ /** Fields the console reports but rejects in request bodies. */
118
+ const READ_ONLY = ["id", "metadata"];
119
+
120
+ const toBody = Effect.fn(function* (props: WifiBroadcastProps, site: Site) {
121
+ const { site: _site, network, ...rest } = props;
122
+ if (network === undefined) return rest satisfies Sdk.WifiBroadcastCreateOrUpdate;
123
+ // The console only accepts the default network as NATIVE; referencing it by ID is rejected.
124
+ if ("type" in network || network.default === true) {
125
+ return { ...rest, network: { type: "NATIVE" } } satisfies Sdk.WifiBroadcastCreateOrUpdate;
126
+ }
127
+ const networkId = yield* resolveNetwork(network, site);
128
+ return { ...rest, network: { type: "SPECIFIC", networkId } } satisfies Sdk.WifiBroadcastCreateOrUpdate;
129
+ });
130
+
131
+ /**
132
+ * The console's broadcast in body form, with its bands sorted: consoles keep
133
+ * them in an order of their own (not the one sent; the test controller
134
+ * sorts them), so order mustn't count as a difference.
135
+ */
136
+ const comparable = (observed: Sdk.WifiBroadcastDetails) => {
137
+ const body = omitKeys(observed, READ_ONLY);
138
+ return observed.type === "STANDARD"
139
+ ? { ...body, broadcastingFrequenciesGHz: byFrequency(observed.broadcastingFrequenciesGHz) }
140
+ : body;
141
+ };
142
+
143
+ const getBroadcast = (siteId: string, wifiBroadcastId: string) =>
144
+ withConnection(Sdk.getWifiBroadcastDetails({ siteId, wifiBroadcastId })).pipe(
145
+ Effect.catchTag("NotFound", () => Effect.undefined),
146
+ );
147
+
148
+ /** The attributes of a broadcast known to exist (e.g. from a list). */
149
+ const attributesOf = (siteId: string, wifiBroadcastId: string) =>
150
+ withConnection(Sdk.getWifiBroadcastDetails({ siteId, wifiBroadcastId })).pipe(
151
+ Effect.map((broadcast) => toAttributes(siteId, broadcast)),
152
+ );
153
+
154
+ const NATIVE_NETWORK: NativeNetwork = { type: "NATIVE" };
155
+
156
+ /**
157
+ * A Wi-Fi network (SSID). `type` picks a standard or an IoT-optimised
158
+ * broadcast; changing it, or the site, replaces the broadcast.
159
+ *
160
+ * Props are authoritative: every update sends the whole broadcast, with
161
+ * explicit defaults for unset fields (a standard broadcast's
162
+ * `bandSteeringEnabled: false`), so changes made in the UI, passphrases
163
+ * included, are put back. The `config` attribute mirrors the console's
164
+ * object, so `alchemy drift` sees them. Existing broadcasts are adopted by name with
165
+ * `--adopt`.
166
+ *
167
+ * @example
168
+ * ```ts
169
+ * import * as UnifiNetwork from "@alchemy-unifi/network";
170
+ * import * as Alchemy from "alchemy";
171
+ * import * as Config from "effect/Config";
172
+ * import * as Effect from "effect/Effect";
173
+ *
174
+ * export default Alchemy.Stack(
175
+ * "HomeNetwork",
176
+ * { providers: UnifiNetwork.providers(), state: UnifiNetwork.state() },
177
+ * Effect.gen(function* () {
178
+ * const lan = yield* UnifiNetwork.Network.lookup({ vlanId: 1 });
179
+ * yield* UnifiNetwork.WifiBroadcast("home", {
180
+ * type: "STANDARD",
181
+ * name: "Home",
182
+ * enabled: true,
183
+ * network: lan,
184
+ * securityConfiguration: {
185
+ * type: "WPA2_PERSONAL",
186
+ * passphrase: yield* Config.Redacted("WIFI_PASSPHRASE"),
187
+ * fastRoamingEnabled: false,
188
+ * },
189
+ * broadcastingFrequenciesGHz: [2.4, 5],
190
+ * hideName: false,
191
+ * clientIsolationEnabled: false,
192
+ * multicastToUnicastConversionEnabled: false,
193
+ * uapsdEnabled: false,
194
+ * channel2gLockedTo6: false,
195
+ * dtimPeriod2gLockedTo3: false,
196
+ * advertiseDeviceName: false,
197
+ * arpProxyEnabled: false,
198
+ * bssTransitionEnabled: true,
199
+ * });
200
+ * }),
201
+ * );
202
+ * ```
203
+ */
204
+ export const WifiBroadcast = Resource<WifiBroadcast>("UnifiNetwork.WifiBroadcast")({
205
+ NATIVE_NETWORK,
206
+ /**
207
+ * A broadcast in the site by name. Dies with a user-facing
208
+ * `LookupNotFound` or `LookupAmbiguous`: stack bodies can't fail with typed
209
+ * errors.
210
+ */
211
+ lookup: Effect.fn("UnifiNetwork.WifiBroadcast.lookup")(function* (query: {
212
+ readonly name: string;
213
+ readonly site?: string;
214
+ }) {
215
+ const site = yield* resolveSite(query.site);
216
+ const broadcasts = (yield* listWifiBroadcasts(site.id)).filter((b) => b.name === query.name);
217
+ const [broadcast] = broadcasts;
218
+ if (broadcast === undefined) {
219
+ return yield* new LookupNotFound({ resource: "Wi-Fi broadcast", query: describeQuery(query), site });
220
+ }
221
+ if (broadcasts.length > 1) {
222
+ return yield* new LookupAmbiguous({
223
+ resource: "Wi-Fi broadcast",
224
+ query: describeQuery(query),
225
+ site,
226
+ count: broadcasts.length,
227
+ });
228
+ }
229
+ return yield* attributesOf(site.id, broadcast.id);
230
+ }, Effect.orDie),
231
+ });
232
+
233
+ export const WifiBroadcastProvider = () =>
234
+ Provider.effect(
235
+ WifiBroadcast,
236
+ Effect.gen(function* () {
237
+ const run = yield* captureServices;
238
+
239
+ return WifiBroadcast.Provider.of({
240
+ list: () =>
241
+ run(
242
+ Effect.gen(function* () {
243
+ const site = yield* resolveSite();
244
+ const broadcasts = yield* listWifiBroadcasts(site.id);
245
+ return yield* Effect.forEach(
246
+ broadcasts.filter((b) => b.metadata.origin === "USER_DEFINED"),
247
+ (b) => attributesOf(site.id, b.id),
248
+ );
249
+ }),
250
+ ),
251
+
252
+ read: ({ olds, output }) =>
253
+ run(
254
+ Effect.gen(function* () {
255
+ if (output !== undefined) {
256
+ const broadcast = yield* getBroadcast(output.siteId, output.wifiBroadcastId);
257
+ return broadcast === undefined ? undefined : toAttributes(output.siteId, broadcast);
258
+ }
259
+ const site = yield* resolveSite(olds.site);
260
+ const matching = (yield* listWifiBroadcasts(site.id)).filter((b) => b.name === olds.name);
261
+ const [broadcast] = matching;
262
+ return broadcast === undefined || matching.length > 1
263
+ ? undefined
264
+ : Unowned(yield* attributesOf(site.id, broadcast.id));
265
+ }),
266
+ ),
267
+
268
+ diff: ({ news, output }) =>
269
+ run(
270
+ Effect.gen(function* () {
271
+ if (output === undefined) return undefined;
272
+ const type = planned(news, "type");
273
+ if (type !== UNKNOWN && type !== output.type) return { action: "replace" } satisfies Diff;
274
+ const siteProp = plannedString(news, "site");
275
+ if (siteProp === UNKNOWN) return undefined;
276
+ const site = yield* resolveSite(siteProp);
277
+ if (site.id !== output.siteId) return { action: "replace" } satisfies Diff;
278
+ return undefined;
279
+ }),
280
+ ),
281
+
282
+ reconcile: ({ news, output }) =>
283
+ run(
284
+ Effect.gen(function* () {
285
+ const site = yield* resolveSite(news.site);
286
+ const body = withDefaults(yield* toBody(news, site));
287
+ const observed =
288
+ output !== undefined && output.siteId === site.id
289
+ ? yield* getBroadcast(site.id, output.wifiBroadcastId)
290
+ : undefined;
291
+ const write = ReadCache.invalidating(broadcastsKey, site.id);
292
+ if (observed === undefined) {
293
+ const created = yield* withConnection(
294
+ Sdk.createWifiBroadcast({ siteId: site.id, body }),
295
+ ).pipe(write);
296
+ return toAttributes(site.id, created);
297
+ }
298
+ if (isCurrent(body, comparable(observed))) return toAttributes(site.id, observed);
299
+ const updated = yield* withConnection(
300
+ Sdk.updateWifiBroadcast({ siteId: site.id, wifiBroadcastId: observed.id, body }),
301
+ ).pipe(write);
302
+ return toAttributes(site.id, updated);
303
+ }),
304
+ ),
305
+
306
+ delete: ({ output }) =>
307
+ run(
308
+ withConnection(
309
+ Sdk.deleteWifiBroadcast({ siteId: output.siteId, wifiBroadcastId: output.wifiBroadcastId }),
310
+ ).pipe(
311
+ Effect.asVoid,
312
+ Effect.catchTag("NotFound", () => Effect.void),
313
+ ReadCache.invalidating(broadcastsKey, output.siteId),
314
+ ),
315
+ ),
316
+ });
317
+ }),
318
+ );
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ export * from "./AuthConfig.ts";
2
+ export { UnifiNetworkAuth } from "./AuthProvider.ts";
3
+ export { type Established, UnifiNetworkConnection, withConnection } from "./Connection.ts";
4
+ export * from "./Credentials.ts";
5
+ export { Providers, providers } from "./Providers.ts";
6
+ export * as ReadCache from "./ReadCache.ts";
7
+ export { UnifiNetworkReadCache } from "./ReadCache.ts";
8
+ export * from "./Site.ts";
9
+ export { DEFAULT_WARN_AT, state, type UnifiStateOptions } from "./State.ts";
10
+ export * from "./DnsPolicy.ts";
11
+ export * from "./FirewallPolicy.ts";
12
+ export * from "./FirewallPolicyOrdering.ts";
13
+ export * from "./FirewallZone.ts";
14
+ export * from "./Lookup.ts";
15
+ export * from "./Network.ts";
16
+ export * from "./TrafficMatchingList.ts";
17
+ export * from "./WifiBroadcast.ts";
@@ -0,0 +1,49 @@
1
+ import { deepEqual } from "alchemy/Diff";
2
+ import * as Redacted from "effect/Redacted";
3
+
4
+ /**
5
+ * Comparison of request bodies with what the console reports. Props are
6
+ * authoritative: a resource's desired body is its props with explicit
7
+ * defaults for anything unset, sent whole on every update, and the console's
8
+ * object must equal it field for field.
9
+ */
10
+
11
+ /** `Omit` applied to each member of a union, keeping the discriminant. */
12
+ export type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
13
+
14
+ /** `value` with every `Redacted` unwrapped, recursively, so passphrases compare by contents. */
15
+ const unwrap = (value: unknown): unknown => {
16
+ if (Redacted.isRedacted(value)) return Redacted.value(value);
17
+ if (Array.isArray(value)) return value.map(unwrap);
18
+ if (typeof value === "object" && value !== null) {
19
+ return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, unwrap(nested)]));
20
+ }
21
+ return value;
22
+ };
23
+
24
+ /**
25
+ * Whether the console's object is exactly the desired body: same fields,
26
+ * same values, recursively (key order and `Redacted` wrappers aside; unset
27
+ * fields must be absent on both sides). Leave read-only fields out of
28
+ * `observed` first.
29
+ */
30
+ export const isCurrent = (desired: unknown, observed: unknown) => deepEqual(unwrap(desired), unwrap(observed));
31
+
32
+ /**
33
+ * `value` with every array sorted, recursively, for fields whose arrays are
34
+ * sets: the console keeps them in an order of its own (e.g. a firewall
35
+ * policy's port list), so order mustn't count as a difference.
36
+ */
37
+ export const sortArrays = (value: unknown): unknown => {
38
+ if (Array.isArray(value)) {
39
+ return value.map(sortArrays).toSorted((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
40
+ }
41
+ if (typeof value === "object" && value !== null && !Redacted.isRedacted(value)) {
42
+ return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, sortArrays(nested)]));
43
+ }
44
+ return value;
45
+ };
46
+
47
+ /** `record` without `keys`, e.g. read-only fields the console rejects in request bodies. */
48
+ export const omitKeys = (record: object, keys: ReadonlyArray<string>): Record<string, unknown> =>
49
+ Object.fromEntries(Object.entries(record).filter(([key]) => !keys.includes(key)));
@@ -0,0 +1,7 @@
1
+ import * as Sdk from "@alchemy-unifi/network-sdk";
2
+ import * as Stream from "effect/Stream";
3
+ import { withConnection } from "../Connection.ts";
4
+
5
+ /** A site's networks, uncached. */
6
+ export const loadNetworks = (siteId: string) =>
7
+ withConnection(Stream.runCollect(Sdk.getNetworksOverviewPage.items({ siteId })));
@@ -0,0 +1,22 @@
1
+ import { isResolved } from "alchemy/Diff";
2
+ import * as Predicate from "effect/Predicate";
3
+
4
+ /** A prop whose value depends on another resource's output, so isn't known while planning. */
5
+ export const UNKNOWN: unique symbol = Symbol("UnifiNetwork.unknown");
6
+
7
+ /**
8
+ * A top-level prop of `diff`'s `news` (props that may still hold outputs of
9
+ * resources not yet deployed): its value, `undefined` when unset, or
10
+ * {@link UNKNOWN}.
11
+ */
12
+ export const planned = (news: unknown, key: string): unknown => {
13
+ if (typeof news !== "object" || news === null) return UNKNOWN;
14
+ const value = Predicate.hasProperty(news, key) ? news[key] : undefined;
15
+ return isResolved(value) ? value : UNKNOWN;
16
+ };
17
+
18
+ /** {@link planned} for string props such as `site`. */
19
+ export const plannedString = (news: unknown, key: string): string | undefined | typeof UNKNOWN => {
20
+ const value = planned(news, key);
21
+ return value === UNKNOWN || value === undefined || typeof value === "string" ? value : UNKNOWN;
22
+ };
@@ -0,0 +1,19 @@
1
+ import * as Effect from "effect/Effect";
2
+ import type { UnifiNetworkConnection } from "../Connection.ts";
3
+ import type { UnifiNetworkCredentials } from "../Credentials.ts";
4
+ import type { UnifiNetworkReadCache } from "../ReadCache.ts";
5
+
6
+ /** What resource lifecycle handlers and lookups need. `providers()` provides all of them. */
7
+ export type Services = UnifiNetworkCredentials | UnifiNetworkConnection | UnifiNetworkReadCache;
8
+
9
+ /**
10
+ * The provider's services, captured when the provider is built, as a
11
+ * function that runs a handler with them. Handlers then don't depend on
12
+ * what the stack's merged context happens to hold when they run.
13
+ */
14
+ export const captureServices = Effect.map(
15
+ Effect.context<Services>(),
16
+ (services) =>
17
+ <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, Exclude<R, Services>> =>
18
+ Effect.provide(effect, services),
19
+ );