Skip to content

fix(filters): evaluate {token} placeholders on the server read path (#3582) - #3809

Merged
os-zhuang merged 2 commits into
mainfrom
claude/template-tokens-filters-notifications-nicmhd
Jul 28, 2026
Merged

fix(filters): evaluate {token} placeholders on the server read path (#3582)#3809
os-zhuang merged 2 commits into
mainfrom
claude/template-tokens-filters-notifications-nicmhd

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes#3582.

问题

筛选值以 JSON 形式传输,所以按时间/按人筛选的切片只能写占位符,不能写代码:

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

词表早已在 @objectstack/spec 里(date-macros.zod.tscontext-tokens.zod.ts),objectstack build 也会拒绝词表之外的 token(#3574)。缺的是真正代入值的那一半:服务端从来没有做过替换。占位符原样到达 driver,被当成字符串比较,匹配不到任何行。scripts/analytics-reconcile/macros.ts 里写得很直白 —— "this repo has no runtime resolver" —— 所以那个对账脚本只能自己先解析一遍。

这个失败是看不见的:一个空的 metric widget,和一个本来就等于 0 的指标长得一模一样。应用只能绕开它,在模块加载时算好日期 —— 于是"今年"被冻结进了构建产物。

Issue 里报的三条,分别对应到当前代码是这样:

#现状
日期 token 不生效✅ 本 PR 修复(服务端没有 resolver)
{current_user} 静默不匹配构建期已被 #3574 的 lint 拦下;本 PR 补上运行期同样的大声报错并给出改法
通知模板不能穿透 lookup穿透本身已有 config.expand opt-in(#3475)+ 构建期告警(#3426);本 PR 修掉残留的字面量 "undefined"

关于 issue 标题里的 "CEL tokens":筛选值不支持 CEL(cel`…` 是 seed loader 的机制,spec 的 date-macros 文档里明确把 CEL 列为 out of scope)。issue 给的例子全部是 date macro,已覆盖。

改动

新增 resolveFilterTokens()(@objectstack/core),接到筛选条件必经的两个服务端接缝:

  • ObjectQL 读路径find / findOne / count / aggregate。REST 查询、相关列表、保存视图的筛选、sharing graph 读取因此全部生效。它跑在中间件链之前,所以只检查作者写的筛选;下游注入的 RLS/sharing 筛选是用具体值拼的,不含占位符。
  • Analytics dataset executor — dataset 自身的 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.mdskills/objectstack-ui/SKILL.mdcontent/docs/data-modeling/analytics.mdx,以及 analytics-reconcile/macros.ts 里那句已经过时的 "no runtime resolver"。同时把 *_end日历日(datetime 列应改用半开区间 < {next_*_start})这一点写进了文档 —— 它一直是词表的既定语义,只是从没写明。

验证

  • 新增 43 个测试: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 test 132/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

…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
@vercel

vercelBot commented Jul 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredJul 28, 2026 7:01am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/core, @objectstack/objectql, packages/services, @objectstack/spec.

116 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/actions-as-tools.mdx(via @objectstack/core)
  • content/docs/ai/agents.mdx(via @objectstack/spec)
  • content/docs/ai/knowledge-rag.mdx(via @objectstack/core)
  • content/docs/ai/natural-language-queries.mdx(via @objectstack/core)
  • content/docs/ai/skills-reference.mdx(via @objectstack/spec)
  • content/docs/ai/skills.mdx(via @objectstack/spec)
  • content/docs/api/client-sdk.mdx(via @objectstack/spec)
  • content/docs/api/environment-routing.mdx(via @objectstack/spec)
  • content/docs/api/error-catalog.mdx(via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx(via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx(via @objectstack/spec)
  • content/docs/api/index.mdx(via @objectstack/spec)
  • content/docs/automation/approvals.mdx(via packages/spec)
  • content/docs/automation/flows.mdx(via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx(via packages/spec)
  • content/docs/automation/hooks.mdx(via @objectstack/spec)
  • content/docs/automation/index.mdx(via @objectstack/spec)
  • content/docs/automation/webhooks.mdx(via @objectstack/core, packages/services, @objectstack/spec)
  • content/docs/automation/workflows.mdx(via @objectstack/spec)
  • content/docs/concepts/architecture.mdx(via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx(via packages/spec)
  • content/docs/concepts/index.mdx(via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx(via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/objectql, packages/spec)
  • content/docs/concepts/north-star.mdx(via packages/core, packages/spec)
  • content/docs/data-modeling/analytics.mdx(via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx(via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx(via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx(via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx(via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx(via packages/objectql, @objectstack/spec)
  • content/docs/data-modeling/index.mdx(via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx(via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx(via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx(via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx(via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx(via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx(via @objectstack/spec)
  • content/docs/deployment/cli.mdx(via @objectstack/spec)
  • content/docs/deployment/migration-from-objectql.mdx(via @objectstack/core, @objectstack/objectql)
  • content/docs/deployment/troubleshooting.mdx(via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx(via @objectstack/spec)
  • content/docs/deployment/vercel.mdx(via @objectstack/objectql)
  • content/docs/getting-started/build-with-claude-code.mdx(via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx(via @objectstack/spec)
  • content/docs/getting-started/examples.mdx(via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx(via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx(via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx(via @objectstack/spec)
  • content/docs/kernel/cluster.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx(via @objectstack/core, @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx(via packages/spec)
  • content/docs/kernel/index.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/audit-service.mdx(via packages/services)
  • content/docs/kernel/runtime-services/email-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx(via @objectstack/core)
  • content/docs/kernel/runtime-services/index.mdx(via packages/services, packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/settings-service.mdx(via packages/services)
  • content/docs/kernel/runtime-services/sharing-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx(via packages/spec)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/core, @objectstack/objectql, @objectstack/spec)
  • content/docs/kernel/services.mdx(via @objectstack/core, @objectstack/objectql)
  • content/docs/permissions/authentication.mdx(via @objectstack/core, @objectstack/objectql)
  • content/docs/permissions/authorization.mdx(via packages/core, @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx(via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx(via @objectstack/spec)
  • content/docs/permissions/positions.mdx(via @objectstack/spec)
  • content/docs/permissions/rls.mdx(via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx(via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx(via @objectstack/spec)
  • content/docs/plugins/anatomy.mdx(via @objectstack/core)
  • content/docs/plugins/development.mdx(via @objectstack/core, @objectstack/spec)
  • content/docs/plugins/index.mdx(via @objectstack/core, @objectstack/objectql, @objectstack/spec)
  • content/docs/plugins/packages.mdx(via @objectstack/core, @objectstack/objectql, packages/services, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx(via @objectstack/spec)
  • content/docs/protocol/diagram.mdx(via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx(via @objectstack/core, @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx(via packages/services, @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/core, @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx(via @objectstack/core, @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx(via @objectstack/core, @objectstack/spec)
  • content/docs/protocol/kernel/runtime-capabilities.mdx(via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx(via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx(via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx(via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx(via @objectstack/core, @objectstack/objectql, @objectstack/spec)
  • content/docs/releases/index.mdx(via @objectstack/spec)
  • content/docs/releases/v12.mdx(via @objectstack/core, @objectstack/spec)
  • content/docs/releases/v13.mdx(via @objectstack/spec)
  • content/docs/releases/v15.mdx(via @objectstack/core)
  • content/docs/releases/v16.mdx(via @objectstack/spec)
  • content/docs/releases/v9.mdx(via @objectstack/objectql, @objectstack/spec)
  • content/docs/ui/actions.mdx(via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx(via @objectstack/spec)
  • content/docs/ui/dashboards.mdx(via @objectstack/spec)
  • content/docs/ui/forms.mdx(via @objectstack/spec)
  • content/docs/ui/index.mdx(via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx(via @objectstack/spec)
  • content/docs/ui/setup-app.mdx(via @objectstack/spec)
  • content/docs/ui/translations.mdx(via @objectstack/spec)
  • content/docs/ui/views.mdx(via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Template tokens in filter values and notification templates are not evaluated at runtime

2 participants

@os-zhuang@claude