@takodotid/circuit 0.43.0 → 0.43.1

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.
package/dist/cli/index.js CHANGED
@@ -1611,7 +1611,9 @@ class FortiOsPlan {
1611
1611
  "Fortinet_SSL_ED25519",
1612
1612
  "Fortinet_SSL_ED448"
1613
1613
  ];
1614
- static ORDERED = { ordered: true };
1614
+ static BY_COMMENTS = { keys: [["comments"]] };
1615
+ static ORDERED = { ordered: true, numbered: FortiOsPlan.BY_COMMENTS };
1616
+ static ROUTES = { numbered: { keys: [["comment"]] }, distinct: true };
1615
1617
  static TABLES = {
1616
1618
  "system interface": {
1617
1619
  factory: ["modem", "ssl.root"],
@@ -1636,8 +1638,11 @@ class FortiOsPlan {
1636
1638
  "firewall local-in-policy": FortiOsPlan.ORDERED,
1637
1639
  "firewall local-in-policy6": FortiOsPlan.ORDERED,
1638
1640
  "firewall central-snat-map": FortiOsPlan.ORDERED,
1639
- "router static": { distinct: true },
1640
- "router static6": { distinct: true }
1641
+ "firewall acl": { numbered: FortiOsPlan.BY_COMMENTS },
1642
+ "firewall acl6": { numbered: FortiOsPlan.BY_COMMENTS },
1643
+ "firewall vip": { numbered: { keys: [["comment"], ["extintf", "extip", "protocol", "extport"]], prefix: "dnat-" } },
1644
+ "router static": FortiOsPlan.ROUTES,
1645
+ "router static6": FortiOsPlan.ROUTES
1641
1646
  };
1642
1647
  static IGNORED = new Set(["uuid", "snmp-index"]);
1643
1648
  static IGNORED_IN = {
@@ -1798,7 +1803,7 @@ class FortiOsPlan {
1798
1803
  }
1799
1804
  for (const [header, child] of want.blocks)
1800
1805
  nested(header, child, have.blocks.get(header) ?? new FortiOsBlock);
1801
- const entries = table?.byContent ? FortiOsPlan.byContent(path, want, have) : table?.distinct ? FortiOsPlan.distinctById(path, want, have, table, context) : FortiOsPlan.byId(path, want, have, table, context);
1806
+ const entries = table?.byContent ? FortiOsPlan.byContent(path, want, have) : table?.numbered ? FortiOsPlan.numbered(path, want, have, table, table.numbered, context) : FortiOsPlan.byId(path, want, have, table, context);
1802
1807
  send.push(...entries.send);
1803
1808
  show.push(...entries.show);
1804
1809
  if (top) {
@@ -1840,55 +1845,100 @@ class FortiOsPlan {
1840
1845
  removals.push(`delete ${entry.id}`);
1841
1846
  removed.push(...FortiOsPlan.shown("-", [...path, FortiOsTree.bare(entry.id)], entry.block));
1842
1847
  }
1843
- if (table?.ordered) {
1844
- const moved = FortiOsPlan.moves(want, have);
1845
- send.push(...moved);
1846
- show.push(...moved.map((line) => `+ ${FortiOsPlan.labelOf(path)}${line}`));
1847
- }
1848
1848
  return { send, show, removals, removed };
1849
1849
  }
1850
- static distinctById(path, want, have, table, context) {
1851
- const deletes = [];
1852
- const creates = [];
1850
+ static numbered(path, want, have, table, numbered, context) {
1851
+ const send = [];
1853
1852
  const show = [];
1853
+ const pattern = new RegExp(`^${numbered.prefix ?? ""}(\\d+)$`);
1854
+ const numberOf = (entry) => Number(pattern.exec(FortiOsTree.bare(entry.id))?.[1] ?? NaN);
1855
+ const held = [...have.entries.values()].filter((entry) => !FortiOsPlan.isFactory(table, entry));
1856
+ const kept = new Map;
1857
+ const unmatched = new Set(held.filter((entry) => Number.isInteger(numberOf(entry))));
1858
+ const keep = (key, old) => {
1859
+ kept.set(key, old);
1860
+ unmatched.delete(old);
1861
+ };
1862
+ const alike = Map.groupBy(unmatched, (old) => FortiOsPlan.signature(old.block, context.section));
1863
+ const same = (entry, old) => !FortiOsPlan.compare([], entry.block, old.block, undefined, false, context).send.length;
1854
1864
  for (const [key, entry] of want.entries) {
1855
- const old = have.entries.get(key);
1856
- const where = [...path, FortiOsTree.bare(entry.id)];
1857
- if (old) {
1858
- if (!FortiOsPlan.compare(where, entry.block, old.block, undefined, false, context).send.length)
1859
- continue;
1860
- deletes.push(`delete ${old.id}`);
1861
- show.push(...FortiOsPlan.shown("-", where, old.block));
1865
+ const candidates = alike.get(FortiOsPlan.signature(entry.block, context.section)) ?? [];
1866
+ const index = candidates.findIndex((old) => same(entry, old));
1867
+ if (index >= 0)
1868
+ keep(key, candidates.splice(index, 1)[0]);
1869
+ }
1870
+ for (const settings of numbered.keys) {
1871
+ const valueOf = (entry) => {
1872
+ const parts = settings.map((setting) => FortiOsPlan.canonical(setting, entry.block.settings.get(setting) ?? ""));
1873
+ return parts.some(Boolean) ? parts.join(`
1874
+ `) : undefined;
1875
+ };
1876
+ const changed = Map.groupBy([...want.entries].filter(([key]) => !kept.has(key)), ([, entry]) => valueOf(entry));
1877
+ const left = Map.groupBy(unmatched, valueOf);
1878
+ for (const [value, entries] of changed) {
1879
+ const old = left.get(value);
1880
+ if (value !== undefined && entries.length === 1 && old?.length === 1)
1881
+ keep(entries[0][0], old[0]);
1862
1882
  }
1863
- creates.push(...FortiOsPlan.creation(entry, table));
1864
- show.push(...FortiOsPlan.shown("+", where, entry.block));
1865
1883
  }
1866
- for (const [key, entry] of have.entries) {
1867
- if (want.entries.has(key) || FortiOsPlan.isFactory(table, entry))
1884
+ let next = Math.max(0, ...[...have.entries.values()].map(numberOf).filter(Number.isInteger)) + 1;
1885
+ const wanted = [];
1886
+ const replaced = [];
1887
+ for (const [key, entry] of want.entries) {
1888
+ const old = kept.get(key);
1889
+ if (!old) {
1890
+ const name = `${numbered.prefix ?? ""}${next++}`;
1891
+ const id = numbered.prefix ? FortiOsTree.quote(name) : name;
1892
+ wanted.push(id);
1893
+ send.push(...FortiOsPlan.creation(entry, table, id));
1894
+ show.push(...FortiOsPlan.shown("+", [...path, name], entry.block));
1868
1895
  continue;
1869
- deletes.push(`delete ${entry.id}`);
1870
- show.push(...FortiOsPlan.shown("-", [...path, FortiOsTree.bare(entry.id)], entry.block));
1896
+ }
1897
+ wanted.push(old.id);
1898
+ const where = [...path, FortiOsTree.bare(old.id)];
1899
+ const diff = FortiOsPlan.compare(where, entry.block, old.block, undefined, false, context);
1900
+ if (!diff.send.length)
1901
+ continue;
1902
+ if (table.distinct) {
1903
+ replaced.push(`delete ${old.id}`);
1904
+ send.push(...FortiOsPlan.creation(entry, table, old.id));
1905
+ show.push(...FortiOsPlan.shown("-", where, old.block), ...FortiOsPlan.shown("+", where, entry.block));
1906
+ continue;
1907
+ }
1908
+ send.push(`edit ${old.id}`, ...diff.send, "next");
1909
+ show.push(...diff.show);
1871
1910
  }
1872
- return { send: [...deletes, ...creates], show, removals: [], removed: [] };
1911
+ const keptEntries = new Set(kept.values());
1912
+ if (table.ordered) {
1913
+ const order = held.filter((entry) => keptEntries.has(entry)).map((entry) => entry.id);
1914
+ const moved = FortiOsPlan.moves(wanted, order);
1915
+ send.push(...moved);
1916
+ show.push(...moved.map((line) => `+ ${FortiOsPlan.labelOf(path)}${line}`));
1917
+ }
1918
+ const gone = held.filter((entry) => !keptEntries.has(entry));
1919
+ const removals = gone.map((entry) => `delete ${entry.id}`);
1920
+ const removed = gone.flatMap((entry) => FortiOsPlan.shown("-", [...path, FortiOsTree.bare(entry.id)], entry.block));
1921
+ if (table.distinct)
1922
+ return { send: [...replaced, ...removals, ...send], show: [...removed, ...show], removals: [], removed: [] };
1923
+ return { send, show, removals, removed };
1873
1924
  }
1874
- static moves(want, have) {
1875
- const wanted = [...want.entries.keys()];
1876
- const order = [...have.entries.keys()].filter((key) => want.entries.has(key));
1877
- order.push(...wanted.filter((key) => !have.entries.has(key)));
1925
+ static moves(wanted, held) {
1926
+ const order = [...held, ...wanted.filter((id) => !held.includes(id))];
1878
1927
  const send = [];
1879
- const idOf = (key) => want.entries.get(key).id;
1880
1928
  for (let index = 0;index < wanted.length; index++) {
1881
- if (order[index] === wanted[index])
1929
+ const id = wanted[index];
1930
+ if (order[index] === id)
1882
1931
  continue;
1883
- const key = wanted[index];
1884
- send.push(index === 0 ? `move ${idOf(key)} before ${idOf(order[0])}` : `move ${idOf(key)} after ${idOf(wanted[index - 1])}`);
1885
- order.splice(order.indexOf(key), 1);
1886
- order.splice(index, 0, key);
1932
+ send.push(index === 0 ? `move ${id} before ${order[0]}` : `move ${id} after ${wanted[index - 1]}`);
1933
+ order.splice(order.indexOf(id), 1);
1934
+ order.splice(index, 0, id);
1887
1935
  }
1888
1936
  return send;
1889
1937
  }
1890
- static signature(block) {
1891
- return [...block.settings].map(([key, value]) => `${key} ${FortiOsPlan.canonical(key, value)}`).sort().join(`
1938
+ static signature(block, section = "") {
1939
+ const defaults = FortiOsPlan.DEFAULTS[section] ?? {};
1940
+ const isDefault = (key, value) => defaults[key] !== undefined && FortiOsPlan.canonical(key, defaults[key]) === FortiOsPlan.canonical(key, value);
1941
+ return [...block.settings].filter(([key, value]) => !isDefault(key, value)).map(([key, value]) => `${key} ${FortiOsPlan.canonical(key, value)}`).sort().join(`
1892
1942
  `);
1893
1943
  }
1894
1944
  static byContent(path, want, have) {
@@ -5,7 +5,11 @@ export declare class FortiOsPlan {
5
5
  private static readonly FACTORY_SERVICES;
6
6
  /** The device's own certificates, which the factory makes for each device. */
7
7
  private static readonly FACTORY_CERTIFICATES;
8
+ /** An entry known by what it holds, or else by its description. */
9
+ private static readonly BY_COMMENTS;
8
10
  private static readonly ORDERED;
11
+ /** A static route known by what it holds, or else by its description. Many routes share a destination, so that is no key. */
12
+ private static readonly ROUTES;
9
13
  /** Tables whose entries need more than a plain comparison, by section. */
10
14
  private static readonly TABLES;
11
15
  /** Settings the device keeps for itself: an object's UUID and an interface's SNMP index. */
@@ -61,13 +65,19 @@ export declare class FortiOsPlan {
61
65
  * What goes is undone before what comes is set, since a setting can hide another: `send-deny-packet` exists only while a policy denies. A nested list that goes is deleted before the settings, since one can hide the list and keep its entries hidden, such as `secondary-IP`; NTP refuses to delete its last server while it synchronises, so `ntpsync` goes before its list.
62
66
  */
63
67
  private static compare;
64
- /** Entries matched by name: new ones created, changed ones edited, gone ones deleted, and an ordered table put in order. */
68
+ /** Entries matched by name: new ones created, changed ones edited, gone ones deleted. */
65
69
  private static byId;
66
- /** Entries matched by name in a table that refuses two alike: every entry that changes or goes is deleted before any is created, so none is ever briefly the same as another. */
67
- private static distinctById;
68
- /** The `move` commands that put a table's entries in the render's order. Kept entries hold their place on the device and new ones are added at the end. */
70
+ /**
71
+ * Entries the render numbers by their place, while on the device each keeps the number it was created with, so they are matched by what they are.
72
+ *
73
+ * An entry that holds the same as one on the device is kept. One that changes is found by the first of the table's keys that no other entry left to match shares, such as its description, and edited under its own number. Any other is new and takes a number the device does not use; in a table the device keeps in order, it is created at the end and moved into place. An entry on the device that nothing matched is deleted last, so every entry the render keeps stays on the device through the whole change.
74
+ *
75
+ * In a `distinct` table, an entry that changes is deleted and created again under its own number, and every deletion comes before any creation.
76
+ */
77
+ private static numbered;
78
+ /** The `move` commands that put a table's entries in the render's order, by the device's numbers: `wanted` in the render's order, `held` the kept ones in the device's. Kept entries hold their place on the device and new ones are added at the end. */
69
79
  private static moves;
70
- /** An entry's settings in the form two entries compare in, whatever their numbers. */
80
+ /** An entry's settings in the form two entries compare in, whatever their numbers. A setting at its section's default is left out, as the device does not print it. */
71
81
  private static signature;
72
82
  /** Entries matched by what they hold. One the device holds and the render does not is deleted; one the render adds takes a number the device does not use. */
73
83
  private static byContent;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takodotid/circuit",
3
- "version": "0.43.0",
3
+ "version": "0.43.1",
4
4
  "description": "Declarative, vendor-neutral network configuration: describe each device in TypeScript, and Circuit makes the device match. Alpha.",
5
5
  "keywords": [
6
6
  "network",
@@ -12,13 +12,17 @@ type Table = {
12
12
  factory?: readonly string[];
13
13
  /** The device keeps the entries in an order of their own, which `move` changes. */
14
14
  ordered?: true;
15
+ /**
16
+ * The render numbers the entries by their place, but on the device each keeps the number it was created with. An entry is known by what it holds, or else by the settings of the first of `keys` that only it holds, such as its description. A new entry takes a number the device does not use, after `prefix` when the render names entries so.
17
+ */
18
+ numbered?: { keys: readonly (readonly string[])[]; prefix?: string };
15
19
  /** An entry is known by what it holds, not by its number. */
16
20
  byContent?: true;
17
21
  /** Settings the device gives an entry it creates. They are unset when the render does not hold them. */
18
22
  created?: (entry: FortiOsBlock) => readonly string[];
19
23
  /** Settings an entry keeps for life. A change deletes the entry and creates it again. */
20
24
  fixed?: readonly string[];
21
- /** The device refuses an entry that holds the same as another, `Duplicated entry`, even for a moment while entries are renumbered. */
25
+ /** Every entry that changes or goes is deleted before any is created. The device refuses a static route the same as another, `Duplicated entry`, even for a moment. */
22
26
  distinct?: true;
23
27
  };
24
28
 
@@ -154,7 +158,13 @@ export class FortiOsPlan {
154
158
  "Fortinet_SSL_ED448",
155
159
  ];
156
160
 
157
- private static readonly ORDERED: Table = { ordered: true };
161
+ /** An entry known by what it holds, or else by its description. */
162
+ private static readonly BY_COMMENTS: Table["numbered"] = { keys: [["comments"]] };
163
+
164
+ private static readonly ORDERED: Table = { ordered: true, numbered: FortiOsPlan.BY_COMMENTS };
165
+
166
+ /** A static route known by what it holds, or else by its description. Many routes share a destination, so that is no key. */
167
+ private static readonly ROUTES: Table = { numbered: { keys: [["comment"]] }, distinct: true };
158
168
 
159
169
  /** Tables whose entries need more than a plain comparison, by section. */
160
170
  private static readonly TABLES: Record<string, Table> = {
@@ -180,8 +190,12 @@ export class FortiOsPlan {
180
190
  "firewall local-in-policy": FortiOsPlan.ORDERED,
181
191
  "firewall local-in-policy6": FortiOsPlan.ORDERED,
182
192
  "firewall central-snat-map": FortiOsPlan.ORDERED,
183
- "router static": { distinct: true },
184
- "router static6": { distinct: true },
193
+ "firewall acl": { numbered: FortiOsPlan.BY_COMMENTS },
194
+ "firewall acl6": { numbered: FortiOsPlan.BY_COMMENTS },
195
+ // A virtual IP that changes is also known by the address it answers on.
196
+ "firewall vip": { numbered: { keys: [["comment"], ["extintf", "extip", "protocol", "extport"]], prefix: "dnat-" } },
197
+ "router static": FortiOsPlan.ROUTES,
198
+ "router static6": FortiOsPlan.ROUTES,
185
199
  };
186
200
 
187
201
  /** Settings the device keeps for itself: an object's UUID and an interface's SNMP index. */
@@ -399,8 +413,8 @@ export class FortiOsPlan {
399
413
 
400
414
  const entries = table?.byContent
401
415
  ? FortiOsPlan.byContent(path, want, have)
402
- : table?.distinct
403
- ? FortiOsPlan.distinctById(path, want, have, table, context)
416
+ : table?.numbered
417
+ ? FortiOsPlan.numbered(path, want, have, table, table.numbered, context)
404
418
  : FortiOsPlan.byId(path, want, have, table, context);
405
419
  send.push(...entries.send);
406
420
  show.push(...entries.show);
@@ -416,7 +430,7 @@ export class FortiOsPlan {
416
430
  return { send, show, removals, removed };
417
431
  }
418
432
 
419
- /** Entries matched by name: new ones created, changed ones edited, gone ones deleted, and an ordered table put in order. */
433
+ /** Entries matched by name: new ones created, changed ones edited, gone ones deleted. */
420
434
  private static byId(path: string[], want: FortiOsBlock, have: FortiOsBlock, table: Table | undefined, context: Context): Diff {
421
435
  const send: string[] = [];
422
436
  const show: string[] = [];
@@ -456,70 +470,144 @@ export class FortiOsPlan {
456
470
  removed.push(...FortiOsPlan.shown("-", [...path, FortiOsTree.bare(entry.id)], entry.block));
457
471
  }
458
472
 
459
- if (table?.ordered) {
460
- const moved = FortiOsPlan.moves(want, have);
461
- send.push(...moved);
462
- show.push(...moved.map((line) => `+ ${FortiOsPlan.labelOf(path)}${line}`));
463
- }
464
-
465
473
  return { send, show, removals, removed };
466
474
  }
467
475
 
468
- /** Entries matched by name in a table that refuses two alike: every entry that changes or goes is deleted before any is created, so none is ever briefly the same as another. */
469
- private static distinctById(path: string[], want: FortiOsBlock, have: FortiOsBlock, table: Table, context: Context): Diff {
470
- const deletes: string[] = [];
471
- const creates: string[] = [];
476
+ /**
477
+ * Entries the render numbers by their place, while on the device each keeps the number it was created with, so they are matched by what they are.
478
+ *
479
+ * An entry that holds the same as one on the device is kept. One that changes is found by the first of the table's keys that no other entry left to match shares, such as its description, and edited under its own number. Any other is new and takes a number the device does not use; in a table the device keeps in order, it is created at the end and moved into place. An entry on the device that nothing matched is deleted last, so every entry the render keeps stays on the device through the whole change.
480
+ *
481
+ * In a `distinct` table, an entry that changes is deleted and created again under its own number, and every deletion comes before any creation.
482
+ */
483
+ private static numbered(
484
+ path: string[],
485
+ want: FortiOsBlock,
486
+ have: FortiOsBlock,
487
+ table: Table,
488
+ numbered: NonNullable<Table["numbered"]>,
489
+ context: Context
490
+ ): Diff {
491
+ const send: string[] = [];
472
492
  const show: string[] = [];
473
493
 
494
+ // The number in a name the render gives, such as 7 in `dnat-7`. An entry named otherwise is never kept.
495
+ const pattern = new RegExp(`^${numbered.prefix ?? ""}(\\d+)$`);
496
+ const numberOf = (entry: Entry) => Number(pattern.exec(FortiOsTree.bare(entry.id))?.[1] ?? NaN);
497
+
498
+ const held = [...have.entries.values()].filter((entry) => !FortiOsPlan.isFactory(table, entry));
499
+ const kept = new Map<string, Entry>();
500
+ const unmatched = new Set(held.filter((entry) => Number.isInteger(numberOf(entry))));
501
+
502
+ const keep = (key: string, old: Entry) => {
503
+ kept.set(key, old);
504
+ unmatched.delete(old);
505
+ };
506
+
507
+ // Only entries with the same signature can hold the same, so an entry is compared with those alone.
508
+ const alike = Map.groupBy(unmatched, (old) => FortiOsPlan.signature(old.block, context.section));
509
+ const same = (entry: Entry, old: Entry) => !FortiOsPlan.compare([], entry.block, old.block, undefined, false, context).send.length;
510
+
474
511
  for (const [key, entry] of want.entries) {
475
- const old = have.entries.get(key);
476
- const where = [...path, FortiOsTree.bare(entry.id)];
512
+ const candidates = alike.get(FortiOsPlan.signature(entry.block, context.section)) ?? [];
513
+ const index = candidates.findIndex((old) => same(entry, old));
514
+ if (index >= 0) keep(key, candidates.splice(index, 1)[0]!);
515
+ }
516
+
517
+ for (const settings of numbered.keys) {
518
+ const valueOf = (entry: Entry) => {
519
+ const parts = settings.map((setting) => FortiOsPlan.canonical(setting, entry.block.settings.get(setting) ?? ""));
520
+ return parts.some(Boolean) ? parts.join("\n") : undefined;
521
+ };
522
+
523
+ const changed = Map.groupBy(
524
+ [...want.entries].filter(([key]) => !kept.has(key)),
525
+ ([, entry]) => valueOf(entry)
526
+ );
527
+ const left = Map.groupBy(unmatched, valueOf);
528
+
529
+ for (const [value, entries] of changed) {
530
+ const old = left.get(value);
531
+ if (value !== undefined && entries.length === 1 && old?.length === 1) keep(entries[0]![0], old[0]!);
532
+ }
533
+ }
534
+
535
+ // A number no entry on the device holds, not even one deleted at the end.
536
+ let next = Math.max(0, ...[...have.entries.values()].map(numberOf).filter(Number.isInteger)) + 1;
537
+ const wanted: string[] = [];
538
+ const replaced: string[] = [];
539
+
540
+ for (const [key, entry] of want.entries) {
541
+ const old = kept.get(key);
542
+
543
+ if (!old) {
544
+ const name = `${numbered.prefix ?? ""}${next++}`;
545
+ const id = numbered.prefix ? FortiOsTree.quote(name) : name;
546
+ wanted.push(id);
547
+ send.push(...FortiOsPlan.creation(entry, table, id));
548
+ show.push(...FortiOsPlan.shown("+", [...path, name], entry.block));
549
+ continue;
550
+ }
551
+
552
+ wanted.push(old.id);
477
553
 
478
- if (old) {
479
- if (!FortiOsPlan.compare(where, entry.block, old.block, undefined, false, context).send.length) continue;
554
+ const where = [...path, FortiOsTree.bare(old.id)];
555
+ const diff = FortiOsPlan.compare(where, entry.block, old.block, undefined, false, context);
556
+ if (!diff.send.length) continue;
480
557
 
481
- deletes.push(`delete ${old.id}`);
482
- show.push(...FortiOsPlan.shown("-", where, old.block));
558
+ if (table.distinct) {
559
+ replaced.push(`delete ${old.id}`);
560
+ send.push(...FortiOsPlan.creation(entry, table, old.id));
561
+ show.push(...FortiOsPlan.shown("-", where, old.block), ...FortiOsPlan.shown("+", where, entry.block));
562
+ continue;
483
563
  }
484
564
 
485
- creates.push(...FortiOsPlan.creation(entry, table));
486
- show.push(...FortiOsPlan.shown("+", where, entry.block));
565
+ send.push(`edit ${old.id}`, ...diff.send, "next");
566
+ show.push(...diff.show);
487
567
  }
488
568
 
489
- for (const [key, entry] of have.entries) {
490
- if (want.entries.has(key) || FortiOsPlan.isFactory(table, entry)) continue;
569
+ const keptEntries = new Set(kept.values());
491
570
 
492
- deletes.push(`delete ${entry.id}`);
493
- show.push(...FortiOsPlan.shown("-", [...path, FortiOsTree.bare(entry.id)], entry.block));
571
+ if (table.ordered) {
572
+ const order = held.filter((entry) => keptEntries.has(entry)).map((entry) => entry.id);
573
+ const moved = FortiOsPlan.moves(wanted, order);
574
+ send.push(...moved);
575
+ show.push(...moved.map((line) => `+ ${FortiOsPlan.labelOf(path)}${line}`));
494
576
  }
495
577
 
496
- return { send: [...deletes, ...creates], show, removals: [], removed: [] };
497
- }
578
+ const gone = held.filter((entry) => !keptEntries.has(entry));
579
+ const removals = gone.map((entry) => `delete ${entry.id}`);
580
+ const removed = gone.flatMap((entry) => FortiOsPlan.shown("-", [...path, FortiOsTree.bare(entry.id)], entry.block));
498
581
 
499
- /** The `move` commands that put a table's entries in the render's order. Kept entries hold their place on the device and new ones are added at the end. */
500
- private static moves(want: FortiOsBlock, have: FortiOsBlock): string[] {
501
- const wanted = [...want.entries.keys()];
502
- const order = [...have.entries.keys()].filter((key) => want.entries.has(key));
503
- order.push(...wanted.filter((key) => !have.entries.has(key)));
582
+ if (table.distinct) return { send: [...replaced, ...removals, ...send], show: [...removed, ...show], removals: [], removed: [] };
583
+ return { send, show, removals, removed };
584
+ }
504
585
 
586
+ /** The `move` commands that put a table's entries in the render's order, by the device's numbers: `wanted` in the render's order, `held` the kept ones in the device's. Kept entries hold their place on the device and new ones are added at the end. */
587
+ private static moves(wanted: readonly string[], held: readonly string[]): string[] {
588
+ const order = [...held, ...wanted.filter((id) => !held.includes(id))];
505
589
  const send: string[] = [];
506
- const idOf = (key: string) => want.entries.get(key)!.id;
507
590
 
508
591
  for (let index = 0; index < wanted.length; index++) {
509
- if (order[index] === wanted[index]) continue;
592
+ const id = wanted[index]!;
593
+ if (order[index] === id) continue;
510
594
 
511
- const key = wanted[index]!;
512
- send.push(index === 0 ? `move ${idOf(key)} before ${idOf(order[0]!)}` : `move ${idOf(key)} after ${idOf(wanted[index - 1]!)}`);
513
- order.splice(order.indexOf(key), 1);
514
- order.splice(index, 0, key);
595
+ send.push(index === 0 ? `move ${id} before ${order[0]}` : `move ${id} after ${wanted[index - 1]}`);
596
+ order.splice(order.indexOf(id), 1);
597
+ order.splice(index, 0, id);
515
598
  }
516
599
 
517
600
  return send;
518
601
  }
519
602
 
520
- /** An entry's settings in the form two entries compare in, whatever their numbers. */
521
- private static signature(block: FortiOsBlock): string {
603
+ /** An entry's settings in the form two entries compare in, whatever their numbers. A setting at its section's default is left out, as the device does not print it. */
604
+ private static signature(block: FortiOsBlock, section = ""): string {
605
+ const defaults = FortiOsPlan.DEFAULTS[section] ?? {};
606
+ const isDefault = (key: string, value: string) =>
607
+ defaults[key] !== undefined && FortiOsPlan.canonical(key, defaults[key]) === FortiOsPlan.canonical(key, value);
608
+
522
609
  return [...block.settings]
610
+ .filter(([key, value]) => !isDefault(key, value))
523
611
  .map(([key, value]) => `${key} ${FortiOsPlan.canonical(key, value)}`)
524
612
  .sort()
525
613
  .join("\n");