Skip to content

Commit 8b90d68

Browse files
huangyiireneclaude
andauthored
fix(objectql): strip the hidden __search companion from every record body (#7642) (#7868)
The `__search` search-normalization companion (#2486) is declared invisible to clients — `hidden` + `readonly` + `system` + `searchable: false` — and every one of those flags does something real: the column stays out of auto-views, out of the `$search` auto-default, and a `$searchFields` override naming it is refused with a 400 ("is hidden"). None of them is a PROJECTION rule. A query that names no `fields` reaches the driver with `ast.fields` undefined, drivers answer that with `SELECT *`, and the column rode back in the four record bodies QA measured (#7629): query results, GET by id, `/search` hits, and the 201 create body. The strip runs at the engine, the producer all four surfaces share — `/search` hits are `engine.find` rows verbatim, the create body is `engine.insert`'s return verbatim, so fixing consumers one at a time would have left three of the four broken. Covered: `find`, `findOne`, the nested records `expand` produces, the create response and the update response. The update response is not one of the four reported surfaces but is the same column in the same response shape; leaving it out would make POST and PATCH on one object disagree about whether a client-invisible column is visible. A predicate update resolves to a count and is unaffected. Two shaping details, both from the report: - Not gated on the schema declaring the column. The symptom survived a restart with `OS_SEARCH_PINYIN_ENABLED=false`: with the switch off the registry stops DECLARING the field, but the physical column and its values remain (ADR-0045 migrations are additive) and `SELECT *` keeps returning them. A strip that asked `schema.fields.__search` first would be silent in exactly the deployment that filed the bug. - One caller keeps its read. `plugin-pinyin-search`'s backfill projects `['id', ...sources, '__search']` under a system context and compares the stored blob against a recomputed one; stripping it unconditionally would make the walk rewrite every row of every object on every pass. A SYSTEM caller that names the column still gets it — a non-system caller does not, even by name, since `select` only gates on whether a field is KNOWN and `?select=__search` would otherwise be a documented way straight through the strip. Scope is this one column. Hidden system columns do come back generally (`organization_id` and its siblings), but they are load-bearing in client payloads today; removing them is a contract decision, not a defect fix. The new suite is a MATRIX over every record-returning door rather than a test for the door that was fixed, in both provisioning states — one contract, five places that can break it independently is the shape that rots one door at a time. Reverse-checked: 17 of its 24 cases fail on the unfixed engine. Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0dcbc11 commit 8b90d68

6 files changed

Lines changed: 603 additions & 0 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): strip the hidden `__search` companion column from every record body (#7642)
6+
7+
The `__search` search-normalization companion (#2486) is declared invisible to
8+
clients — `hidden` + `readonly` + `system` + `searchable: false` — and every one
9+
of those flags does something real: the column stays out of auto-views, out of
10+
the `$search` auto-default, and a `$searchFields` override naming it is refused
11+
with a 400 ("is hidden"). None of them is a **projection** rule. A query that
12+
names no `fields` reaches the driver with `ast.fields` undefined, drivers answer
13+
that with `SELECT *`, and the column rode back in the four record bodies a QA
14+
run measured (#7629): list/query results, `GET /data/:object/:id`,
15+
`GET /api/v1/search` hits, and the 201 create body.
16+
17+
The strip now runs at the engine, which is the producer all four surfaces share
18+
(`/search` hits are `engine.find` rows verbatim; the create body is
19+
`engine.insert`'s return verbatim). Fixing them one consumer at a time is how
20+
three of the four would have stayed broken. `find`, `findOne`, the nested
21+
records `expand` produces, the create response and the **update** response are
22+
all covered; the update response is not one of the four reported surfaces but is
23+
the same column in the same response shape, and leaving it out would have made
24+
POST and PATCH on one object disagree about whether a client-invisible column is
25+
visible. A predicate update resolves to an affected-row count and is unaffected.
26+
27+
Two details the fix is shaped around, both from the report:
28+
29+
- **It is not gated on the schema declaring the column.** The symptom survived a
30+
restart with `OS_SEARCH_PINYIN_ENABLED=false`, and that is not a stale process:
31+
with the switch off the registry stops declaring the field, but the physical
32+
column and its values remain (ADR-0045 migrations are additive) and `SELECT *`
33+
keeps returning them. A strip that asked `schema.fields.__search` first would
34+
be silent in exactly the deployment that reported the bug, so the key on the
35+
row is the signal.
36+
- **One caller keeps its read.** `plugin-pinyin-search`'s backfill/reconcile walk
37+
projects `['id', …sources, '__search']` under a system context and compares the
38+
stored blob against a recomputed one; stripping that unconditionally would make
39+
it rewrite every row of every object on every pass. A **system** caller that
40+
names the column in `fields` still gets it. A non-system caller does not, even
41+
by name — `select` only gates on whether a field is *known*, so `?select=__search`
42+
would otherwise be a documented way straight through the strip.
43+
44+
Scope is this one column. Hidden system columns do come back generally
45+
(`organization_id` and its siblings), but they are load-bearing in client
46+
payloads today; removing them is a contract decision, not a defect fix.
47+
48+
New exports from `@objectstack/objectql`: `stripSearchCompanion` and
49+
`isSearchCompanionRequested`.

packages/objectql/src/core.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export {
3131
resolveSearchCompanionSources,
3232
isCompanionSourceEligible,
3333
isCompanionMatchableTerm,
34+
isSearchCompanionRequested,
35+
stripSearchCompanion,
3436
containsCJK,
3537
} from './search-companion.js';
3638
export type { CompanionFieldMeta, CompanionObjectMeta } from './search-companion.js';

packages/objectql/src/engine.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ import {
131131
import { pluralToSingular, ExternalWriteForbiddenError } from '@objectstack/spec/shared';
132132
import { SchemaRegistry, computeFQN } from './registry.js';
133133
import { expandSearchToFilter } from './search-filter.js';
134+
import { isSearchCompanionRequested, stripSearchCompanion } from './search-companion.js';
134135
import { ExpressionEngine } from '@objectstack/formula';
135136
import type { Expression } from '@objectstack/spec';
136137
import { isAggregatedViewContainer, expandViewContainer } from '@objectstack/spec';
@@ -4755,6 +4756,52 @@ export class ObjectQL implements IObjectQLEngine {
47554756
}
47564757
}
47574758

4759+
/**
4760+
* [#7642] Strip the hidden `__search` companion column from what a read
4761+
* hands back, unless a SYSTEM caller named it in its projection.
4762+
*
4763+
* The column is declared client-invisible (`hidden` + `readonly` + `system`
4764+
* + `searchable: false`) and the enforcement that exists is real: it is kept
4765+
* out of auto-views, out of the `$search` auto-default, and a `$searchFields`
4766+
* override naming it is refused with a 400 ("is hidden"). What was missing is
4767+
* the PROJECTION half — a query that names no `fields` reaches the driver
4768+
* with `ast.fields` undefined, every driver answers that with `SELECT *`, and
4769+
* the companion rode back in every record body: list results, GET by id,
4770+
* `/search` hits (which are `engine.find` rows verbatim) and the 201 create
4771+
* body. The rule is applied HERE, at the engine, because the engine is the
4772+
* PRODUCER those four surfaces share; fixing them one consumer at a time is
4773+
* how three of the four would stay broken.
4774+
*
4775+
* Two carve-outs, both measured rather than defensive:
4776+
*
4777+
* - **A system caller that asks for it by name keeps it.** The companion has
4778+
* exactly one such reader: `plugin-pinyin-search`'s backfill/reconcile
4779+
* walk, which projects `['id', ...sources, '__search']` under
4780+
* `{ isSystem: true }` and compares the stored blob against the recomputed
4781+
* one. Strip it unconditionally and that comparison reads `undefined`
4782+
* every pass — the backfill would rewrite every row of every object on
4783+
* every run, which is worse than the disclosure it was fixing.
4784+
* - **A non-system caller does NOT keep it, even by name.** `select` only
4785+
* gates on whether a field is KNOWN (`assertProjectionFieldsExist`), and
4786+
* the companion is known once provisioned — so `?select=__search` would
4787+
* otherwise be an open door straight through this strip, and a
4788+
* client-invisibility rule with a documented spelling that bypasses it is
4789+
* not one. `isSystem` is server-derived (never client input), the same
4790+
* trust the read-only strips on the write path already place in it.
4791+
*
4792+
* ⚠️ `requestedFields` must be the CALLER's `fields`, captured before
4793+
* `planFormulaProjection` — that pass rewrites the projection to every stored
4794+
* column when a formula is in play, companion included.
4795+
*/
4796+
private stripSearchCompanionFromRead(
4797+
rows: unknown,
4798+
requestedFields: readonly string[] | undefined,
4799+
context: ExecutionContext | undefined,
4800+
): void {
4801+
if (context?.isSystem && isSearchCompanionRequested(requestedFields)) return;
4802+
stripSearchCompanion(rows);
4803+
}
4804+
47584805
/**
47594806
* Dereference a stored secret ref back to its plaintext. Intended for
47604807
* privileged, server-side consumers (e.g. a datasource connection-pool
@@ -6867,6 +6914,10 @@ export class ObjectQL implements IObjectQLEngine {
68676914
const _findSchema = this._registry.getObject(object);
68686915

68696916
this.expandSearchOnAst(ast, _findSchema);
6917+
// [#7642] The caller's OWN projection, captured before any planning pass
6918+
// rewrites it — the only thing that can answer "did this caller ask for
6919+
// `__search`?". See `stripSearchCompanionFromRead`.
6920+
const _findRequestedFields = Array.isArray(ast.fields) ? [...ast.fields] : undefined;
68706921
// [#7095] Before the projection is planned and before anything is handed to
68716922
// a driver: an ORDER BY this engine cannot materialise is refused, not
68726923
// dropped. `fillQueryAstDefaults` has already normalised `orderBy` into
@@ -6953,6 +7004,12 @@ export class ObjectQL implements IObjectQLEngine {
69537004
// resolveSecret() against the stored ref instead.
69547005
this.maskSecretFields(object, hookContext.result);
69557006

7007+
// [#7642] …and never let the hidden `__search` companion column out
7008+
// through the default projection either. After the hooks, for the
7009+
// same reason the mask is: a server-side `afterFind` handler is not
7010+
// the client this column is hidden from.
7011+
this.stripSearchCompanionFromRead(hookContext.result, _findRequestedFields, opCtx.context);
7012+
69567013
return hookContext.result;
69577014
} catch (e) {
69587015
this.logger.error('Find operation failed', e as Error, { object });
@@ -7023,6 +7080,8 @@ export class ObjectQL implements IObjectQLEngine {
70237080
// dropped sort does not merely reorder the answer, it returns a DIFFERENT
70247081
// record, and the one it returns looks exactly as legitimate.
70257082
assertOrderByIsMaterializable(objectName, 'findOne', _findOneSchema, ast.orderBy);
7083+
// [#7642] Caller's own projection, before planning rewrites it — see `find`.
7084+
const _findOneRequestedFields = Array.isArray(ast.fields) ? [...ast.fields] : undefined;
70267085
const _findOneFormula = planFormulaProjection(_findOneSchema, ast.fields);
70277086
if (_findOneFormula.projected) ast.fields = _findOneFormula.projected;
70287087

@@ -7089,6 +7148,10 @@ export class ObjectQL implements IObjectQLEngine {
70897148

70907149
// Mask secret fields — plaintext never leaves through the read path.
70917150
this.maskSecretFields(objectName, hookContext.result);
7151+
// [#7642] Hidden `__search` companion — same door, same rule as `find`.
7152+
// This is the `GET /data/:object/:id` surface (`getData` reads through
7153+
// findOne), one of the four the issue measured.
7154+
this.stripSearchCompanionFromRead(hookContext.result, _findOneRequestedFields, opCtx.context);
70927155

70937156
return hookContext.result;
70947157
});
@@ -7655,6 +7718,15 @@ export class ObjectQL implements IObjectQLEngine {
76557718
rowCtx.event = 'afterInsert';
76567719
rowCtx.result = coerceBooleanFields(schemaForValidation as any, resultRows[k] as any);
76577720
await this.triggerHooks('afterInsert', rowCtx);
7721+
// [#7642] The 201 create body is the surface most likely to be missed
7722+
// on this card, and the one no read-path fix reaches: `createData`
7723+
// returns `engine.insert`'s value verbatim as `record`, so the
7724+
// companion the `beforeInsert` stamp just wrote came straight back to
7725+
// the client. A write has no projection to consult, so there is no
7726+
// "asked for it by name" case to honour — the strip is unconditional.
7727+
// AFTER the hook dispatch, matching the read path: `afterInsert`
7728+
// handlers still observe the whole stored row.
7729+
stripSearchCompanion(rowCtx.result);
76587730
}
76597731

76607732
// Roll-up: recompute parent summary fields that aggregate this object.
@@ -8581,6 +8653,17 @@ export class ObjectQL implements IObjectQLEngine {
85818653
}
85828654
}
85838655

8656+
// [#7642] Same strip the create body gets, for the same reason: a
8657+
// by-id update resolves to a RECORD, `updateData` returns it as
8658+
// `record`, and the `beforeUpdate` companion stamp had just written
8659+
// `__search` into the row it echoes. The issue measured four
8660+
// surfaces and this is not one of them — it is the same column, the
8661+
// same contract and the same response shape, and leaving it out
8662+
// would mean POST and PATCH on one object disagreed about whether a
8663+
// client-invisible column is visible. A predicate update resolves to
8664+
// an affected-row COUNT (#4639), which the strip skips as a
8665+
// non-object.
8666+
stripSearchCompanion(hookContext.result);
85848667
// The record IS updated; a summary that could not recompute after
85858668
// retries must surface, not stay silent (framework#3147).
85868669
if (summaryFailures.length > 0) throw new SummaryRecomputeError(summaryFailures, hookContext.result);

packages/objectql/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ export {
3737
resolveSearchCompanionSources,
3838
isCompanionSourceEligible,
3939
isCompanionMatchableTerm,
40+
isSearchCompanionRequested,
41+
stripSearchCompanion,
4042
containsCJK,
4143
} from './search-companion.js';
4244
export type { CompanionFieldMeta, CompanionObjectMeta } from './search-companion.js';

0 commit comments

Comments
 (0)