diff --git a/.changeset/config.json b/.changeset/config.json index ead8c55b2c..fb3cdfc0e8 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -41,6 +41,7 @@ "@objectstack/plugin-email", "@objectstack/plugin-hono-server", "@objectstack/mcp", + "@objectstack/plugin-pinyin-search", "@objectstack/plugin-reports", "@objectstack/plugin-security", "@objectstack/plugin-sharing", diff --git a/.changeset/pinyin-search-companion.md b/.changeset/pinyin-search-companion.md new file mode 100644 index 0000000000..ca9fd06d6f --- /dev/null +++ b/.changeset/pinyin-search-companion.md @@ -0,0 +1,18 @@ +--- +'@objectstack/objectql': minor +'@objectstack/plugin-pinyin-search': minor +'@objectstack/types': minor +'@objectstack/cli': minor +--- + +Generic pinyin search recall (#2486, ADR-0097): a locale-gated +`OS_SEARCH_PINYIN_ENABLED` switch (auto-on when the stack configures any +`zh-*` locale) provisions a hidden `__search` companion column for each +object's display/name field at compile time, the new +`@objectstack/plugin-pinyin-search` fills it with full pinyin + initials +("张伟" → "zhangwei zw") on before-save (plus boot backfill and a +`rebuildSearchCompanion` reconcile entry), and `$search` ORs the column in at +query time — so lookup pickers, list quick-search and ⌘K transparently match +`zhangwei` / `zw` against CJK names. Purely additive: `resolveSearchFields`, +`searchableFields`, drivers and non-Chinese deployments are untouched; FLS +restricted / secret / PII fields never feed the companion. diff --git a/docs/adr/0097-pinyin-search-companion-column.md b/docs/adr/0097-pinyin-search-companion-column.md new file mode 100644 index 0000000000..8086ec111f --- /dev/null +++ b/docs/adr/0097-pinyin-search-companion-column.md @@ -0,0 +1,98 @@ +# ADR-0097: Pinyin Search via a Locale-Gated Companion Column + +- **Status**: Accepted +- **Date**: 2026-07-16 +- **Issue**: #2486 +- **Extends**: ADR-0061 (record search architecture — purely additive), ADR-0045 (additive materialization), ADR-0079 (display-name contract) + +## Context + +Pinyin search is a cross-cutting requirement for Chinese deployments: user +pickers (`sys_user`), accounts, products, departments — any object with CJK +names — must be findable by typing full pinyin (`zhangwei`) or initials +(`zw`). ADR-0061 Tier-1 `$search` expands to `$contains` over source columns, +which can never match: the stored value is the CJK original. Hand-rolling +denormalized pinyin columns per object would copy the same hack N times. + +Database tokenizers were investigated and rejected: Turso Cloud does not load +traditional C extensions (no `simple` pinyin tokenizer), neither SQLite FTS5 +nor Turso's Tantivy engine ships native pinyin, and each dialect's FTS is +mutually incompatible (FTS5 / tsvector·pg_trgm / Tantivy). A denormalized +companion column + `$contains` is dialect- and engine-agnostic, and a future +Tier-2 native FTS simply indexes it as one more column. + +Of the four input shapes against CJK records, only two need anything new: +CJK text and other-field originals (email, codes) already hit source columns +via `$contains`. Full pinyin and initials do not. So the entire net increment +is: **one normalized companion column for the name field, OR-ed in at query +time**. + +## Decision + +1. **Locale-gated platform switch, no field metadata.** + `OS_SEARCH_PINYIN_ENABLED` (resolved by `resolveSearchPinyinEnabled()` in + `@objectstack/types`) gates the feature end-to-end. When unset, the CLI + boot path derives the default from the stack's configured locales (any + `zh-*` → on) and stamps the decision back into the env var so every + consumer — the per-engine `SchemaRegistry` and the plugin gate — reads the + same single decision. No field-level `pinyin` marker exists, so there is + no declared-but-unenforced dead metadata (ADR-0049) and no half-state + where a field "pretends" to support pinyin. + +2. **Materialization set ≠ search set: one column per object.** Only the + ADR-0079 display/name field (`resolveDisplayField`) feeds the hidden + companion column `__search` — `provisionSearchCompanion` in + `packages/objectql/src/search-companion.ts`, invoked from + `SchemaRegistry.registerObject` right after `provisionPrimary`. The column + is `hidden` / `readonly` / `system` / `searchable: false` / `index: true`; + migration is a plain additive column (ADR-0045). `searchableFields` and + the auto-default are untouched — text search stays wide via source + columns, pinyin materialization stays narrow. + +3. **One blob recalls both shapes.** The column stores full pinyin AND + initials (`"张伟"` → `"zhangwei zw"`), so a single `$contains` recalls + `zhang`/`wei`/`zhangwei` (full-form substrings) and `zw` (initials). + Relevance ranking (full-pinyin first) and short-initial noise guards are + Tier-2 (native FTS) — deliberately out of scope, hence one column, not two. + +4. **Plugin fills, never declares** (the `plugin-sharing` primary-BU + projection pattern). `@objectstack/plugin-pinyin-search` binds global + `beforeInsert`/`beforeUpdate` hooks that recompute the blob (lazy-loaded + `pinyin-pro`, default polyphone heuristics) **only when the source field is + in the write** — no write amplification. A non-CJK new value clears the + blob (no stale recall). + +5. **Query-time is purely additive.** `expandSearchToFilter` + (`packages/objectql/src/search-filter.ts`) ORs + `{ __search: { $contains: term } }` into each latin term's clause when the + object carries the column. `resolveSearchFields` is unchanged; the + companion is invisible to `$searchFields` overrides and clients. CJK terms + skip the clause (they hit source columns). `$or` + `$contains` are + supported by every driver — zero driver changes. + +6. **Security guardrail (ADR-0061 D5 extended).** Only fields readable by + every accessor may feed the shared companion: fields with + `requiredPermissions` (FLS), `hidden` fields, and secret/virtual types are + excluded at the single eligibility gate (`isCompanionSourceEligible`) — + otherwise "which search hits" becomes an FLS-bypass inference oracle. + Fail-closed, not author-remembered. + +7. **Write-path fallback.** Hook-bypassing writes (bulk import, direct + migration) leave the blob empty → `backfillSearchCompanion` runs once per + boot (`kernel:bootstrapped`, paged, idempotent) and + `rebuildSearchCompanion` is the explicit reconcile/rebuild entry. + +## Generalization seam + +The companion is one instance of "search-normalization companion": +`SEARCH_COMPANION_NORMALIZERS = ['pinyin']` names the applied normalizers. +Future normalizers (simplified/traditional conversion, width folding, accent +folding) extend the same list and column; only pinyin is implemented. + +## Non-goals + +- Relevance ranking, typo tolerance, whole-word guards → Tier-2 (native FTS). +- Materializing non-name fields or a search blob; changing + `searchableFields`/auto-default behavior. +- Surname polyphone dictionaries (P2; `pinyin-pro` heuristics accepted). +- Turso/SQLite tokenizers or loadable extensions. diff --git a/packages/cli/package.json b/packages/cli/package.json index fb3740e4bf..17e30c0aef 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -62,6 +62,7 @@ "@objectstack/plugin-hono-server": "workspace:*", "@objectstack/plugin-reports": "workspace:*", "@objectstack/plugin-security": "workspace:*", + "@objectstack/plugin-pinyin-search": "workspace:*", "@objectstack/plugin-sharing": "workspace:*", "@objectstack/plugin-webhooks": "workspace:*", "@objectstack/rest": "workspace:*", diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 7912f809f4..90c832b2b4 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -8,7 +8,7 @@ import chalk from 'chalk'; import { bundleRequire } from 'bundle-require'; import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js'; import { isHostConfig, shouldBootWithLibrary } from '../utils/plugin-detection.js'; -import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveAllowDegradedTenancy, isMcpServerEnabled } from '@objectstack/types'; +import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveAllowDegradedTenancy, isMcpServerEnabled, resolveSearchPinyinEnabled } from '@objectstack/types'; import { resolveObjectStackHome } from '@objectstack/runtime'; import { LOG_LEVELS, resolveLogLevel, readLogLevelEnv } from '../utils/log-level.js'; import { @@ -486,6 +486,25 @@ export default class Serve extends Command { if (isMcpServerEnabled() && !requires.includes('mcp')) { requires.push('mcp'); } + // Pinyin search recall (#2486): locale-gated platform capability. When + // `OS_SEARCH_PINYIN_ENABLED` is unset, the default derives from the + // stack's configured locales (any `zh-*` → on). This is the ONE place + // that sees the stack config, so the resolved decision is stamped back + // into the env var — every later consumer (each engine's SchemaRegistry + // provisioning the `__search` companion column, the plugin's own gate) + // reads the same answer via the no-arg `resolveSearchPinyinEnabled()`. + { + const i18nCfg = (config as any).i18n ?? {}; + const configuredLocales = [ + i18nCfg.defaultLocale, + i18nCfg.fallbackLocale, + ...(Array.isArray(i18nCfg.supportedLocales) ? i18nCfg.supportedLocales : []), + ].filter((l: unknown): l is string => typeof l === 'string'); + if (resolveSearchPinyinEnabled({ locales: configuredLocales })) { + process.env.OS_SEARCH_PINYIN_ENABLED = 'true'; + if (!requires.includes('pinyin-search')) requires.push('pinyin-search'); + } + } // Default capability slate — every preset except `minimal` gets the // foundational services (queue + job + cache + settings + email + // storage). Opt out with `objectstack serve --preset minimal`. @@ -1812,6 +1831,13 @@ export default class Serve extends Command { export: 'SharingServicePlugin', nameMatch: ['plugin-sharing', 'SharingServicePlugin', 'SharingPlugin'], }, + // #2486 — auto-required above when resolveSearchPinyinEnabled() + // (explicit env, else any configured zh-* locale) says on. + 'pinyin-search': { + pkg: '@objectstack/plugin-pinyin-search', + export: 'PinyinSearchPlugin', + nameMatch: ['plugin-pinyin-search', 'PinyinSearchPlugin'], + }, reports: { pkg: '@objectstack/plugin-reports', export: 'ReportsServicePlugin', diff --git a/packages/objectql/src/core.ts b/packages/objectql/src/core.ts index b01df4d44f..ce8397c5d7 100644 --- a/packages/objectql/src/core.ts +++ b/packages/objectql/src/core.ts @@ -23,6 +23,18 @@ export { } from './registry.js'; export type { ObjectContributor, SchemaRegistryOptions } from './registry.js'; +// Search-normalization companion column (#2486 — pinyin recall) +export { + SEARCH_COMPANION_FIELD, + SEARCH_COMPANION_NORMALIZERS, + provisionSearchCompanion, + resolveSearchCompanionSources, + isCompanionSourceEligible, + isCompanionMatchableTerm, + containsCJK, +} from './search-companion.js'; +export type { CompanionFieldMeta, CompanionObjectMeta } from './search-companion.js'; + // Engine export { ObjectQL, ObjectRepository, ScopedContext } from './engine.js'; export type { ObjectQLHostContext, HookHandler, HookEntry, OperationContext, EngineMiddleware } from './engine.js'; diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index 889371d172..c8467c97c0 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -12,6 +12,20 @@ export { } from './registry.js'; export type { ObjectContributor, SchemaRegistryOptions } from './registry.js'; +// Search-normalization companion column (#2486 — pinyin recall). Shared by +// the registry's compile-time provisioning seam, the engine's `$search` +// expansion, and plugin-pinyin-search's populate hooks. +export { + SEARCH_COMPANION_FIELD, + SEARCH_COMPANION_NORMALIZERS, + provisionSearchCompanion, + resolveSearchCompanionSources, + isCompanionSourceEligible, + isCompanionMatchableTerm, + containsCJK, +} from './search-companion.js'; +export type { CompanionFieldMeta, CompanionObjectMeta } from './search-companion.js'; + // Export Protocol Implementation export { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 5a19593fc3..cd0f838fea 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -1,7 +1,8 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { ServiceObject, ObjectSchema, ObjectOwnership, provisionPrimary } from '@objectstack/spec/data'; -import { resolveMultiOrgEnabled } from '@objectstack/types'; +import { resolveMultiOrgEnabled, resolveSearchPinyinEnabled } from '@objectstack/types'; +import { provisionSearchCompanion } from './search-companion.js'; import { ObjectStackManifest, ManifestSchema, InstalledPackage, InstalledPackageSchema } from '@objectstack/spec/kernel'; import { AppSchema } from '@objectstack/spec/ui'; import { applyProtection } from '@objectstack/spec/shared'; @@ -142,6 +143,17 @@ export interface SchemaRegistryOptions { */ multiTenant?: boolean; + /** + * Whether to provision the hidden `__search` search-normalization companion + * column on registered objects that have an eligible display/name field + * (#2486 — pinyin search recall). Sourced from `OS_SEARCH_PINYIN_ENABLED` + * via `resolveSearchPinyinEnabled()` when not explicitly set (the CLI boot + * path stamps the locale-derived decision into that env var before the + * kernel constructs engines). Default off — non-Chinese deployments carry + * no extra column. Pass an explicit boolean to override (useful in tests). + */ + searchCompanion?: boolean; + /** * Policy for the install-time namespace gate (ADR-0048 Phase 1) — installing * a package whose `manifest.namespace` is already owned by a *different* @@ -412,6 +424,9 @@ export class SchemaRegistry { /** Whether to auto-inject multi-tenant system fields. */ private readonly multiTenant: boolean; + /** Whether to provision the `__search` companion column (#2486). */ + private readonly searchCompanion: boolean; + /** Cross-package base-layer collision policy (ADR-0048). */ private readonly collisionPolicy: 'error' | 'warn'; @@ -423,6 +438,12 @@ export class SchemaRegistry { this.multiTenant = resolveMultiOrgEnabled(); } + // Pinyin-search companion column (#2486). Env-driven like multiTenant; + // the CLI boot path resolves the locale-derived default once and stamps + // it into OS_SEARCH_PINYIN_ENABLED so this read agrees with the plugin + // gate. + this.searchCompanion = options.searchCompanion ?? resolveSearchPinyinEnabled(); + // ADR-0048 — default to a loud error on cross-package collision; allow an // env opt-out for deliberate migrations. this.collisionPolicy = @@ -581,6 +602,18 @@ export class SchemaRegistry { // resolving their title on read via `resolveDisplayField` / `titleFormat`. if (ownership === 'own') { schema = provisionPrimary(schema, { synthesize: false }); + + // [#2486] Search-normalization companion column (`__search`). Runs + // AFTER `provisionPrimary` so the just-designated `nameField` is + // visible, and ONLY when the deployment enables pinyin search — the + // column is a real additive migration (ADR-0045) that the driver's + // `syncSchema` materializes, populated by plugin-pinyin-search's + // before-save hooks and OR-ed into `$search` by the engine's + // `expandSearchToFilter`. Owned objects only: extensions merge into + // the owner's already-provisioned shape. + if (this.searchCompanion) { + schema = provisionSearchCompanion(schema); + } } const shortName = schema.name; diff --git a/packages/objectql/src/search-companion.test.ts b/packages/objectql/src/search-companion.test.ts new file mode 100644 index 0000000000..2dea3d5dde --- /dev/null +++ b/packages/objectql/src/search-companion.test.ts @@ -0,0 +1,180 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #2486 — `__search` companion column: provisioning seam, eligibility gate, + * registry integration, and query-time OR-ing in expandSearchToFilter. + */ + +import { describe, it, expect } from 'vitest'; +import { + SEARCH_COMPANION_FIELD, + provisionSearchCompanion, + resolveSearchCompanionSources, + isCompanionSourceEligible, + isCompanionMatchableTerm, + containsCJK, +} from './search-companion'; +import { expandSearchToFilter, resolveSearchFields } from './search-filter'; +import { SchemaRegistry } from './registry'; + +const contact = () => ({ + name: 'crm_contact', + fields: { + name: { type: 'text', label: 'Name' }, + email: { type: 'email' }, + notes: { type: 'textarea' }, + }, +}); + +describe('isCompanionSourceEligible (ADR-0061 D5 security gate)', () => { + it('accepts plain stored text-ish fields', () => { + expect(isCompanionSourceEligible({ type: 'text' })).toBe(true); + expect(isCompanionSourceEligible({ type: 'textarea' })).toBe(true); + expect(isCompanionSourceEligible({ type: 'email' })).toBe(true); + }); + + it('rejects secret-ish / non-text / virtual types (fail-closed)', () => { + for (const type of ['secret', 'password', 'lookup', 'select', 'formula', 'json', undefined]) { + expect(isCompanionSourceEligible({ type })).toBe(false); + } + }); + + it('rejects hidden fields and fields with field-level read restrictions', () => { + expect(isCompanionSourceEligible({ type: 'text', hidden: true })).toBe(false); + expect(isCompanionSourceEligible({ type: 'text', requiredPermissions: ['view_pii'] })).toBe(false); + expect(isCompanionSourceEligible({ type: 'text', requiredPermissions: [] })).toBe(true); + }); +}); + +describe('resolveSearchCompanionSources', () => { + it('returns the resolved display/name field only', () => { + expect(resolveSearchCompanionSources(contact())).toEqual(['name']); + }); + + it('honors an explicit nameField pointer', () => { + const schema = { + name: 'crm_ticket', + nameField: 'subject', + fields: { subject: { type: 'text' }, name: { type: 'text' } }, + }; + expect(resolveSearchCompanionSources(schema)).toEqual(['subject']); + }); + + it('returns [] when the name source is ineligible or absent', () => { + expect(resolveSearchCompanionSources({ + name: 'x', + nameField: 'code', + fields: { code: { type: 'text', requiredPermissions: ['view_secret'] } }, + })).toEqual([]); + expect(resolveSearchCompanionSources({ + name: 'junction', + fields: { left_id: { type: 'lookup' }, right_id: { type: 'lookup' } }, + })).toEqual([]); + expect(resolveSearchCompanionSources(undefined)).toEqual([]); + }); +}); + +describe('provisionSearchCompanion', () => { + it('appends the hidden companion column for an eligible object', () => { + const out = provisionSearchCompanion(contact()); + const col = out.fields[SEARCH_COMPANION_FIELD] as any; + expect(col).toBeDefined(); + expect(col.type).toBe('text'); + expect(col.hidden).toBe(true); + expect(col.readonly).toBe(true); + expect(col.system).toBe(true); + expect(col.searchable).toBe(false); + expect(col.index).toBe(true); + }); + + it('is idempotent and skips ineligible / opted-out objects unchanged', () => { + const once = provisionSearchCompanion(contact()); + expect(provisionSearchCompanion(once)).toBe(once); + + const titleless = { name: 'junction', fields: { a_id: { type: 'lookup' } } }; + expect(provisionSearchCompanion(titleless)).toBe(titleless); + + const optedOut = { ...contact(), searchable: false }; + expect(provisionSearchCompanion(optedOut)).toBe(optedOut); + }); +}); + +describe('SchemaRegistry integration (compile-time seam)', () => { + it('provisions the companion on registered objects when searchCompanion is on', () => { + const registry = new SchemaRegistry({ multiTenant: false, searchCompanion: true }); + registry.registerObject(contact() as any, 'test-pkg', 'crm'); + const schema = registry.getObject('crm_contact')!; + expect(schema.fields![SEARCH_COMPANION_FIELD]).toBeDefined(); + expect((schema.fields![SEARCH_COMPANION_FIELD] as any).hidden).toBe(true); + }); + + it('does NOT provision when the flag is off (default) — pure additive', () => { + const registry = new SchemaRegistry({ multiTenant: false }); + registry.registerObject(contact() as any, 'test-pkg', 'crm'); + expect(registry.getObject('crm_contact')!.fields![SEARCH_COMPANION_FIELD]).toBeUndefined(); + }); + + it('skips objects with no eligible name source even when on', () => { + const registry = new SchemaRegistry({ multiTenant: false, searchCompanion: true }); + registry.registerObject( + { name: 'crm_link', systemFields: false, fields: { a_id: { type: 'lookup' }, b_id: { type: 'lookup' } } } as any, + 'test-pkg', + 'crm', + ); + expect(registry.getObject('crm_link')!.fields![SEARCH_COMPANION_FIELD]).toBeUndefined(); + }); +}); + +describe('expandSearchToFilter with companion column (query-time, additive)', () => { + const fields = provisionSearchCompanion(contact()).fields as any; + + it('ORs the companion clause for latin terms (lowercased)', () => { + const filter = expandSearchToFilter('ZhangWei', { fields }); + expect(filter.$or).toContainEqual({ [SEARCH_COMPANION_FIELD]: { $contains: 'zhangwei' } }); + // Source-field clauses are untouched alongside it. + expect(filter.$or).toContainEqual({ name: { $contains: 'ZhangWei' } }); + }); + + it('skips the companion clause for CJK and letterless terms', () => { + const cjk = expandSearchToFilter('张伟', { fields }); + expect(JSON.stringify(cjk)).not.toContain(SEARCH_COMPANION_FIELD); + const digits = expandSearchToFilter('12345', { fields }); + expect(JSON.stringify(digits)).not.toContain(SEARCH_COMPANION_FIELD); + }); + + it('applies per-term for multi-term queries (terms stay AND-ed)', () => { + const filter = expandSearchToFilter('zw 张', { fields }); + expect(filter.$and).toHaveLength(2); + expect(JSON.stringify(filter.$and[0])).toContain('"__search":{"$contains":"zw"}'); + expect(JSON.stringify(filter.$and[1])).not.toContain(SEARCH_COMPANION_FIELD); + }); + + it('emits no companion clause for objects without the column', () => { + const filter = expandSearchToFilter('zhangwei', { fields: contact().fields as any }); + expect(JSON.stringify(filter)).not.toContain(SEARCH_COMPANION_FIELD); + }); + + it('keeps resolveSearchFields untouched — the companion is invisible to clients', () => { + const resolved = resolveSearchFields({ fields }); + expect(resolved).not.toContain(SEARCH_COMPANION_FIELD); + // …and a client cannot force it in via the $searchFields override. + const forced = resolveSearchFields({ fields, requestedFields: [SEARCH_COMPANION_FIELD] }); + expect(forced).not.toContain(SEARCH_COMPANION_FIELD); + }); +}); + +describe('containsCJK / isCompanionMatchableTerm', () => { + it('detects Han characters', () => { + expect(containsCJK('张伟')).toBe(true); + expect(containsCJK('Zhang Wei')).toBe(false); + expect(containsCJK(42)).toBe(false); + }); + + it('classifies companion-matchable terms', () => { + expect(isCompanionMatchableTerm('zhangwei')).toBe(true); + expect(isCompanionMatchableTerm('zw')).toBe(true); + expect(isCompanionMatchableTerm('张伟')).toBe(false); + expect(isCompanionMatchableTerm('zh张')).toBe(false); + expect(isCompanionMatchableTerm('123')).toBe(false); + }); +}); diff --git a/packages/objectql/src/search-companion.ts b/packages/objectql/src/search-companion.ts new file mode 100644 index 0000000000..8e159b5192 --- /dev/null +++ b/packages/objectql/src/search-companion.ts @@ -0,0 +1,179 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Search-normalization companion column (`__search`) — pinyin recall (#2486). + * + * `$search` (ADR-0061 Tier 1) is a `$contains` over source columns, so typing + * the full pinyin (`zhangwei`) or initials (`zw`) of a CJK name ("张伟") can + * never hit — the stored value is the CJK original. This module provides the + * additive fix: a single hidden companion column per object that stores + * search-normalized forms of the object's display/name field + * ("zhangwei zw"), OR-ed into the search filter at query time. + * + * Design constraints (issue #2486): + * - NO field-level metadata: the capability is deployment/locale-gated via + * `OS_SEARCH_PINYIN_ENABLED` (see `resolveSearchPinyinEnabled` in + * `@objectstack/types`), so there is no declared-but-unenforced marker + * (ADR-0049) and no per-field opt-in to audit. + * - Materialization set ≠ search set: ONLY the resolved display/name field + * (ADR-0079 `resolveDisplayField`) feeds the companion — one column per + * object. Other fields are searched via their source columns directly. + * - Declared at object compile time (the SchemaRegistry materialization + * seam, next to `provisionPrimary`), so the driver's `syncSchema` + * migrates the column additively (ADR-0045) and every consumer sees the + * same shape. `plugin-pinyin-search` only FILLS the value (the + * `plugin-sharing` primary-BU projection pattern). + * - Security (ADR-0061 D5): a source field with field-level read + * restrictions (`requiredPermissions`) or a secret-ish type never feeds + * the companion — otherwise "search hit ⇒ value inference" becomes an + * FLS-bypass oracle. Enforced here at the single eligibility gate, not + * left to author discipline. + * + * The companion is an instance of the generic "search normalizer" seam — + * `SEARCH_COMPANION_NORMALIZERS` names the applied normalizers. Only + * `pinyin` is implemented; future normalizers (simplified/traditional + * conversion, width folding, accent folding) extend the same list and reuse + * the same column. + */ + +import { resolveDisplayField, TITLE_ELIGIBLE_TYPES } from '@objectstack/spec/data'; + +/** + * Name of the hidden companion column. Double-underscore prefixed so it can + * never collide with an author-declared field (machine names are snake_case + * words) and reads as platform plumbing in raw rows. + */ +export const SEARCH_COMPANION_FIELD = '__search'; + +/** + * Normalizers applied to the companion column. Generalization seam: pinyin is + * the only implemented normalizer; the list form keeps the door open for + * future ones without a schema change. + */ +export const SEARCH_COMPANION_NORMALIZERS = ['pinyin'] as const; + +/** Minimal field shape this module inspects (same spirit as SearchFieldMeta). */ +export interface CompanionFieldMeta { + type?: string; + hidden?: boolean; + system?: boolean; + requiredPermissions?: readonly string[]; + [k: string]: unknown; +} + +/** Minimal object shape this module inspects. */ +export interface CompanionObjectMeta { + name?: string; + nameField?: string; + displayNameField?: string; + searchable?: boolean; + fields?: Record; + [k: string]: unknown; +} + +/** + * Types whose values may feed the companion. Stored text-ish columns only — + * `formula` is excluded even when title-eligible because its value is virtual + * (computed on read), so there is no persisted source to normalize from. + */ +function isCompanionSourceType(type: string | undefined): boolean { + if (!type) return false; + if (type === 'formula') return false; + return TITLE_ELIGIBLE_TYPES.has(type); +} + +/** + * May `fieldMeta` feed the companion column? + * + * Fail-closed security gate (ADR-0061 D5 extended): only fields readable by + * EVERY accessor of the object may be denormalized into the shared companion. + * Fields with field-level read restrictions (`requiredPermissions`), hidden + * fields, and non-text/secret-ish types are excluded — a companion hit on a + * restricted value would leak it through the result set even though the field + * itself is masked. + */ +export function isCompanionSourceEligible(fieldMeta: CompanionFieldMeta | undefined | null): boolean { + if (!fieldMeta) return false; + if (fieldMeta.hidden === true) return false; + if (Array.isArray(fieldMeta.requiredPermissions) && fieldMeta.requiredPermissions.length > 0) return false; + return isCompanionSourceType(fieldMeta.type); +} + +/** + * Resolve the source fields that feed an object's companion column: the + * ADR-0079 display/name field, when it exists and passes the eligibility + * gate. Returns `[]` when the object has no eligible name source (the + * companion is then neither provisioned nor populated). + * + * Single source of truth shared by the compile-time provisioning seam + * ({@link provisionSearchCompanion}) and the `plugin-pinyin-search` populate + * hook — deriving both from the same function means there is no stored + * mapping to drift. + */ +export function resolveSearchCompanionSources(schema: CompanionObjectMeta | undefined | null): string[] { + if (!schema?.fields) return []; + const display = resolveDisplayField(schema as any); + if (!display) return []; + const meta = schema.fields[display]; + return isCompanionSourceEligible(meta) ? [display] : []; +} + +/** + * Compile-time provisioning: return `schema` with the hidden `__search` + * companion column appended when the object has an eligible name source. + * Pure and idempotent — an already-provisioned or ineligible schema is + * returned as-is (same reference). + * + * The column is `hidden` + `system` + `readonly` (+ `searchable: false`), so + * it never appears in auto-generated views/forms, is excluded from the + * `$search` auto-default (hidden fields are skipped) and from `$searchFields` + * overrides (the override intersects with the allowed set), and non-system + * callers cannot forge it on update (#2948 readonly write guard). It IS + * `index`ed — every search touches it. + * + * Objects that opt out of search entirely (`searchable: false`, ADR-0061 D2) + * are skipped: a companion no query will ever read is dead weight. + */ +export function provisionSearchCompanion(schema: T): T { + if (!schema?.fields) return schema; + if (schema.fields[SEARCH_COMPANION_FIELD]) return schema; + if (schema.searchable === false) return schema; + if (resolveSearchCompanionSources(schema).length === 0) return schema; + + return { + ...schema, + fields: { + ...schema.fields, + [SEARCH_COMPANION_FIELD]: { + type: 'text', + label: 'Search Index', + required: false, + hidden: true, + readonly: true, + system: true, + searchable: false, + index: true, + description: + `Search-normalized forms of the display/name field (normalizers: ${SEARCH_COMPANION_NORMALIZERS.join(', ')}) — ` + + 'e.g. full pinyin + initials for CJK names. Maintained by plugin-pinyin-search; never hand-edited. See #2486.', + }, + }, + }; +} + +const CJK_RE = /\p{Script=Han}/u; + +/** Does the string contain CJK (ideograph) characters? */ +export function containsCJK(value: unknown): boolean { + return typeof value === 'string' && CJK_RE.test(value); +} + +/** + * Is `term` worth matching against the companion column? Pinyin input is + * latin: a term carrying CJK characters can never match the ASCII-normalized + * companion, and a term with no letters at all (pure punctuation/digits) + * isn't pinyin. Keeps the extra OR clause off queries it cannot help. + */ +export function isCompanionMatchableTerm(term: string): boolean { + return /[a-z]/i.test(term) && !CJK_RE.test(term); +} diff --git a/packages/objectql/src/search-filter.ts b/packages/objectql/src/search-filter.ts index 1db688207f..1b2f8ae749 100644 --- a/packages/objectql/src/search-filter.ts +++ b/packages/objectql/src/search-filter.ts @@ -18,8 +18,18 @@ * (every term must hit some field); fields are OR-ed. `select`/`status` columns * store a value but users type the label, so the term is mapped to option * values whose label matches (with a raw-value `$contains` fallback). + * + * Pinyin recall (#2486): when the object carries the hidden `__search` + * companion column (provisioned by the SchemaRegistry when + * `OS_SEARCH_PINYIN_ENABLED` is on, populated by plugin-pinyin-search), each + * latin term additionally ORs `{ __search: { $contains: term } }` so full + * pinyin (`zhangwei`) and initials (`zw`) hit CJK names. Purely additive: + * `resolveSearchFields` still returns only source fields (the companion is + * invisible to `$searchFields` overrides and to clients). */ +import { SEARCH_COMPANION_FIELD, isCompanionMatchableTerm } from './search-companion.js'; + export interface SearchFieldMeta { type?: string; hidden?: boolean; @@ -148,9 +158,19 @@ export function expandSearchToFilter(raw: unknown, opts: ExpandSearchOptions): a if (searchFields.length === 0) return null; const terms = query.trim().split(/\s+/).filter(Boolean); - const andClauses = terms.map((term) => ({ - $or: searchFields.flatMap((f) => fieldClausesForTerm(f, term, opts.fields[f] || {})), - })); + // [#2486] Companion recall: present only when the registry provisioned the + // hidden `__search` column for this object (deployment-gated). The companion + // stores lowercase normalized forms, so the term is lowercased; CJK terms + // skip the clause (they hit the source columns directly and can never match + // the ASCII companion). + const hasCompanion = !!opts.fields[SEARCH_COMPANION_FIELD]; + const andClauses = terms.map((term) => { + const clauses = searchFields.flatMap((f) => fieldClausesForTerm(f, term, opts.fields[f] || {})); + if (hasCompanion && isCompanionMatchableTerm(term)) { + clauses.push({ [SEARCH_COMPANION_FIELD]: { $contains: term.toLowerCase() } }); + } + return { $or: clauses }; + }); if (andClauses.length === 0) return null; return andClauses.length === 1 ? andClauses[0] : { $and: andClauses }; diff --git a/packages/plugins/plugin-pinyin-search/LICENSE b/packages/plugins/plugin-pinyin-search/LICENSE new file mode 100644 index 0000000000..16bc23f404 --- /dev/null +++ b/packages/plugins/plugin-pinyin-search/LICENSE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute + must include a readable copy of the attribution notices + contained within such NOTICE file, excluding those notices + that do not pertain to any part of the Derivative Works, + in at least one of the following places: within a NOTICE + text file distributed as part of the Derivative Works; within + the Source form or documentation, if provided along with + the Derivative Works; or, within a display generated by the + Derivative Works, if and wherever such third-party notices + normally appear. The contents of the NOTICE file are for + informational purposes only and do not modify the License. + You may add Your own attribution notices within Derivative + Works that You distribute, alongside or as an addendum to + the NOTICE text from the Work, provided that such additional + attribution notices cannot be construed as modifying the + License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 ObjectStack + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/plugins/plugin-pinyin-search/package.json b/packages/plugins/plugin-pinyin-search/package.json new file mode 100644 index 0000000000..80a8ddbbc3 --- /dev/null +++ b/packages/plugins/plugin-pinyin-search/package.json @@ -0,0 +1,37 @@ +{ + "name": "@objectstack/plugin-pinyin-search", + "version": "15.0.0", + "license": "Apache-2.0", + "description": "Pinyin search recall for ObjectStack — populates the hidden `__search` companion column (full pinyin + initials of the display/name field) so `$search` hits CJK names typed as pinyin. Locale-gated via OS_SEARCH_PINYIN_ENABLED (#2486).", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "scripts": { + "build": "tsup --config ../../../tsup.config.ts", + "test": "vitest run --passWithNoTests" + }, + "dependencies": { + "@objectstack/core": "workspace:*", + "@objectstack/objectql": "workspace:*", + "@objectstack/types": "workspace:*", + "pinyin-pro": "^3.28.1" + }, + "devDependencies": { + "@types/node": "^26.1.1", + "typescript": "^6.0.3", + "vitest": "^4.1.10" + }, + "keywords": [ + "objectstack", + "plugin", + "search", + "pinyin", + "i18n" + ] +} diff --git a/packages/plugins/plugin-pinyin-search/src/companion-projection.test.ts b/packages/plugins/plugin-pinyin-search/src/companion-projection.test.ts new file mode 100644 index 0000000000..ace93a414a --- /dev/null +++ b/packages/plugins/plugin-pinyin-search/src/companion-projection.test.ts @@ -0,0 +1,168 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #2486 — companion projection: before-save stamping, no-write-amplification + * guard, backfill and rebuild reconcile. Uses a minimal fake engine (the + * boot-backfill.test.ts pattern) with a stub registry. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { + bindSearchCompanionHooks, + backfillSearchCompanion, + rebuildSearchCompanion, + PINYIN_SEARCH_HOOK_PACKAGE, +} from './companion-projection.js'; + +interface Row { [k: string]: any } + +const contactSchema = { + name: 'crm_contact', + nameField: 'name', + fields: { + name: { type: 'text' }, + email: { type: 'email' }, + __search: { type: 'text', hidden: true, readonly: true, system: true }, + }, +}; + +const plainSchema = { + name: 'crm_note', + fields: { title: { type: 'text' } }, // no companion provisioned +}; + +function makeEngine(schemas: any[]) { + const tables: Record = {}; + const hooks: Record any; opts: any }>> = {}; + const byName = new Map(schemas.map((s) => [s.name, s])); + const engine = { + _tables: tables, + registry: { + getObject: (n: string) => byName.get(n), + getAllObjects: () => [...byName.values()], + }, + registerHook(event: string, handler: any, opts?: any) { + (hooks[event] ??= []).push({ handler, opts }); + }, + unregisterHooksByPackage(packageId: string) { + let removed = 0; + for (const [event, entries] of Object.entries(hooks)) { + const kept = entries.filter((e) => e.opts?.packageId !== packageId); + removed += entries.length - kept.length; + hooks[event] = kept; + } + return removed; + }, + async trigger(event: string, ctx: any) { + for (const { handler } of hooks[event] ?? []) await handler(ctx); + }, + async find(o: string, opts?: any) { + const rows = tables[o] ?? []; + const offset = opts?.offset ?? 0; + return rows.slice(offset, offset + (opts?.limit ?? rows.length)); + }, + async insert(o: string, data: Row) { + const ctx = { object: o, event: 'beforeInsert', input: { data } }; + await engine.trigger('beforeInsert', ctx); + (tables[o] ??= []).push({ ...ctx.input.data }); + return ctx.input.data; + }, + async update(o: string, data: Row) { + const ctx = { object: o, event: 'beforeUpdate', input: { id: data.id, data } }; + await engine.trigger('beforeUpdate', ctx); + const t = tables[o] ?? []; + const i = t.findIndex((r) => r.id === data.id); + if (i >= 0) t[i] = { ...t[i], ...ctx.input.data }; + return t[i]; + }, + _hooks: hooks, + }; + return engine; +} + +describe('bindSearchCompanionHooks', () => { + let engine: ReturnType; + + beforeEach(() => { + engine = makeEngine([contactSchema, plainSchema]); + bindSearchCompanionHooks(engine as any); + }); + + it('binds beforeInsert + beforeUpdate globally and is idempotent (rebind-safe)', () => { + bindSearchCompanionHooks(engine as any); // rebind + expect(engine._hooks.beforeInsert).toHaveLength(1); + expect(engine._hooks.beforeUpdate).toHaveLength(1); + expect(engine._hooks.beforeInsert[0].opts.packageId).toBe(PINYIN_SEARCH_HOOK_PACKAGE); + expect(engine._hooks.beforeInsert[0].opts.object).toBeUndefined(); // global + }); + + it('stamps __search on insert when the name field carries CJK', async () => { + const row = await engine.insert('crm_contact', { id: 'c1', name: '张伟' }); + expect(row.__search).toBe('zhangwei zw'); + }); + + it('leaves __search null for latin names (source column already matches)', async () => { + const row = await engine.insert('crm_contact', { id: 'c2', name: 'Ada Lovelace' }); + expect(row.__search).toBe(null); + }); + + it('recomputes on update ONLY when the source field is in the patch', async () => { + await engine.insert('crm_contact', { id: 'c3', name: '张伟' }); + // email-only patch: no recompute, no companion key added + const patch: Row = { id: 'c3', email: 'zw@example.com' }; + await engine.update('crm_contact', patch); + expect('__search' in patch).toBe(false); + // name patch: recompute + const updated = await engine.update('crm_contact', { id: 'c3', name: '王芳' }); + expect(updated.__search).toBe('wangfang wf'); + }); + + it('clears the companion when a CJK name is renamed to latin (no stale recall)', async () => { + await engine.insert('crm_contact', { id: 'c4', name: '张伟' }); + const updated = await engine.update('crm_contact', { id: 'c4', name: 'Victor Zhang' }); + expect(updated.__search).toBe(null); + }); + + it('ignores objects without a provisioned companion column', async () => { + const row = await engine.insert('crm_note', { id: 'n1', title: '会议纪要' }); + expect('__search' in row).toBe(false); + }); +}); + +describe('backfillSearchCompanion / rebuildSearchCompanion', () => { + it('fills rows missing a blob, in pages, and skips rows that need none', async () => { + const engine = makeEngine([contactSchema]); + engine._tables.crm_contact = [ + { id: 'a', name: '张伟', __search: null }, // hook-bypassing write → fill + { id: 'b', name: 'Ada', __search: null }, // latin → skip + { id: 'c', name: '王芳', __search: 'wangfang wf' }, // already filled → skip + { id: 'd', name: '李雷', __search: null }, // fill (second page) + ]; + const result = await backfillSearchCompanion(engine as any, undefined, { batchSize: 2 }); + expect(result).toEqual({ objects: 1, scanned: 4, updated: 2 }); + const rows = engine._tables.crm_contact; + expect(rows.find((r) => r.id === 'a')!.__search).toBe('zhangwei zw'); + expect(rows.find((r) => r.id === 'b')!.__search).toBe(null); + expect(rows.find((r) => r.id === 'd')!.__search).toBe('lilei ll'); + }); + + it('is idempotent — a second pass updates nothing', async () => { + const engine = makeEngine([contactSchema]); + engine._tables.crm_contact = [{ id: 'a', name: '张伟', __search: null }]; + await backfillSearchCompanion(engine as any); + const second = await backfillSearchCompanion(engine as any); + expect(second.updated).toBe(0); + }); + + it('rebuild recomputes everything, clearing stale blobs (reconcile entry)', async () => { + const engine = makeEngine([contactSchema]); + engine._tables.crm_contact = [ + { id: 'a', name: 'Renamed To Latin', __search: 'zhangwei zw' }, // stale → cleared + { id: 'b', name: '王芳', __search: 'wrongblob' }, // wrong → recomputed + ]; + const result = await rebuildSearchCompanion(engine as any); + expect(result.updated).toBe(2); + expect(engine._tables.crm_contact[0].__search).toBe(null); + expect(engine._tables.crm_contact[1].__search).toBe('wangfang wf'); + }); +}); diff --git a/packages/plugins/plugin-pinyin-search/src/companion-projection.ts b/packages/plugins/plugin-pinyin-search/src/companion-projection.ts new file mode 100644 index 0000000000..dce617d2ac --- /dev/null +++ b/packages/plugins/plugin-pinyin-search/src/companion-projection.ts @@ -0,0 +1,214 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `__search` companion-column projection (#2486). + * + * The column itself is DECLARED at object compile time by the SchemaRegistry + * (`provisionSearchCompanion`, gated on `OS_SEARCH_PINYIN_ENABLED`); this + * module only FILLS the value — the `plugin-sharing` primary-BU projection + * pattern (column on the object, plugin maintains it via hooks). + * + * Write path: global `beforeInsert`/`beforeUpdate` hooks stamp + * `data.__search` whenever a companion source field (the object's + * display/name field) is present in the write — i.e. only when the source + * actually changed, avoiding write amplification. Writes that bypass hooks + * (bulk import, direct migration) leave the companion empty; the boot + * backfill and the `rebuildSearchCompanion` reconcile entry cover that. + */ + +import { + SEARCH_COMPANION_FIELD, + resolveSearchCompanionSources, + containsCJK, +} from '@objectstack/objectql'; +import { computeSearchCompanionValue } from './pinyin.js'; + +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; + +export const PINYIN_SEARCH_HOOK_PACKAGE = 'plugin-pinyin-search:companion'; + +interface MinimalEngine { + registerHook( + event: string, + handler: (ctx: any) => any | Promise, + options?: { object?: string | string[]; priority?: number; packageId?: string }, + ): void; + unregisterHooksByPackage(packageId: string): number; + find(object: string, query?: any, options?: any): Promise; + update(object: string, data: any, options?: any): Promise; + registry?: { + getObject(name: string): any; + getAllObjects?(packageId?: string): any[]; + }; +} + +interface MinimalLogger { + info?: (msg: any, ...rest: any[]) => void; + warn?: (msg: any, ...rest: any[]) => void; + debug?: (msg: any, ...rest: any[]) => void; +} + +/** + * Stamp `data.__search` on a before-save hook context when a companion source + * field is part of the write. Recomputes from the NEW value; a non-CJK new + * value clears the companion (null) so stale pinyin never recalls a renamed + * record. Never throws — a normalization failure must not fail the write. + */ +async function stampCompanion(engine: MinimalEngine, ctx: any, logger?: MinimalLogger): Promise { + const object = ctx?.object; + if (!object) return; + const schema = engine.registry?.getObject?.(object); + if (!schema?.fields?.[SEARCH_COMPANION_FIELD]) return; + + const data = ctx?.input?.data; + if (!data || typeof data !== 'object' || Array.isArray(data)) return; + + const sources = resolveSearchCompanionSources(schema); + if (sources.length === 0) return; + const touched = sources.filter((s) => Object.prototype.hasOwnProperty.call(data, s)); + if (touched.length === 0) return; // source unchanged → no recompute (no write amplification) + + try { + data[SEARCH_COMPANION_FIELD] = await computeSearchCompanionValue(sources.map((s) => data[s])); + } catch (err: any) { + logger?.warn?.('[pinyin-search] companion normalization failed — write proceeds without it', { + object, + error: err?.message, + }); + } +} + +/** + * Bind the global before-save hooks that keep `__search` in step. Idempotent + * (unbinds the package first). Hooks are global (no object filter) with a + * cheap early-out: objects without a provisioned companion column return + * immediately. They run for system-context writes too — the projection must + * stay correct regardless of who writes (seeds, imports, admin UI). + */ +export function bindSearchCompanionHooks(engine: MinimalEngine, logger?: MinimalLogger): void { + if (typeof engine.registerHook !== 'function') return; + if (typeof engine.unregisterHooksByPackage === 'function') { + engine.unregisterHooksByPackage(PINYIN_SEARCH_HOOK_PACKAGE); + } + const opts = { packageId: PINYIN_SEARCH_HOOK_PACKAGE, priority: 150 }; + const handler = (ctx: any) => stampCompanion(engine, ctx, logger); + engine.registerHook('beforeInsert', handler, opts); + engine.registerHook('beforeUpdate', handler, opts); + logger?.info?.('[pinyin-search] companion hooks bound (beforeInsert/beforeUpdate, all objects)'); +} + +export interface CompanionBackfillOptions { + /** Rows fetched per page during the scan. Default 1000. */ + batchSize?: number; + /** Restrict to one object (reconcile entry); default: every provisioned object. */ + object?: string; + /** + * Recompute EVERY row's companion, not just missing ones — the periodic + * reconcile/rebuild mode. Default false (backfill: only rows whose + * companion is empty but whose source has CJK content). + */ + force?: boolean; +} + +export interface CompanionBackfillResult { + objects: number; + scanned: number; + updated: number; +} + +/** + * Backfill / reconcile the companion column. + * + * Denormalized-on-write columns go stale when writes bypass hooks (bulk + * import, direct migration) and are empty for rows that predate the switch + * being enabled. This scans every object that carries the companion column + * (paged, system context) and fills the gaps; with `force: true` it + * recomputes unconditionally (the periodic reconcile / rebuild entry). + * Idempotent; per-row failures are skipped so one bad row never aborts the + * pass. + */ +export async function backfillSearchCompanion( + engine: MinimalEngine, + logger?: MinimalLogger, + options?: CompanionBackfillOptions, +): Promise { + const batchSize = Math.max(1, options?.batchSize ?? 1000); + const all = options?.object + ? [engine.registry?.getObject?.(options.object)].filter(Boolean) + : engine.registry?.getAllObjects?.() ?? []; + + const result: CompanionBackfillResult = { objects: 0, scanned: 0, updated: 0 }; + + for (const schema of all) { + if (!schema?.name || !schema?.fields?.[SEARCH_COMPANION_FIELD]) continue; + const sources = resolveSearchCompanionSources(schema); + if (sources.length === 0) continue; + result.objects++; + + let offset = 0; + for (;;) { + let rows: any[] = []; + try { + rows = await engine.find(schema.name, { + fields: ['id', ...sources, SEARCH_COMPANION_FIELD], + limit: batchSize, + offset, + context: SYSTEM_CTX, + }); + } catch (err: any) { + logger?.warn?.('[pinyin-search] backfill scan failed', { object: schema.name, error: err?.message }); + break; + } + if (!rows?.length) break; + result.scanned += rows.length; + + for (const row of rows) { + if (row?.id == null) continue; + const hasBlob = typeof row[SEARCH_COMPANION_FIELD] === 'string' && row[SEARCH_COMPANION_FIELD] !== ''; + const hasCjkSource = sources.some((s) => containsCJK(row[s])); + // Backfill mode: touch only rows missing a blob they should have. + // Force mode: recompute everything (also clears stale blobs). + if (!options?.force && (hasBlob || !hasCjkSource)) continue; + try { + const value = await computeSearchCompanionValue(sources.map((s) => row[s])); + if (!options?.force && value == null) continue; + if (value === row[SEARCH_COMPANION_FIELD]) continue; + await engine.update( + schema.name, + { id: row.id, [SEARCH_COMPANION_FIELD]: value }, + { context: SYSTEM_CTX }, + ); + result.updated++; + } catch (err: any) { + logger?.warn?.('[pinyin-search] backfill row skipped', { + object: schema.name, + id: row.id, + error: err?.message, + }); + } + } + + if (rows.length < batchSize) break; + offset += batchSize; + } + } + + if (result.updated > 0) { + logger?.info?.('[pinyin-search] companion backfill complete', result); + } + return result; +} + +/** + * Periodic reconcile / rebuild entry: recompute the companion for every row + * (optionally one object). Alias for `backfillSearchCompanion` with + * `force: true` — exposed under its own name so operators/jobs have an + * explicit "rebuild the pinyin index" handle. + */ +export function rebuildSearchCompanion( + engine: MinimalEngine, + logger?: MinimalLogger, + options?: Omit, +): Promise { + return backfillSearchCompanion(engine, logger, { ...options, force: true }); +} diff --git a/packages/plugins/plugin-pinyin-search/src/index.ts b/packages/plugins/plugin-pinyin-search/src/index.ts new file mode 100644 index 0000000000..2f0eaf09ed --- /dev/null +++ b/packages/plugins/plugin-pinyin-search/src/index.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export { PinyinSearchPlugin } from './pinyin-search-plugin.js'; +export type { PinyinSearchPluginOptions } from './pinyin-search-plugin.js'; +export { + bindSearchCompanionHooks, + backfillSearchCompanion, + rebuildSearchCompanion, + PINYIN_SEARCH_HOOK_PACKAGE, +} from './companion-projection.js'; +export type { + CompanionBackfillOptions, + CompanionBackfillResult, +} from './companion-projection.js'; +export { computeSearchCompanionValue } from './pinyin.js'; diff --git a/packages/plugins/plugin-pinyin-search/src/pinyin-search-plugin.ts b/packages/plugins/plugin-pinyin-search/src/pinyin-search-plugin.ts new file mode 100644 index 0000000000..ed183b908f --- /dev/null +++ b/packages/plugins/plugin-pinyin-search/src/pinyin-search-plugin.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * PinyinSearchPlugin (#2486) — pinyin recall for `$search`. + * + * Pure hook plugin: the `__search` companion column is declared at object + * compile time by the SchemaRegistry (gated on the SAME + * `OS_SEARCH_PINYIN_ENABLED` decision point), the engine ORs it into the + * `$search` filter, and this plugin fills the value: + * + * - before-save hooks: recompute full pinyin + initials of the + * display/name field when it changes; + * - boot backfill (`kernel:bootstrapped`): fill rows that predate the + * switch or arrived via hook-bypassing writes; + * - `rebuildSearchCompanion` (exported): explicit reconcile/rebuild entry. + * + * When the flag is off the plugin is inert: no hooks, no backfill, and + * `pinyin-pro` is never imported. + */ + +import type { Plugin, PluginContext } from '@objectstack/core'; +import { resolveSearchPinyinEnabled } from '@objectstack/types'; +import { + bindSearchCompanionHooks, + backfillSearchCompanion, +} from './companion-projection.js'; + +export interface PinyinSearchPluginOptions { + /** + * Force-enable/disable regardless of `OS_SEARCH_PINYIN_ENABLED` (tests / + * embedders). Default: `resolveSearchPinyinEnabled()`. + */ + enabled?: boolean; + /** Skip the boot backfill (default: run it once per boot). */ + backfill?: boolean; +} + +export class PinyinSearchPlugin implements Plugin { + name = 'com.objectstack.plugin.pinyin-search'; + version = '1.0.0'; + type = 'standard'; + dependencies = ['com.objectstack.engine.objectql']; + + private readonly options: PinyinSearchPluginOptions; + + constructor(options: PinyinSearchPluginOptions = {}) { + this.options = options; + } + + private get enabled(): boolean { + return this.options.enabled ?? resolveSearchPinyinEnabled(); + } + + async init(_ctx: PluginContext): Promise { + // Nothing to register: the companion column is provisioned by the + // SchemaRegistry's compile-time seam, not injected at runtime. + } + + async start(ctx: PluginContext): Promise { + if (!this.enabled) { + ctx.logger.debug?.('PinyinSearchPlugin: OS_SEARCH_PINYIN_ENABLED is off — inert'); + return; + } + + ctx.hook('kernel:ready', async () => { + const engine = this.resolveEngine(ctx); + if (!engine) { + ctx.logger.warn('PinyinSearchPlugin: no ObjectQL engine — companion hooks NOT bound'); + return; + } + try { + bindSearchCompanionHooks(engine, ctx.logger as any); + } catch (err: any) { + ctx.logger.warn('PinyinSearchPlugin: companion hooks not bound', { error: err?.message }); + } + }); + + // Backfill AFTER boot settles (`kernel:bootstrapped` fires once every + // `kernel:ready` hook — including seed loading — has completed), so + // seeded rows written before/around hook binding are reconciled too. + if (this.options.backfill !== false) { + ctx.hook('kernel:bootstrapped', async () => { + const engine = this.resolveEngine(ctx); + if (!engine) return; + try { + await backfillSearchCompanion(engine, ctx.logger as any); + } catch (err: any) { + ctx.logger.warn('PinyinSearchPlugin: companion backfill failed', { error: err?.message }); + } + }); + } + } + + private resolveEngine(ctx: PluginContext): any { + try { + return ctx.getService('objectql'); + } catch { + try { + return ctx.getService('data'); + } catch { + return null; + } + } + } +} diff --git a/packages/plugins/plugin-pinyin-search/src/pinyin.test.ts b/packages/plugins/plugin-pinyin-search/src/pinyin.test.ts new file mode 100644 index 0000000000..5d6862466a --- /dev/null +++ b/packages/plugins/plugin-pinyin-search/src/pinyin.test.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { computeSearchCompanionValue } from './pinyin.js'; + +describe('computeSearchCompanionValue (#2486)', () => { + it('stores full pinyin + initials in one blob ("张伟" → "zhangwei zw")', async () => { + expect(await computeSearchCompanionValue(['张伟'])).toBe('zhangwei zw'); + }); + + it('recalls every documented input shape as a substring of the blob', async () => { + const blob = (await computeSearchCompanionValue(['张伟']))!; + for (const typed of ['zhang', 'wei', 'zhangwei', 'zw']) { + expect(blob.includes(typed)).toBe(true); + } + }); + + it('handles multi-word and mixed CJK/latin values', async () => { + const blob = (await computeSearchCompanionValue(['上海分公司']))!; + expect(blob).toBe('shanghaifengongsi shfgs'); + + const mixed = (await computeSearchCompanionValue(['张伟2号']))!; + expect(mixed.includes('zhangwei2hao')).toBe(true); + }); + + it('returns null for non-CJK / empty / non-string values (companion cleared)', async () => { + expect(await computeSearchCompanionValue(['Zhang Wei'])).toBe(null); + expect(await computeSearchCompanionValue([''])).toBe(null); + expect(await computeSearchCompanionValue([null, undefined, 42])).toBe(null); + expect(await computeSearchCompanionValue([])).toBe(null); + }); + + it('deduplicates when initials equal the full form (single-char name)', async () => { + const blob = (await computeSearchCompanionValue(['张']))!; + expect(blob).toBe('zhang z'); + // no duplicated tokens + expect(new Set(blob.split(' ')).size).toBe(blob.split(' ').length); + }); +}); diff --git a/packages/plugins/plugin-pinyin-search/src/pinyin.ts b/packages/plugins/plugin-pinyin-search/src/pinyin.ts new file mode 100644 index 0000000000..cab7c26874 --- /dev/null +++ b/packages/plugins/plugin-pinyin-search/src/pinyin.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Pinyin normalization for the `__search` companion column (#2486). + * + * One normalized blob per record — full pinyin AND initials in the same + * column — so a single `$contains` recalls every latin input shape: + * + * "张伟" → "zhangwei zw" + * `zhang` / `wei` / `zhangwei` → substring of the full form + * `zw` → substring of the initials form + * + * `pinyin-pro` is loaded lazily on first use: non-Chinese deployments (flag + * off → hooks never bound) never import it and pay zero cost. + * + * Polyphones: pinyin-pro's default heuristics are accepted (issue #2486 + * "待定" — surname polyphone dictionaries are a P2 follow-up). + */ + +import { containsCJK } from '@objectstack/objectql'; + +type PinyinFn = (text: string, options?: Record) => string | string[]; + +let _pinyin: Promise | null = null; + +/** Lazy-load `pinyin-pro` (cached module-wide). */ +function loadPinyin(): Promise { + _pinyin ??= import('pinyin-pro').then((m: any) => (m.pinyin ?? m.default?.pinyin) as PinyinFn); + return _pinyin; +} + +/** Lowercase and strip everything that is not a latin letter or digit. */ +function squash(syllables: string | string[]): string { + const joined = Array.isArray(syllables) ? syllables.join('') : String(syllables ?? ''); + return joined.toLowerCase().replace(/[^a-z0-9]+/g, ''); +} + +/** + * Compute the companion value for the given source-field values. + * + * Returns the normalized blob (`" "`, deduplicated) + * when at least one value contains CJK characters, else `null` — a `null` + * companion means "nothing pinyin-searchable here" and clears any stale blob + * when a name is edited away from CJK. Non-CJK values need no companion: + * their source column already matches latin input directly. + */ +export async function computeSearchCompanionValue(values: ReadonlyArray): Promise { + const cjkValues = values.filter((v): v is string => containsCJK(v)); + if (cjkValues.length === 0) return null; + + const pinyin = await loadPinyin(); + const parts: string[] = []; + for (const value of cjkValues) { + // `nonZh: 'consecutive'` keeps latin/digit runs intact inside mixed + // values ("张伟2号" → "zhangwei2hao"), so mixed names stay one token. + const full = squash(pinyin(value, { toneType: 'none', type: 'array', nonZh: 'consecutive' })); + const initials = squash(pinyin(value, { pattern: 'first', toneType: 'none', type: 'array', nonZh: 'consecutive' })); + if (full) parts.push(full); + if (initials && initials !== full) parts.push(initials); + } + if (parts.length === 0) return null; + return [...new Set(parts)].join(' '); +} diff --git a/packages/plugins/plugin-pinyin-search/tsconfig.json b/packages/plugins/plugin-pinyin-search/tsconfig.json new file mode 100644 index 0000000000..385be7ea89 --- /dev/null +++ b/packages/plugins/plugin-pinyin-search/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules", "**/*.test.ts"] +} diff --git a/packages/types/src/env.test.ts b/packages/types/src/env.test.ts index 99a7e0de42..3c294a6643 100644 --- a/packages/types/src/env.test.ts +++ b/packages/types/src/env.test.ts @@ -5,6 +5,7 @@ import { _resetEnvDeprecationWarnings, readEnvWithDeprecation, resolveAllowDegradedTenancy, + resolveSearchPinyinEnabled, } from './env.js'; describe('readEnvWithDeprecation', () => { @@ -129,3 +130,45 @@ describe('resolveAllowDegradedTenancy (ADR-0093 D5)', () => { } }); }); + +describe('resolveSearchPinyinEnabled (#2486)', () => { + const original = process.env.OS_SEARCH_PINYIN_ENABLED; + afterEach(() => { + if (original === undefined) delete process.env.OS_SEARCH_PINYIN_ENABLED; + else process.env.OS_SEARCH_PINYIN_ENABLED = original; + }); + + it('defaults OFF with no env and no locales', () => { + delete process.env.OS_SEARCH_PINYIN_ENABLED; + expect(resolveSearchPinyinEnabled()).toBe(false); + expect(resolveSearchPinyinEnabled({ locales: [] })).toBe(false); + expect(resolveSearchPinyinEnabled({ locales: ['en', 'ja-JP'] })).toBe(false); + }); + + it('derives ON from any configured zh-* locale when env is unset', () => { + delete process.env.OS_SEARCH_PINYIN_ENABLED; + for (const locales of [['zh-CN'], ['en', 'zh-TW'], ['zh'], ['ZH-hans'], ['en', 'zh_CN']]) { + expect(resolveSearchPinyinEnabled({ locales })).toBe(true); + } + expect(resolveSearchPinyinEnabled({ locales: ['zhx-nonsense'] })).toBe(false); + }); + + it('explicit env overrides the locale-derived default in both directions', () => { + process.env.OS_SEARCH_PINYIN_ENABLED = 'false'; + expect(resolveSearchPinyinEnabled({ locales: ['zh-CN'] })).toBe(false); + process.env.OS_SEARCH_PINYIN_ENABLED = 'true'; + expect(resolveSearchPinyinEnabled({ locales: ['en'] })).toBe(true); + expect(resolveSearchPinyinEnabled()).toBe(true); + }); + + it('accepts truthy values case-insensitively; anything else is off', () => { + for (const v of ['1', 'true', 'TRUE', 'on', 'Yes']) { + process.env.OS_SEARCH_PINYIN_ENABLED = v; + expect(resolveSearchPinyinEnabled()).toBe(true); + } + for (const v of ['0', 'false', 'off', 'no', 'maybe']) { + process.env.OS_SEARCH_PINYIN_ENABLED = v; + expect(resolveSearchPinyinEnabled()).toBe(false); + } + }); +}); diff --git a/packages/types/src/env.ts b/packages/types/src/env.ts index 3204a1c459..874d94db1d 100644 --- a/packages/types/src/env.ts +++ b/packages/types/src/env.ts @@ -166,6 +166,38 @@ export function resolveOrgLimit(): number | undefined { return Number.isFinite(n) && n > 0 ? n : undefined; } +/** + * SINGLE decision point for "is pinyin search recall on?" (#2486). + * + * Pinyin search is a deployment/locale-level capability, not field metadata: + * Chinese deployments want it, pure-Japanese/English deployments don't. The + * flag gates the whole feature end-to-end — the SchemaRegistry's compile-time + * `__search` companion-column seam AND the `plugin-pinyin-search` populate + * hooks — so there is no half-state where a column exists but nobody fills it + * (ADR-0049: no declared-but-unenforced capability). + * + * Resolution: + * 1. An explicit `OS_SEARCH_PINYIN_ENABLED` always wins — truthy + * (`1`/`true`/`on`/`yes`) enables, anything else disables. + * 2. When unset, the default derives from the deployment's configured + * locales (`opts.locales`, e.g. the stack's `i18n.defaultLocale` + + * `supportedLocales`): any `zh-*` locale turns it on. + * 3. No env var and no `zh-*` locale → off. OSS / non-Chinese deployments + * never load `pinyin-pro` and pay zero compute cost. + * + * Hosts that know the stack's i18n config (the CLI `serve` boot path) resolve + * once with locales and stamp the decision back into the env, so downstream + * consumers constructed without config access (per-engine SchemaRegistry) + * read the same answer via the no-arg form. + */ +export function resolveSearchPinyinEnabled(opts?: { locales?: readonly string[] }): boolean { + const raw = readEnvWithDeprecation('OS_SEARCH_PINYIN_ENABLED', [], { silent: true }); + if (raw != null && String(raw).trim() !== '') { + return ['1', 'true', 'on', 'yes'].includes(String(raw).trim().toLowerCase()); + } + return (opts?.locales ?? []).some((l) => /^zh([-_]|$)/i.test(String(l ?? '').trim())); +} + /** * Internal: clear the dedupe set. Test-only; exposed so suite-wide * deprecation warnings don't bleed between tests. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20c745d678..6e1d529b89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -409,6 +409,9 @@ importers: '@objectstack/plugin-hono-server': specifier: workspace:* version: link:../plugins/plugin-hono-server + '@objectstack/plugin-pinyin-search': + specifier: workspace:* + version: link:../plugins/plugin-pinyin-search '@objectstack/plugin-reports': specifier: workspace:* version: link:../plugins/plugin-reports @@ -1483,6 +1486,31 @@ importers: specifier: ^4.1.10 version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(msw@2.14.6(@types/node@26.1.1)(typescript@6.0.3))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) + packages/plugins/plugin-pinyin-search: + dependencies: + '@objectstack/core': + specifier: workspace:* + version: link:../../core + '@objectstack/objectql': + specifier: workspace:* + version: link:../../objectql + '@objectstack/types': + specifier: workspace:* + version: link:../../types + pinyin-pro: + specifier: ^3.28.1 + version: 3.28.1 + devDependencies: + '@types/node': + specifier: ^26.1.1 + version: 26.1.1 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(msw@2.14.6(@types/node@26.1.1)(typescript@6.0.3))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) + packages/plugins/plugin-reports: dependencies: '@objectstack/core': @@ -7596,6 +7624,9 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} + pinyin-pro@3.28.1: + resolution: {integrity: sha512-oqz8ulwRgtUXRi0vbqEfGNly19zpyCxYrjhkk5TibGcgSW6eNwS5woajCXRwqURi8Ehc2yOFTiB4uNoZ+NJOnA==} + pirates@4.0.7: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} @@ -14741,6 +14772,8 @@ snapshots: pify@4.0.1: {} + pinyin-pro@3.28.1: {} + pirates@4.0.7: {} pkce-challenge@5.0.1: {}