analogger 2.8.1 → 2.9.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.
package/README.md CHANGED
@@ -1436,6 +1436,190 @@ anaLogger.log("lid: USR_LOGIN, color: purple", "User logged in");
1436
1436
  ---
1437
1437
 
1438
1438
 
1439
+ ---
1440
+
1441
+ ## Snapshots
1442
+
1443
+ Snapshots let you capture the set of log IDs (lids) seen at a specific point in time, persist them, and later compare two captures side by side. This is useful for regression checks — verifying that the same lids fire in two different runs, or that a code change does not add or remove unexpected log calls.
1444
+
1445
+ Snapshots work in both **Node.js** (persisted as JSON files under `os.tmpdir()/analogger-snapshots/`) and the **browser** (persisted in `localStorage`).
1446
+
1447
+ > `keepLogHistory()` must be called before any logging starts, as snapshots read from the in-memory log history.
1448
+
1449
+ ---
1450
+
1451
+ ### startSnapshotProcess()
1452
+
1453
+ Marks the start of a snapshot window. Any subsequent `takeSnapshot()` call will only capture lids logged **from this point forward**, ignoring everything already in the history. Calling it again moves the window start to the current position.
1454
+
1455
+ ```javascript
1456
+ anaLogger.keepLogHistory();
1457
+
1458
+ anaLogger.log({ lid: "BOOT_001" }, "application boot"); // excluded
1459
+ anaLogger.log({ lid: "BOOT_002" }, "config loaded"); // excluded
1460
+
1461
+ anaLogger.startSnapshotProcess();
1462
+
1463
+ anaLogger.log({ lid: "WEB_001" }, "request received"); // included
1464
+ anaLogger.log({ lid: "WEB_002" }, "response sent"); // included
1465
+
1466
+ anaLogger.takeSnapshot("SNAP01"); // captures only WEB_001 and WEB_002
1467
+ ```
1468
+
1469
+ If `startSnapshotProcess()` is never called, `takeSnapshot()` captures the entire history.
1470
+
1471
+ <br/>
1472
+
1473
+ ---
1474
+
1475
+ ### takeSnapshot()
1476
+
1477
+ Captures all lids logged since the last `startSnapshotProcess()` (or since session start) and persists the snapshot under a unique ID.
1478
+
1479
+ Returns the array of captured entries so you can inspect or assert on them programmatically.
1480
+
1481
+ ```javascript
1482
+ takeSnapshot(snapshotID, options?)
1483
+ ```
1484
+
1485
+ | Parameter | Type | Default | Description |
1486
+ |---|---|---|---|
1487
+ | `snapshotID` | `string` | — | Unique label for this snapshot, e.g. `"SNAP01"` |
1488
+ | `options.messages` | `boolean` | `true` | Store the log message alongside each lid |
1489
+ | `options.context` | `boolean` | `true` | Store the full context object alongside each lid |
1490
+
1491
+ ```javascript
1492
+ anaLogger.keepLogHistory();
1493
+ anaLogger.log({ lid: "API_001" }, "initialised");
1494
+ anaLogger.log({ lid: "WEB_002" }, "request in");
1495
+
1496
+ // Capture lids + messages + context (defaults)
1497
+ const entries = anaLogger.takeSnapshot("SNAP01");
1498
+ // entries = [{ lid: "API_001", message: "initialised", context: {...} }, ...]
1499
+
1500
+ // Capture lids only (lighter footprint)
1501
+ anaLogger.takeSnapshot("SNAP02", { messages: false, context: false });
1502
+ ```
1503
+
1504
+ In Node.js the snapshot is written to a file and its path is logged:
1505
+
1506
+ ```
1507
+ [AnaLogger] Snapshot "SNAP01" saved - 2 lid(s) captured.
1508
+ [AnaLogger] Snapshot persisted to: /tmp/analogger-snapshots/analogger_snapshot_default_SNAP01.json
1509
+ ```
1510
+
1511
+ Because the file lives in `os.tmpdir()`, snapshots persist across Node process restarts — you can take `SNAP01` in one run and compare it in a later one.
1512
+
1513
+ <br/>
1514
+
1515
+ ---
1516
+
1517
+ ### compareSnapshots()
1518
+
1519
+ Loads two previously saved snapshots and prints a side-by-side diff table using `console.table`, which gives automatic column alignment in both Node.js terminals and browser devtools.
1520
+
1521
+ ```javascript
1522
+ compareSnapshots(snapshotID1, snapshotID2, displayOpts?)
1523
+ ```
1524
+
1525
+ | Parameter | Type | Default | Description |
1526
+ |---|---|---|---|
1527
+ | `snapshotID1` | `string` | — | ID of the first snapshot |
1528
+ | `snapshotID2` | `string` | — | ID of the second snapshot |
1529
+ | `displayOpts[0].messages` | `boolean` | `false` | Show a message column for snapshot 1 |
1530
+ | `displayOpts[1].messages` | `boolean` | `false` | Show a message column for snapshot 2 |
1531
+
1532
+ Returns `{ onlyInSnap1, onlyInSnap2, inBoth }` for programmatic use, or `null` if either snapshot is not found.
1533
+
1534
+ #### Basic comparison (lids only)
1535
+
1536
+ ```javascript
1537
+ anaLogger.compareSnapshots("SNAP01", "SNAP02");
1538
+ ```
1539
+
1540
+ ```
1541
+ Snapshot comparison: "SNAP01" vs "SNAP02"
1542
+ SNAP01 captured at 09:04:28 - 3 lid(s)
1543
+ SNAP02 captured at 09:04:31 - 4 lid(s)
1544
+
1545
+ ┌─────────┬─────────┬─────────┐
1546
+ │ (index) │ SNAP01 │ SNAP02 │
1547
+ ├─────────┼─────────┼─────────┤
1548
+ │ 0 │ API_001 │ API_001 │
1549
+ │ 1 │ WEB_002 │ WEB_002 │
1550
+ │ 2 │ DB_003 │ │
1551
+ │ 3 │ │ WEB_004 │
1552
+ └─────────┴─────────┴─────────┘
1553
+ ```
1554
+
1555
+ #### With message columns
1556
+
1557
+ ```javascript
1558
+ anaLogger.compareSnapshots("SNAP01", "SNAP02", [{ messages: true }, { messages: true }]);
1559
+ ```
1560
+
1561
+ ```
1562
+ ┌─────────┬─────────┬──────────────────────┬─────────┬──────────────────────┐
1563
+ │ (index) │ SNAP01 │ SNAP01 message │ SNAP02 │ SNAP02 message │
1564
+ ├─────────┼─────────┼──────────────────────┼─────────┼──────────────────────┤
1565
+ │ 0 │ API_001 │ 'initialised' │ API_001 │ 'initialised' │
1566
+ │ 1 │ WEB_002 │ 'request in' │ │ │
1567
+ │ 2 │ │ │ WEB_004 │ 'new endpoint added' │
1568
+ └─────────┴─────────┴──────────────────────┴─────────┴──────────────────────┘
1569
+ ```
1570
+
1571
+ You can also enable messages for only one side:
1572
+
1573
+ ```javascript
1574
+ // Messages only for SNAP02
1575
+ anaLogger.compareSnapshots("SNAP01", "SNAP02", [{ messages: false }, { messages: true }]);
1576
+ ```
1577
+
1578
+ In the browser, `compareSnapshots` also injects a colour-coded HTML widget into the `#analogger` DOM container (or `document.body` as a fallback): lids present in both snapshots are shown in neutral, snap1-only rows are highlighted in red, and snap2-only rows in green.
1579
+
1580
+ #### Using the return value
1581
+
1582
+ ```javascript
1583
+ const { onlyInSnap1, onlyInSnap2, inBoth } = anaLogger.compareSnapshots("SNAP01", "SNAP02");
1584
+
1585
+ if (onlyInSnap2.length > 0) {
1586
+ console.warn("New lids appeared in SNAP02:", onlyInSnap2);
1587
+ }
1588
+ ```
1589
+
1590
+ #### Full workflow example
1591
+
1592
+ ```javascript
1593
+ const { anaLogger } = require("analogger");
1594
+
1595
+ anaLogger.keepLogHistory();
1596
+
1597
+ // --- First run (or first phase) ---
1598
+ anaLogger.startSnapshotProcess();
1599
+ anaLogger.log({ lid: "API_001" }, "initialised");
1600
+ anaLogger.log({ lid: "WEB_002" }, "request received");
1601
+ anaLogger.log({ lid: "DB_003" }, "query executed");
1602
+ anaLogger.takeSnapshot("SNAP01");
1603
+
1604
+ // --- Second run (or second phase, e.g. after a code change) ---
1605
+ anaLogger.startSnapshotProcess();
1606
+ anaLogger.log({ lid: "API_001" }, "initialised");
1607
+ anaLogger.log({ lid: "WEB_002" }, "request received");
1608
+ // DB_003 is gone, WEB_004 is new
1609
+ anaLogger.log({ lid: "WEB_004" }, "cache hit");
1610
+ anaLogger.takeSnapshot("SNAP02");
1611
+
1612
+ // --- Compare ---
1613
+ const diff = anaLogger.compareSnapshots("SNAP01", "SNAP02", [{ messages: true }, { messages: true }]);
1614
+ // diff.onlyInSnap1 => ["DB_003"] (removed)
1615
+ // diff.onlyInSnap2 => ["WEB_004"] (added)
1616
+ // diff.inBoth => ["API_001", "WEB_002"]
1617
+ ```
1618
+
1619
+ <br/>
1620
+
1621
+ ---
1622
+
1439
1623
  ## Changelog
