Skip to content

Commit 0d9be46

Browse files
os-zhuangclaude
andcommitted
feat(cli): lint date-equality vs time values in flow query filters (#1874)
Extends the flow anti-pattern lint from trigger CONDITIONS to query FILTERS. A scheduled flow whose get_record filter binds a field directly / via $eq / $in to a time-function value (daysFromNow/today/now/...) silently matches nothing, because a Field.date stores a time component and an exact match against a re-computed timestamp never holds — the bug the templates discrete-tier alerts hit. Range ops ($gte/$lt day windows) are the correct shape and are exempt. New rule flow-date-equality-filter; advisory warning, never fails the build. 10 tests incl. false-positive guards (windows, $or, plain ranges, non-time eq). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9ad1198 commit 0d9be46

3 files changed

Lines changed: 148 additions & 1 deletion

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/cli": minor
3+
---
4+
5+
feat(cli): lint date-EQUALITY against time values in flow query filters (#1874)
6+
7+
The flow anti-pattern lint already flagged a record-change trigger CONDITION
8+
using date equality (`end_date == daysFromNow(60)`). It now also scans
9+
get_record/query node FILTERS for the same footgun: a field bound directly, or
10+
via `$eq` / `$in`, to a time-function value (`daysFromNow`/`today`/`now`/…).
11+
A `Field.date` is stored with a time component, so an exact match against a
12+
re-computed timestamp silently returns nothing — the failure the templates
13+
discrete-tier alerts hit. Range operators (`$gte`/`$lt` day windows) are the
14+
correct shape and are never flagged. Advisory warning; never fails the build.

packages/cli/src/utils/lint-flow-patterns.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,24 @@ import { describe, it, expect } from 'vitest';
44
import {
55
lintFlowPatterns,
66
FLOW_TIME_RELATIVE_ANTIPATTERN,
7+
FLOW_DATE_EQUALITY_FILTER,
78
FLOW_DOUBLE_BRACE_INTERP,
89
FLOW_BARE_DOLLAR_REF,
910
} from './lint-flow-patterns.js';
1011

12+
const CEL = (source: string) => ({ dialect: 'cel', source });
13+
/** A scheduled flow with a get_record node carrying `filter`. */
14+
const filterFlow = (filter: unknown) => ({
15+
flows: [{
16+
name: 'expiry_alert',
17+
nodes: [
18+
{ id: 'start', type: 'start', config: { triggerType: 'schedule', schedule: 'cron:0 9 * * *' } },
19+
{ id: 'query', type: 'get_record', config: { objectName: 'contract', filter } },
20+
],
21+
edges: [],
22+
}],
23+
});
24+
1125
const flow = (condition: unknown, triggerType = 'record-after-update') => ({
1226
flows: [{
1327
name: 'renewal_alert',
@@ -50,6 +64,52 @@ describe('lintFlowPatterns — time-relative anti-pattern (#1874)', () => {
5064
});
5165
});
5266

67+
describe('lintFlowPatterns — date-equality in query filter (#1874)', () => {
68+
it('flags a field bound directly to a time value (implicit equality)', () => {
69+
const fnds = lintFlowPatterns(filterFlow({ expires_at: CEL('daysFromNow(30)') }));
70+
expect(fnds).toHaveLength(1);
71+
expect(fnds[0].rule).toBe(FLOW_DATE_EQUALITY_FILTER);
72+
expect(fnds[0].hint).toMatch(/\$gte.*daysFromNow\(N\).*\$lt/);
73+
});
74+
75+
it('flags `$in` against time values (the original renewal_alert bug)', () => {
76+
const fnds = lintFlowPatterns(filterFlow({
77+
status: 'active',
78+
end_date: { $in: [CEL('daysFromNow(60)'), CEL('daysFromNow(30)'), CEL('daysFromNow(7)')] },
79+
}));
80+
expect(fnds).toHaveLength(1);
81+
expect(fnds[0].rule).toBe(FLOW_DATE_EQUALITY_FILTER);
82+
});
83+
84+
it('flags `$eq` against a time value', () => {
85+
expect(lintFlowPatterns(filterFlow({ d: { $eq: CEL('today()') } }))).toHaveLength(1);
86+
});
87+
88+
describe('does NOT flag (false-positive guards)', () => {
89+
it('a one-day window (the correct fix)', () => {
90+
expect(lintFlowPatterns(filterFlow({
91+
end_date: { $gte: CEL('daysFromNow(7)'), $lt: CEL('daysFromNow(8)') },
92+
}))).toHaveLength(0);
93+
});
94+
it('multi-tier windows wrapped in $or', () => {
95+
expect(lintFlowPatterns(filterFlow({
96+
status: 'active',
97+
$or: [
98+
{ end_date: { $gte: CEL('daysFromNow(7)'), $lt: CEL('daysFromNow(8)') } },
99+
{ end_date: { $gte: CEL('daysFromNow(30)'), $lt: CEL('daysFromNow(31)') } },
100+
],
101+
}))).toHaveLength(0);
102+
});
103+
it('a plain range like `due_date < today()` (overdue query)', () => {
104+
expect(lintFlowPatterns(filterFlow({ status: 'open', due_date: { $lt: CEL('today()') } }))).toHaveLength(0);
105+
});
106+
it('equality against a non-time value (status, interpolated id)', () => {
107+
expect(lintFlowPatterns(filterFlow({ status: 'active', id: '{record.id}' }))).toHaveLength(0);
108+
expect(lintFlowPatterns(filterFlow({ amount: { $eq: CEL('record.threshold') } }))).toHaveLength(0);
109+
});
110+
});
111+
});
112+
53113
/** A flow with a create_record node carrying `config`. */
54114
const nodeFlow = (config: Record<string, unknown>) => ({
55115
flows: [{

packages/cli/src/utils/lint-flow-patterns.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,16 +38,82 @@ function conditionSource(raw: unknown): string {
3838
}
3939

4040
const TIME_FNS = 'daysFromNow|daysAgo|today|now|date|datetime';
41+
const TIME_FN_RE = new RegExp(`\\b(?:${TIME_FNS})\\s*\\(`);
4142
// A time function adjacent to an equality operator, either side:
4243
// `end_date == daysFromNow(60)` / `today() != record.start`
4344
const DATE_EQ = new RegExp(
4445
`(?:(?:${TIME_FNS})\\s*\\([^)]*\\)\\s*(?:==|!=))|(?:(?:==|!=)\\s*(?:${TIME_FNS})\\s*\\()`,
4546
);
4647

4748
export const FLOW_TIME_RELATIVE_ANTIPATTERN = 'flow-time-relative-antipattern';
49+
export const FLOW_DATE_EQUALITY_FILTER = 'flow-date-equality-filter';
4850
export const FLOW_DOUBLE_BRACE_INTERP = 'flow-double-brace-interpolation';
4951
export const FLOW_BARE_DOLLAR_REF = 'flow-bare-dollar-reference';
5052

53+
/** If `v` is a CEL expression whose source calls a time function, return that source. */
54+
function celTimeSource(v: unknown): string | null {
55+
if (v && typeof v === 'object' && (v as AnyRec).dialect === 'cel') {
56+
const src = (v as AnyRec).source;
57+
if (typeof src === 'string' && TIME_FN_RE.test(src)) return src;
58+
}
59+
return null;
60+
}
61+
62+
/** Range operators — the building block of the CORRECT time-window pattern, never flagged. */
63+
const RANGE_OPS = new Set(['$gte', '$gt', '$lte', '$lt', '$ne']);
64+
65+
/**
66+
* Walk a get_record/query `filter` for the date-EQUALITY footgun: a field bound
67+
* directly (`field: daysFromNow(N)`) or via `$eq` / `$in` to a time-function value.
68+
* A `Field.date` is stored with a time component, so two independently-computed
69+
* timestamps never compare equal — the query silently returns nothing (#1928 /
70+
* templates #1874). Range operators (`$gte`/`$lt` day windows) are the correct
71+
* shape and are never flagged.
72+
*/
73+
function scanFilterForDateEquality(
74+
filter: unknown,
75+
where: string,
76+
findings: FlowLintFinding[],
77+
): void {
78+
if (!filter || typeof filter !== 'object' || Array.isArray(filter)) return;
79+
for (const [key, val] of Object.entries(filter as AnyRec)) {
80+
if (key === '$or' || key === '$and') {
81+
if (Array.isArray(val)) for (const sub of val) scanFilterForDateEquality(sub, where, findings);
82+
continue;
83+
}
84+
// `key` is a field name; `val` is its constraint.
85+
const direct = celTimeSource(val); // `field: daysFromNow(N)` → implicit equality
86+
let hit: { op: string; src: string } | null = direct ? { op: '==', src: direct } : null;
87+
if (!hit && val && typeof val === 'object' && (val as AnyRec).dialect !== 'cel') {
88+
for (const [op, operand] of Object.entries(val as AnyRec)) {
89+
if (RANGE_OPS.has(op)) continue; // correct pattern — leave it
90+
if (op === '$eq') {
91+
const s = celTimeSource(operand);
92+
if (s) { hit = { op: '$eq', src: s }; break; }
93+
} else if (op === '$in' && Array.isArray(operand)) {
94+
for (const item of operand) {
95+
const s = celTimeSource(item);
96+
if (s) { hit = { op: '$in', src: s }; break; }
97+
}
98+
if (hit) break;
99+
}
100+
}
101+
}
102+
if (hit) {
103+
findings.push({
104+
where,
105+
message:
106+
`filter matches \`${key}\` by ${hit.op} against a time value (\`${hit.src}\`) — a date field carries a ` +
107+
`time component, so exact equality against \`${hit.src}\` (re-computed each run) silently matches nothing.`,
108+
hint:
109+
`Use a one-day window instead: \`${key}: { $gte: daysFromNow(N), $lt: daysFromNow(N+1) }\` ` +
110+
`(wrap multiple tiers in \`$or\`). The abutting windows tile the timeline so each row matches exactly once. (#1874)`,
111+
rule: FLOW_DATE_EQUALITY_FILTER,
112+
});
113+
}
114+
}
115+
}
116+
51117
// Flow node VALUES interpolate with SINGLE braces (`{var}` / `{rec.field}` /
52118
// `{$User.Id}`). Two wrong-syntax mistakes AI/human authors carry over from the
53119
// *formula* template dialect (`{{ path }}`) or other platforms:
@@ -106,9 +172,16 @@ export function lintFlowPatterns(stack: AnyRec): FlowLintFinding[] {
106172
// node values use SINGLE braces; double-brace `{{ }}` and bare `$ref.x`
107173
// are carried over from the formula template dialect / other platforms.
108174
for (const node of nodes) {
175+
const nodeWhere = `flow '${flowName}' · node '${node.id}' (${node.type})`;
176+
177+
// (a2) #1874 — date-EQUALITY (`==`/`$eq`/`$in`) against a time value in a
178+
// query filter. A scheduled flow that filters this way silently matches
179+
// nothing; the robust shape is a `$gte`/`$lt` day window.
180+
const cfg = (node.config ?? {}) as AnyRec;
181+
if (cfg.filter) scanFilterForDateEquality(cfg.filter, `${nodeWhere} filter`, findings);
182+
109183
const strings: string[] = [];
110184
collectTemplateStrings(node.config, undefined, strings);
111-
const nodeWhere = `flow '${flowName}' · node '${node.id}' (${node.type})`;
112185
for (const str of strings) {
113186
if (DOUBLE_BRACE.test(str)) {
114187
findings.push({

0 commit comments

Comments
 (0)