|
| 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. |
0 commit comments