@checkstack/template-engine 0.2.0 → 0.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # @checkstack/template-engine
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 270ef29: Add live state in scope plus duration helpers to the automation sensing layer (Wave 2 Phase 14).
8
+
9
+ - `@checkstack/template-engine` ships four pure, synchronous duration filters: `minutes` and `hours` (number to milliseconds), `duration_since` (ms elapsed since an ISO timestamp), and `older_than(thresholdMs)` (boolean dwell check). They compute against real time at call time, so "now" is fresh per evaluation. Fail-safe on null/unparseable input.
10
+ - The dispatch engine pre-resolves live health state into scope before any condition or template evaluation (the engine is synchronous, so inline state queries are impossible). State is folded under a `health` namespace - `health.system.*` for the trigger's context system and `health.systems[<id>]` for ids listed in the automation's new `uses_state` field. One batched `getBulkHealthState` query per evaluation, wired at the fresh-run, resume, and trigger-gate sites. Fail-open: a missing client or provider error yields an empty namespace and a warning, never wedging unrelated automations.
11
+ - New `automationFilterExtensionPoint` lets plugins contribute pure template filters without forking the engine's default registry. Name collisions with built-ins are skipped with a warning.
12
+ - The editor variable-scope resolver and autocomplete catalogue now surface the `health.*` namespace and the new duration filters.
13
+
14
+ With this phase alone, an operator can build "notify me when a system has been unhealthy for 30 minutes" using an interval trigger plus a single `health.*` condition - no dwell timer required (the precise event-driven path lands in Phase 15).
15
+
3
16
  ## 0.2.0
4
17
 
5
18
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/template-engine",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -10,11 +10,11 @@
10
10
  }
11
11
  },
12
12
  "dependencies": {
13
- "@checkstack/common": "0.11.0",
13
+ "@checkstack/common": "0.12.0",
14
14
  "zod": "^4.0.0"
15
15
  },
16
16
  "devDependencies": {
17
- "@checkstack/scripts": "0.3.3",
17
+ "@checkstack/scripts": "0.3.4",
18
18
  "@checkstack/tsconfig": "0.0.7",
19
19
  "typescript": "^5.7.2"
20
20
  },
@@ -0,0 +1,87 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { parseCondition } from "../parser";
3
+ import { evaluate, evaluateBoolean } from "../renderer";
4
+ import { createDefaultFilterRegistry } from "../filters";
5
+
6
+ const registry = createDefaultFilterRegistry();
7
+
8
+ function evalExpr(src: string, scope: Record<string, unknown>): unknown {
9
+ return evaluate(parseCondition(src), scope, { filters: registry });
10
+ }
11
+
12
+ function evalBool(src: string, scope: Record<string, unknown>): boolean {
13
+ return evaluateBoolean(parseCondition(src), scope, { filters: registry });
14
+ }
15
+
16
+ describe("minutes / hours filters", () => {
17
+ it("minutes converts a number to milliseconds", () => {
18
+ expect(evalExpr("30 | minutes", {})).toBe(30 * 60_000);
19
+ });
20
+
21
+ it("hours converts a number to milliseconds", () => {
22
+ expect(evalExpr("2 | hours", {})).toBe(2 * 3_600_000);
23
+ });
24
+
25
+ it("minutes coerces numeric strings", () => {
26
+ expect(evalExpr('"15" | minutes', {})).toBe(15 * 60_000);
27
+ });
28
+
29
+ it("minutes passes through non-numeric input as NaN-safe 0", () => {
30
+ expect(evalExpr('"abc" | minutes', {})).toBe(0);
31
+ });
32
+ });
33
+
34
+ describe("duration_since filter", () => {
35
+ it("returns ms elapsed since the given ISO timestamp", () => {
36
+ const since = new Date(Date.now() - 5 * 60_000).toISOString();
37
+ const ms = evalExpr("ts | duration_since", { ts: since });
38
+ expect(typeof ms).toBe("number");
39
+ // ~5 minutes, allow a small execution window
40
+ expect(ms as number).toBeGreaterThanOrEqual(5 * 60_000 - 1000);
41
+ expect(ms as number).toBeLessThanOrEqual(5 * 60_000 + 5000);
42
+ });
43
+
44
+ it("returns 0 for null / undefined (fail-safe, not negative)", () => {
45
+ expect(evalExpr("missing | duration_since", {})).toBe(0);
46
+ expect(evalExpr("ts | duration_since", { ts: null })).toBe(0);
47
+ });
48
+
49
+ it("returns 0 for an unparseable value", () => {
50
+ expect(evalExpr("ts | duration_since", { ts: "not-a-date" })).toBe(0);
51
+ });
52
+
53
+ it("accepts a Date instance", () => {
54
+ const since = new Date(Date.now() - 60_000);
55
+ const ms = evalExpr("ts | duration_since", { ts: since });
56
+ expect(ms as number).toBeGreaterThanOrEqual(60_000 - 1000);
57
+ });
58
+ });
59
+
60
+ describe("older_than filter", () => {
61
+ it("is true when the timestamp is older than the threshold", () => {
62
+ const since = new Date(Date.now() - 31 * 60_000).toISOString();
63
+ expect(evalBool("ts | older_than(30 | minutes)", { ts: since })).toBe(true);
64
+ });
65
+
66
+ it("is false when the timestamp is newer than the threshold", () => {
67
+ const since = new Date(Date.now() - 10 * 60_000).toISOString();
68
+ expect(evalBool("ts | older_than(30 | minutes)", { ts: since })).toBe(
69
+ false,
70
+ );
71
+ });
72
+
73
+ it("is false (fail-safe) for null / unknown timestamps", () => {
74
+ expect(evalBool("missing | older_than(30 | minutes)", {})).toBe(false);
75
+ expect(evalBool("ts | older_than(30 | minutes)", { ts: null })).toBe(false);
76
+ });
77
+
78
+ it("composes in a realistic dwell condition", () => {
79
+ const since = new Date(Date.now() - 45 * 60_000).toISOString();
80
+ const scope = {
81
+ health: { system: { status: "unhealthy", in_status_since: since } },
82
+ };
83
+ const expr =
84
+ "health.system.status == 'unhealthy' && (health.system.in_status_since | older_than(30 | minutes))";
85
+ expect(evalBool(expr, scope)).toBe(true);
86
+ });
87
+ });
package/src/filters.ts CHANGED
@@ -42,6 +42,10 @@ export function createDefaultFilterRegistry(): FilterRegistry {
42
42
  registry.register("join", filterJoin);
43
43
  registry.register("replace", filterReplace);
44
44
  registry.register("not", filterNot);
45
+ registry.register("minutes", filterMinutes);
46
+ registry.register("hours", filterHours);
47
+ registry.register("duration_since", filterDurationSince);
48
+ registry.register("older_than", filterOlderThan);
45
49
  return registry;
46
50
  }
