@homeflare/alchemy 0.57.0 → 0.59.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 (38) hide show
  1. package/README.md +17 -0
  2. package/dist/caddy/config.d.ts +6 -0
  3. package/dist/caddy/config.d.ts.map +1 -1
  4. package/dist/caddy/index.js +128 -61
  5. package/dist/caddy/index.js.map +6 -4
  6. package/dist/scope/duplicate-id.d.ts +32 -0
  7. package/dist/scope/duplicate-id.d.ts.map +1 -0
  8. package/dist/scope/guard.d.ts +28 -0
  9. package/dist/scope/guard.d.ts.map +1 -0
  10. package/dist/scope/index.d.ts +21 -0
  11. package/dist/scope/index.d.ts.map +1 -0
  12. package/dist/scope/index.js +71 -0
  13. package/dist/scope/index.js.map +11 -0
  14. package/dist/unifi/firewall-policy-drift.d.ts +3 -3
  15. package/dist/unifi/firewall-policy-drift.d.ts.map +1 -1
  16. package/dist/unifi/firewall-policy-form.d.ts +18 -15
  17. package/dist/unifi/firewall-policy-form.d.ts.map +1 -1
  18. package/dist/unifi/index.js +64 -8
  19. package/dist/unifi/index.js.map +5 -5
  20. package/dist/unifi/network-update.fixtures.d.ts +53 -0
  21. package/dist/unifi/network-update.fixtures.d.ts.map +1 -0
  22. package/dist/unifi/update-reconcile.d.ts.map +1 -1
  23. package/docs/duplicate-resource-id.md +91 -0
  24. package/docs/provider-standard.md +9 -0
  25. package/package.json +5 -1
  26. package/src/caddy/config.ts +12 -3
  27. package/src/scope/duplicate-id.test.ts +95 -0
  28. package/src/scope/duplicate-id.ts +113 -0
  29. package/src/scope/guard.test.ts +128 -0
  30. package/src/scope/guard.ts +49 -0
  31. package/src/scope/index.ts +20 -0
  32. package/src/unifi/firewall-policy-drift.test.ts +64 -0
  33. package/src/unifi/firewall-policy-drift.ts +24 -7
  34. package/src/unifi/firewall-policy-form.ts +61 -15
  35. package/src/unifi/network-update-accept.test.ts +59 -0
  36. package/src/unifi/network-update.fixtures.ts +74 -0
  37. package/src/unifi/network-update.test.ts +11 -69
  38. package/src/unifi/update-reconcile.ts +37 -3
