fix(filters): evaluate {token} placeholders on the server read path (#3582) - #3809
Merged
os-zhuang merged 2 commits intoJul 28, 2026
Merged
Conversation
…3582) Filter values travel as JSON, so a time- or user-scoped slice writes a placeholder instead of code: 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. The placeholder reached the driver as the literal string, compared as text, and matched nothing. `scripts/analytics-reconcile/macros.ts` said it outright — "this repo has no runtime resolver" — and pre-resolved tokens itself so the harness would not diverge. That failure is invisible: an empty widget looks exactly like a metric that is legitimately zero. Apps worked around it by computing dates at module load, which freezes "this year" into the built artifact. `resolveFilterTokens()` in `@objectstack/core` is the evaluator, wired into the two server-side seams a filter passes through: - ObjectQL read path — find / findOne / count / aggregate, so REST queries, related lists, saved-view filters and sharing-graph reads 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 — the dataset's intrinsic `filter`, a widget's `runtimeFilter`, measure-scoped filters, time-dimension `dateRange`s. This path needs its own call: NativeSQLStrategy compiles raw SQL and binds comparands directly, so a dashboard widget never passes engine.find(). Semantics: - Date tokens resolve to ISO strings; turning those 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, and one instant is pinned per resolve call so a `>= {current_month_start}` / `< {next_month_start}` pair can never straddle a boundary. - Month/quarter/year arithmetic normalizes to the period start before stepping, and the parameterised grammar clamps day-of-month, so `{1_month_ago}` from Mar 31 is Feb 28 rather than rolling to Mar 3. - `{current_user_id}` / `{current_org_id}` read userId / tenantId. A request carrying neither throws instead of resolving to null — a null comparand degrades to IS NULL on most drivers and would return the rows the filter was written to exclude. - An unrecognised placeholder throws, carrying the near-miss fix (`{current_user}` -> `{current_user_id}`), matching what the build lint already enforces. Both errors carry status 400 + a code, so the REST layer's generic 4xx passthrough reports them as fixable client errors, not 500s. Also: `notify` no longer sends the literal 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 anywhere. Unresolved recipients are dropped, and a node left with none fails naming the offending template and pointing at the start node's `config.expand` (#3475), which does hydrate the relation. Docs corrected where they claimed resolution was client-side only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EdCvGtER9SzJopS4Yh3Pa1
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 4 package(s): 116 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…s-filters-notifications-nicmhd
os-zhuang
marked this pull request as ready for review
July 28, 2026 07:14
os-zhuang
deleted the
claude/template-tokens-filters-notifications-nicmhd
branch
July 28, 2026 07:15
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #3582.
问题
筛选值以 JSON 形式传输,所以按时间/按人筛选的切片只能写占位符,不能写代码:
词表早已在
@objectstack/spec里(date-macros.zod.ts、context-tokens.zod.ts),objectstack build也会拒绝词表之外的 token(#3574)。缺的是真正代入值的那一半:服务端从来没有做过替换。占位符原样到达 driver,被当成字符串比较,匹配不到任何行。scripts/analytics-reconcile/macros.ts里写得很直白 —— "this repo has no runtime resolver" —— 所以那个对账脚本只能自己先解析一遍。这个失败是看不见的:一个空的 metric widget,和一个本来就等于 0 的指标长得一模一样。应用只能绕开它,在模块加载时算好日期 —— 于是"今年"被冻结进了构建产物。
Issue 里报的三条,分别对应到当前代码是这样:
{current_user}静默不匹配config.expandopt-in(#3475)+ 构建期告警(#3426);本 PR 修掉残留的字面量"undefined"改动
新增
resolveFilterTokens()(@objectstack/core),接到筛选条件必经的两个服务端接缝:find/findOne/count/aggregate。REST 查询、相关列表、保存视图的筛选、sharing graph 读取因此全部生效。它跑在中间件链之前,所以只检查作者写的筛选;下游注入的 RLS/sharing 筛选是用具体值拼的,不含占位符。filter、widget 的runtimeFilter、measure 级筛选、time dimension 的dateRange。这条路必须单独调用:NativeSQLStrategy直接编译裸 SQL 并绑定比较值,dashboard widget 根本不经过engine.find()—— 这正是这类 widget 一直显示 0 的原因。语义上的几点决定:
日期 token 解析为 ISO 字符串;转成列的落盘形式仍然是 driver 的职责(
SqlDriver.temporalFilterValue),存储约定保持单一事实来源。日历边界跟随
ExecutionContext.timezone;每次 resolve 只取一个瞬间,所以>= {current_month_start}/< {next_month_start}这一对不可能跨月错开。月/季/年运算先归一到周期起点再步进,参数化文法按月长 clamp:从 3 月 31 日算
{1_month_ago}得到 2 月 28 日,而不是溢出到 3 月 3 日。{current_user_id}/{current_org_id}读userId/tenantId。两者都没有时抛错,不解析成null——null比较值在多数 driver 上退化成IS NULL,会把筛选本想排除的行交回来。无法识别的占位符抛错,并带上近似拼写的改法(
{current_user}→{current_user_id}),与构建期 lint 保持一致。{...}的筛选值一律按占位符解释,因此无法用它表示字面量。这是 Dashboard widget filters do not interpolate {current_user_id} — user-scoped widgets silently render 0 #3574 的 lint 文案里已经写下的约定,本 PR 只是让运行期与之对齐。两个错误都带
status: 400+code,直接命中 REST 层已有的 4xx 透传分支 —— 报成可修复的客户端错误,而不是 500。另外:
notify不再把字面量字符串"undefined"当收件人发出去。to: ['{record.owner.manager}']在标量外键 id 上走.manager,解析为空,而String(undefined)把它变成了一个幽灵收件人 —— emit "成功"、谁也没收到、任何地方都没有报错。现在解析为空的收件人会被丢弃;若一个都不剩,节点失败并指名是哪个模板没解析出来,同时指向 start 节点的config.expand(#3475,那才是真正能 hydrate 关系的开关)。文档
修正了几处仍然声称"只在客户端解析"的说法:
date-macros.zod.ts/context-tokens.zod.ts的模块注释、skills/objectstack-query/rules/filters.md、skills/objectstack-ui/SKILL.md、content/docs/data-modeling/analytics.mdx,以及analytics-reconcile/macros.ts里那句已经过时的 "no runtime resolver"。同时把*_end是日历日(datetime列应改用半开区间< {next_*_start})这一点写进了文档 —— 它一直是词表的既定语义,只是从没写明。验证
packages/core/src/utils/filter-tokens.test.ts(28)、packages/objectql/src/engine-filter-tokens.test.ts(9)、packages/services/service-analytics/src/__tests__/dataset-filter-tokens.test.ts(6),另有 3 个补进notify-node.test.ts。engine / analytics 的断言都落在驱动真正收到的东西上(driver AST、绑定的 SQL 参数),而不是中间结果。pnpm build通过;pnpm test132/132 task 全绿。一个范围外的发现(会另开 issue)
flow 节点的
config.filter会先经过 flow 模板引擎,后者在该位置拥有{…}的解释权,并把任何非 flow 变量的 token 抹成空。所以 flow 的筛选用不了 date macro,而且是静默的。这是既有行为,不在本 PR 范围内,已在 skill 文档里加了一句说明,并会单独开 issue 跟进。🤖 Generated with Claude Code
https://claude.ai/code/session_01EdCvGtER9SzJopS4Yh3Pa1
Generated by Claude Code