47
51
 
@@ -130,6 +134,68 @@ function filterNot(value: unknown): unknown {
130
134
  return !isTruthy(value);
131
135
  }
132
136
 
137
+ // ─── Duration helpers ────────────────────────────────────────────────────
138
+ //
139
+ // All pure and synchronous (no DB, no async). `duration_since` /
140
+ // `older_than` compute against `Date.now()` at call time, so "now" is
141
+ // fresh per evaluation rather than the run-start `now` in scope. This is
142
+ // deliberate: dwell re-checks and time-of-day logic must use real time,
143
+ // not the frozen scope timestamp.
144
+
145
+ /** Coerce an unknown to a finite number, or 0. */
146
+ function toFiniteNumber(value: unknown): number {
147
+ const n = typeof value === "number" ? value : Number(value);
148
+ return Number.isFinite(n) ? n : 0;
149
+ }
150
+
151
+ /** Parse an unknown into epoch-ms, or null when unparseable. */
152
+ function toEpochMs(value: unknown): number | null {
153
+ if (value instanceof Date) {
154
+ return Number.isNaN(value.getTime()) ? null : value.getTime();
155
+ }
156
+ if (typeof value === "number") {
157
+ return Number.isFinite(value) ? value : null;
158
+ }
159
+ if (typeof value === "string") {
160
+ const ms = new Date(value).getTime();
161
+ return Number.isNaN(ms) ? null : ms;
162
+ }
163
+ return null;
164
+ }
165
+
166
+ /** `{{ 30 | minutes }}` -> 1_800_000 (a number of minutes to ms). */
167
+ function filterMinutes(value: unknown): unknown {
168
+ return toFiniteNumber(value) * 60_000;
169
+ }
170
+
171
+ /** `{{ 2 | hours }}` -> 7_200_000 (a number of hours to ms). */
172
+ function filterHours(value: unknown): unknown {
173
+ return toFiniteNumber(value) * 3_600_000;
174
+ }
175
+
176
+ /**
177
+ * `{{ someIso | duration_since }}` -> milliseconds elapsed since the
178
+ * given timestamp. Fail-safe: null/undefined/unparseable -> 0 (never
179
+ * negative, never throws). Clamped at 0 to absorb clock skew.
180
+ */
181
+ function filterDurationSince(value: unknown): unknown {
182
+ const ms = toEpochMs(value);
183
+ if (ms === null) return 0;
184
+ return Math.max(0, Date.now() - ms);
185
+ }
186
+
187
+ /**
188
+ * `{{ someIso | older_than(30 | minutes) }}` -> boolean. True when the
189
+ * timestamp is at least `thresholdMs` in the past. Fail-safe: a
190
+ * null/unparseable timestamp returns false (an unknown age is never
191
+ * "older than" a threshold).
192
+ */
193
+ function filterOlderThan(value: unknown, thresholdMs: unknown): unknown {
194
+ const ms = toEpochMs(value);
195
+ if (ms === null) return false;
196
+ return Date.now() - ms >= toFiniteNumber(thresholdMs);
197
+ }
198
+
133
199
  /**
134
200
  * Centralised truthiness rule. Mirrors JavaScript's notion plus: empty
135
201
  * string is falsy, empty arrays are falsy, empty plain objects are falsy.