Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions .changeset/filter-preset-comparand-refused.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
---
"@objectstack/spec": minor
"@objectstack/lint": minor
---

feat(spec,lint): refuse a bare date-range preset name in an ordering filter comparand at publish time (#8793 — the ruled C half of #8690)

**BREAKING** accept-set narrowing on a published authoring surface, landing
after the v17.0.0 cut (the lockstep launch-window convention ships it as
`minor`; the migration prescription is registered under protocol major 18).

`last_7_days` / `last_30_days` / `last_90_days` and their ten calendar
siblings are real, declared preset names — for the dashboard date-filter
positions, where the console lowers them to `{date-macro}` bounds before any
query is sent. Authored as a bare filter comparand nothing resolves them:
measured on #8690, `$gte "last_30_days"` returned HTTP 200 with 0 of 51 rows
where `$gte "{30_days_ago}"` returned the 38 in-window. The engine now
refuses the bare name on a declared temporal field at query time
(`INVALID_FILTER` / 400, PR #8808 — the B half); this change is the
authoring-time half the same ruling shipped alongside it.

**What is refused — ordering positions only, in all three authored filter
shapes:** a `$gt` / `$gte` / `$lt` / `$lte` comparand or `$between` endpoint
on every carrier of `FilterConditionSchema` (dashboard widget filter, dataset
filter, report `runtimeFilter`, page/component filter, rollup filter), a
`greater_than` / `less_than` / `before` / `after` / `between` view filter
rule value, and an ordering `[field, op, value]` filter triple (the latter
two via `@objectstack/lint`'s new gating rule `filter-preset-comparand`,
which also runs at the runtime publish gate for `dashboard` / `view` /
`object` / `page` / `flow` writes). The refusal names the offending value,
the position, and the exact `{date-macro}` window that works.

**What stays accepted:** the preset names in the dashboard date-filter
positions (`dateRange.defaultRange`, a date global filter's `defaultValue`) —
the only positions any layer ever resolved them; equality and membership
comparands (`{ period: 'this_quarter' }`, `$in: [...]`) — a select/picklist
column legitimately stores colliding values, and the engine's field-typed
door already covers the temporal case; undeclared strings
(`'not-a-date-at-all'`) — the field-typed engine door owns those; and the
empty-string cell, which stays its own card by ruling.

## FROM → TO

```ts
// before — parsed green, returned a silent zero (or 400 at query time since #8808)
filter: { closed_at: { $gte: 'last_30_days' } }

// after — rejected naming the window; write the date-macro spelling
filter: { closed_at: { $gte: '{30_days_ago}' } }
// calendar presets prescribe their pair:
filter: { closed_at: { $between: ['{week_start}', '{week_end}'] } }
```

`DATE_RANGE_PRESETS` moved to `@objectstack/spec/data`
(`data/date-range-presets.ts`) with `ui` re-exporting it, so both import
paths keep working; `DATE_RANGE_PRESET_MACRO_WINDOWS` (the per-preset macro
window table the refusals quote) and `isDateRangePresetName` are new exports.

<!-- adr-0087: registered filter-preset-ordering-comparand-refused -->
8 changes: 7 additions & 1 deletion packages/lint/src/authoring-rule-wiring.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -495,7 +495,13 @@ describe('authoring-rule registry wiring (#4409)', () => {
// for `view`. Flipping the family back to CLI-only is a legal edit that
// must go through this line rather than around it.
expect(wired.map((e) => e.name)).toEqual(FAMILY);
expect(runtimeAuthoringRulesFor('view').map((r) => r.name)).toEqual(FAMILY);
// `view` writes also dispatch `validatePresetComparands` (#8793) — a
// different judgement (filter comparands, not predicates), registered
// ahead of the family, so it is named here rather than folded into it.
expect(runtimeAuthoringRulesFor('view').map((r) => r.name)).toEqual([
'validatePresetComparands',
...FAMILY,
]);
});
});

Expand Down
23 changes: 23 additions & 0 deletions packages/lint/src/authoring-rules.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -105,6 +105,7 @@ import { validateViewContainers } from './validate-view-containers.js';
import { validateWidgetBindings } from './validate-widget-bindings.js';
import { validateDashboardActionRefs } from './validate-dashboard-action-refs.js';
import { validateFilterTokens } from './validate-filter-tokens.js';
import { validatePresetComparands } from './validate-preset-comparands.js';
import { validateEmptyCombinators } from './validate-empty-combinators.js';
import { validateReferenceIntegrity } from './reference-integrity-suite.js';
import { validateComponentProps } from './validate-component-props.js';
Expand DownExpand Up@@ -542,6 +543,28 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT,
run: (stack) => validateFilterTokens(stack),
},
// #8793 (the ruled C half of #8690) — a declared dashboard date-range preset
// name (`last_30_days`, …) authored as a bare ORDERING comparand resolves in
// no layer: the engine refuses it on a declared temporal field at query time
// (INVALID_FILTER / 400, PR #8808), and anywhere else it compares as a
// literal string. This is the authoring-time refusal the ruling shipped
// alongside the engine door, judging the filter literal in isolation —
// ordering positions only, all three authored filter shapes. Like
// `validateEmptyCombinators` it needs NO resolution context, so
// RUNTIME_NEEDS_FULL_SNAPSHOT does not apply and the runtime gate runs it
// for every filter-carrying type the gate already maps: the write path is
// the one door an AI author uses, and dashboards/views are where the preset
// vocabulary is near enough to reach for.
{
name: 'validatePresetComparands',
tier: 'gating',
input: 'parsed',
commands: ALL,
source: 'packages/lint/src/validate-preset-comparands.ts',
surfaces: CLI_AND_RUNTIME,
runtimeTypes: ['dashboard', 'view', 'object', 'page', 'flow'],
run: (stack) => validatePresetComparands(stack),
},
// #5330 — the LITERAL empty combinators (`$and: []`, `$or: []`, `$not: {}`,
// `{}`). #5322 ruled their RUNTIME meaning to be the boolean identity, and
// this rule does not touch it: it refuses the literal SPELLINGS at authoring
Expand Down
2 changes: 2 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -304,6 +304,8 @@ export type {

export { validateFilterTokens, FILTER_TOKEN_UNKNOWN } from './validate-filter-tokens.js';
export type { FilterTokenFinding, FilterTokenSeverity } from './validate-filter-tokens.js';
export { validatePresetComparands, FILTER_PRESET_COMPARAND } from './validate-preset-comparands.js';
export type { PresetComparandFinding, PresetComparandSeverity } from './validate-preset-comparands.js';

// #5330 — the same subtree, judged for SHAPE rather than for its strings. The
// runtime meaning of an empty combinator is settled (#5322: boolean identity,
Expand Down
11 changes: 10 additions & 1 deletion packages/lint/src/runtime-gate.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -322,6 +322,10 @@ describe('the views[] visibility-predicate family at the runtime publish gate (#
expect(runtimeGatedTypes()).toContain('view');
expect(stackKeyForType('view')).toBe('views');
expect(runtimeAuthoringRulesFor('view').map((r) => r.name)).toEqual([
// #8793 — filter comparands, not predicates; registered ahead of the
// family and dispatched for `view` because list-view filter rules are
// one of the three shapes it judges.
'validatePresetComparands',
'validateVisibilityPredicates',
'validatePredicatePathRefs',
]);
Expand DownExpand Up@@ -480,6 +484,7 @@ describe('the views[] visibility-predicate family at the runtime publish gate (#
expect(result.advisories, JSON.stringify(result.advisories)).toEqual([]);
// "clean" and "nothing ran" must stay distinguishable.
expect(result.rulesRun).toEqual([
'validatePresetComparands', // #8793 — dispatched for `view`, clean here
'validateVisibilityPredicates',
'validatePredicatePathRefs',
]);
Expand DownExpand Up@@ -572,6 +577,7 @@ describe('the publish gate judges a schema-bound form at its own layer (#7815)',
expect(result.advisories, JSON.stringify(result.advisories)).toEqual([]);
// "clean" and "nothing ran" must stay distinguishable.
expect(result.rulesRun).toEqual([
'validatePresetComparands', // #8793 — dispatched for `view`, clean here
'validateVisibilityPredicates',
'validatePredicatePathRefs',
]);
Expand DownExpand Up@@ -711,6 +717,9 @@ describe('dashboard widget dataset bindings at the runtime publish gate (#7529)'
expect(stackKeyForType('dashboard')).toBe('dashboards');
expect(runtimeAuthoringRulesFor('dashboard').map((r) => r.name)).toEqual([
'validateWidgetBindings',
// #8793 — bare preset names in widget-filter ordering comparands are
// judged at the same door (no resolution context needed).
'validatePresetComparands',
]);
});

Expand DownExpand Up@@ -749,7 +758,7 @@ describe('dashboard widget dataset bindings at the runtime publish gate (#7529)'
expect(result.errors, JSON.stringify(result.errors)).toEqual([]);
expect(result.advisories, JSON.stringify(result.advisories)).toEqual([]);
// "clean" and "nothing ran" must stay distinguishable.
expect(result.rulesRun).toEqual(['validateWidgetBindings']);
expect(result.rulesRun).toEqual(['validateWidgetBindings', 'validatePresetComparands']);
});

it('the `datasets` snapshot key is LOAD-BEARING: without it the same board is refused', () => {
Expand Down
157 changes: 157 additions & 0 deletions packages/lint/src/validate-preset-comparands.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { validatePresetComparands, FILTER_PRESET_COMPARAND } from './validate-preset-comparands.js';

describe('validatePresetComparands (#8793 — the ruled C half of #8690)', () => {
it('returns nothing for an empty / absent stack', () => {
expect(validatePresetComparands(undefined)).toEqual([]);
expect(validatePresetComparands(null)).toEqual([]);
expect(validatePresetComparands({})).toEqual([]);
});

// The exact measured defect shape from #8690: $gte "last_30_days" → 200 / 0.
it('catches a bare preset under $gte in a dashboard widget filter, naming widget, path and fix', () => {
const findings = validatePresetComparands({
dashboards: [{
name: 'sales',
widgets: [{
id: 'won_deals', type: 'metric', dataset: 'deals', values: ['total'],
filter: { closed_at: { $gte: 'last_30_days' } },
}],
}],
});
expect(findings).toHaveLength(1);
const f = findings[0];
expect(f.severity).toBe('error');
expect(f.rule).toBe(FILTER_PRESET_COMPARAND);
expect(f.where).toBe('dashboard "sales" · widget "won_deals"');
expect(f.path).toBe('dashboards[0].widgets[0].filter.closed_at.$gte');
expect(f.message).toContain('last_30_days');
expect(f.message).toContain('{30_days_ago}'); // the spelling that works
expect(f.hint).toContain('{date-macro}');
});

it('judges all three authored filter shapes', () => {
const findings = validatePresetComparands({
views: [{
name: 'recent',
// Shape 2: view filter rules — ordering spelling, alias fold included.
filter: [
{ field: 'created_at', operator: 'after', value: 'last_7_days' },
{ field: 'created_at', operator: 'gte', value: 'last_30_days' },
{ field: 'created_at', operator: 'between', value: ['last_90_days', '2026-01-01'] },
],
}],
pages: [{
name: 'board',
// Shape 3: triples — infix and alias spellings.
components: [
{ type: 'list', filter: ['created_at', '>=', 'last_90_days'] },
{ type: 'list', filter: ['and', ['created_at', 'after', 'this_week'], ['status', '=', 'open']] },
],
}],
flows: [{
name: 'sweep',
// Shape 1: Mongo-style, on a flow CRUD node.
nodes: [{ id: 'find', config: { filter: { updated_at: { $lt: 'last_month' } } } }],
}],
});
const paths = findings.map((f) => f.path).sort();
expect(paths).toEqual([
'flows[0].nodes[0].config.filter.updated_at.$lt',
'pages[0].components[0].filter[2]',
'pages[0].components[1].filter[1][2]',
'views[0].filter[0].value',
'views[0].filter[1].value',
'views[0].filter[2].value[0]',
].sort());
for (const f of findings) expect(f.severity).toBe('error');
});

it('stays quiet on every legitimate spelling — the discriminating controls', () => {
expect(validatePresetComparands({
dashboards: [{
name: 'ok',
widgets: [{
id: 'w', type: 'metric', dataset: 'd', values: ['v'],
// The platform's own correct spellings in the SAME positions.
filter: {
closed_at: { $gte: '{30_days_ago}' },
opened_at: { $between: ['{week_start}', '{week_end}'] },
signed_at: { $lt: '2026-01-15' },
},
}],
}],
views: [{
name: 'v',
filter: [
{ field: 'closed_at', operator: 'after', value: '{30_days_ago}' },
{ field: 'closed_at', operator: 'after', value: '2026-01-15' },
// Equality against a picklist value that collides with a preset
// name is an author's own vocabulary — not this rule's business.
{ field: 'period', operator: 'equals', value: 'this_quarter' },
{ field: 'period', operator: 'in', value: ['last_30_days'] },
],
}],
pages: [{
name: 'p',
components: [
{ type: 'list', filter: ['closed_at', '>=', '{30_days_ago}'] },
// Equality triple — not judged.
{ type: 'list', filter: ['period', '=', 'last_30_days'] },
],
}],
flows: [{
name: 'f',
nodes: [{
id: 'n',
config: {
filter: {
// Equality / membership — not judged (engine door owns the
// temporal-field case, field type in hand).
period: 'this_quarter',
window: { $in: ['last_7_days'] },
// Undeclared string — the field-typed engine door owns it.
updated_at: { $gte: 'not-a-date-at-all' },
// The empty-string cell stays its own card, by ruling.
created_at: { $gte: '' },
},
},
}],
}],
})).toEqual([]);
});

it('walks $and / $or / $not and nested relations in the Mongo shape', () => {
const findings = validatePresetComparands({
objects: [{
name: 'deal',
listViews: [{
name: 'recent',
filter: {
$and: [{ $or: [{ closed_at: { $gte: 'last_7_days' } }] }],
$not: { account: { created_at: { $lt: 'this_year' } } },
},
}],
}],
});
const paths = findings.map((f) => f.path).sort();
expect(paths).toEqual([
'objects[0].listViews[0].filter.$and[0].$or[0].closed_at.$gte',
'objects[0].listViews[0].filter.$not.account.created_at.$lt',
].sort());
});

it('does not double-report a value the token rule already owns', () => {
// `{last_30_days}` is a WRAPPED unknown token — validate-filter-tokens'
// verdict (FILTER_TOKEN_UNKNOWN), not this rule's: a preset name carries
// no braces, so the two vocabularies cannot collide on one value.
expect(validatePresetComparands({
dashboards: [{
name: 'd',
widgets: [{ id: 'w', type: 'metric', dataset: 'x', values: ['v'], filter: { closed_at: { $gte: '{last_30_days}' } } }],
}],
})).toEqual([]);
});
});
Loading
Loading