Skip to content

Commit e7f69db

Browse files
feat(spec): a record-scoped filter token {record_id}, refused by name where no record is in context (#20180)
Fixes #20003 Clause-②: yes ## What this does Adds `{record_id}`, a filter token for the id of the record a `type: 'record'` page is showing. It is the spec half of objectui#7297; the renderer's resolver is that card's work. - **Recognised by name.** `classifyFilterToken('{record_id}')` returns a new kind, `{ kind: 'record-context', token: 'record_id' }`, instead of `unknown`. - **Refused by name on every server path.** `resolveFilterTokens()` throws `UnresolvedFilterTokenError` (`FILTER_TOKEN_UNRESOLVED` / 400, `token: 'record_id'`) whatever the request carries. The token never becomes `null`, `undefined` or the literal. - **Allowed by lint only where a record is in context.** `validate-filter-tokens` accepts it in any filter on a `type: 'record'` page. It refuses it everywhere else with "no record in context on this surface". - **Documented** in the module header, which generates `content/docs/references/data/context-tokens.mdx`. The header says where the token is honoured and separates it from `{recordId}` and the `'record_id'` page-variable type. Maintainer ruling, verbatim (quoted in the card): 「需要我决定的 6 件事: 6947 7300 不做;其他同意」. objectui#7297 is among 「其他」, so the ruled shape is: do `{record_id}`; with no record context it fails with a clear error, never an empty value. ## Decisions taken, with the measurement behind each **1. A sibling list (`RECORD_CONTEXT_TOKENS`), not a member of `CONTEXT_TOKENS`.** The card left this open, so I measured every consumer of `CONTEXT_TOKENS` / `isContextToken` / `CONTEXT_TOKEN_SUGGESTIONS`: - objectstack at merge base `84880f9`: two readers. - The lint hint's token list (`validate-filter-tokens.ts`): joining would advertise `{record_id}` on the surfaces that refuse it. - `ContextTokenSchema` / `ContextTokenPlaceholderSchema`: joining would accept it on every surface. - objectui at `a2f361b` (`packages/*/src`, `apps/*/src`): one reader, `packages/core/src/utils/filter-tokens.ts` (`resolveContextTokens`). It fills a session map pinned by a `satisfies` clause: a `Record` keyed by `ContextTokenName`. Joining would red objectui's typecheck on the spec bump. At runtime, `{record_id}` would resolve to `undefined` with a "no active organization" warning. With the sibling list, objectui's current code passes `{record_id}` through untouched and the server refuses it by name, which is loud. objectui#7297 then adds the surface-scoped resolver as its own thing. The kind is separate for the same reason: the verdict depends on where the token is written, so a caller with no record refuses it by name instead of calling it `unknown`. **2. Refusal code: reuse `FILTER_TOKEN_UNRESOLVED`; no new code and no ledger entry.** I applied the branch-information test ("Does this defect give a consumer a branch it could act on differently? No ⇒ widen the existing member's comment/message"): - No consumer branches on either filter-token code. In objectstack, the only non-test mentions are comments. In objectui at `a2f361b` there are zero hits for `FILTER_TOKEN_UNRESOLVED`, `FILTER_TOKEN_UNKNOWN` or `UnresolvedFilterToken`. - The condition is the existing member's own definition: "a vocabulary token the request carries no value for". The error's `token` field already tells which context is missing. So I widened `UnresolvedFilterTokenError`'s docblock and gave it a record-specific detail message. `read-scope-sql.ts`'s statement "Today that is its two refusals" stays true. **3. `filter-walk.ts` unchanged.** The walk hands its visitor `{ value, path, where }` and nothing about the item a filter came from, so it cannot tell a record page from any other page. The rule now walks the `pages` collection itself, page by page, through `scanForFilters`, which the walk already exports for "a caller that already has a single item in hand". The page's `type` is then in the closure. The other six surfaces still go through `walkAuthoredFilters`, one surface per call and in the declared order, so findings keep the order they had. A test holds the page loop's `path` and `where` equal to the walk's, including a map-shaped `pages` collection and an unnamed page. An absent `type` is judged as `PageSchema`'s declared default `'record'`; the rule runs on parsed input, where the default is already applied. A test pins that default against `PageSchema.parse`. **4. Lint rule id: `filter-token-unknown`, message widened.** The branch-information test from decision 2 gives the same answer here: a consumer keyed on the id acts the same way (this filter cannot run where it is written), and the message and hint say which of the two causes fired. The refusal text says "no record in context on this surface" and names the surface. For a page it gives the page type, e.g. `a page of type "list"`. It does not say "is not a resolvable placeholder". No new constant, so `packages/lint/src/index.ts` is untouched. **5. `isKnownFilterToken('record_id')` stays `false`.** Its one consumer is the flow engine's filter hand-off (`interpolateFilter`), a server position. There, an unset `{record_id}` keeps being the flow's own unresolved-variable refusal (the CRUD guard) instead of being handed to ObjectQL. `service-automation` is untouched and its behaviour is byte-identical. **6. The write path goes through the resolver, measured.** `update`/`delete` call `withResolvedWhere` (`engine.ts` update and delete paths), which calls `resolveFilterTokens`. The refusal pins below cover `update(multi)`, `delete(multi)` and update by `where.id`. None needed an engine change. `engine.ts` is untouched; the `domain:engine` claim on #20148 holds it. **7. Liveness ledger:** no row under `packages/spec/liveness/` covers the token vocabulary. The one mention is a navigation `recordId` note in `app.json`, which is a different mechanism. ## Compile surfaces: one line each Measured by grepping each file for the vocabulary (`classifyFilterToken`, `isContextToken`, `CONTEXT_TOKENS`, `isKnownFilterToken`, `resolveFilterToken(s)`, the wrapped-token regexes) at `251c5f49b`. - driver-sql `applyFilterCondition` (`sql-driver.ts`; sqlite-wasm and turso local mode inherit it): **already compliant** (0 vocabulary refs). It sits downstream of the engine's `resolveWhereTokens` / `withResolvedWhere`, which now refuse `{record_id}` before the driver. - turso RemoteTransport `buildWhereSQL` (`remote-transport.ts`): **already compliant** (0 refs), for the same reason: it is a driver reached only after the engine resolves. Output is unchanged. - service-analytics `compileScopedFilterToSql` (`read-scope-sql.ts`): **changed only in the wrapped cause.** It resolves the scope with `resolveFilterTokens` (2 refs) and re-raises as `READ_SCOPE_COMPILE_FAILED` / 500. For a scope carrying `{record_id}`, the embedded resolver message is now the UNRESOLVED one instead of UNKNOWN. The envelope is unchanged. - service-analytics `lowerAnalyticsWhere` (`filter-normalizer.ts`): **already compliant** (0 refs). The analytics doors resolve the query's `where` first (`analytics-service.ts`), and those now refuse `{record_id}`. Pinned on the direct query door and on the dataset executor's three positions. - formula `matchesFilterCondition` (`matches-filter.ts`): **explicitly out of scope** (0 refs). It never consults the vocabulary, so this PR moves no byte of its output. A literal `{record_id}` reaching it compares exactly as before. - objectql having-filter `applyHaving` / `matchesHaving` (`having-filter.ts`): **explicitly out of scope** (0 refs). `having` is not a token position on this tree: `resolveWhereTokens` resolves `where` and `aggregations[].filter` only. This behaviour is unchanged for every token, not only `{record_id}`. - driver-memory `checkCondition` (`memory-matcher.ts`): **already compliant** (0 refs); a driver downstream of the engine's refusal. - driver-mongodb `translateFieldOperators` (`mongodb-filter.ts`): **already compliant** (0 refs); a driver downstream of the engine's refusal. ## Changeset `.changeset/20003-record-id-filter-token.md`: `@objectstack/spec`, `@objectstack/core` and `@objectstack/lint` are each `minor`. It lists the surfaces that now accept the token and the ones that refuse it, per the card's rule 5. objectql and service-analytics move test files only. I dry-ran `check-changeset-no-major` with a synthetic event carrying this body's `Clause-②: yes` line; it answered exit 0 ("no package whose `packages/**/src/**` it moves is graded `patch`"). ## Evidence (all at `251c5f49b`) Each package's full `test` script: | Package | Files | Tests | |---|---|---| | spec | 540 | 15820 passed, 2 todo | | core | 53 | 1363 | | lint | 108 | 4163 | | objectql | 316 | 5591 | | service-analytics | 128 | 3022 | | service-automation | 145 | 1729 | - **Typecheck:** `typecheck` is green for spec, core, lint, objectql, service-analytics and service-automation. - **Reverse type probe.** I planted `const __probe: RecordContextToken = 'current_user_id'` in lint, and `tsc` went red with `TS2322: Type '"current_user_id"' is not assignable to type '"record_id"'`. Restored with `git diff HEAD` empty. This shows lint reads the rebuilt spec `.d.ts`. - **Ablation, core** (via `scripts/ablation-replace.mjs`, marker proved in `packages/core/dist` by `ablation-dist-preflight`). I made the record-context branch in `resolveFilterToken` unreachable. The new pins went red: core 6, objectql 6, service-analytics 4. The other tests stayed green: 45, 28 and 20. With the branch gone, the resolver's drift guard throws `UnknownFilterTokenError` instead. Restore leg: rebuilt; `--absent` found the marker in none of 14 dist files; tree clean; objectql 34/34 green again. - **Ablation, lint.** With the record-page judgement ablated, the 5 acceptance pins went red. With the refusal branch ablated, the 12 refusal pins went red. Both restored, blob equal to HEAD. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 113. `--ran` reconciliation: 113 accounted for, 111 run with exit 0, 0 unrun, and 2 NOT MEASURED (exit 3, PREREQUISITE NOT MET: both need the whole workspace built): - `check:dual-build-cjs-loads` - `check:type-check-debt`, whose DEBT rows are cloud-connection, hono, observability and the root. None of those is in this diff. Two first runs refused on a prerequisite; each was satisfied and re-run to exit 0: - `check-engine-split-ratio` needed history deepened past the window (`--shallow-since=2026-06-22`). - `check:skill-examples` needed `client-react` built. - **ESLint, narrowed to the 9 changed TS files** (`--no-inline-config --format json`): 9 files, 0 errors, 0 warnings, and each file resolves a config. Invariance: `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, stated at its lines 327–328), so this diff cannot move a verdict on an untouched file. - **Base:** the branch sits 2 commits behind `origin/main` (`e2c4e125`). Those commits touch `packages/core/src/security/**`, `packages/spec/src/automation/**` and their artifacts, which is disjoint from this diff. CI's merge ref validates the joint tree. ## Acceptance notes (not filed) - `packages/lint/src/validate-flow-filter-tokens.ts` has an early-return comment that names `context` / `date-macro` as the kinds ObjectQL owns after the hand-off. `record-context` now also returns early there. Behaviour is unchanged: a bare `record_id` is an open-oracle identifier in the flow dialect, and was silent before too. Only the comment is one kind short. Carrier: none (outside this card's claimed surface). - The published skills teach only `{current_user_id}` / `{current_org_id}` (`skills/objectstack-ui/SKILL.md`, `skills/objectstack-query/rules/filters.md`). They should teach `{record_id}` once a renderer resolves it. Teaching it before then would steer authors into a server refusal. `skills/**` is a Tier H surface. Carrier: whoever lands objectui#7297's resolver and the spec bump that carries it. - `packages/cli/test/authoring-rule-command-parity.test.ts` lives in the CLI's `integration` project and is left to CI. The CLI's `unit` project here ran 172 files and 2407 tests green. Its other 52 files did not load, because `plugin-email`, `verify` and `create-objectstack` are not built in this container (MODULE_NOT_FOUND), so they are NOT MEASURED. ## Not in this PR - The objectui resolver (objectui#7297). - Any token other than `{record_id}`. - RLS `current_user.*`. - `AppContextSelector` ids. - `titleFormat` interpolation. --- _Generated by [Claude Code](https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e5cf27d commit e7f69db

13 files changed

Lines changed: 886 additions & 40 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/core": minor
4+
"@objectstack/lint": minor
5+
---
6+
7+
A record-scoped filter token, `{record_id}`: the id of the record a `type: 'record'` page is showing. It resolves where a record is in context, and is refused by name everywhere else (#20003).
8+
9+
On a record page, `record:related_list` was the only component that could scope itself to the record in view. Every other data-bearing component takes a `FilterCondition`, and the only dynamic values a filter could hold named the signed-in viewer. So "open tasks" on a person's record page counted the whole organisation's tasks, under that person's name. `{ assignee: '{record_id}' }` now says "this record's".
10+
11+
**Where it is accepted, and where it is refused:**
12+
13+
- **Accepted:** a filter on a component of a `type: 'record'` page (`regions[].components[]`, `slots`, and any filter key inside them). `os lint` / `os validate` pass it there. A page with no `type` is a record page by `PageSchema`'s default.
14+
- **Refused by `os lint` / `os validate`** (rule `filter-token-unknown`, `error`), with the reason "no record in context on this surface" rather than the unknown-token message: list views (top-level `views` and an object's list views and field filters), dashboard widgets and dashboard filters, reports, datasets, app navigation filters, and every page whose `type` is not `'record'` (`home`, `app`, `utility`, `list`, including a list page's `interfaceConfig.filterBy`).
15+
- **Refused on every server path.** `resolveFilterTokens()` in `@objectstack/core` throws `UnresolvedFilterTokenError` (`FILTER_TOKEN_UNRESOLVED` / 400, `token: 'record_id'`) on the ObjectQL read path (`find`, `findOne`, `count`, `aggregate`), the write path (`update` / `delete`, by id or `multi`), the analytics query door and the dataset executor. That is the same envelope a session token gets when the request has no value for it. It happens whatever the request carries, because no server path knows which record a page is showing. The token never becomes `null` (a count "about nobody"), is never dropped (a count "about everybody"), and never reaches the driver.
16+
17+
**What is in `@objectstack/spec/data`:**
18+
19+
- `RECORD_CONTEXT_TOKENS` (`['record_id']`), `RecordContextToken` and `isRecordContextToken()`: a sibling of `CONTEXT_TOKENS`, not a member. `CONTEXT_TOKENS` resolves against the caller's session, and `{record_id}` resolves against the surface. So `isContextToken('record_id')`, `ContextTokenSchema` and `ContextTokenPlaceholderSchema` are unchanged and still reject it, and a client resolver that fills `CONTEXT_TOKENS` from the session does not pick it up.
20+
- `classifyFilterToken('{record_id}')` returns the new kind `{ kind: 'record-context', token: 'record_id' }` instead of `unknown`. A consumer that switches exhaustively on `kind` gets a compile error until it handles the new kind.
21+
- `isKnownFilterToken('record_id')` stays `false`. That predicate answers "can the server resolve it?", and its one consumer, the flow engine's filter hand-off, is a server position. A flow addresses its own record as `{record.id}`.
22+
- Near misses are still refused, now with `{record_id}` suggested: `{recordId}` (the URL / flow-template placeholder), `{record.id}`, `{record-id}`, `{current_record_id}`. `CONTEXT_TOKEN_SUGGESTIONS`' value type widens to `ContextToken | RecordContextToken`.
23+
24+
**Presentation scope, not access.** Like `{current_user_id}`, `{record_id}` narrows what a component shows. It decides nothing about which rows the caller may read; that is still RLS.
25+
26+
**What you do:** on a record page, filter a component on the record in view with `{ <field>: '{record_id}' }`. If `os validate` refuses it with "no record in context on this surface", the filter is on a surface with no record: move it onto a component of a `type: 'record'` page, or filter on a concrete id. Until the renderer you run resolves `{record_id}`, a record-page query that carries it is refused by the server with `FILTER_TOKEN_UNRESOLVED` rather than answered with a wrong number.

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

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,18 @@ description: Context Tokens protocol schemas
66
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
77

88
Context Tokens — the declarative placeholders that resolve against the
9-
**caller's session** (who am I, which org am I in) rather than the clock.
9+
**caller's session** (who am I, which org am I in) rather than the clock,
10+
plus one sibling that resolves against the **surface**: `{record_id}`, the
11+
record a `type: 'record'` page is showing.
12+
13+
The two resolve against different things, so they are two lists, never one:
14+
15+
| | `CONTEXT_TOKENS` | `RECORD_CONTEXT_TOKENS` |
16+
|---|---|---|
17+
| Tokens | `{current_user_id}`, `{current_org_id}` | `{record_id}` |
18+
| Resolves against | the caller's session | the record the page is bound to |
19+
| Known on the server | yes — `ExecutionContext` | never — only the page renderer knows which record is in view |
20+
| Valid on | every filter surface | a component on a `type: 'record'` page, and nowhere else |
1021

1122
## Why this lives in `spec`
1223

@@ -38,11 +49,24 @@ neither is an ERROR, not a null comparand: resolving to `null`
3849
degrades to `IS NULL` on most drivers and would hand back the rows
3950
the filter was written to exclude.
4051

52+
`{record_id}` is resolved on ONE side of the wire only: by the page
53+
renderer, which substitutes the id of the record a `type: 'record'` page
54+
is showing before the filter leaves the browser. The server never knows
55+
which record a page is showing, so a filter that reaches
56+
`resolveFilterTokens()` still carrying `{record_id}` is refused by name
57+
(`FILTER_TOKEN_UNRESOLVED` / 400) — which is also what an author sees
58+
from a renderer that does not resolve the token yet. It never becomes
59+
`null`, `undefined` or the literal string: a number written as "about
60+
this record" must neither silently become "about everybody" (the
61+
condition dropped) nor "about nobody" (the token compared as text).
62+
4163
## Presentation scope, NOT a security boundary
4264

4365
This is the single most important thing to understand about these
4466
tokens. `{current_user_id}` scopes what a surface *shows*; it does not
45-
decide what a caller is *allowed* to read. Enforcement is RLS, which
67+
decide what a caller is *allowed* to read. `{record_id}` is the same: it
68+
narrows a record-page component to the record in view, and which of those
69+
rows the caller may read is still not its decision. Enforcement is RLS, which
4670
uses a different and genuinely server-side vocabulary rooted at
4771
`current_user` (`owner_id = current_user.id`, compiled by
4872
`@objectstack/plugin-security`'s RLS compiler).
@@ -62,19 +86,48 @@ a `{current_user_id}` filter widens a *view*; it must never widen
6286

6387
## Where the tokens are honoured
6488

65-
Filter values on every surface that resolves placeholders — object list
66-
views, dashboard widgets, reports, SDUI page components. Navigation
67-
(`recordId` / `params`) additionally resolves `AppContextSelector` ids
68-
such as `{active_package}`; those are nav-only and are NOT valid inside
69-
filter values, because filters are not evaluated with the sidebar's
70-
selector state.
89+
`{current_user_id}` and `{current_org_id}`: filter values on every surface
90+
that resolves placeholders — object list views, dashboard widgets,
91+
reports, datasets, SDUI page components — and on the server's ObjectQL
92+
read and write paths and analytics doors.
93+
94+
`{record_id}`: filter values on a component of a `type: 'record'` page,
95+
and nowhere else. `@objectstack/lint`'s `validate-filter-tokens` refuses it
96+
by name ("no record in context on this surface") on list views, dashboard
97+
widgets, reports, datasets, apps, object definitions and every page that is
98+
not `type: 'record'`. The server refuses it on every path, because no
99+
server path has a record in context.
100+
101+
Navigation (`recordId` / `params`) additionally resolves
102+
`AppContextSelector` ids such as `{active_package}`; those are nav-only and
103+
are NOT valid inside filter values, because filters are not evaluated with
104+
the sidebar's selector state.
105+
106+
## `{record_id}` is not `{recordId}`, and not the `'record_id'` variable type
107+
108+
Three spellings look alike and are three different mechanisms:
109+
110+
- `{record_id}` — this filter token. A whole filter VALUE, resolved to the
111+
id of the record the page shows.
112+
- `{recordId}` — a URL / flow-template placeholder: an action's
113+
`newTabUrl` (`action.zod.ts`) and the flow template dialect
114+
(`@objectstack/lint`'s `flow-template-grammar.ts`). It is interpolated by
115+
those surfaces' own template engines and is NOT a filter token. Written
116+
inside a filter it is refused, with `{record_id}` suggested.
117+
- `'record_id'` — a page-variable TYPE (`PageVariableSchema.type` in
118+
`page.zod.ts`), naming what a page variable holds, e.g. the selection of
119+
an `element:record_picker`. It is read in expressions as `page.<name>`,
120+
never written as a placeholder.
71121

72122
## Out of scope
73123

74124
- `current_user.*` RLS expressions — see `@objectstack/plugin-security`.
75125
- `{date-macros}` — the clock-based sibling; see `./date-macros.zod.ts`.
76126
- `titleFormat` field interpolation (`{user_id}` etc.) — that substitutes
77127
*record fields*, an unrelated mechanism that happens to share braces.
128+
- Tokens for a record OTHER than the one in view (a related record, a
129+
parent of the record). Each is its own contract with its own resolver,
130+
not an extension of `{record_id}`.
78131

79132
<Callout type="info">
80133
**Source:** `packages/spec/src/data/context-tokens.zod.ts`

‎packages/core/src/utils/filter-tokens.test.ts‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,72 @@ describe('resolveFilterToken — context tokens', () => {
145145
});
146146
});
147147

148+
/**
149+
* `{record_id}` — the record-context token. The server never has a record in
150+
* context, so it refuses by name on every path, whatever the request carries.
151+
* The refusal is the same envelope as a session token the request has no value
152+
* for (`FILTER_TOKEN_UNRESOLVED` / 400): the spelling is right, the context is
153+
* missing, and `token` names which context.
154+
*/
155+
describe('resolveFilterTokens — the record-context token refuses by name', () => {
156+
// A FULL context on purpose: the refusal must not depend on what the
157+
// request happens to carry.
158+
const full = { now: NOW, userId: 'usr_1', orgId: 'org_9', timezone: 'UTC' };
159+
160+
function refusal(filter: unknown): UnresolvedFilterTokenError {
161+
let err: unknown;
162+
try {
163+
resolveFilterTokens(filter, full);
164+
} catch (e) {
165+
err = e;
166+
}
167+
expect(err).toBeInstanceOf(UnresolvedFilterTokenError);
168+
return err as UnresolvedFilterTokenError;
169+
}
170+
171+
it.each([
172+
['a MongoDB-style condition', { assignee: '{record_id}' }],
173+
['an operator comparand', { assignee: { $eq: '{record_id}' } }],
174+
['the `${…}` spelling', { assignee: '${record_id}' }],
175+
['a rule-array filter', [{ field: 'assignee', operator: 'equals', value: '{record_id}' }]],
176+
['a logical branch beside a resolvable token', { $and: [{ owner: '{current_user_id}' }, { account: '{record_id}' }] }],
177+
])('%s: FILTER_TOKEN_UNRESOLVED / 400, the token named', (_label, filter) => {
178+
const err = refusal(filter);
179+
expect(err.code).toBe('FILTER_TOKEN_UNRESOLVED');
180+
expect(err.status).toBe(400);
181+
expect(err.name).toBe('UnresolvedFilterTokenError');
182+
expect(err.token).toBe('record_id');
183+
expect(err.message).toContain('{record_id}');
184+
});
185+
186+
it('resolveFilterToken refuses it directly too — never `undefined`, never the literal', () => {
187+
expect(() => resolveFilterToken('record_id', full)).toThrow(UnresolvedFilterTokenError);
188+
});
189+
190+
it('lit control — a session token in the same request still resolves', () => {
191+
expect(resolveFilterTokens({ owner: '{current_user_id}' }, full)).toEqual({ owner: 'usr_1' });
192+
});
193+
194+
it.each([
195+
['{recordid}', 'recordid'],
196+
['{record-id}', 'record-id'],
197+
['{recordId}', 'recordId'],
198+
])('near miss %s stays FILTER_TOKEN_UNKNOWN, with {record_id} suggested', (value, token) => {
199+
let err: unknown;
200+
try {
201+
resolveFilterTokens({ assignee: value }, full);
202+
} catch (e) {
203+
err = e;
204+
}
205+
expect(err).toBeInstanceOf(UnknownFilterTokenError);
206+
const e = err as UnknownFilterTokenError;
207+
expect(e.code).toBe('FILTER_TOKEN_UNKNOWN');
208+
expect(e.status).toBe(400);
209+
expect(e.token).toBe(token);
210+
expect(e.suggestion).toBe('record_id');
211+
});
212+
});
213+
148214
describe('resolveFilterTokens — tree walk', () => {
149215
const ctx = { now: NOW, userId: 'usr_1', orgId: 'org_9' };
150216

‎packages/core/src/utils/filter-tokens.ts‎

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,22 @@
7272
* natural-language spellings are exactly what an author migrating from another
7373
* system's macro syntax reaches for first. See `FILTER_TOKEN_WRAPPED_RE` in
7474
* `@objectstack/spec`.
75+
*
76+
* # A record-context token always refuses here
77+
*
78+
* `{record_id}` (`RECORD_CONTEXT_TOKENS` in `@objectstack/spec`) is the id of
79+
* the record a `type: 'record'` page is showing. Only the page renderer knows
80+
* that record; no server path has one in context. So a filter that arrives
81+
* here still carrying `{record_id}` throws {@link UnresolvedFilterTokenError}
82+
* naming it, on every path, whatever the request carries. It never resolves
83+
* to `null`, `undefined` or the literal: a count written as "about this
84+
* record" would otherwise come back as "about nobody", or, with the condition
85+
* gone, "about everybody".
7586
*/
7687

7788
import {
7889
classifyFilterToken,
90+
isRecordContextToken,
7991
parseDateMacroParam,
8092
type DateMacroUnit,
8193
} from '@objectstack/spec/data';
@@ -143,10 +155,15 @@ export class UnknownFilterTokenError extends Error {
143155

144156
/**
145157
* Raised when a token IS in the vocabulary but the request carries no value
146-
* for it — an unauthenticated caller filtering on `{current_user_id}`.
158+
* for it — an unauthenticated caller filtering on `{current_user_id}`, or any
159+
* filter carrying the record-context token `{record_id}`, which no server
160+
* path has a value for (see the module doc).
147161
*
148162
* Distinct from {@link UnknownFilterTokenError} because the fix is different:
149-
* the metadata is correct, the context is not. Never silently resolves to
163+
* the spelling is correct, the context is not. For `{record_id}` the fix is
164+
* WHERE the filter is authored or resolved: a component on a `type: 'record'`
165+
* page, whose renderer resolves the token before the query leaves the
166+
* browser. `token` names which context is missing. Never silently resolves to
150167
* `null`/`undefined`, which on most drivers degrades to `IS NULL` and would
151168
* quietly hand back rows the filter was written to exclude.
152169
*/
@@ -320,6 +337,23 @@ export function resolveFilterToken(
320337
return ctx.orgId;
321338
}
322339

340+
// ── Record-context tokens ─────────────────────────────────────────────
341+
// Never resolvable here, whatever the request carries: the server does not
342+
// know which record a page is showing. See the module doc.
343+
if (isRecordContextToken(token)) {
344+
throw new UnresolvedFilterTokenError(
345+
token,
346+
'no record in context on the server. {record_id} is the id of the record a ' +
347+
"`type: 'record'` page is showing, and only that page's renderer knows it: it " +
348+
'resolves the token before the query leaves the browser. A filter still carrying ' +
349+
'it here was authored on a surface with no record in context (a list view, a ' +
350+
'dashboard widget, a report, a dataset, a page that is not a record page), or sent ' +
351+
'by a client that did not resolve it. Move the filter onto a component of a record ' +
352+
'page, or filter on a concrete id. Resolving it to null would count no records, and ' +
353+
'dropping the condition would count them all.',
354+
);
355+
}
356+
323357
// ── Date macros ───────────────────────────────────────────────────────
324358
const today = proxyDay(now, ctx.timezone);
325359

0 commit comments

Comments
 (0)