@homeflare/alchemy 0.57.0 → 0.58.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.
@@ -149,3 +149,67 @@ describe('I2 -- every declarable field is wired into the drift field list', () =
149
149
  },
150
150
  );
151
151
  });
152
+
153
+ describe('set-like nested filter arrays compare as sets (homeflare-network PR 79)', () => {
154
+ const dest = (ports: number[], ips: string[]) =>
155
+ ({
156
+ zoneId: 'zone-storage',
157
+ trafficFilter: {
158
+ type: 'IP_ADDRESS',
159
+ ipAddressFilter: {
160
+ type: 'IP_ADDRESSES',
161
+ matchOpposite: false,
162
+ items: ips.map((value) => ({ type: 'IP_ADDRESS', value })),
163
+ },
164
+ portFilter: {
165
+ type: 'PORTS',
166
+ matchOpposite: false,
167
+ items: ports.map((value) => ({ type: 'PORT_NUMBER', value })),
168
+ },
169
+ },
170
+ }) as unknown as FirewallPolicyProps['destination'];
171
+
172
+ test('the measured case: declared [443, 9100], live [9100, 443] is a noop', () => {
173
+ const props = { ...PROPS, destination: dest([443, 9100], ['10.20.14.10']) };
174
+ const live = liveFirewallPolicy({ destination: dest([9100, 443], ['10.20.14.10']) as never });
175
+ expect(matches(attributesOf(live, props), props)).toBe(true);
176
+ expect(driftOf(live, props)).toEqual([]);
177
+ });
178
+
179
+ test('IP items in another order are a noop too, on source as well as destination', () => {
180
+ const props = { ...PROPS, source: dest([], ['10.0.0.1', '10.0.0.2']) as never };
181
+ const live = liveFirewallPolicy({ source: dest([], ['10.0.0.2', '10.0.0.1']) as never });
182
+ expect(driftOf(live, props)).toEqual([]);
183
+ });
184
+
185
+ test('a membership change still drifts: a port swapped, added or dropped', () => {
186
+ const props = { ...PROPS, destination: dest([443, 9100], ['10.20.14.10']) };
187
+ for (const ports of [[443, 22], [443, 9100, 22], [443]]) {
188
+ const live = liveFirewallPolicy({ destination: dest(ports, ['10.20.14.10']) as never });
189
+ expect(
190
+ driftOf(live, props).map((d) => d.field),
191
+ String(ports),
192
+ ).toEqual(['destination']);
193
+ }
194
+ });
195
+
196
+ test('a duplicate is not collapsed away: [443, 443] differs from [443]', () => {
197
+ const props = { ...PROPS, destination: dest([443], ['10.20.14.10']) };
198
+ const live = liveFirewallPolicy({ destination: dest([443, 443], ['10.20.14.10']) as never });
199
+ expect(driftOf(live, props).map((d) => d.field)).toEqual(['destination']);
200
+ });
201
+
202
+ test('mixed port items sort the same whatever their key order (range vs number)', () => {
203
+ const range = { start: 8000, stop: 8100, type: 'PORT_NUMBER_RANGE' };
204
+ const rangeDecoded = { type: 'PORT_NUMBER_RANGE', start: 8000, stop: 8100 };
205
+ const one = { type: 'PORT_NUMBER', value: 443 };
206
+ const withItems = (items: unknown[]) =>
207
+ ({
208
+ zoneId: 'zone-storage',
209
+ trafficFilter: { type: 'PORT', portFilter: { type: 'PORTS', matchOpposite: false, items } },
210
+ }) as unknown as FirewallPolicyProps['destination'];
211
+ const props = { ...PROPS, destination: withItems([range, one]) };
212
+ const live = liveFirewallPolicy({ destination: withItems([one, rangeDecoded]) as never });
213
+ expect(driftOf(live, props)).toEqual([]);
214
+ });
215
+ });
@@ -10,9 +10,9 @@
10
10
  * before comparing — never applied to only `live` or only `declared` (the specific gap MEDIUM-4's
11
11
  * own mutant found in `network-drift.ts`; see its header, and `acl-rule-drift.ts`'s own repeat of
12
12
  * this same rule for `protocolFilter`/`enforcingDeviceFilter`).
