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
66 changes: 66 additions & 0 deletions .changeset/filter-tokens-runtime-resolver.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
---
"@objectstack/core": minor
"@objectstack/objectql": minor
"@objectstack/service-analytics": minor
"@objectstack/service-automation": patch
"@objectstack/spec": patch
---

feat(filters): evaluate `{filter-token}` placeholders server-side (#3582)

Filter values travel as JSON, so a time- or user-scoped slice writes a
placeholder instead of code:

```ts
filter: { close_date: { $gte: '{current_year_start}' }, owner: '{current_user_id}' }
```

The vocabulary has been in `@objectstack/spec` for a while (`date-macros.zod.ts`,
`context-tokens.zod.ts`) and `objectstack build` rejects tokens outside it
(#3574). What was missing is the half that *substitutes a value*: **nothing on
the server ever did**. A placeholder reached the driver as the literal string
`'{current_year_start}'`, compared as text, and matched nothing.

That failure is invisible — an empty widget looks exactly like a metric that is
legitimately zero — so apps worked around it by computing dates at module load,
which freezes "this year" into the built artifact and quietly goes stale.

**New: `resolveFilterTokens()` in `@objectstack/core`**, wired into the two
server-side seams every filter passes through:

- **ObjectQL read path** — `find` / `findOne` / `count` / `aggregate`, so REST
queries, related lists, saved-view filters and flow `find_records` all resolve.
It runs before the middleware chain, so only author-supplied filters are
inspected; RLS/sharing filters are injected downstream from concrete values.
- **Analytics dataset executor** — a dataset's intrinsic `filter`, a widget's
`runtimeFilter`, measure-scoped filters, and time-dimension `dateRange`s.
This path needs its own call: `NativeSQLStrategy` compiles raw SQL and binds
comparands directly, so a dashboard widget never passes through `engine.find()`.

Behavioural notes:

- Date tokens resolve to ISO strings (`YYYY-MM-DD`, or a full timestamp for
`{now}` / `{N_hours_ago}` / `{N_minutes_ago}`). Turning that into a column's
on-disk form stays the driver's job (`SqlDriver.temporalFilterValue`), so
there is still exactly one source of truth for the storage convention.
- Calendar boundaries follow `ExecutionContext.timezone`; one instant is pinned
per filter tree, so a `>= {current_month_start}` / `< {next_month_start}` pair
can never straddle a boundary.
- `{current_org_id}` reads `ExecutionContext.tenantId`; `{current_user_id}` reads
`userId`. A request carrying neither now **throws** instead of resolving to
`null` — a null comparand degrades to `IS NULL` on most drivers and would hand
back the rows the filter was written to exclude.
- An unrecognised placeholder **throws**, carrying the near-miss fix
(`{current_user}` → `{current_user_id}`, `{this_quarter_start}` →
`{current_quarter_start}`). This matches what `objectstack build` already
enforces. Consequence, previously implicit and now load-bearing: a filter value
that is *entirely* `{...}` is always read as a placeholder, so a literal value
of that shape is not expressible — rename the value.

Also in this change: `notify` no longer sends the six-character string
`"undefined"` as an audience member. `to: ['{record.owner.manager}']` walks
`.manager` on a scalar foreign-key id, resolves to nothing, and `String(undefined)`
turned that into a phantom recipient — the emit "succeeded", addressed nobody,
and said nothing. Unresolved recipients are now dropped, and a node with no
recipient left fails naming the offending template and pointing at the start
node's `config.expand` (#3475), which does hydrate the relation.
11 changes: 9 additions & 2 deletions content/docs/data-modeling/analytics.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,15 @@ export const SalesByStageReport = {
```

`rows` are the pivot's down-axis dimensions; `values` are measure names. A matrix
report adds across-axis dimensions; `runtimeFilter` is the render-time scope
(`{date-macro}` placeholders are resolved by the renderer before querying).
report adds across-axis dimensions; `runtimeFilter` is the render-time scope.

Placeholders inside a `filter` / `runtimeFilter` — `{date-macro}`s and the
session tokens `{current_user_id}` / `{current_org_id}` — are resolved on **both**
sides: by the renderer before it queries, and by the analytics dataset executor
server-side. The server pass is what makes a widget work at all, since the
native-SQL strategy compiles raw SQL and binds comparands directly without ever
passing through a renderer. An unrecognised placeholder is a build error and a
runtime error — never a literal string that quietly matches nothing.

## Cross-object joins

Expand Down
24 changes: 20 additions & 4 deletions content/docs/references/data/context-tokens.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,29 @@ slice cannot call `currentUser().id` inline — it writes a placeholder:

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

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

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

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

concrete ids, never `\{tokens\}`.
`resolveFilterTokens()` in `@objectstack/core` on the ObjectQL read

path and the analytics dataset executor for filters that reach the

database without passing through a renderer. The DRIVER only ever

sees concrete ids, never `\{tokens\}`.

The server resolver reads `ExecutionContext` — `\{current_user_id\}` is

`userId`, `\{current_org_id\}` is `tenantId`. A request that carries

neither is an ERROR, not a null comparand: resolving to `null`

degrades to `IS NULL` on most drivers and would hand back the rows

the filter was written to exclude.

# Presentation scope, NOT a security boundary

Expand Down
46 changes: 41 additions & 5 deletions content/docs/references/data/date-macros.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,41 @@ inline; they use a tiny placeholder grammar instead:

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

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

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

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

sees ISO date / timestamp strings, never `\{tokens\}`.
before the filter is handed to the data source.

- **Server** — `resolveFilterTokens()` in `@objectstack/core`, wired

into the ObjectQL read path (`find`/`findOne`/`count`/`aggregate`)

and the analytics dataset executor. Filters that reach the database

WITHOUT passing through a renderer — dashboard widgets, dataset

definitions, REST query params — need this: before it, the token

compared as a literal string and matched nothing.

Either way the DRIVER only ever sees ISO date / timestamp strings,

never `\{tokens\}`. Translating an ISO comparand into a column's on-disk

form (SQLite epoch-ms, `YYYY-MM-DD` text, native timestamp) is the

driver's job — see `SqlDriver.temporalFilterValue`.

A token OUTSIDE this vocabulary is rejected rather than passed

through: `@objectstack/lint`'s `validate-filter-tokens` fails the

build, and the runtime resolver throws. Silently matching nothing is

the failure mode the vocabulary exists to prevent.

AI agents and template authors author these placeholders directly,

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

calendars) are defined by the resolver implementation; spec only

freezes the **vocabulary**.
freezes the **vocabulary**. One property is worth stating here

because it is authored against: a `*_end` token is the period's

last calendar DAY (`\{current_year_end\}` → `2026-12-31`), so on a

`datetime` column `<= \{current_year_end\}` stops at midnight on the

31st. Filter a timestamp with the half-open `< \{next_year_start\}`.

<Callout type="info">
**Source:** `packages/spec/src/data/date-macros.zod.ts`
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ export * from './utils/datetime.js';
// Export the shared batched-write helper (framework#2678)
export * from './utils/bulk-write.js';

// Export the runtime filter-placeholder resolver (framework#3582)
export * from './utils/filter-tokens.js';

// Export in-memory fallbacks for core-criticality services
export * from './fallbacks/index.js';

Expand Down
Loading
Loading