1440
1624
 
1441
1625
  ##### current:
@@ -1445,6 +1629,9 @@ anaLogger.log("lid: USR_LOGIN, color: purple", "User logged in");
1445
1629
  * Add `order` option to detect and warn on out-of-sequence log calls
1446
1630
  * Add `maxSeen` option to warn when a lid is logged more times than allowed
1447
1631
  * Add `test` context option and `report()` method for inline assertions and test summaries
1632
+ * Add `startSnapshotProcess()` to mark the start of a snapshot window
1633
+ * Add `takeSnapshot(id, opts?)` to capture lids at a point in time; persists to `localStorage` (browser) or `os.tmpdir()` (Node); returns captured entries
1634
+ * Add `compareSnapshots(id1, id2, displayOpts?)` to diff two snapshots side by side using `console.table`; optional message columns via `[{ messages: true }, { messages: true }]`
1448
1635
 
1449
1636
 
1450
1637
  ##### 1.23.2:
package/ana-logger.d.cts CHANGED
@@ -423,6 +423,69 @@ declare class ____AnaLogger {
423
423
  }): Promise<any>;
424
424
  alert(...args: any[]): void;
425
425
  assert(condition: any, expected?: boolean, ...args: any[]): boolean;
426
+ /**
427
+ * Mark the start of a snapshot window.
428
+ * When called, any subsequent takeSnapshot() will only capture lids logged
429
+ * from this point forward, ignoring everything already in the history.
430
+ * Calling it again moves the window start to the current position.
431
+ *
432
+ * @example
433
+ * anaLogger.keepLogHistory();
434
+ * anaLogger.log({ lid: "WEB0001" }, "boot"); // excluded
435
+ * anaLogger.startSnapshotProcess();
436
+ * anaLogger.log({ lid: "WEB0002" }, "step 1"); // included
437
+ * anaLogger.takeSnapshot("SNAP01"); // captures only WEB0002
438
+ */
439
+ startSnapshotProcess(): void;
440
+ _snapshotWindowStart: number;
441
+ /**
442
+ * Capture all lids logged since the last startSnapshotProcess() (or since the
443
+ * beginning of the session if startSnapshotProcess was never called) into a
444
+ * named snapshot stored in localStorage / temp file.
445
+ *
446
+ * @param {string} snapshotID - unique label, e.g. "SNAP01"
447
+ * @param {{ messages?: boolean, context?: boolean }} [opts]
448
+ * messages: true - also store the log message per lid (default: true)
449
+ * context: true - also store the full context object (default: true)
450
+ * @returns {object[]} The array of snapshot entries that were saved
451
+ *
452
+ * @example
453
+ * anaLogger.keepLogHistory();
454
+ * anaLogger.log({ lid: "WEB0001" }, "step 1");
455
+ * const entries = anaLogger.takeSnapshot("SNAP01");
456
+ * anaLogger.log({ lid: "WEB0002" }, "step 2");
457
+ * anaLogger.takeSnapshot("SNAP02", { messages: false, context: false });
458
+ */
459
+ takeSnapshot(snapshotID: string, opts?: {
460
+ messages?: boolean;
461
+ context?: boolean;
462
+ }): object[];
463
+ /**
464
+ * Compare two previously saved snapshots and print a side-by-side diff table
465
+ * showing which lids are shared, present only in snap1, or only in snap2.
466
+ *
467
+ * @param {string} snapshotID1
468
+ * @param {string} snapshotID2
469
+ * @param {[{ messages?: boolean }, { messages?: boolean }]} [displayOpts]
470
+ * Optional per-snapshot display options.
471
+ * displayOpts[0].messages = true - add a message column for snapshotID1
472
+ * displayOpts[1].messages = true - add a message column for snapshotID2
473
+ * @returns {{ onlyInSnap1: string[], onlyInSnap2: string[], inBoth: string[] } | null}
474
+ *
475
+ * @example
476
+ * anaLogger.compareSnapshots("SNAP01", "SNAP02");
477
+ * anaLogger.compareSnapshots("SNAP01", "SNAP02", [{ messages: true }, { messages: true }]);
478
+ * anaLogger.compareSnapshots("SNAP01", "SNAP02", [{ messages: true }, { messages: false }]);
479
+ */
480
+ compareSnapshots(snapshotID1: string, snapshotID2: string, displayOpts?: [{
481
+ messages?: boolean;
482
+ }, {
483
+ messages?: boolean;
484
+ }]): {
485
+ onlyInSnap1: string[];
486
+ onlyInSnap2: string[];
487
+ inBoth: string[];
488
+ } | null;
426
489
  /**
427
490
  * Set standard Analogger format
428
491
  * @example
@@ -3565,6 +3565,360 @@ class ____AnaLogger
3565
3565
  return false;
3566
3566
  }
3567
3567
 
3568
+ // ─── Snapshot storage ─────────────────────────────────────────────────────
3569
+ // Browser → localStorage
3570
+ // Node.js → os.tmpdir()/analogger-snapshots/<key>.json (persists across runs)
3571
+
3572
+ /**
3573
+ * Resolve the file path used to persist a snapshot in Node.js.
3574
+ * The directory is created on first use.
3575
+ * @param {string} key – storage key, e.g. "analogger_snapshot_default_SNAP01"
3576
+ * @returns {string} – absolute file path
3577
+ */
3578
+ #_snapshotFilePath(key) {
3579
+
3580
+ }
3581
+
3582
+ #_snapshotGet(key) {
3583
+ // Browser path
3584
+ try {
3585
+ if (this.isBrowser() && typeof localStorage !== "undefined") {
3586
+ return localStorage.getItem(key);
3587
+ }
3588
+ } catch (e) {}
3589
+
3590
+ // Node.js path – read from temp file
3591
+
3592
+
3593
+ return null;
3594
+ }
3595
+
3596
+ #_snapshotSet(key, value) {
3597
+ // Browser path
3598
+ try {
3599
+ if (this.isBrowser() && typeof localStorage !== "undefined") {
3600
+ localStorage.setItem(key, value);
3601
+ return;
3602
+ }
3603
+ } catch (e) {}
3604
+
3605
+ // Node.js path – write to temp file
3606
+
3607
+ }
3608
+
3609
+ #_snapshotKey(snapshotID) {
3610
+ return `analogger_snapshot_${this.instanceName || "default"}_${snapshotID}`;
3611
+ }
3612
+
3613
+ /**
3614
+ * Harvest lid entries from the current log history.
3615
+ * Only entries logged after the last startSnapshotProcess() call are included
3616
+ * when a snapshot window is active.
3617
+ * @param {{ messages?: boolean, context?: boolean }} opts
3618
+ * @returns {object[]}
3619
+ */
3620
+ #_harvestSnapshot(opts = {}) {
3621
+ const { messages = true, context: includeContext = true } = opts;
3622
+ const fullHistory = this.getRawLogHistory ? this.getRawLogHistory() : (this.logHistory || []);
3623
+
3624
+ // If a snapshot window is active, slice from the marker index
3625
+ const history = this._snapshotWindowStart !== undefined
3626
+ ? fullHistory.slice(this._snapshotWindowStart)
3627
+ : fullHistory;
3628
+
3629
+ /** Deduplicate by lid - keep the last occurrence */
3630
+ const seen = new Map();
3631
+ for (const entry of history) {
3632
+ const ctx = entry.context || {};
3633
+ const lid = ctx.lid;
3634
+ if (!lid) continue;
3635
+ const record = { lid };
3636
+ if (messages) record.message = entry.message ?? "";
3637
+ if (includeContext) record.context = { ...ctx };
3638
+ seen.set(lid, record);
3639
+ }
3640
+ return Array.from(seen.values());
3641
+ }
3642
+
3643
+ /**
3644
+ * Mark the start of a snapshot window.
3645
+ * When called, any subsequent takeSnapshot() will only capture lids logged
3646
+ * from this point forward, ignoring everything already in the history.
3647
+ * Calling it again moves the window start to the current position.
3648
+ *
3649
+ * @example
3650
+ * anaLogger.keepLogHistory();
3651
+ * anaLogger.log({ lid: "WEB0001" }, "boot"); // excluded
3652
+ * anaLogger.startSnapshotProcess();
3653
+ * anaLogger.log({ lid: "WEB0002" }, "step 1"); // included
3654
+ * anaLogger.takeSnapshot("SNAP01"); // captures only WEB0002
3655
+ */
3656
+ startSnapshotProcess() {
3657
+ const history = this.getRawLogHistory ? this.getRawLogHistory() : (this.logHistory || []);
3658
+ this._snapshotWindowStart = history.length;
3659
+ console.log(
3660
+ `[AnaLogger] Snapshot window started at history index ${this._snapshotWindowStart}.`
3661
+ );
3662
+ }
3663
+
3664
+ /**
3665
+ * Capture all lids logged since the last startSnapshotProcess() (or since the
3666
+ * beginning of the session if startSnapshotProcess was never called) into a
3667
+ * named snapshot stored in localStorage / temp file.
3668
+ *
3669
+ * @param {string} snapshotID - unique label, e.g. "SNAP01"
3670
+ * @param {{ messages?: boolean, context?: boolean }} [opts]
3671
+ * messages: true - also store the log message per lid (default: true)
3672
+ * context: true - also store the full context object (default: true)
3673
+ * @returns {object[]} The array of snapshot entries that were saved
3674
+ *
3675
+ * @example
3676
+ * anaLogger.keepLogHistory();
3677
+ * anaLogger.log({ lid: "WEB0001" }, "step 1");
3678
+ * const entries = anaLogger.takeSnapshot("SNAP01");
3679
+ * anaLogger.log({ lid: "WEB0002" }, "step 2");
3680
+ * anaLogger.takeSnapshot("SNAP02", { messages: false, context: false });
3681
+ */
3682
+ takeSnapshot(snapshotID, opts = {}) {
3683
+ try {
3684
+ if (!snapshotID || typeof snapshotID !== "string") {
3685
+ console.warn("[AnaLogger] takeSnapshot: snapshotID must be a non-empty string.");
3686
+ return [];
3687
+ }
3688
+
3689
+ const { messages = true, context: includeContext = true } = opts;
3690
+ const entries = this.#_harvestSnapshot({ messages, context: includeContext });
3691
+
3692
+ const payload = {
3693
+ snapshotID,
3694
+ timestamp : Date.now(),
3695
+ options : { messages, context: includeContext },
3696
+ entries,
3697
+ };
3698
+
3699
+ this.#_snapshotSet(this.#_snapshotKey(snapshotID), JSON.stringify(payload));
3700
+
3701
+ console.log(
3702
+ `[AnaLogger] Snapshot "${snapshotID}" saved - ${entries.length} lid(s) captured.`
3703
+ );
3704
+
3705
+ return entries;
3706
+ } catch (e) {
3707
+ console.error("[AnaLogger] takeSnapshot error:", e.message);
3708
+ return [];
3709
+ }
3710
+ }
3711
+
3712
+ /**
3713
+ * Compare two previously saved snapshots and print a side-by-side diff table
3714
+ * showing which lids are shared, present only in snap1, or only in snap2.
3715
+ *
3716
+ * @param {string} snapshotID1
3717
+ * @param {string} snapshotID2
3718
+ * @param {[{ messages?: boolean }, { messages?: boolean }]} [displayOpts]
3719
+ * Optional per-snapshot display options.
3720
+ * displayOpts[0].messages = true - add a message column for snapshotID1
3721
+ * displayOpts[1].messages = true - add a message column for snapshotID2
3722
+ * @returns {{ onlyInSnap1: string[], onlyInSnap2: string[], inBoth: string[] } | null}
3723
+ *
3724
+ * @example
3725
+ * anaLogger.compareSnapshots("SNAP01", "SNAP02");
3726
+ * anaLogger.compareSnapshots("SNAP01", "SNAP02", [{ messages: true }, { messages: true }]);
3727
+ * anaLogger.compareSnapshots("SNAP01", "SNAP02", [{ messages: true }, { messages: false }]);
3728
+ */
3729
+ compareSnapshots(snapshotID1, snapshotID2, displayOpts = []) {
3730
+ try {
3731
+ const raw1 = this.#_snapshotGet(this.#_snapshotKey(snapshotID1));
3732
+ const raw2 = this.#_snapshotGet(this.#_snapshotKey(snapshotID2));
3733
+
3734
+ if (!raw1) console.warn(`[AnaLogger] compareSnapshots: snapshot "${snapshotID1}" not found.`);
3735
+ if (!raw2) console.warn(`[AnaLogger] compareSnapshots: snapshot "${snapshotID2}" not found.`);
3736
+ if (!raw1 || !raw2) return null;
3737
+
3738
+ const snap1 = JSON.parse(raw1);
3739
+ const snap2 = JSON.parse(raw2);
3740
+
3741
+ // Resolve per-snapshot message display flags
3742
+ const showMsg1 = !!(displayOpts[0] && displayOpts[0].messages);
3743
+ const showMsg2 = !!(displayOpts[1] && displayOpts[1].messages);
3744
+
3745
+ const map1 = new Map(snap1.entries.map((e) => [e.lid, e]));
3746
+ const map2 = new Map(snap2.entries.map((e) => [e.lid, e]));
3747
+ const allLids = new Set([...map1.keys(), ...map2.keys()]);
3748
+
3749
+ const inBoth = [];
3750
+ const onlyIn1 = [];
3751
+ const onlyIn2 = [];
3752
+
3753
+ for (const lid of allLids) {
3754
+ const in1 = map1.has(lid);
3755
+ const in2 = map2.has(lid);
3756
+ if (in1 && in2) inBoth.push(lid);
3757
+ else if (in1) onlyIn1.push(lid);
3758
+ else onlyIn2.push(lid);
3759
+ }
3760
+
3761
+ // Build display rows: shared lids paired, then staggered exclusives
3762
+ const rows = [];
3763
+ for (const lid of inBoth) {
3764
+ rows.push({
3765
+ left: lid, right: lid, status: "both",
3766
+ msgLeft : map1.get(lid)?.message ?? "",
3767
+ msgRight: map2.get(lid)?.message ?? "",
3768
+ });
3769
+ }
3770
+ const maxOnly = Math.max(onlyIn1.length, onlyIn2.length);
3771
+ for (let i = 0; i < maxOnly; i++) {
3772
+ const l = onlyIn1[i] ?? "";
3773
+ const r = onlyIn2[i] ?? "";
3774
+ rows.push({
3775
+ left: l, right: r,
3776
+ status: l && r ? "diff" : (l ? "left" : "right"),
3777
+ msgLeft : l ? (map1.get(l)?.message ?? "") : "",
3778
+ msgRight: r ? (map2.get(r)?.message ?? "") : "",
3779
+ });
3780
+ }
3781
+
3782
+ this.#_renderSnapshotTable(
3783
+ snapshotID1, snapshotID2, rows, snap1, snap2,
3784
+ { showMsg1, showMsg2 }
3785
+ );
3786
+
3787
+ return { onlyInSnap1: onlyIn1, onlyInSnap2: onlyIn2, inBoth };
3788
+ } catch (e) {
3789
+ console.error("[AnaLogger] compareSnapshots error:", e.message);
3790
+ return null;
3791
+ }
3792
+ }
3793
+
3794
+ // ─── Snapshot table renderers ─────────────────────────────────────────────
3795
+
3796
+ #_renderSnapshotTable(id1, id2, rows, snap1, snap2, { showMsg1 = false, showMsg2 = false } = {}) {
3797
+ const ts = (snap) =>
3798
+ snap.timestamp ? new Date(snap.timestamp).toLocaleTimeString() : "-";
3799
+
3800
+ // ── Header ───────────────────────────────────────────────────────────
3801
+ console.log(`\nSnapshot comparison: "${id1}" vs "${id2}"`);
3802
+ console.log(` ${id1} captured at ${ts(snap1)} - ${snap1.entries.length} lid(s)`);
3803
+ console.log(` ${id2} captured at ${ts(snap2)} - ${snap2.entries.length} lid(s)\n`);
3804
+
3805
+ // ── console.table (auto-aligned in Node and browser devtools) ────────
3806
+ // Column keys are the snapshot IDs so headers are self-documenting.
3807
+ // Message columns are appended only when requested.
3808
+ const msgKey1 = `${id1} message`;
3809
+ const msgKey2 = `${id2} message`;
3810
+
3811
+ const tableData = rows.length
3812
+ ? rows.map((r) => {
3813
+ const row = { [id1]: r.left, [id2]: r.right };
3814
+ if (showMsg1) row[msgKey1] = r.msgLeft;
3815
+ if (showMsg2) row[msgKey2] = r.msgRight;
3816
+ return row;
3817
+ })
3818
+ : [(() => {
3819
+ const row = { [id1]: "(empty)", [id2]: "(empty)" };
3820
+ if (showMsg1) row[msgKey1] = "";
3821
+ if (showMsg2) row[msgKey2] = "";
3822
+ return row;
3823
+ })()];
3824
+
3825
+ console.table(tableData);
3826
+
3827
+ // ── Browser HTML widget (progressive enhancement) ────────────────────
3828
+ if (typeof document !== "undefined") {
3829
+ this.#_renderSnapshotHtml(id1, id2, rows, snap1, snap2, ts, { showMsg1, showMsg2 });
3830
+ }
3831
+ }
3832
+
3833
+ #_renderSnapshotHtml(id1, id2, rows, snap1, snap2, ts, { showMsg1 = false, showMsg2 = false } = {}) {
3834
+ try {
3835
+ const C = {
3836
+ both: "#1e293b", bothBg: "#f8fafc",
3837
+ left: "#7f1d1d", leftBg: "#fef2f2",
3838
+ right: "#14532d", rightBg: "#f0fdf4",
3839
+ msg: "#475569", msgBg: "#f8fafc",
3840
+ header: "#1e40af", border: "#e2e8f0",
3841
+ };
3842
+
3843
+ const sharedCount = snap1.entries.filter(e => snap2.entries.some(e2 => e2.lid === e.lid)).length;
3844
+ const only1Count = snap1.entries.length - sharedCount;
3845
+ const only2Count = snap2.entries.length - sharedCount;
3846
+
3847
+ // Total visible columns: lid1, [msg1], sym, lid2, [msg2]
3848
+ const totalCols = 3 + (showMsg1 ? 1 : 0) + (showMsg2 ? 1 : 0);
3849
+ const td = (extra = "") =>
3850
+ `padding:4px 10px;font-family:monospace;font-size:12px;border-bottom:1px solid ${C.border};${extra}`;
3851
+ const tdMsg = (extra = "") =>
3852
+ `padding:4px 10px;font-size:11px;color:${C.msg};border-bottom:1px solid ${C.border};max-width:200px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;${extra}`;
3853
+ const thStyle = `padding:5px 10px;text-align:left;background:#f1f5f9;font-size:11px;color:#475569;border-bottom:2px solid ${C.border}`;
3854
+
3855
+ const mkRow = (row) => {
3856
+ const lBg = row.status === "left" ? C.leftBg : C.bothBg;
3857
+ const rBg = row.status === "right" ? C.rightBg : C.bothBg;
3858
+ const lCl = row.status === "left" ? C.left : row.status === "right" ? "#94a3b8" : C.both;
3859
+ const rCl = row.status === "right" ? C.right : row.status === "left" ? "#94a3b8" : C.both;
3860
+ const sym = row.status === "both" ? "=" : row.status === "left" ? "◀" : row.status === "right" ? "▶" : "≠";
3861
+
3862
+ return `<tr>
3863
+ <td style="${td(`background:${lBg};color:${lCl}`)}">${row.left || ""}</td>
3864
+ ${showMsg1 ? `<td style="${tdMsg(`background:${lBg}`)}" title="${(row.msgLeft || "").replace(/"/g, "&quot;")}">${row.msgLeft || ""}</td>` : ""}
3865
+ <td style="width:20px;text-align:center;border-bottom:1px solid ${C.border};color:#94a3b8;font-size:10px">${sym}</td>
3866
+ <td style="${td(`background:${rBg};color:${rCl}`)}">${row.right || ""}</td>
3867
+ ${showMsg2 ? `<td style="${tdMsg(`background:${rBg}`)}" title="${(row.msgRight || "").replace(/"/g, "&quot;")}">${row.msgRight || ""}</td>` : ""}
3868
+ </tr>`;
3869
+ };
3870
+
3871
+ const html = `
3872
+ <div id="analogger-snapshot-cmp" style="
3873
+ font-family:system-ui,sans-serif;font-size:13px;
3874
+ border:1px solid ${C.border};border-radius:8px;overflow:hidden;
3875
+ max-width:${showMsg1 || showMsg2 ? "820px" : "520px"};margin:12px 0;box-shadow:0 2px 8px rgba(0,0,0,.08)">
3876
+ <div style="background:${C.header};color:#fff;padding:8px 14px;font-weight:600">
3877
+ Snapshot diff &mdash;
3878
+ <code style="font-size:12px">${id1}</code>
3879
+ <span style="opacity:.7"> vs </span>
3880
+ <code style="font-size:12px">${id2}</code>
3881
+ </div>
3882
+ <div style="display:flex;background:#e2e8f0;font-size:11px;color:#64748b;padding:3px 0">
3883
+ <span style="flex:1;padding-left:10px">${id1} &middot; ${ts(snap1)} &middot; ${snap1.entries.length} lids</span>
3884
+ <span style="width:20px"></span>
3885
+ <span style="flex:1;padding-left:10px">${id2} &middot; ${ts(snap2)} &middot; ${snap2.entries.length} lids</span>
3886
+ </div>
3887
+ <table style="width:100%;border-collapse:collapse">
3888
+ <thead>
3889
+ <tr>
3890
+ <th style="${thStyle}">${id1}</th>
3891
+ ${showMsg1 ? `<th style="${thStyle};color:#94a3b8;font-style:italic">message</th>` : ""}
3892
+ <th style="width:20px;background:#f1f5f9;border-bottom:2px solid ${C.border}"></th>
3893
+ <th style="${thStyle}">${id2}</th>
3894
+ ${showMsg2 ? `<th style="${thStyle};color:#94a3b8;font-style:italic">message</th>` : ""}
3895
+ </tr>
3896
+ </thead>
3897
+ <tbody>
3898
+ ${rows.length
3899
+ ? rows.map(mkRow).join("")
3900
+ : `<tr><td colspan="${totalCols}" style="padding:10px;color:#94a3b8;text-align:center">(no lids in either snapshot)</td></tr>`}
3901
+ </tbody>
3902
+ </table>
3903
+ <div style="background:#f8fafc;padding:5px 10px;font-size:11px;color:#64748b;display:flex;gap:16px">
3904
+ <span>&#x2714; ${sharedCount} shared</span>
3905
+ <span style="color:${C.left}">&#x25C4; ${only1Count} only in ${id1}</span>
3906
+ <span style="color:${C.right}">&#x25BA; ${only2Count} only in ${id2}</span>
3907
+ </div>
3908
+ </div>`;
3909
+
3910
+ const $old = document.getElementById("analogger-snapshot-cmp");
3911
+ if ($old) $old.remove();
3912
+
3913
+ const $mount = document.querySelector("#analogger") || document.body;
3914
+ const $wrapper = document.createElement("div");
3915
+ $wrapper.innerHTML = html;
3916
+ $mount.appendChild($wrapper.firstElementChild);
3917
+ } catch (e) {
3918
+ // Non-fatal - plain-text already printed
3919
+ }
3920
+ }
3921
+
3568
3922
  /**
3569
3923
  * Set default targets, contexts and log levels
3570
3924
  * @returns {boolean}