Skip to content

Commit 763931e

Browse files
authored
fix(filters): evaluate {token} placeholders on the server read path (#3582) (#3809)
The `{date-macro}` / `{current_user_id}` vocabulary has been in `@objectstack/spec` for a while, and `objectstack build` rejects tokens outside it (#3574). What was missing is the half that SUBSTITUTES A VALUE: nothing on the server ever did, so a placeholder reached the driver as a literal string, compared as text, and matched nothing — an empty widget indistinguishable from a legitimately-zero metric. `resolveFilterTokens()` (`@objectstack/core`) is the evaluator, wired into the two server-side seams a filter passes through: the ObjectQL read path (find/findOne/count/aggregate, ahead of the middleware chain so only author-supplied filters are inspected) and the analytics dataset executor (a dashboard widget never passes engine.find() — NativeSQLStrategy binds comparands into raw SQL directly). Date tokens resolve to ISO strings; the driver still owns the on-disk form. One instant is pinned per resolve call, so a `>= {current_month_start}` / `< {next_month_start}` pair cannot straddle a boundary. Period arithmetic normalizes to the period start before stepping and clamps day-of-month, so `{1_month_ago}` from Mar 31 is Feb 28, not Mar 3. An unrecognised or unresolvable placeholder throws with the near-miss fix and `status: 400` + a code, landing on the REST layer's existing 4xx passthrough instead of a 500. Also: `notify` no longer sends the literal string "undefined" as an audience member when a recipient template resolves to nothing; the node fails naming the template and pointing at the start node's `config.expand` (#3475). Follow-up filed as #3810: flow node `config.filter` is interpolated by the flow template engine first, which still blanks filter tokens silently.
1 parent 3ea7271 commit 763931e

18 files changed

Lines changed: 1427 additions & 45 deletions

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
---
2+
"@objectstack/core": minor
3+
"@objectstack/objectql": minor
4+
"@objectstack/service-analytics": minor
5+
"@objectstack/service-automation": patch
6+
"@objectstack/spec": patch
7+
---
8+
9+
feat(filters): evaluate `{filter-token}` placeholders server-side (#3582)
10+
11+
Filter values travel as JSON, so a time- or user-scoped slice writes a
12+
placeholder instead of code:
13+
14+
```ts
15+
filter: { close_date: { $gte: '{current_year_start}' }, owner: '{current_user_id}' }
16+
```
17+
18+
The vocabulary has been in `@objectstack/spec` for a while (`date-macros.zod.ts`,
19+
`context-tokens.zod.ts`) and `objectstack build` rejects tokens outside it
20+
(#3574). What was missing is the half that *substitutes a value*: **nothing on
21+
the server ever did**. A placeholder reached the driver as the literal string
22+
`'{current_year_start}'`, compared as text, and matched nothing.
23+
24+
That failure is invisible — an empty widget looks exactly like a metric that is
25+
legitimately zero — so apps worked around it by computing dates at module load,
26+
which freezes "this year" into the built artifact and quietly goes stale.
27+
28+
**New: `resolveFilterTokens()` in `@objectstack/core`**, wired into the two
29+
server-side seams every filter passes through:
30+
31+
- **ObjectQL read path**`find` / `findOne` / `count` / `aggregate`, so REST
32+
queries, related lists, saved-view filters and flow `find_records` all resolve.
33+
It runs before the middleware chain, so only author-supplied filters are
34+
inspected; RLS/sharing filters are injected downstream from concrete values.
35+
- **Analytics dataset executor** — a dataset's intrinsic `filter`, a widget's
36+
`runtimeFilter`, measure-scoped filters, and time-dimension `dateRange`s.
37+
This path needs its own call: `NativeSQLStrategy` compiles raw SQL and binds
38+
comparands directly, so a dashboard widget never passes through `engine.find()`.
39+
40+
Behavioural notes:
41+
42+
- Date tokens resolve to ISO strings (`YYYY-MM-DD`, or a full timestamp for
43+
`{now}` / `{N_hours_ago}` / `{N_minutes_ago}`). Turning that into a column's
44+
on-disk form stays the driver's job (`SqlDriver.temporalFilterValue`), so
45+
there is still exactly one source of truth for the storage convention.
46+
- Calendar boundaries follow `ExecutionContext.timezone`; one instant is pinned
47+
per filter tree, so a `>= {current_month_start}` / `< {next_month_start}` pair
48+
can never straddle a boundary.
49+
- `{current_org_id}` reads `ExecutionContext.tenantId`; `{current_user_id}` reads
50+
`userId`. A request carrying neither now **throws** instead of resolving to
51+
`null` — a null comparand degrades to `IS NULL` on most drivers and would hand
52+
back the rows the filter was written to exclude.
53+
- An unrecognised placeholder **throws**, carrying the near-miss fix
54+
(`{current_user}``{current_user_id}`, `{this_quarter_start}`
55+
`{current_quarter_start}`). This matches what `objectstack build` already
56+
enforces. Consequence, previously implicit and now load-bearing: a filter value
57+
that is *entirely* `{...}` is always read as a placeholder, so a literal value
58+
of that shape is not expressible — rename the value.
59+
60+
Also in this change: `notify` no longer sends the six-character string
61+
`"undefined"` as an audience member. `to: ['{record.owner.manager}']` walks
62+
`.manager` on a scalar foreign-key id, resolves to nothing, and `String(undefined)`
63+
turned that into a phantom recipient — the emit "succeeded", addressed nobody,
64+
and said nothing. Unresolved recipients are now dropped, and a node with no
65+
recipient left fails naming the offending template and pointing at the start
66+
node's `config.expand` (#3475), which does hydrate the relation.

content/docs/data-modeling/analytics.mdx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,15 @@ export const SalesByStageReport = {
122122
```
123123

124124
`rows` are the pivot's down-axis dimensions; `values` are measure names. A matrix
125-
report adds across-axis dimensions; `runtimeFilter` is the render-time scope
126-
(`{date-macro}` placeholders are resolved by the renderer before querying).
125+
report adds across-axis dimensions; `runtimeFilter` is the render-time scope.
126+
127+
Placeholders inside a `filter` / `runtimeFilter``{date-macro}`s and the
128+
session tokens `{current_user_id}` / `{current_org_id}` — are resolved on **both**
129+
sides: by the renderer before it queries, and by the analytics dataset executor
130+
server-side. The server pass is what makes a widget work at all, since the
131+
native-SQL strategy compiles raw SQL and binds comparands directly without ever
132+
passing through a renderer. An unrecognised placeholder is a build error and a
133+
runtime error — never a literal string that quietly matches nothing.
127134

128135
## Cross-object joins
129136

content/docs/references/data/context-tokens.mdx

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,29 @@ slice cannot call `currentUser().id` inline — it writes a placeholder:
2121

2222
[\{ field: 'owner', operator: 'equals', value: '\{current_user_id\}' \}]
2323

24-
Like date macros, the placeholders are expanded **client-side** by
24+
Like date macros, the placeholders are expanded on **both** sides of
2525

26-
`resolveContextTokens()` in `@object-ui/core` immediately before the
26+
the wire (framework#3582): `resolveContextTokens()` in
2727

28-
filter is handed to the data source. The data engine only ever sees
28+
`@object-ui/core` before the filter leaves the browser, and
2929

30-
concrete ids, never `\{tokens\}`.
30+
`resolveFilterTokens()` in `@objectstack/core` on the ObjectQL read
31+
32+
path and the analytics dataset executor for filters that reach the
33+
34+
database without passing through a renderer. The DRIVER only ever
35+
36+
sees concrete ids, never `\{tokens\}`.
37+
38+
The server resolver reads `ExecutionContext``\{current_user_id\}` is
39+
40+
`userId`, `\{current_org_id\}` is `tenantId`. A request that carries
41+
42+
neither is an ERROR, not a null comparand: resolving to `null`
43+
44+
degrades to `IS NULL` on most drivers and would hand back the rows
45+
46+
the filter was written to exclude.
3147

3248
# Presentation scope, NOT a security boundary
3349

content/docs/references/data/date-macros.mdx

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,41 @@ inline; they use a tiny placeholder grammar instead:
2121

2222
\{ signal_at: \{ $gte: '\{30_days_ago\}' \} \}
2323

24-
The placeholders are expanded **client-side** by
24+
The placeholders are expanded on **both** sides of the wire, so a
2525

26-
`resolveDateMacros()` in `@object-ui/core` immediately before the
26+
filter behaves the same wherever it is executed (framework#3582):
2727

28-
filter is handed to the data source. The data engine itself only
28+
- **Client**`resolveDateMacros()` in `@object-ui/core`, just
2929

30-
sees ISO date / timestamp strings, never `\{tokens\}`.
30+
before the filter is handed to the data source.
31+
32+
- **Server**`resolveFilterTokens()` in `@objectstack/core`, wired
33+
34+
into the ObjectQL read path (`find`/`findOne`/`count`/`aggregate`)
35+
36+
and the analytics dataset executor. Filters that reach the database
37+
38+
WITHOUT passing through a renderer — dashboard widgets, dataset
39+
40+
definitions, REST query params — need this: before it, the token
41+
42+
compared as a literal string and matched nothing.
43+
44+
Either way the DRIVER only ever sees ISO date / timestamp strings,
45+
46+
never `\{tokens\}`. Translating an ISO comparand into a column's on-disk
47+
48+
form (SQLite epoch-ms, `YYYY-MM-DD` text, native timestamp) is the
49+
50+
driver's job — see `SqlDriver.temporalFilterValue`.
51+
52+
A token OUTSIDE this vocabulary is rejected rather than passed
53+
54+
through: `@objectstack/lint`'s `validate-filter-tokens` fails the
55+
56+
build, and the runtime resolver throws. Silently matching nothing is
57+
58+
the failure mode the vocabulary exists to prevent.
3159

3260
AI agents and template authors author these placeholders directly,
3361

@@ -65,7 +93,15 @@ formula engine. They are unrelated to these placeholders; see
6593

6694
calendars) are defined by the resolver implementation; spec only
6795

68-
freezes the **vocabulary**.
96+
freezes the **vocabulary**. One property is worth stating here
97+
98+
because it is authored against: a `*_end` token is the period's
99+
100+
last calendar DAY (`\{current_year_end\}``2026-12-31`), so on a
101+
102+
`datetime` column `<= \{current_year_end\}` stops at midnight on the
103+
104+
31st. Filter a timestamp with the half-open `< \{next_year_start\}`.
69105

70106
<Callout type="info">
71107
**Source:** `packages/spec/src/data/date-macros.zod.ts`

packages/core/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ export * from './utils/datetime.js';
2929
// Export the shared batched-write helper (framework#2678)
3030
export * from './utils/bulk-write.js';
3131

32+
// Export the runtime filter-placeholder resolver (framework#3582)
33+
export * from './utils/filter-tokens.js';
34+
3235
// Export in-memory fallbacks for core-criticality services
3336
export * from './fallbacks/index.js';
3437

0 commit comments

Comments
 (0)