@@ -0,0 +1,71 @@
1
+ // src/scope/duplicate-id.ts
2
+ import { toFqn } from "alchemy/FQN";
3
+ import { CurrentNamespace } from "alchemy/Namespace";
4
+ import { Stack } from "alchemy/Stack";
5
+ import * as Data from "effect/Data";
6
+ import * as Effect from "effect/Effect";
7
+
8
+ class DuplicateResourceIdError extends Data.TaggedError("DuplicateResourceIdError") {
9
+ }
10
+ var scopeLabel = (namespace) => {
11
+ if (!namespace)
12
+ return "(root)";
13
+ const path = [];
14
+ let node = namespace;
15
+ while (node) {
16
+ path.unshift(node.Id);
17
+ node = node.Parent;
18
+ }
19
+ return path.join("/");
20
+ };
21
+ var registries = new WeakMap;
22
+ var registryFor = (stack) => {
23
+ const existing = registries.get(stack);
24
+ if (existing)
25
+ return existing;
26
+ const created = new Map;
27
+ registries.set(stack, created);
28
+ return created;
29
+ };
30
+ var claimResourceId = (type, id) => Effect.gen(function* () {
31
+ const stack = yield* Stack;
32
+ const namespace = yield* CurrentNamespace;
33
+ const fqn = toFqn(namespace, id);
34
+ const registry = registryFor(stack);
35
+ const firstType = registry.get(fqn);
36
+ if (firstType === undefined) {
37
+ registry.set(fqn, type);
38
+ return;
39
+ }
40
+ if (firstType !== type) {
41
+ return yield* Effect.die(new DuplicateResourceIdError({
42
+ message: `REFUSING: duplicate resource id "${fqn}" in scope ${scopeLabel(namespace)}: ` + `first declared by ${firstType}, again by ${type}`,
43
+ fqn,
44
+ scope: scopeLabel(namespace),
45
+ firstType,
46
+ secondType: type
47
+ }));
48
+ }
49
+ });
50
+ // src/scope/guard.ts
51
+ import * as Effect2 from "effect/Effect";
52
+ var guardDuplicateIds = (resourceClass) => new Proxy(resourceClass, {
53
+ apply(target, thisArg, args) {
54
+ const id = args[0];
55
+ if (typeof id !== "string") {
56
+ return Reflect.apply(target, thisArg, args);
57
+ }
58
+ return Effect2.gen(function* () {
59
+ yield* claimResourceId(resourceClass.Type, id);
60
+ return yield* Reflect.apply(target, thisArg, args);
61
+ });
62
+ }
63
+ });
64
+ export {
65
+ DuplicateResourceIdError,
66
+ claimResourceId,
67
+ guardDuplicateIds
68
+ };
69
+
70
+ //# debugId=1AB58BD65E38742164756E2164756E21
71
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,11 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/scope/duplicate-id.ts", "../src/scope/guard.ts"],
4
+ "sourcesContent": [
5
+ "/**\n * Refuse a second resource claiming a logical id already taken in the same stack scope.\n *\n * ★ WHY THIS EXISTS (measured, homeflare-mini PR 329, Tim 2026-10-19). `NixJobRetirement('caddy',\n * …)` and `Caddy.Config` (`CADDY_ID = 'caddy'`) both declared logical id `caddy` in one stack.\n * Alchemy's own `Resource()` constructor treats a second `yield*` at an already-claimed FQN as\n * an idempotent re-reference — correct for \"the same resource, read from two call sites\" — and\n * returns the FIRST-registered resource without ever comparing `Type` (`Resource.ts`, the\n * `if (existing) { … return existing; }` branch; a sibling TODO there already flags a related\n * gap — \"check if props are different and die\"). The SECOND resource's props are never written\n * anywhere: `stack.resources[fqn]` never changes, so nothing downstream — not `Plan.make`, not a\n * later inspection of the compiled stack — can tell two different registrations ever happened.\n * Measured directly (dup-experiment.ts, this PR): registering `Test.ResourceB('shared-id', …)`\n * after `Test.ResourceA('shared-id', …)` compiles a stack whose ONLY entry at that fqn reports\n * `Type: 'Test.ResourceA'` — the second declaration leaves no trace to check after the fact.\n * ★ WHY THE CHECK CANNOT BE BOLTED ON AFTER THE FACT. Because the losing registration leaves no\n * trace (above), a guard built from `CompiledStack.resources` — inspected after the stack's body\n * has run, e.g. before `Plan.make` — sees only one entry per fqn and cannot know a second,\n * different-typed claim ever happened. The only point where both the existing claim AND the new\n * claimant's `Type` are both in hand is at the moment of registration, which is why this module\n * is a REGISTRY populated and checked as each resource is `yield*`ed (see `guard.ts`), not a\n * post-hoc scan.\n * ⛔ UPSTREAM IS THE RIGHT HOME FOR THE REAL FIX. The comparison belongs in `Resource.ts` itself,\n * right where its own TODO already sits — every resource (ours and every vendor built-in) goes\n * through that one function, and this kit cannot wrap a type it does not author (e.g.\n * `Cloudflare.Worker`). This module is the house-side stopgap decision 49 allows (\"a house rule\n * only fills a gap upstream is silent on\"): it protects any TWO resources that both go through\n * `guardDuplicateIds` (guard.ts), which is everywhere this kit's own providers adopt it. The\n * upstream issue text proposing the real fix is in this PR's body.\n */\nimport { toFqn } from 'alchemy/FQN';\nimport { CurrentNamespace, type NamespaceNode } from 'alchemy/Namespace';\nimport { Stack } from 'alchemy/Stack';\nimport * as Data from 'effect/Data';\nimport * as Effect from 'effect/Effect';\n\n/**\n * Two resources of different types both claimed `fqn` in `scope`. Carries both type names so the\n * message — and any catcher — can tell the attacker from the defender without re-deriving them.\n */\nexport class DuplicateResourceIdError extends Data.TaggedError('DuplicateResourceIdError')<{\n message: string;\n fqn: string;\n scope: string;\n firstType: string;\n secondType: string;\n}> {}\n\nconst scopeLabel = (namespace: NamespaceNode | undefined): string => {\n if (!namespace) return '(root)';\n const path: string[] = [];\n let node: NamespaceNode | undefined = namespace;\n while (node) {\n path.unshift(node.Id);\n node = node.Parent;\n }\n return path.join('/');\n};\n\n/**\n * One registry per compiled stack, keyed by the `Stack` service object `yield* Stack` returns\n * (a fresh `StackSpec` per `Alchemy.Stack(...)`/`make()` call — see `Stack.ts`'s `Layer.effect`).\n * A `WeakMap` so two unrelated stacks (sequential test runs, two stacks in one process) never see\n * each other's claims, and so the registry is collected with the stack instead of growing forever\n * in a long-running process (an `alchemy dev` watch loop, this package's own test suite).\n */\nconst registries = new WeakMap<object, Map<string, string>>();\n\nconst registryFor = (stack: object): Map<string, string> => {\n const existing = registries.get(stack);\n if (existing) return existing;\n const created = new Map<string, string>();\n registries.set(stack, created);\n return created;\n};\n\n/**\n * Claim `id` for `type` in the current namespace scope. Fine the first time, and fine again for\n * the SAME type (the legitimate \"yield the same resource from two call sites\" pattern Alchemy's\n * own idempotent registration supports). Dies with {@link DuplicateResourceIdError} the moment a\n * DIFFERENT type claims an id already taken — before that second resource's provider ever runs.\n *\n * ⚠️ `Effect.die`, NOT a typed failure. This runs at registration, not inside a lifecycle\n * operation (`read`/`diff`/`reconcile`/`delete`) — the provider-standard's S20 ban on\n * `Effect.orDie` in LIFECYCLE operations does not apply here, and `ConflictingProviderModeError`\n * right next door in upstream's own `Resource.ts` dies the same way for the same reason: there is\n * no recovery a caller could usefully attempt mid-registration.\n */\nexport const claimResourceId = (type: string, id: string): Effect.Effect<void, never, Stack> =>\n Effect.gen(function* () {\n const stack = yield* Stack;\n const namespace = yield* CurrentNamespace;\n const fqn = toFqn(namespace, id);\n const registry = registryFor(stack);\n const firstType = registry.get(fqn);\n if (firstType === undefined) {\n registry.set(fqn, type);\n return;\n }\n if (firstType !== type) {\n return yield* Effect.die(\n new DuplicateResourceIdError({\n message:\n `REFUSING: duplicate resource id \"${fqn}\" in scope ${scopeLabel(namespace)}: ` +\n `first declared by ${firstType}, again by ${type}`,\n fqn,\n scope: scopeLabel(namespace),\n firstType,\n secondType: type,\n }),\n );\n }\n });\n",
6
+ "/**\n * `guardDuplicateIds` — wrap a Resource class so every `(\"id\", props)` call it makes first\n * claims that id through `duplicate-id.ts`'s per-stack registry, naming both resource types if\n * a second, different-typed resource already claimed it. See duplicate-id.ts's header for why\n * the check has to live HERE (at the moment of registration) rather than after the stack body\n * has finished running.\n *\n * Usage, in a resource's own file, right where it's exported today:\n *\n * ```ts\n * export const CaddyConfig = guardDuplicateIds(Resource<CaddyConfig>('Caddy.Config', { … }));\n * ```\n *\n * ★ A `Proxy` around the REAL class, not a reimplementation. `Resource()`'s return value is a\n * callable object wearing an `Effectable.Prototype` (`Effect.isEffect(MyResource) === true`,\n * for the bare-tag platform-resource pattern) plus `.ref`, `.Type`, `.Provider`, `.Self`,\n * `.Aliases` — reconstructing that shape risks silently dropping one of them. A `Proxy` whose\n * `get` trap is the default (`Reflect.get`) forwards every property read untouched; only the\n * `apply` trap — the `MyResource(\"id\", props)` call form — is new behaviour.\n * ⚠️ THE \"REGISTER METHODS\" OVERLOAD IS DELIBERATELY LEFT ALONE. `ResourceClass` is also callable\n * with a single object argument to attach methods (`Resource()`'s own `typeof args[0] ===\n * 'object' ? Object.assign(...) : constructor(...)` branch) — that call mutates and returns the\n * SAME class, carries no logical id, and has nothing to claim. Only a string first argument\n * (the `(id, props)` form) goes through `claimResourceId`.\n */\nimport type { ResourceClass, ResourceLike } from 'alchemy/Resource';\nimport * as Effect from 'effect/Effect';\nimport { claimResourceId } from './duplicate-id.ts';\n\nexport const guardDuplicateIds = <R extends ResourceLike>(\n resourceClass: ResourceClass<R>,\n): ResourceClass<R> =>\n new Proxy(resourceClass, {\n apply(target, thisArg, args: unknown[]) {\n const id = args[0];\n if (typeof id !== 'string') {\n // The \"attach methods\" overload — not a registration, nothing to claim.\n return Reflect.apply(target as (...a: unknown[]) => unknown, thisArg, args);\n }\n return Effect.gen(function* () {\n yield* claimResourceId(resourceClass.Type, id);\n return yield* Reflect.apply(\n target as (...a: unknown[]) => unknown,\n thisArg,\n args,\n ) as Effect.Effect<R>;\n });\n },\n }) as ResourceClass<R>;\n"
7
+ ],
8
+ "mappings": ";AA8BA;AACA;AACA;AACA;AACA;AAAA;AAMO,MAAM,iCAAsC,iBAAY,0BAA0B,EAMtF;AAAC;AAEJ,IAAM,aAAa,CAAC,cAAiD;AAAA,EACnE,IAAI,CAAC;AAAA,IAAW,OAAO;AAAA,EACvB,MAAM,OAAiB,CAAC;AAAA,EACxB,IAAI,OAAkC;AAAA,EACtC,OAAO,MAAM;AAAA,IACX,KAAK,QAAQ,KAAK,EAAE;AAAA,IACpB,OAAO,KAAK;AAAA,EACd;AAAA,EACA,OAAO,KAAK,KAAK,GAAG;AAAA;AAUtB,IAAM,aAAa,IAAI;AAEvB,IAAM,cAAc,CAAC,UAAuC;AAAA,EAC1D,MAAM,WAAW,WAAW,IAAI,KAAK;AAAA,EACrC,IAAI;AAAA,IAAU,OAAO;AAAA,EACrB,MAAM,UAAU,IAAI;AAAA,EACpB,WAAW,IAAI,OAAO,OAAO;AAAA,EAC7B,OAAO;AAAA;AAeF,IAAM,kBAAkB,CAAC,MAAc,OACrC,WAAI,UAAU,GAAG;AAAA,EACtB,MAAM,QAAQ,OAAO;AAAA,EACrB,MAAM,YAAY,OAAO;AAAA,EACzB,MAAM,MAAM,MAAM,WAAW,EAAE;AAAA,EAC/B,MAAM,WAAW,YAAY,KAAK;AAAA,EAClC,MAAM,YAAY,SAAS,IAAI,GAAG;AAAA,EAClC,IAAI,cAAc,WAAW;AAAA,IAC3B,SAAS,IAAI,KAAK,IAAI;AAAA,IACtB;AAAA,EACF;AAAA,EACA,IAAI,cAAc,MAAM;AAAA,IACtB,OAAO,OAAc,WACnB,IAAI,yBAAyB;AAAA,MAC3B,SACE,oCAAoC,iBAAiB,WAAW,SAAS,QACzE,qBAAqB,uBAAuB;AAAA,MAC9C;AAAA,MACA,OAAO,WAAW,SAAS;AAAA,MAC3B;AAAA,MACA,YAAY;AAAA,IACd,CAAC,CACH;AAAA,EACF;AAAA,CACD;;ACtFH;AAGO,IAAM,oBAAoB,CAC/B,kBAEA,IAAI,MAAM,eAAe;AAAA,EACvB,KAAK,CAAC,QAAQ,SAAS,MAAiB;AAAA,IACtC,MAAM,KAAK,KAAK;AAAA,IAChB,IAAI,OAAO,OAAO,UAAU;AAAA,MAE1B,OAAO,QAAQ,MAAM,QAAwC,SAAS,IAAI;AAAA,IAC5E;AAAA,IACA,OAAc,YAAI,UAAU,GAAG;AAAA,MAC7B,OAAO,gBAAgB,cAAc,MAAM,EAAE;AAAA,MAC7C,OAAO,OAAO,QAAQ,MACpB,QACA,SACA,IACF;AAAA,KACD;AAAA;AAEL,CAAC;",
9
+ "debugId": "1AB58BD65E38742164756E2164756E21",
10
+ "names": []
11
+ }
@@ -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 { type FirewallPolicyAttributes, type FirewallPolicyProps } from './firewall-policy-form.ts';
@@ -1 +1 @@
1
- {"version":3,"file":"firewall-policy-drift.d.ts","sourceRoot":"","sources":["../../src/unifi/firewall-policy-drift.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,KAAK,KAAK,QAAQ,MAAM,yCAAyC,CAAC;AAEzE,OAAO,EACL,KAAK,wBAAwB,EAC7B,KAAK,mBAAmB,EAGzB,MAAM,2BAA2B,CAAC;AAoBnC,eAAO,MAAM,OAAO,eACN,wBAAwB,SAC7B,mBAAmB,KACzB,OAAqD,CAAC;AAEzD;;;;GAIG;AACH,eAAO,MAAM,OAAO,SAAU,QAAQ,CAAC,cAAc,SAAS,mBAAmB,sCACnC,CAAC"}
1
+ {"version":3,"file":"firewall-policy-drift.d.ts","sourceRoot":"","sources":["../../src/unifi/firewall-policy-drift.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,KAAK,KAAK,QAAQ,MAAM,yCAAyC,CAAC;AAEzE,OAAO,EACL,KAAK,wBAAwB,EAC7B,KAAK,mBAAmB,EAIzB,MAAM,2BAA2B,CAAC;AAoCnC,eAAO,MAAM,OAAO,eACN,wBAAwB,SAC7B,mBAAmB,KACzB,OAAqD,CAAC;AAEzD;;;;GAIG;AACH,eAAO,MAAM,OAAO,SAAU,QAAQ,CAAC,cAAc,SAAS,mBAAmB,sCACnC,CAAC"}
@@ -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
@@ -48,6 +49,8 @@ import type * as firewall from '@distilled.cloud/unifi-network/firewall';
48
49
  * see `acl-rule-form.ts`'s own header for why a normalizer applied to only one side is a bug
49
50
  * class (MEDIUM-4). */
50
51
  export declare const normalizeConnectionStateFilter: (value: readonly string[] | undefined) => string[] | undefined;
52
+ /** A deep copy with every SET_ARRAY_KEYS array sorted by canonical JSON (duplicates kept). */
53
+ export declare const normalizeSetArrays: <T>(value: T) => T;
51
54
  /** ⚠️ EVERY OPTIONAL FIELD SPELLS OUT `| undefined` — `exactOptionalPropertyTypes`; see
52
55
  * `network-form.ts`'s own header.
53
56
  *
@@ -64,17 +67,17 @@ export interface FirewallPolicyProps {
64
67
  /** Connection states this policy matches — compared as a SET, see the header. */
65
68
  connectionStateFilter?: string[] | undefined;
66
69
  description?: string | undefined;
67
- /** Traffic destination — compound, compared wholesale; see the header's known gap. */
70
+ /** Traffic destination — compound, compared wholesale with set-like arrays normalized; see the header. */
68
71
  destination: firewall.FirewallPolicyDestination;
69
72
  enabled: boolean;
70
- /** IP version + protocol scope — compound, compared wholesale; see the header's known gap. */
73
+ /** IP version + protocol scope — compound, compared wholesale with set-like arrays normalized; see the header. */
71
74
  ipProtocolScope: firewall.FirewallPolicyIPProtocolScope;
72
75
  ipsecFilter?: string | undefined;
73
76
  loggingEnabled: boolean;
74
77
  name: string;
75
- /** Schedule this policy is active on — compound, compared wholesale; see the header's known gap. */
78
+ /** Schedule this policy is active on — compound, compared wholesale with set-like arrays normalized; see the header. */
76
79
  schedule?: firewall.FirewallSchedule | undefined;
77
- /** Traffic source — compound, compared wholesale; see the header's known gap. */
80
+ /** Traffic source — compound, compared wholesale with set-like arrays normalized; see the header. */
78
81
  source: firewall.FirewallPolicySource;
79
82
  }
80
83
  export interface FirewallPolicyAttributes {
@@ -1 +1 @@
1
- {"version":3,"file":"firewall-policy-form.d.ts","sourceRoot":"","sources":["../../src/unifi/firewall-policy-form.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AACH,OAAO,KAAK,KAAK,QAAQ,MAAM,yCAAyC,CAAC;AAIzE;;wBAEwB;AACxB,eAAO,MAAM,8BAA8B,UAClC,SAAS,MAAM,EAAE,GAAG,SAAS,KACnC,MAAM,EAAE,GAAG,SAA2D,CAAC;AAE1E;;;;;;;;6FAQ6F;AAC7F,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,gBAAgB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACtC,MAAM,EAAE,QAAQ,CAAC,oBAAoB,CAAC;IACtC,iFAAiF;IACjF,qBAAqB,CAAC,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC;IAC7C,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC,sFAAsF;IACtF,WAAW,EAAE,QAAQ,CAAC,yBAAyB,CAAC;IAChD,OAAO,EAAE,OAAO,CAAC;IACjB,8FAA8F;IAC9F,eAAe,EAAE,QAAQ,CAAC,6BAA6B,CAAC;IACxD,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC,cAAc,EAAE,OAAO,CAAC;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,oGAAoG;IACpG,QAAQ,CAAC,EAAE,QAAQ,CAAC,gBAAgB,GAAG,SAAS,CAAC;IACjD,iFAAiF;IACjF,MAAM,EAAE,QAAQ,CAAC,oBAAoB,CAAC;CACvC;AAED,MAAM,WAAW,wBAAwB;IACvC,MAAM,EAAE,MAAM,CAAC;IACf,gBAAgB,EAAE,MAAM,CAAC;IACzB,MAAM,EAAE,QAAQ,CAAC,oBAAoB,CAAC;IACtC,qBAAqB,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC;IAC5C,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,WAAW,EAAE,QAAQ,CAAC,yBAAyB,CAAC;IAChD,OAAO,EAAE,OAAO,CAAC;IACjB,oGAAoG;IACpG,KAAK,EAAE,MAAM,CAAC;IACd,eAAe,EAAE,QAAQ,CAAC,6BAA6B,CAAC;IACxD,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,cAAc,EAAE,OAAO,CAAC;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,QAAQ,CAAC,gBAAgB,GAAG,SAAS,CAAC;IAChD,MAAM,EAAE,QAAQ,CAAC,oBAAoB,CAAC;IACtC,6FAA6F;IAC7F,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,eAAO,MAAM,YAAY,SACjB,QAAQ,CAAC,cAAc,SACtB,mBAAmB,KACzB,wBAgBD,CAAC;AAEH;;;;;;;GAOG;AACH,eAAO,MAAM,qBAAqB,SAC1B,QAAQ,CAAC,cAAc,UACrB,MAAM,KACb,mBAcD,CAAC"}
1
+ {"version":3,"file":"firewall-policy-form.d.ts","sourceRoot":"","sources":["../../src/unifi/firewall-policy-form.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CG;AACH,OAAO,KAAK,KAAK,QAAQ,MAAM,yCAAyC,CAAC;AAIzE;;wBAEwB;AACxB,eAAO,MAAM,8BAA8B,UAClC,SAAS,MAAM,EAAE,GAAG,SAAS,KACnC,MAAM,EAAE,GAAG,SAA2D,CAAC;AA6B1E,8FAA8F;AAC9F,eAAO,MAAM,kBAAkB,GAAI,CAAC,SAAS,CAAC,KAAG,CAehD,CAAC;AAEF;;;;;;;;6FAQ6F;AAC7F,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,gBAAgB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACtC,MAAM,EAAE,QAAQ,CAAC,oBAAoB,CAAC;IACtC,iFAAiF;IACjF,qBAAqB,CAAC,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC;IAC7C,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC,0GAA0G;IAC1G,WAAW,EAAE,QAAQ,CAAC,yBAAyB,CAAC;IAChD,OAAO,EAAE,OAAO,CAAC;IACjB,kHAAkH;IAClH,eAAe,EAAE,QAAQ,CAAC,6BAA6B,CAAC;IACxD,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC,cAAc,EAAE,OAAO,CAAC;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,wHAAwH;IACxH,QAAQ,CAAC,EAAE,QAAQ,CAAC,gBAAgB,GAAG,SAAS,CAAC;IACjD,qGAAqG;IACrG,MAAM,EAAE,QAAQ,CAAC,oBAAoB,CAAC;CACvC;AAED,MAAM,WAAW,wBAAwB;IACvC,MAAM,EAAE,MAAM,CAAC;IACf,gBAAgB,EAAE,MAAM,CAAC;IACzB,MAAM,EAAE,QAAQ,CAAC,oBAAoB,CAAC;IACtC,qBAAqB,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC;IAC5C,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,WAAW,EAAE,QAAQ,CAAC,yBAAyB,CAAC;IAChD,OAAO,EAAE,OAAO,CAAC;IACjB,oGAAoG;IACpG,KAAK,EAAE,MAAM,CAAC;IACd,eAAe,EAAE,QAAQ,CAAC,6BAA6B,CAAC;IACxD,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,cAAc,EAAE,OAAO,CAAC;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,QAAQ,CAAC,gBAAgB,GAAG,SAAS,CAAC;IAChD,MAAM,EAAE,QAAQ,CAAC,oBAAoB,CAAC;IACtC,6FAA6F;IAC7F,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,eAAO,MAAM,YAAY,SACjB,QAAQ,CAAC,cAAc,SACtB,mBAAmB,KACzB,wBAgBD,CAAC;AAEH;;;;;;;GAOG;AACH,eAAO,MAAM,qBAAqB,SAC1B,QAAQ,CAAC,cAAc,UACrB,MAAM,KACb,mBAcD,CAAC"}
@@ -318,6 +318,15 @@ var computePatch = (news, olds, patchKeys) => {
318
318
  }
319
319
  return { set, unset };
320
320
  };
321
+ var acceptInto = (olds, news, accepted) => {
322
+ const next = { ...olds };
323
+ for (const field of accepted) {
324
+ const key = String(field);
325
+ if (key in news || key in next)
326
+ next[key] = news[key];
327
+ }
328
+ return next;
329
+ };
321
330
  var updateReconcile = (spec, { news, olds, output, note }) => Effect5.gen(function* () {
322
331
  const identity = spec.describe(news);
323
332
  const live = yield* spec.fetchLive(news);
@@ -334,15 +343,22 @@ var updateReconcile = (spec, { news, olds, output, note }) => Effect5.gen(functi
334
343
  if (previous !== identity) {
335
344
  return yield* new UnifiIdentityChanged({ type: spec.type, identity, previous });
336
345
  }
346
+ const disagrees = new Set(update.driftOf(live, news).map((d) => String(d.field)));
337
347
  const drifted = update.driftOf(live, olds).map((d) => d.field);
338
- if (drifted.length > 0) {
348
+ const conflicts = drifted.filter((field) => disagrees.has(String(field)));
349
+ if (conflicts.length > 0) {
339
350
  return yield* new UnifiLiveDriftedSinceDeploy({
340
351
  type: spec.type,
341
352
  identity,
342
- fields: drifted
353
+ fields: conflicts
343
354
  });
344
355
  }
345
- const patch = computePatch(news, olds, update.patchKeys);
356
+ const accepted = drifted.filter((field) => !disagrees.has(String(field)));
357
+ const baseline = acceptInto(olds, news, accepted);
358
+ if (accepted.length > 0) {
359
+ yield* note(`${spec.type} ${identity}: live edits since the last deploy already match the declaration, accepted: [${accepted.map(String).join(", ")}]`);
360
+ }
361
+ const patch = computePatch(news, baseline, update.patchKeys);
346
362
  if (Object.keys(patch.set).length === 0 && patch.unset.length === 0) {
347
363
  return yield* refuseWrite(spec.type, identity, "update");
348
364
  }
@@ -936,6 +952,30 @@ var writeFirewallPolicyCreate = (props) => firewall2.createFirewallPolicy(firewa
936
952
  // src/unifi/firewall-policy-form.ts
937
953
  var sortedSet4 = (values) => [...new Set(values)].sort();
938
954
  var normalizeConnectionStateFilter = (value) => value == null ? undefined : sortedSet4(value);
955
+ var SET_ARRAY_KEYS = new Set([
956
+ "items",
957
+ "macAddresses",
958
+ "networkIds",
959
+ "applicationIds",
960
+ "regions",
961
+ "vpnServerIds",
962
+ "repeatOnDays",
963
+ "domains",
964
+ "applicationCategoryIds"
965
+ ]);
966
+ var sortKeys = (value) => Array.isArray(value) ? value.map(sortKeys) : value !== null && typeof value === "object" ? Object.fromEntries(Object.keys(value).sort().map((k) => [k, sortKeys(value[k])])) : value;
967
+ var canonical = (value) => JSON.stringify(sortKeys(normalizeSetArrays(value)));
968
+ var normalizeSetArrays = (value) => {
969
+ if (Array.isArray(value))
970
+ return value.map((v) => normalizeSetArrays(v));
971
+ if (value === null || typeof value !== "object")
972
+ return value;
973
+ const out = {};
974
+ for (const [key, inner] of Object.entries(value)) {
975
+ out[key] = SET_ARRAY_KEYS.has(key) && Array.isArray(inner) ? [...inner].map((v) => normalizeSetArrays(v)).sort((x, y) => canonical(x) < canonical(y) ? -1 : canonical(x) > canonical(y) ? 1 : 0) : normalizeSetArrays(inner);
976
+ }
977
+ return out;
978
+ };
939
979
  var attributesOf7 = (live, props) => ({
940
980
  siteId: props.siteId,
941
981
  firewallPolicyId: live.id,
@@ -978,14 +1018,30 @@ var fieldDrift7 = makeDriftOf([
978
1018
  declared: (p) => normalizeConnectionStateFilter(p.connectionStateFilter)
979
1019
  },
980
1020
  { field: "description", live: (a) => a.description, declared: (p) => p.description },
981
- { field: "destination", live: (a) => a.destination, declared: (p) => p.destination },
1021
+ {
1022
+ field: "destination",
1023
+ live: (a) => normalizeSetArrays(a.destination),
1024
+ declared: (p) => normalizeSetArrays(p.destination)
1025
+ },
982
1026
  { field: "enabled", live: (a) => a.enabled, declared: (p) => p.enabled },
983
- { field: "ipProtocolScope", live: (a) => a.ipProtocolScope, declared: (p) => p.ipProtocolScope },
1027
+ {
1028
+ field: "ipProtocolScope",
1029
+ live: (a) => normalizeSetArrays(a.ipProtocolScope),
1030
+ declared: (p) => normalizeSetArrays(p.ipProtocolScope)
1031
+ },
984
1032
  { field: "ipsecFilter", live: (a) => a.ipsecFilter, declared: (p) => p.ipsecFilter },
985
1033
  { field: "loggingEnabled", live: (a) => a.loggingEnabled, declared: (p) => p.loggingEnabled },
986
1034
  { field: "name", live: (a) => a.name, declared: (p) => p.name },
987
- { field: "schedule", live: (a) => a.schedule, declared: (p) => p.schedule },
988
- { field: "source", live: (a) => a.source, declared: (p) => p.source }
1035
+ {
1036
+ field: "schedule",
1037
+ live: (a) => normalizeSetArrays(a.schedule),
1038
+ declared: (p) => normalizeSetArrays(p.schedule)
1039
+ },
1040
+ {
1041
+ field: "source",
1042
+ live: (a) => normalizeSetArrays(a.source),
1043
+ declared: (p) => normalizeSetArrays(p.source)
1044
+ }
989
1045
  ]);
990
1046
  var matches7 = (attributes, props) => fieldDrift7(attributes, props).length === 0;
991
1047
  var driftOf7 = (live, props) => fieldDrift7(attributesOf7(live, props), props);
@@ -1195,5 +1251,5 @@ export {
1195
1251
  driftOf6 as wifiBroadcastDriftOf
1196
1252
  };
1197
1253
 
1198
- //# debugId=F58E0BCA65985B3464756E2164756E21
1254
+ //# debugId=D14D4404F58A5F1264756E2164756E21
1199
1255
  //# sourceMappingURL=index.js.map