Skip to content

Commit 1c58abd

Browse files
os-zhuangclaude
andauthored
feat(search): generic pinyin recall via locale-gated __search companion column (#3027)
Implements #2486 / ADR-0097 on top of ADR-0061 Tier-1 $search, purely additive: - OS_SEARCH_PINYIN_ENABLED platform switch with locale-derived default (any zh-* locale turns it on); CLI serve resolves once and stamps the decision into the env so the SchemaRegistry seam and the plugin gate agree. - SchemaRegistry provisions a hidden `__search` companion column for the ADR-0079 display/name field at object compile time; FLS-restricted / secret / hidden source fields are excluded fail-closed (ADR-0061 D5). - New @objectstack/plugin-pinyin-search fills the blob (full pinyin + initials, "zhangwei zw") via lazy-loaded pinyin-pro on before-save, with paged idempotent boot backfill and a rebuildSearchCompanion reconcile entry. - expandSearchToFilter ORs the companion into each latin term's clause; resolveSearchFields unchanged; zero driver changes. Verified end-to-end on the showcase app: $search=zhangwei / zw / 张 all recall 张伟; latin and CJK source-column behavior unchanged. Closes #2486 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RMugRriiNv3nsnmLczfhuY
1 parent fae5dd0 commit 1c58abd

23 files changed

Lines changed: 1548 additions & 5 deletions

.changeset/config.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"@objectstack/plugin-email",
4242
"@objectstack/plugin-hono-server",
4343
"@objectstack/mcp",
44+
"@objectstack/plugin-pinyin-search",
4445
"@objectstack/plugin-reports",
4546
"@objectstack/plugin-security",
4647
"@objectstack/plugin-sharing",
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/objectql': minor
3+
'@objectstack/plugin-pinyin-search': minor
4+
'@objectstack/types': minor
5+
'@objectstack/cli': minor
6+
---
7+
8+
Generic pinyin search recall (#2486, ADR-0097): a locale-gated
9+
`OS_SEARCH_PINYIN_ENABLED` switch (auto-on when the stack configures any
10+
`zh-*` locale) provisions a hidden `__search` companion column for each
11+
object's display/name field at compile time, the new
12+
`@objectstack/plugin-pinyin-search` fills it with full pinyin + initials
13+
("张伟" → "zhangwei zw") on before-save (plus boot backfill and a
14+
`rebuildSearchCompanion` reconcile entry), and `$search` ORs the column in at
15+
query time — so lookup pickers, list quick-search and ⌘K transparently match
16+
`zhangwei` / `zw` against CJK names. Purely additive: `resolveSearchFields`,
17+
`searchableFields`, drivers and non-Chinese deployments are untouched; FLS
18+
restricted / secret / PII fields never feed the companion.
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# ADR-0097: Pinyin Search via a Locale-Gated Companion Column
2+
3+
- **Status**: Accepted
4+
- **Date**: 2026-07-16
5+
- **Issue**: #2486
6+
- **Extends**: ADR-0061 (record search architecture — purely additive), ADR-0045 (additive materialization), ADR-0079 (display-name contract)
7+
8+
## Context
9+
10+
Pinyin search is a cross-cutting requirement for Chinese deployments: user
11+
pickers (`sys_user`), accounts, products, departments — any object with CJK
12+
names — must be findable by typing full pinyin (`zhangwei`) or initials
13+
(`zw`). ADR-0061 Tier-1 `$search` expands to `$contains` over source columns,
14+
which can never match: the stored value is the CJK original. Hand-rolling
15+
denormalized pinyin columns per object would copy the same hack N times.
16+
17+
Database tokenizers were investigated and rejected: Turso Cloud does not load
18+
traditional C extensions (no `simple` pinyin tokenizer), neither SQLite FTS5
19+
nor Turso's Tantivy engine ships native pinyin, and each dialect's FTS is
20+
mutually incompatible (FTS5 / tsvector·pg_trgm / Tantivy). A denormalized
21+
companion column + `$contains` is dialect- and engine-agnostic, and a future
22+
Tier-2 native FTS simply indexes it as one more column.
23+
24+
Of the four input shapes against CJK records, only two need anything new:
25+
CJK text and other-field originals (email, codes) already hit source columns
26+
via `$contains`. Full pinyin and initials do not. So the entire net increment
27+
is: **one normalized companion column for the name field, OR-ed in at query
28+
time**.
29+
30+
## Decision
31+
32+
1. **Locale-gated platform switch, no field metadata.**
33+
`OS_SEARCH_PINYIN_ENABLED` (resolved by `resolveSearchPinyinEnabled()` in
34+
`@objectstack/types`) gates the feature end-to-end. When unset, the CLI
35+
boot path derives the default from the stack's configured locales (any
36+
`zh-*` → on) and stamps the decision back into the env var so every
37+
consumer — the per-engine `SchemaRegistry` and the plugin gate — reads the
38+
same single decision. No field-level `pinyin` marker exists, so there is
39+
no declared-but-unenforced dead metadata (ADR-0049) and no half-state
40+
where a field "pretends" to support pinyin.
41+
42+
2. **Materialization set ≠ search set: one column per object.** Only the
43+
ADR-0079 display/name field (`resolveDisplayField`) feeds the hidden
44+
companion column `__search``provisionSearchCompanion` in
45+
`packages/objectql/src/search-companion.ts`, invoked from
46+
`SchemaRegistry.registerObject` right after `provisionPrimary`. The column
47+
is `hidden` / `readonly` / `system` / `searchable: false` / `index: true`;
48+
migration is a plain additive column (ADR-0045). `searchableFields` and
49+
the auto-default are untouched — text search stays wide via source
50+
columns, pinyin materialization stays narrow.
51+
52+
3. **One blob recalls both shapes.** The column stores full pinyin AND
53+
initials (`"张伟"``"zhangwei zw"`), so a single `$contains` recalls
54+
`zhang`/`wei`/`zhangwei` (full-form substrings) and `zw` (initials).
55+
Relevance ranking (full-pinyin first) and short-initial noise guards are
56+
Tier-2 (native FTS) — deliberately out of scope, hence one column, not two.
57+
58+
4. **Plugin fills, never declares** (the `plugin-sharing` primary-BU
59+
projection pattern). `@objectstack/plugin-pinyin-search` binds global
60+
`beforeInsert`/`beforeUpdate` hooks that recompute the blob (lazy-loaded
61+
`pinyin-pro`, default polyphone heuristics) **only when the source field is
62+
in the write** — no write amplification. A non-CJK new value clears the
63+
blob (no stale recall).
64+
65+
5. **Query-time is purely additive.** `expandSearchToFilter`
66+
(`packages/objectql/src/search-filter.ts`) ORs
67+
`{ __search: { $contains: term } }` into each latin term's clause when the
68+
object carries the column. `resolveSearchFields` is unchanged; the
69+
companion is invisible to `$searchFields` overrides and clients. CJK terms
70+
skip the clause (they hit source columns). `$or` + `$contains` are
71+
supported by every driver — zero driver changes.
72+
73+
6. **Security guardrail (ADR-0061 D5 extended).** Only fields readable by
74+
every accessor may feed the shared companion: fields with
75+
`requiredPermissions` (FLS), `hidden` fields, and secret/virtual types are
76+
excluded at the single eligibility gate (`isCompanionSourceEligible`) —
77+
otherwise "which search hits" becomes an FLS-bypass inference oracle.
78+
Fail-closed, not author-remembered.
79+
80+
7. **Write-path fallback.** Hook-bypassing writes (bulk import, direct
81+
migration) leave the blob empty → `backfillSearchCompanion` runs once per
82+
boot (`kernel:bootstrapped`, paged, idempotent) and
83+
`rebuildSearchCompanion` is the explicit reconcile/rebuild entry.
84+
85+
## Generalization seam
86+
87+
The companion is one instance of "search-normalization companion":
88+
`SEARCH_COMPANION_NORMALIZERS = ['pinyin']` names the applied normalizers.
89+
Future normalizers (simplified/traditional conversion, width folding, accent
90+
folding) extend the same list and column; only pinyin is implemented.
91+
92+
## Non-goals
93+
94+
- Relevance ranking, typo tolerance, whole-word guards → Tier-2 (native FTS).
95+
- Materializing non-name fields or a search blob; changing
96+
`searchableFields`/auto-default behavior.
97+
- Surname polyphone dictionaries (P2; `pinyin-pro` heuristics accepted).
98+
- Turso/SQLite tokenizers or loadable extensions.

packages/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
"@objectstack/plugin-hono-server": "workspace:*",
6363
"@objectstack/plugin-reports": "workspace:*",
6464
"@objectstack/plugin-security": "workspace:*",
65+
"@objectstack/plugin-pinyin-search": "workspace:*",
6566
"@objectstack/plugin-sharing": "workspace:*",
6667
"@objectstack/plugin-webhooks": "workspace:*",
6768
"@objectstack/rest": "workspace:*",

packages/cli/src/commands/serve.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import chalk from 'chalk';
88
import { bundleRequire } from 'bundle-require';
99
import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js';
1010
import { isHostConfig, shouldBootWithLibrary } from '../utils/plugin-detection.js';
11-
import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveAllowDegradedTenancy, isMcpServerEnabled } from '@objectstack/types';
11+
import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveAllowDegradedTenancy, isMcpServerEnabled, resolveSearchPinyinEnabled } from '@objectstack/types';
1212
import { resolveObjectStackHome } from '@objectstack/runtime';
1313
import { LOG_LEVELS, resolveLogLevel, readLogLevelEnv } from '../utils/log-level.js';
1414
import {
@@ -486,6 +486,25 @@ export default class Serve extends Command {
486486
if (isMcpServerEnabled() && !requires.includes('mcp')) {
487487
requires.push('mcp');
488488
}
489+
// Pinyin search recall (#2486): locale-gated platform capability. When
490+
// `OS_SEARCH_PINYIN_ENABLED` is unset, the default derives from the
491+
// stack's configured locales (any `zh-*` → on). This is the ONE place
492+
// that sees the stack config, so the resolved decision is stamped back
493+
// into the env var — every later consumer (each engine's SchemaRegistry
494+
// provisioning the `__search` companion column, the plugin's own gate)
495+
// reads the same answer via the no-arg `resolveSearchPinyinEnabled()`.
496+
{
497+
const i18nCfg = (config as any).i18n ?? {};
498+
const configuredLocales = [
499+
i18nCfg.defaultLocale,
500+
i18nCfg.fallbackLocale,
501+
...(Array.isArray(i18nCfg.supportedLocales) ? i18nCfg.supportedLocales : []),
502+
].filter((l: unknown): l is string => typeof l === 'string');
503+
if (resolveSearchPinyinEnabled({ locales: configuredLocales })) {
504+
process.env.OS_SEARCH_PINYIN_ENABLED = 'true';
505+
if (!requires.includes('pinyin-search')) requires.push('pinyin-search');
506+
}
507+
}
489508
// Default capability slate — every preset except `minimal` gets the
490509
// foundational services (queue + job + cache + settings + email +
491510
// storage). Opt out with `objectstack serve --preset minimal`.
@@ -1812,6 +1831,13 @@ export default class Serve extends Command {
18121831
export: 'SharingServicePlugin',
18131832
nameMatch: ['plugin-sharing', 'SharingServicePlugin', 'SharingPlugin'],
18141833
},
1834+
// #2486 — auto-required above when resolveSearchPinyinEnabled()
1835+
// (explicit env, else any configured zh-* locale) says on.
1836+
'pinyin-search': {
1837+
pkg: '@objectstack/plugin-pinyin-search',
1838+
export: 'PinyinSearchPlugin',
1839+
nameMatch: ['plugin-pinyin-search', 'PinyinSearchPlugin'],
1840+
},
18151841
reports: {
18161842
pkg: '@objectstack/plugin-reports',
18171843
export: 'ReportsServicePlugin',

packages/objectql/src/core.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,18 @@ export {
2323
} from './registry.js';
2424
export type { ObjectContributor, SchemaRegistryOptions } from './registry.js';
2525

26+
// Search-normalization companion column (#2486 — pinyin recall)
27+
export {
28+
SEARCH_COMPANION_FIELD,
29+
SEARCH_COMPANION_NORMALIZERS,
30+
provisionSearchCompanion,
31+
resolveSearchCompanionSources,
32+
isCompanionSourceEligible,
33+
isCompanionMatchableTerm,
34+
containsCJK,
35+
} from './search-companion.js';
36+
export type { CompanionFieldMeta, CompanionObjectMeta } from './search-companion.js';
37+
2638
// Engine
2739
export { ObjectQL, ObjectRepository, ScopedContext } from './engine.js';
2840
export type { ObjectQLHostContext, HookHandler, HookEntry, OperationContext, EngineMiddleware } from './engine.js';

packages/objectql/src/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,20 @@ export {
1212
} from './registry.js';
1313
export type { ObjectContributor, SchemaRegistryOptions } from './registry.js';
1414

15+
// Search-normalization companion column (#2486 — pinyin recall). Shared by
16+
// the registry's compile-time provisioning seam, the engine's `$search`
17+
// expansion, and plugin-pinyin-search's populate hooks.
18+
export {
19+
SEARCH_COMPANION_FIELD,
20+
SEARCH_COMPANION_NORMALIZERS,
21+
provisionSearchCompanion,
22+
resolveSearchCompanionSources,
23+
isCompanionSourceEligible,
24+
isCompanionMatchableTerm,
25+
containsCJK,
26+
} from './search-companion.js';
27+
export type { CompanionFieldMeta, CompanionObjectMeta } from './search-companion.js';
28+
1529
// Export Protocol Implementation
1630
export { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
1731

packages/objectql/src/registry.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { ServiceObject, ObjectSchema, ObjectOwnership, provisionPrimary } from '@objectstack/spec/data';
4-
import { resolveMultiOrgEnabled } from '@objectstack/types';
4+
import { resolveMultiOrgEnabled, resolveSearchPinyinEnabled } from '@objectstack/types';
5+
import { provisionSearchCompanion } from './search-companion.js';
56
import { ObjectStackManifest, ManifestSchema, InstalledPackage, InstalledPackageSchema } from '@objectstack/spec/kernel';
67
import { AppSchema } from '@objectstack/spec/ui';
78
import { applyProtection } from '@objectstack/spec/shared';
@@ -142,6 +143,17 @@ export interface SchemaRegistryOptions {
142143
*/
143144
multiTenant?: boolean;
144145

146+
/**
147+
* Whether to provision the hidden `__search` search-normalization companion
148+
* column on registered objects that have an eligible display/name field
149+
* (#2486 — pinyin search recall). Sourced from `OS_SEARCH_PINYIN_ENABLED`
150+
* via `resolveSearchPinyinEnabled()` when not explicitly set (the CLI boot
151+
* path stamps the locale-derived decision into that env var before the
152+
* kernel constructs engines). Default off — non-Chinese deployments carry
153+
* no extra column. Pass an explicit boolean to override (useful in tests).
154+
*/
155+
searchCompanion?: boolean;
156+
145157
/**
146158
* Policy for the install-time namespace gate (ADR-0048 Phase 1) — installing
147159
* a package whose `manifest.namespace` is already owned by a *different*
@@ -412,6 +424,9 @@ export class SchemaRegistry {
412424
/** Whether to auto-inject multi-tenant system fields. */
413425
private readonly multiTenant: boolean;
414426

427+
/** Whether to provision the `__search` companion column (#2486). */
428+
private readonly searchCompanion: boolean;
429+
415430
/** Cross-package base-layer collision policy (ADR-0048). */
416431
private readonly collisionPolicy: 'error' | 'warn';
417432

@@ -423,6 +438,12 @@ export class SchemaRegistry {
423438
this.multiTenant = resolveMultiOrgEnabled();
424439
}
425440

441+
// Pinyin-search companion column (#2486). Env-driven like multiTenant;
442+
// the CLI boot path resolves the locale-derived default once and stamps
443+
// it into OS_SEARCH_PINYIN_ENABLED so this read agrees with the plugin
444+
// gate.
445+
this.searchCompanion = options.searchCompanion ?? resolveSearchPinyinEnabled();
446+
426447
// ADR-0048 — default to a loud error on cross-package collision; allow an
427448
// env opt-out for deliberate migrations.
428449
this.collisionPolicy =
@@ -581,6 +602,18 @@ export class SchemaRegistry {
581602
// resolving their title on read via `resolveDisplayField` / `titleFormat`.
582603
if (ownership === 'own') {
583604
schema = provisionPrimary(schema, { synthesize: false });
605+
606+
// [#2486] Search-normalization companion column (`__search`). Runs
607+
// AFTER `provisionPrimary` so the just-designated `nameField` is
608+
// visible, and ONLY when the deployment enables pinyin search — the
609+
// column is a real additive migration (ADR-0045) that the driver's
610+
// `syncSchema` materializes, populated by plugin-pinyin-search's
611+
// before-save hooks and OR-ed into `$search` by the engine's
612+
// `expandSearchToFilter`. Owned objects only: extensions merge into
613+
// the owner's already-provisioned shape.
614+
if (this.searchCompanion) {
615+
schema = provisionSearchCompanion(schema);
616+
}
584617
}
585618

586619
const shortName = schema.name;

0 commit comments

Comments
 (0)