Skip to content

Commit 26f0ee8

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-5079-delete-overlay-listing
2 parents 559e6d0 + b230e5e commit 26f0ee8

20 files changed

Lines changed: 1648 additions & 97 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
'@objectstack/formula': patch
3+
---
4+
5+
fix(formula): `classifyError` grades a CEL fault by error class + code, never by the message (#6223)
6+
7+
`EvalResult.error.kind` is author-facing — `@objectstack/objectql`'s `cel-fault`
8+
puts it in front of the author as `` `${kind}: ${first line}` `` and
9+
`packages/rest` re-emits it as the HTTP body's `reason`. cel-js embeds the
10+
author's own **source line** in `message` (`formatErrorWithHighlight`), so a
11+
classifier that regex-matches that text is matching text the author writes.
12+
PR #6202 closed the `ParseError` arm this way and left `type` / `runtime` on the
13+
keyword table pending a per-code audit. This is that audit, and its verdict is
14+
that the table goes entirely.
15+
16+
Measured on cel-js 8.0.0 — one `no such overload` **evaluation** fault, four
17+
field names, three wrong answers:
18+
19+
```text
20+
record.status > 1 -> runtime (right)
21+
record.parse_status > 1 -> parse (wrong)
22+
record.syntax_mode > 1 -> parse (wrong)
23+
record.type_code > 1 -> type (wrong)
24+
```
25+
26+
`parse` is the inverse of the #6133 misdirection: the expression is
27+
syntactically perfect and failed on the data, and the author was told to go fix
28+
an expression that has nothing wrong with it.
29+
30+
`classifyError` now reads only structured contract:
31+
32+
- `ParseError` -> `bounds` when `code === 'limit_exceeded'`, else `parse`
33+
(unchanged, from #6202);
34+
- `EvaluationError` -> `type` for the one declaration-class code
35+
(`unknown_variable`, the root identifier is not bound in this scope at all),
36+
else `runtime`;
37+
- anything that is not a cel-js error -> `runtime`.
38+
39+
Two findings from the audit worth recording. First, the residual keyword arm was
40+
**not** dormant: `matches()` is an ObjectStack stdlib binding over `new
41+
RegExp(...)`, so an uncompilable pattern escapes as a native `SyntaxError` whose
42+
message echoes the pattern — and the pattern can come off the row, not just out
43+
of the source. `matches(record.name, record.re)` with `re = "type("` was
44+
graded `type`; with `"Exceeded maxAstNodes("` it was graded `bounds`. A data
45+
value was picking the error kind. Second, there is deliberately no `TypeError`
46+
arm: cel-js raises that class only from its non-evaluating `TypeChecker`, which
47+
runs only inside `Environment#check`, and that method catches it and *returns*
48+
`{ valid: false, error }`. The check-time `TypeError -> type` mapping already
49+
lives in `celEngine.compile`, which reads that object.
50+
51+
Six evaluate-time codes change verdict from `type` to `runtime`
52+
(`int_conversion_error`, `uint_conversion_error`, `double_conversion_error`,
53+
`invalid_index_type`, `heterogeneous_list_element`,
54+
`invalid_comprehension_range`). Each is a fault decided against the row; every
55+
one of them was graded `type` only because cel-js happens to use the word "type"
56+
in its prose (`int() type error: cannot convert to int`). Every evaluate-time
57+
code the engine can reach now has a fixture pinning its `kind`.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
"@objectstack/core": patch
3+
---
4+
5+
refactor(core): one implementation per hook-dispatch flavour, plus a paired-pin gate (#5282)
6+
7+
`ObjectKernel` does not extend `ObjectKernelBase` — it is a standalone
8+
production kernel with its own `hooks` map, and only `LiteKernel` extends the
9+
base. Lifecycle-hook dispatch therefore existed **twice**, with no shared code
10+
path: the base's `triggerHook` (isolating) / `triggerHookOrThrow` (propagating) /
11+
`context.trigger` on one side, and `ObjectKernel`'s private
12+
`triggerShutdownHookIsolating` / `context.trigger` on the other. The two
13+
isolating loops printed the same `Hook handler failed: kernel:shutdown` line
14+
because someone typed it twice.
15+
16+
That seam produced three consecutive bugs, each the same shape — one hook name
17+
meaning opposite things on the two kernels: `kernel:ready` (#5170),
18+
`kernel:bootstrapped` / `kernel:listening` (#5257, where a swallowed
19+
`server.listen()` failure let a process print "✅ Bootstrap complete" with
20+
nothing listening), and `kernel:shutdown` in the other direction (#5274, where
21+
one bad handler skipped every `destroy()`).
22+
23+
**No behaviour change.** The two dispatch flavours move verbatim into an
24+
internal module, `packages/core/src/hook-dispatch.ts`, which both kernels now
25+
call:
26+
27+
- `dispatchHookIsolating` — a failing handler is logged as
28+
`Hook handler failed: <name>` and the remaining handlers still run.
29+
- `dispatchHookPropagating` — the first failure escapes unwrapped and the
30+
handlers behind it are skipped.
31+
32+
Every call path keeps the flavour, the log wording and the trace line it had
33+
before, including the one asymmetry inside the propagating flavour:
34+
`PluginContext.trigger` has never emitted the `Triggering hook: <name>` trace on
35+
either kernel, so it still does not. The kernels' two `hooks` maps are
36+
deliberately **not** unified, and `ObjectKernel` deliberately does **not** gain a
37+
base class — both were considered and ruled out of scope.
38+
39+
How "no behaviour change" was proved: the paired kernel pins from #5170 / #5257 /
40+
#5274 pass untouched, and deleting the shared dispatcher's error log now turns
41+
**both** kernels' test files red from a single edit — a property the hand-mirrored
42+
copies could not have (editing `ObjectKernel`'s private loop could never turn
43+
`lite-kernel.test.ts` red).
44+
45+
Shared dispatch cannot cover the residual two-maps seam, so the pairing of the
46+
tests is now a gate rather than a convention: `pnpm check:kernel-hook-pairs`
47+
(`scripts/check-kernel-hook-pairs.mjs`, wired into the ESLint job) requires every
48+
`kernel:*` hook dispatched in `packages/core/src` to be named in a test title in
49+
**both** `kernel.test.ts` and `lite-kernel.test.ts`, and fails naming the hook
50+
and the side that lacks it. A fifth lifecycle hook can no longer arrive paired on
51+
one kernel only.
52+
53+
Also pinned, deliberately unchanged: `kernel:shutdown` has two dispatch paths
54+
with different flavours on both kernels — the kernel's own teardown isolates,
55+
while a plugin calling `ctx.trigger('kernel:shutdown')` by hand propagates.
56+
Nothing in the repo triggers it by hand today, so this is dormant; it is now a
57+
documented fact with a named test on each side rather than a surprise found at
58+
teardown.

.github/workflows/lint.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,32 @@ jobs:
448448
- name: Engine test-double contract gate
449449
run: pnpm check:engine-double-contract
450450

451+
# Paired kernel-hook pin gate (#5282, from #5170 / #5257 / #5274). The two
452+
# kernels — ObjectKernel (production) and LiteKernel (vitest / serverless /
453+
# edge) — run the same plugin code and the same hook vocabulary, but do NOT
454+
# share a class: ObjectKernel does not extend ObjectKernelBase, so each
455+
# keeps its own hooks map. #5282 shared the two dispatch LOOPS
456+
# (packages/core/src/hook-dispatch.ts); this gate covers what sharing them
457+
# cannot: three consecutive bugs were all "one hook name means opposite
458+
# things on the two kernels" (kernel:ready swallowed on one side and fatal
459+
# on the other; kernel:listening swallowing a failed server.listen() behind
460+
# a cheerful "Bootstrap complete"; kernel:shutdown skipping every destroy()
461+
# in the other direction), and each was caught by a human noticing the
462+
# asymmetry. What held the kernels together afterwards was a pair of
463+
# hand-written tests — a convention, not a mechanism: a fifth lifecycle
464+
# hook gets no pairing automatically and nothing goes red when it is
465+
# missing. So every kernel:* hook DISPATCHED in packages/core/src must be
466+
# named in a test title in BOTH kernel.test.ts and lite-kernel.test.ts, and
467+
# a missing pair fails naming the hook and the side that lacks it.
468+
# Subscriptions (ctx.hook) are deliberately not dispatches. Static AST over
469+
# five files, no build needed, so it belongs in this job. Runs its own
470+
# --self-test first: the detector can be broken while every hook is fine,
471+
# and a scan that stops matching would report OK while reading nothing
472+
# (#4868's family). Measured against main's corpus before being pinned
473+
# here: 4 dispatched hooks, 0 problems.
474+
- name: Paired kernel-hook pin gate
475+
run: pnpm check:kernel-hook-pairs
476+
451477
# Resume-authority declaration gate (#5561, from #3823). The #3801 resume
452478
# gate keys on the SUSPENDED NODE, so it covers a pausing node type exactly
453479
# when that type's author remembered to declare `resumeAuthority`. #3823 is

content/docs/api/data-api.mdx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ corrupts something the earlier axes do not:
128128
| `?search=alpha&searchFields=title` | scans only `title` |
129129
| `?search=alpha&searchFields=no_such_field` | `400 INVALID_FIELD` |
130130
| `?search=alpha&searchFields=amount` | `400 INVALID_FIELD` — real field, but not searchable |
131+
| `?search=alpha&searchFields=project_id.name` | `400 INVALID_FIELD` — search scans this object's own columns; mirror the related title instead (see below) |
131132
| `groupBy: ["status"]` | one bucket per status value |
132133
| `groupBy: ["no_such_field"]` | `400 INVALID_FIELD` |
133134
| `aggregations: [{function:"sum", field:"amount", alias:"total"}]` | the real total |
@@ -143,6 +144,18 @@ corrupts something the earlier axes do not:
143144
`searchableFields`), and a `searchableFields` entry that names no field (a
144145
stale declaration — the bug is on the object, and clients that echo the
145146
declaration verbatim are told so).
147+
148+
A **dotted path** (`project_id.name`) is the typo case with its own hint:
149+
`search` scans this object's own columns, so a related record's column can
150+
never be a search target, and the search axis does not resolve traversal the
151+
way `$select` / `$orderby` / `$filter` do. To search by a related record's
152+
title, **mirror** that title into a stored field on this object and declare
153+
*that* field searchable — a task list searched by project name carries a
154+
`project_name` text column on `task`, maintained on write and listed in
155+
`task.searchableFields`. It must be a stored field: a `formula` field is
156+
virtual, so no driver has a column for `$contains` to scan. Cross-object
157+
search paths are rejected by design, not pending — see
158+
[Schema Design → Searching by a related record's title](/docs/data-modeling/schema-design#searching-by-a-related-records-title--mirror-the-value).
146159
- **`groupBy`** — an unknown column projected `null` for every row, so all
147160
rows fell into **one bucket** whose count is the true row count:
148161
structurally perfect, indistinguishable from a column that really holds a

content/docs/data-modeling/queries.mdx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,30 @@ that exists to narrow a search, silently widening it. Internal callers reaching
513513
`engine.find()` directly are unaffected.
514514
</Callout>
515515

516+
### Searching by a related record's title — mirror the value
517+
518+
`search` scans **the queried object's own columns**. A dotted path
519+
(`project_id.name`) is not a search target — unlike `fields` / `sort` / `filters`,
520+
the search axis does not resolve traversal, and a dotted entry is refused, not
521+
silently dropped:
522+
523+
```text
524+
Unknown field 'project_id.name' on object 'task'. '$searchFields' narrows which
525+
columns 'search' scans, so a name the object does not declare cannot narrow
526+
anything — and the engine used to drop it and scan the default columns instead,
527+
answering a NARROWER search with a WIDER one. 'search' scans this object's own
528+
columns; a related record's column cannot be a search target.
529+
```
530+
531+
The answer is a **mirror field**: copy the related record's title into a stored
532+
field on this object and declare *that* field searchable. A task list searched by
533+
project name gets a `project_name` text column on `task`, maintained on write and
534+
listed in `task.searchableFields`. It has to be a **stored** field — a `formula`
535+
field is virtual, so no driver has a column for `$contains` to scan. Cross-object
536+
search paths are rejected by design, not pending. Full recipe (the hooks that keep
537+
the mirror fresh, and the lint wording) in [Schema Design → Searching by a related
538+
record's title](/docs/data-modeling/schema-design#searching-by-a-related-records-title--mirror-the-value).
539+
516540
### Pinyin recall (Chinese deployments)
517541

518542
When pinyin search is enabled (`OS_SEARCH_PINYIN_ENABLED` — auto-on when the stack's

content/docs/data-modeling/schema-design.mdx

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,95 @@ Queries then pass a top-level `$search` parameter to match across `searchableFie
103103
`$searchFields`. When `searchableFields` is unset, search falls back to the
104104
name/title field plus short-text fields.
105105

106+
#### Searching by a related record's title — mirror the value
107+
108+
`$search` scans **the queried object's own columns**. A dotted path such as
109+
`project_id.name` is not a search target: unlike `$select` / `$orderby` /
110+
`$filter`, the search axis does not resolve traversal, and a dotted entry is
111+
refused rather than silently dropped (#4254). That refusal is deliberate, not a
112+
missing feature — cross-object search paths are rejected by design.
113+
114+
The declarative answer is a **mirror field**: copy the related record's title
115+
into a stored field on *this* object, and make that field the search target.
116+
To let users search a task list by project name:
117+
118+
```typescript
119+
// `project_name` is a stored, denormalized copy of the parent's title.
120+
{
121+
name: 'task',
122+
enable: { searchable: true },
123+
fields: {
124+
name: { type: 'text', required: true },
125+
project_id: { type: 'lookup', reference: 'project' },
126+
project_name: { type: 'text', label: 'Project Name' }, // ← the mirror
127+
},
128+
searchableFields: ['name', 'project_name'],
129+
}
130+
```
131+
132+
`?search=apollo` now expands to `name $contains 'apollo' OR project_name
133+
$contains 'apollo'` — one single-table scan, on every driver, with no traversal.
134+
If the object declares no `searchableFields` at all, a `text` mirror is picked up
135+
by the auto-default anyway; declare the set explicitly when you want to pin it.
136+
137+
<Callout type="warn">
138+
**The mirror must be a stored field — a `formula` field does not work.** A
139+
`formula` field is *virtual*: no driver materializes a column for it, so a
140+
`$contains` predicate against one has nothing to scan (the SQL driver would emit
141+
a `WHERE` over a column that does not exist). A CEL formula also only reads this
142+
record's own fields (`record.<field>`), so it cannot fetch the related title in
143+
the first place. Nothing catches the mistake for you — `searchableFields` admits
144+
any field the object declares, so a formula entry passes both lint and the
145+
ingress gate and then just never matches.
146+
</Callout>
147+
148+
**Keeping the mirror fresh.** A mirror is denormalized data, only as current as
149+
whatever maintains it. Two write paths have to be covered:
150+
151+
| When | What maintains the mirror |
152+
|:-----|:--------------------------|
153+
| A task is created, or re-pointed at another project | `beforeInsert` / `beforeUpdate` hook on `task` — read the parent's `name` for the incoming `project_id` and stamp `project_name` |
154+
| A project is renamed | `afterUpdate` hook on `project` — re-stamp `project_name` on that project's tasks |
155+
156+
Rows written by a path that bypasses hooks (bulk import, direct SQL) need a
157+
one-off backfill. See [Hooks](/docs/automation/hooks) for the hook shapes.
158+
159+
**The errors you get if you try the dotted path.** Both the lint and the runtime
160+
send you to the same fix, so either message is greppable back to this section.
161+
162+
`os validate` reports `searchable-field-unknown`:
163+
164+
```text
165+
searchableFields entry "project_id.name" is not a field on object "task". The
166+
declaration is stale: searching it can never match, and the engine silently
167+
drops it — leaving a narrower search than declared, or the auto-default set once
168+
every entry is dropped.
169+
170+
hint: 'search' scans this object's own columns, so a related record's column
171+
cannot be a search target — expand the relation and search the related object,
172+
or copy the value onto a formula field here. Clients echo this declaration
173+
verbatim as the '$searchFields' override, so a stale entry becomes a 400
174+
INVALID_FIELD on list search (#4254), not just a quietly narrowed one.
175+
```
176+
177+
(That hint's "text/formula" family of wording is loose — only the **stored**
178+
half works; see the callout above.)
179+
180+
A request that sends the dotted path is `400 INVALID_FIELD`:
181+
182+
```text
183+
Unknown field 'project_id.name' on object 'task'. '$searchFields' narrows which
184+
columns 'search' scans, so a name the object does not declare cannot narrow
185+
anything — and the engine used to drop it and scan the default columns instead,
186+
answering a NARROWER search with a WIDER one. 'search' scans this object's own
187+
columns; a related record's column cannot be a search target.
188+
```
189+
190+
If the dotted path is in the object's own `searchableFields` (so clients echo it
191+
back verbatim), the same 400 arrives under its stale-declaration wording
192+
instead: `Field 'project_id.name' on object 'task' is declared in
193+
'searchableFields' but does not exist.`
194+
106195
---
107196

108197
## Field Types & Configuration

content/docs/protocol/objectql/query-syntax.mdx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -887,6 +887,32 @@ a user typing `acme` does not find `ACME Corp`. Only `select` / `status` option
887887
`[EXPERIMENTAL — not enforced]` markers (#4286): the schema accepts them, the
888888
expansion ignores them.
889889

890+
#### Searching by a related record's title — mirror the value
891+
892+
Search targets are **this object's own columns**. A dotted path is not one of
893+
them: `searchFields: ['project_id.name']` is refused at the ingress rather than
894+
dropped, because the search axis does not resolve traversal the way `fields`,
895+
`sort` and `filters` do:
896+
897+
```text
898+
Unknown field 'project_id.name' on object 'task'. '$searchFields' narrows which
899+
columns 'search' scans, so a name the object does not declare cannot narrow
900+
anything — and the engine used to drop it and scan the default columns instead,
901+
answering a NARROWER search with a WIDER one. 'search' scans this object's own
902+
columns; a related record's column cannot be a search target.
903+
```
904+
905+
The declarative answer is a **mirror field**: copy the related record's title
906+
into a stored field on this object and declare *that* field searchable — a task
907+
list searched by project name carries a `project_name` text column on `task`,
908+
maintained on write and listed in `task.searchableFields`, so the expansion stays
909+
a single-table `$or` of `$contains`. The mirror must be **stored**: a `formula`
910+
field is virtual, no driver materializes a column for it, and a `$contains`
911+
against one has nothing to scan. Cross-object search paths are rejected by
912+
design, not pending — see [Schema Design → Searching by a related record's
913+
title](/docs/data-modeling/schema-design#searching-by-a-related-records-title--mirror-the-value)
914+
for the maintenance hooks.
915+
890916
### Joins — removed (#4286)
891917

892918
`query.joins` was **removed in `@objectstack/spec` 17** (#4286, ADR-0049

content/docs/ui/views.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ A List View controls how a collection of records is presented. It supports multi
103103
| `data` | `ViewData` | optional | Data source configuration (defaults to the `object` provider) |
104104
| `filter` | `array` | optional | Base filter criteria |
105105
| `sort` | `array` | optional | Sort configuration |
106-
| `searchableFields` | `string[]` | optional | Fields included in search |
106+
| `searchableFields` | `string[]` | optional | Fields the toolbar search scans**narrows** the object's set, never widens it (ADR-0061). Entries must be the object's **own** columns: a lookup (`project_id`) or a dotted path (`project_id.name`) is refused, and every toolbar search on the list then returns `400 INVALID_FIELD` (#4254). To search by a related record's title, [mirror it into a stored field](/docs/data-modeling/schema-design#searching-by-a-related-records-title--mirror-the-value) on the object and list that |
107107
| `grouping` | `object` | optional | Row grouping configuration |
108108
| `pagination` | `object` | optional | Pagination settings |
109109
| `selection` | `object` | optional | Row selection mode |

0 commit comments

Comments
 (0)