13
- * ★ `action`/`destination`/`ipProtocolScope`/`schedule`/`source` compare with the plain default
14
- * `equal` (whole-value `deepEqual`) — see `firewall-policy-form.ts`'s header for the accepted
15
- * known gap this leaves in their own nested set-like arrays.
13
+ * ★ `destination`/`ipProtocolScope`/`schedule`/`source` run through `normalizeSetArrays` on BOTH
14
+ * sides (set-like nested arrays compare as sets; `firewall-policy-form.ts`'s header), then the
15
+ * default `deepEqual`. `action` compares wholesale.
16
16
  */
17
17
  import type * as firewall from '@distilled.cloud/unifi-network/firewall';
18
18
  import { makeDriftOf } from './drift.ts';
@@ -21,6 +21,7 @@ import {
21
21
  type FirewallPolicyProps,
22
22
  attributesOf,
23
23
  normalizeConnectionStateFilter,
24
+ normalizeSetArrays,
24
25
  } from './firewall-policy-form.ts';
25
26
 
26
27
  const fieldDrift = makeDriftOf<FirewallPolicyAttributes, FirewallPolicyProps>([
@@ -31,14 +32,30 @@ const fieldDrift = makeDriftOf<FirewallPolicyAttributes, FirewallPolicyProps>([
31
32
  declared: (p) => normalizeConnectionStateFilter(p.connectionStateFilter),
32
33
  },
33
34
  { field: 'description', live: (a) => a.description, declared: (p) => p.description },
34
- { field: 'destination', live: (a) => a.destination, declared: (p) => p.destination },
35
+ {
36
+ field: 'destination',
37
+ live: (a) => normalizeSetArrays(a.destination),
38
+ declared: (p) => normalizeSetArrays(p.destination),
39
+ },
35
40
  { field: 'enabled', live: (a) => a.enabled, declared: (p) => p.enabled },
36
- { field: 'ipProtocolScope', live: (a) => a.ipProtocolScope, declared: (p) => p.ipProtocolScope },
41
+ {
42
+ field: 'ipProtocolScope',
43
+ live: (a) => normalizeSetArrays(a.ipProtocolScope),
44
+ declared: (p) => normalizeSetArrays(p.ipProtocolScope),
45
+ },
37
46
  { field: 'ipsecFilter', live: (a) => a.ipsecFilter, declared: (p) => p.ipsecFilter },
38
47
  { field: 'loggingEnabled', live: (a) => a.loggingEnabled, declared: (p) => p.loggingEnabled },
39
48
  { field: 'name', live: (a) => a.name, declared: (p) => p.name },
40
- { field: 'schedule', live: (a) => a.schedule, declared: (p) => p.schedule },
41
- { field: 'source', live: (a) => a.source, declared: (p) => p.source },
49
+ {
50
+ field: 'schedule',
51
+ live: (a) => normalizeSetArrays(a.schedule),
52
+ declared: (p) => normalizeSetArrays(p.schedule),
53
+ },
54
+ {
55
+ field: 'source',
56
+ live: (a) => normalizeSetArrays(a.source),
57
+ declared: (p) => normalizeSetArrays(p.source),
58
+ },
42
59
  ]);
43
60
 
44
61
  export const matches = (
@@ -15,17 +15,18 @@
15
15
  * `deepEqual` (`stripNullish`) as ONE value, same proportion `acl-rule-form.ts`'s
16
16
  * `enforcingDeviceFilter` gets (one level of structure, not exploded into scalars).
17
17
  *
18
- * ⚠️ KNOWN GAP (T15, same shape as `acl-rule-form.ts`'s own — accepted, not fixed, here either):
19
- * several of the vendor's filter variants nested inside `source`/`destination`/`ipProtocolScope`
20
- * are themselves SET-LIKE arrays with no documented order —
21
- * `FirewallPolicyMACAddressFilter.macAddresses`, `FirewallPolicyNetworkFilter.networkIds`,
22
- * `FirewallPolicyApplicationFilter.applicationIds`, `FirewallPolicyIPAddressFilter.items`,
23
- * `FirewallPolicyRegionFilter.regions`, `FirewallPolicyVPNServerFilter.vpnServerIds`,
24
- * `FirewallSchedule.repeatOnDays` — `deepEqual` compares every one of them order-sensitively, so a
25
- * console-side reorder with no membership change would report a spurious drift this read-only
26
- * family's `reconcile` then refuses forever. Not fixed here for the same reason ACL's own gap
27
- * isn't: normalizing 12 filter variants' own nested arrays is a bigger, separate change, not a
28
- * proportional extension of typing per-discriminator (A3) into set-aware comparison too.
18
+ * ★ SET-LIKE NESTED ARRAYS COMPARE AS SETS (closed 2026-10-19; was T15's accepted gap). The
19
+ * vendor's filter variants inside `source`/`destination`/`ipProtocolScope`/`schedule` carry
20
+ * arrays with no documented order: `FirewallPolicyIPAddressFilter.items`, the port filter's
21
+ * `items`, `FirewallPolicyMACAddressFilter.macAddresses`, `FirewallPolicyNetworkFilter.networkIds`,
22
+ * `FirewallPolicyApplicationFilter.applicationIds`, `FirewallPolicyRegionFilter.regions`,
23
+ * `FirewallPolicyVPNServerFilter.vpnServerIds`, `FirewallPolicyDomainFilter.domains`,
24
+ * `applicationCategoryIds` and `FirewallSchedule.repeatOnDays`. Measured:
25
+ * the controller (Network 11.0.83) stored a declared port list [443, 9100] as [9100, 443], so a
26
+ * correct create failed its read-back (`UnifiCreateDidNotConverge`, homeflare-network PR 79).
27
+ * `normalizeSetArrays` sorts every array under one of those keys (by canonical JSON) on BOTH
28
+ * sides before `deepEqual`. Membership still has to match exactly, and duplicates are kept, so a
29
+ * real change is never hidden.
29
30
  *
30
31
  * ⛔ `connectionStateFilter` IS THE ONE TOP-LEVEL PROPS ARRAY, AND IS A SET — "Match on firewall
31
32
  * connection state. If null, matches all connection states" describes membership, not a sequence
@@ -54,6 +55,51 @@ export const normalizeConnectionStateFilter = (
54
55
  value: readonly string[] | undefined,
55
56
  ): string[] | undefined => (value == null ? undefined : sortedSet(value));
56
57
 
58
+ /** Keys whose array value is a set in the vendor's firewall filter schemas (header). */
59
+ const SET_ARRAY_KEYS: ReadonlySet<string> = new Set([
60
+ 'items',
61
+ 'macAddresses',
62
+ 'networkIds',
63
+ 'applicationIds',
64
+ 'regions',
65
+ 'vpnServerIds',
66
+ 'repeatOnDays',
67
+ 'domains',
68
+ 'applicationCategoryIds',
69
+ ]);
70
+
71
+ /** Key-order-independent JSON: a hand-written `{start, stop, type}` and a decoded `{type, start,
72
+ * stop}` must sort to the same place (Opus red team, kit PR 466). */
73
+ const sortKeys = (value: unknown): unknown =>
74
+ Array.isArray(value)
75
+ ? value.map(sortKeys)
76
+ : value !== null && typeof value === 'object'
77
+ ? Object.fromEntries(
78
+ Object.keys(value as object)
79
+ .sort()
80
+ .map((k) => [k, sortKeys((value as Record<string, unknown>)[k])]),
81
+ )
82
+ : value;
83
+ const canonical = (value: unknown): string => JSON.stringify(sortKeys(normalizeSetArrays(value)));
84
+
85
+ /** A deep copy with every SET_ARRAY_KEYS array sorted by canonical JSON (duplicates kept). */
86
+ export const normalizeSetArrays = <T>(value: T): T => {
87
+ if (Array.isArray(value)) return value.map((v) => normalizeSetArrays(v)) as T;
88
+ if (value === null || typeof value !== 'object') return value;
89
+ const out: Record<string, unknown> = {};
90
+ for (const [key, inner] of Object.entries(value as Record<string, unknown>)) {
91
+ out[key] =
92
+ SET_ARRAY_KEYS.has(key) && Array.isArray(inner)
93
+ ? [...inner]
94
+ .map((v) => normalizeSetArrays(v))
95
+ .sort((x, y) =>
96
+ canonical(x) < canonical(y) ? -1 : canonical(x) > canonical(y) ? 1 : 0,
97
+ )
98
+ : normalizeSetArrays(inner);
99
+ }
100
+ return out as T;
101
+ };
102
+
57
103
  /** ⚠️ EVERY OPTIONAL FIELD SPELLS OUT `| undefined` — `exactOptionalPropertyTypes`; see
58
104
  * `network-form.ts`'s own header.
59
105
  *
@@ -70,17 +116,17 @@ export interface FirewallPolicyProps {
70
116
  /** Connection states this policy matches — compared as a SET, see the header. */
71
117
  connectionStateFilter?: string[] | undefined;
72
118
  description?: string | undefined;
73
- /** Traffic destination — compound, compared wholesale; see the header's known gap. */
119
+ /** Traffic destination — compound, compared wholesale with set-like arrays normalized; see the header. */
74
120
  destination: firewall.FirewallPolicyDestination;
75
121
  enabled: boolean;
76
- /** IP version + protocol scope — compound, compared wholesale; see the header's known gap. */
122
+ /** IP version + protocol scope — compound, compared wholesale with set-like arrays normalized; see the header. */
77
123
  ipProtocolScope: firewall.FirewallPolicyIPProtocolScope;
78
124
  ipsecFilter?: string | undefined;
79
125
  loggingEnabled: boolean;
80
126
  name: string;
81
- /** Schedule this policy is active on — compound, compared wholesale; see the header's known gap. */
127
+ /** Schedule this policy is active on — compound, compared wholesale with set-like arrays normalized; see the header. */
82
128
  schedule?: firewall.FirewallSchedule | undefined;
83
- /** Traffic source — compound, compared wholesale; see the header's known gap. */
129
+ /** Traffic source — compound, compared wholesale with set-like arrays normalized; see the header. */
84
130
  source: firewall.FirewallPolicySource;
85
131
  }
86
132
 
@@ -0,0 +1,59 @@
1
+ /**
2
+ * A live edit made since the last deploy that the declaration ALREADY matches (a re-import
3
+ * recorded it) is accepted, not refused: it is folded into the baseline, so it is neither a
4
+ * conflict nor part of the PUT patch. A live edit the declaration disagrees with is still
5
+ * refused (network-update.test.ts case 6). Measured case: homeflare-network VLAN 16,
6
+ * 2026-10-19 (mdnsForwardingEnabled edited on the console, re-imported, refused forever).
7
+ */
8
+ import { describe, expect, test } from 'bun:test';
9
+ import type { NetworkProps } from './network.ts';
10
+ import {
11
+ BASE,
12
+ IPV6,
13
+ WITH_V6,
14
+ answering,
15
+ live,
16
+ putBody,
17
+ reconcile,
18
+ } from './network-update.fixtures.ts';
19
+
20
+ describe('Unifi.Network update: accepted live drift', () => {
21
+ test('a console edit the re-import recorded is accepted; the declared change still PUTs', async () => {
22
+ const liveBody = live({ mdnsForwardingEnabled: true });
23
+ const news = { ...WITH_V6, mdnsForwardingEnabled: true } as NetworkProps;
24
+ const route = answering(liveBody, () =>
25
+ Response.json({ ...liveBody, ipv6Configuration: IPV6 }),
26
+ );
27
+ const { fake, exit, notes } = reconcile(route, news, BASE);
28
+ expect((await exit)._tag).toBe('Success');
29
+ expect(fake.seen.map((s) => s.method)).toEqual(['GET', 'PUT']);
30
+ const body = putBody(fake.seen)[0] as Record<string, unknown>;
31
+ expect(body.ipv6Configuration).toEqual(IPV6);
32
+ expect(body.mdnsForwardingEnabled).toBe(true);
33
+ expect(notes.some((n) => n.includes('accepted: [mdnsForwardingEnabled]'))).toBe(true);
34
+ expect(notes.some((n) => /PUT changes \[ipv6Configuration\]/.test(n))).toBe(true);
35
+ });
36
+
37
+ test('accepted drift never widens the patch past the declared change', async () => {
38
+ const liveBody = live({ name: 'Renamed' });
39
+ const news = { ...WITH_V6, name: 'Renamed' } as NetworkProps;
40
+ const route = answering(liveBody, () =>
41
+ Response.json({ ...liveBody, ipv6Configuration: IPV6 }),
42
+ );
43
+ const { exit, notes } = reconcile(route, news, BASE);
44
+ expect((await exit)._tag).toBe('Success');
45
+ expect(notes.find((n) => n.includes('PUT changes'))).toContain(
46
+ 'PUT changes [ipv6Configuration]',
47
+ );
48
+ });
49
+
50
+ test('drift the declaration disagrees with stays refused, even beside an accepted field', async () => {
51
+ const liveBody = live({ mdnsForwardingEnabled: true, name: 'Renamed' });
52
+ const news = { ...WITH_V6, mdnsForwardingEnabled: true } as NetworkProps;
53
+ const { fake, exit } = reconcile(answering(liveBody), news, BASE);
54
+ const result = JSON.stringify(await exit);
55
+ expect(result).toContain('UnifiLiveDriftedSinceDeploy');
56
+ expect(result).toContain('"fields":["name"]');
57
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
58
+ });
59
+ });
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Shared fixtures for the `Unifi.Network` update tests (network-update.test.ts,
3
+ * network-update-accept.test.ts): the fake controller route, the base declaration and the
4
+ * reconcile driver. Extracted so neither test file passes the 250-line cap.
5
+ */
6
+ import * as Retry from '@distilled.cloud/unifi-network/Retry';
7
+ import * as Effect from 'effect/Effect';
8
+ import { fakeFailure, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
9
+ import { type NetworkProps, spec } from './network.ts';
10
+ import { unifiOperations } from './resource.ts';
11
+
12
+ export const PATH = '/proxy/network/integration/v1/sites/site-1/networks/net-1';
13
+ export const IPV6 = {
14
+ interfaceType: 'STATIC',
15
+ prefixLength: 64,
16
+ hostIpAddress: '2602:f660:0:16::254',
17
+ clientAddressAssignment: { slaacEnabled: true },
18
+ routerAdvertisement: { priority: 'HIGH' },
19
+ };
20
+
21
+ export const BASE: NetworkProps = {
22
+ siteId: 'site-1',
23
+ networkId: 'net-1',
24
+ name: 'Compute',
25
+ enabled: true,
26
+ management: 'ADVANCED',
27
+ vlanId: 16,
28
+ dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.1'] },
29
+ ipv4Configuration: { dhcpConfiguration: { mode: 'SERVER' }, hostIpAddress: '10.20.16.254' },
30
+ };
31
+ export const WITH_V6 = { ...BASE, ipv6Configuration: IPV6 } as NetworkProps;
32
+
33
+ /** Raw live object, with a key the SDK schema has never heard of. */
34
+ export const live = (extra: Record<string, unknown> = {}) => ({
35
+ id: 'net-1',
36
+ default: false,
37
+ metadata: { origin: 'USER' },
38
+ futureTopLevelKey: 'x',
39
+ ...BASE,
40
+ siteId: undefined,
41
+ networkId: undefined,
42
+ ...extra,
43
+ });
44
+
45
+ export const reconcile = (
46
+ route: (method: string, url: URL) => Response,
47
+ news: NetworkProps,
48
+ olds: NetworkProps | undefined,
49
+ outputArg: object | null = {},
50
+ ) => {
51
+ const output = outputArg ?? undefined;
52
+ const fake = fakeUnifi(route);
53
+ const notes: string[] = [];
54
+ const run = unifiOperations(spec)
55
+ .reconcile(news, {
56
+ olds,
57
+ output,
58
+ note: (m) => Effect.sync(() => void notes.push(m)),
59
+ })
60
+ .pipe(Retry.none, Effect.provide(fakeUnifiLayer(fake.fetch)));
61
+ return { fake, notes, exit: Effect.runPromiseExit(run) };
62
+ };
63
+
64
+ export const answering =
65
+ (liveBody: object, put: () => Response = () => Response.json(liveBody)) =>
66
+ (method: string, url: URL) =>
67
+ url.pathname === PATH
68
+ ? method === 'GET'
69
+ ? Response.json(liveBody)
70
+ : put()
71
+ : fakeFailure(400, 'unexpected');
72
+
73
+ export const putBody = (seen: ReadonlyArray<{ method: string; body?: unknown }>) =>
74
+ seen.filter((s) => s.method === 'PUT').map((s) => s.body as Record<string, unknown>);
@@ -8,81 +8,23 @@
8
8
  */
9
9
  import { describe, expect, test } from 'bun:test';
10
10
  import type * as networks from '@distilled.cloud/unifi-network/networks';
11
- import * as Retry from '@distilled.cloud/unifi-network/Retry';
12
- import * as Effect from 'effect/Effect';
13
- import { fakeFailure, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
14
- import { NETWORK_PATCH_KEYS, type NetworkProps, spec } from './network.ts';
11
+ import { fakeFailure } from './fake-unifi.ts';
12
+ import { NETWORK_PATCH_KEYS, type NetworkProps } from './network.ts';
13
+ import {
14
+ BASE,
15
+ IPV6,
16
+ WITH_V6,
17
+ answering,
18
+ live,
19
+ putBody,
20
+ reconcile,
21
+ } from './network-update.fixtures.ts';
15
22
  import { SERVER_FIELDS } from './network-update.ts';
16
23
  import {
17
24
  UnifiLiveDriftedSinceDeploy,
18
25
  UnifiUpdateDidNotConverge,
19
26
  UnifiWriteRefused,
20
27
  } from './policy.ts';
21
- import { unifiOperations } from './resource.ts';
22
-
23
- const PATH = '/proxy/network/integration/v1/sites/site-1/networks/net-1';
24
- const IPV6 = {
25
- interfaceType: 'STATIC',
26
- prefixLength: 64,
27
- hostIpAddress: '2602:f660:0:16::254',
28
- clientAddressAssignment: { slaacEnabled: true },
29
- routerAdvertisement: { priority: 'HIGH' },
30
- };
31
-
32
- const BASE: NetworkProps = {
33
- siteId: 'site-1',
34
- networkId: 'net-1',
35
- name: 'Compute',
36
- enabled: true,
37
- management: 'ADVANCED',
38
- vlanId: 16,
39
- dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.1'] },
40
- ipv4Configuration: { dhcpConfiguration: { mode: 'SERVER' }, hostIpAddress: '10.20.16.254' },
41
- };
42
- const WITH_V6 = { ...BASE, ipv6Configuration: IPV6 } as NetworkProps;
43
-
44
- /** Raw live object, with a key the SDK schema has never heard of. */
45
- const live = (extra: Record<string, unknown> = {}) => ({
46
- id: 'net-1',
47
- default: false,
48
- metadata: { origin: 'USER' },
49
- futureTopLevelKey: 'x',
50
- ...BASE,
51
- siteId: undefined,
52
- networkId: undefined,
53
- ...extra,
54
- });
55
-
56
- const reconcile = (
57
- route: (method: string, url: URL) => Response,
58
- news: NetworkProps,
59
- olds: NetworkProps | undefined,
60
- outputArg: object | null = {},
61
- ) => {
62
- const output = outputArg ?? undefined;
63
- const fake = fakeUnifi(route);
64
- const notes: string[] = [];
65
- const run = unifiOperations(spec)
66
- .reconcile(news, {
67
- olds,
68
- output,
69
- note: (m) => Effect.sync(() => void notes.push(m)),
70
- })
71
- .pipe(Retry.none, Effect.provide(fakeUnifiLayer(fake.fetch)));
72
- return { fake, notes, exit: Effect.runPromiseExit(run) };
73
- };
74
-
75
- const answering =
76
- (liveBody: object, put: () => Response = () => Response.json(liveBody)) =>
77
- (method: string, url: URL) =>
78
- url.pathname === PATH
79
- ? method === 'GET'
80
- ? Response.json(liveBody)
81
- : put()
82
- : fakeFailure(400, 'unexpected');
83
-
84
- const putBody = (seen: ReadonlyArray<{ method: string; body?: unknown }>) =>
85
- seen.filter((s) => s.method === 'PUT').map((s) => s.body as Record<string, unknown>);
86
28
 
87
29
  describe('Unifi.Network update', () => {
88
30
  test('(1) one GET then one PUT: live carried through byte-equal, server fields dropped, patch added', async () => {
@@ -72,6 +72,21 @@ export const computePatch = <Props extends object>(
72
72
  return { set, unset };
73
73
  };
74
74
 
75
+ /** `olds` with each accepted field taken from `news` (which equals live there), so the patch
76
+ * skips it. A `FieldDrift.field` names a props key; one that is not is left alone. */
77
+ const acceptInto = <Props extends object>(
78
+ olds: Props,
79
+ news: Props,
80
+ accepted: ReadonlyArray<unknown>,
81
+ ): Props => {
82
+ const next: Record<string, unknown> = { ...(olds as Record<string, unknown>) };
83
+ for (const field of accepted) {
84
+ const key = String(field);
85
+ if (key in (news as object) || key in next) next[key] = (news as Record<string, unknown>)[key];
86
+ }
87
+ return next as Props;
88
+ };
89
+
75
90
  export type ReconcileArgs<Props extends object> = {
76
91
  readonly news: Props;
77
92
  readonly olds: Props | undefined;
@@ -99,15 +114,34 @@ export const updateReconcile = <Props extends object, Live, Attributes extends o
99
114
  if (previous !== identity) {
100
115
  return yield* new UnifiIdentityChanged({ type: spec.type, identity, previous });
101
116
  }
117
+ // ★ A FIELD THAT DRIFTED SINCE THE LAST DEPLOY IS A CONFLICT ONLY IF THE DECLARATION STILL
118
+ // DISAGREES WITH LIVE ON IT. When the declaration already equals live there (a re-import
119
+ // recorded the console edit, the documented recovery for UnifiLiveDriftedSinceDeploy),
120
+ // the edit is ACCEPTED: folded into the baseline, so it is neither refused nor patched. The
121
+ // PUT is live plus the patch, so live's value survives untouched. Without this, `olds`
122
+ // (the last deployed state) only moves on a successful deploy, so a re-import could never
123
+ // clear the refusal (homeflare-network VLAN 16, 2026-10-19: mdnsForwardingEnabled edited on
124
+ // the console, re-imported, and still refused on every deploy).
125
+ // ⛔ A drifted field the declaration DISAGREES with stays a refusal: the PUT would overwrite
126
+ // a hand edit nobody re-imported.
127
+ const disagrees = new Set(update.driftOf(live, news).map((d) => String(d.field)));
102
128
  const drifted = update.driftOf(live, olds).map((d) => d.field);
103
- if (drifted.length > 0) {
129
+ const conflicts = drifted.filter((field) => disagrees.has(String(field)));
130
+ if (conflicts.length > 0) {
104
131
  return yield* new UnifiLiveDriftedSinceDeploy({
105
132
  type: spec.type,
106
133
  identity,
107
- fields: drifted,
134
+ fields: conflicts,
108
135
  });
109
136
  }
110
- const patch = computePatch(news, olds, update.patchKeys);
137
+ const accepted = drifted.filter((field) => !disagrees.has(String(field)));
138
+ const baseline = acceptInto(olds, news, accepted);
139
+ if (accepted.length > 0) {
140
+ yield* note(
141
+ `${spec.type} ${identity}: live edits since the last deploy already match the declaration, accepted: [${accepted.map(String).join(', ')}]`,
142
+ );
143
+ }
144
+ const patch = computePatch(news, baseline, update.patchKeys);
111
145
  if (Object.keys(patch.set).length === 0 && patch.unset.length === 0) {
112
146
  return yield* refuseWrite(spec.type, identity, 'update');
113
147
  }