@objectstack/plugin-pinyin-search 17.0.0-rc.0 → 17.0.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,202 @@
1
1
  # @objectstack/plugin-pinyin-search
2
2
 
3
+ ## 17.0.0-rc.2
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [ce5242c]
8
+ - Updated dependencies [257d97a]
9
+ - Updated dependencies [98877c9]
10
+ - Updated dependencies [c44dd5e]
11
+ - Updated dependencies [20b1a9e]
12
+ - Updated dependencies [f2445c9]
13
+ - Updated dependencies [462b713]
14
+ - Updated dependencies [63b33e6]
15
+ - Updated dependencies [a52e2ef]
16
+ - Updated dependencies [4c45be1]
17
+ - Updated dependencies [ce92674]
18
+ - Updated dependencies [ec975f1]
19
+ - Updated dependencies [eb4204b]
20
+ - Updated dependencies [cb5a75e]
21
+ - Updated dependencies [84b6e58]
22
+ - Updated dependencies [f160ba4]
23
+ - Updated dependencies [b25a116]
24
+ - Updated dependencies [127f091]
25
+ - Updated dependencies [833b512]
26
+ - Updated dependencies [071d0dc]
27
+ - Updated dependencies [1ee48bc]
28
+ - Updated dependencies [26bb053]
29
+ - Updated dependencies [50185a8]
30
+ - Updated dependencies [d6bd5a1]
31
+ - Updated dependencies [ad5fe25]
32
+ - @objectstack/objectql@17.0.0-rc.2
33
+ - @objectstack/core@17.0.0-rc.2
34
+ - @objectstack/types@17.0.0-rc.2
35
+
36
+ ## 17.0.0-rc.1
37
+
38
+ ### Patch Changes
39
+
40
+ - 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261)
41
+
42
+ The AGENTS.md post-task checklist requires breaking changesets to carry their
43
+ FROM → TO migration because "this text ships to consumers as `CHANGELOG.md`
44
+ inside the npm package and is what an upgrading agent greps after the tombstone
45
+ error." That delivery path was severed for 68 of the 69 publishable packages:
46
+ npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike
47
+ older npm versions — not `CHANGELOG.md`, and the canonical
48
+ `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm
49
+ 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its
50
+ 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it
51
+ explicitly.
52
+
53
+ The tombstone-error scenario is precisely the one where the repo is out of
54
+ reach — the upgrading agent has `node_modules` and nothing else — so the
55
+ migration text has to ride in the tarball. Every publishable package now
56
+ declares `CHANGELOG.md` in `files`, and the canonical whitelist is
57
+ `["dist", "README.md", "CHANGELOG.md"]`.
58
+
59
+ The other half is the gate: `check:published-files` gains a fifth invariant,
60
+ COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the
61
+ always-required lint job, so the next package cannot silently sever the path
62
+ again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves
63
+ into the canonical set.
64
+
65
+ Consumer-visible change: one more file per install (the package's changelog,
66
+ e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key"
67
+ node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was
68
+ promised.
69
+
70
+ - 9881074: fix(batch): the background walks seek instead of counting, so they stop skipping rows (#4363)
71
+
72
+ #4363 made a single paged read a partition of its result set. It could not make
73
+ a _walk_ one: seven background scans paged with a growing `offset` while writing
74
+ to the very rows they were reading, and an offset counts into a set those writes
75
+ are changing. Rows slide past the cursor and are never visited.
76
+
77
+ That is not a slow page in any of these — it is a wrong answer wearing the shape
78
+ of a clean run:
79
+
80
+ - **`rebuildApproverIndex`** built its desired state by walking
81
+ `sys_approval_request WHERE status = 'pending'` with no `orderBy` at all, then
82
+ **deleted** every index row that state did not explain. A skipped request
83
+ meant an approver silently dropped from someone's queue. (The loop beside it
84
+ ordered by `created_at` — not unique, so its pages were never a partition
85
+ either.)
86
+ - **`verifyFileReferences`** decides which files nothing references. A record it
87
+ never visits is reported as an unreferenced file.
88
+ - **`backfillFileReferences`** and the **pinyin companion backfill** rewrite
89
+ each row they read, so their own writes were shifting the set out from under
90
+ the cursor. Records were left unconverted and unsearchable by a run that
91
+ reported success.
92
+ - **`scanValueShapes`** exists to vouch that no stored value is off-shape, and
93
+ it opens a migration gate on that evidence.
94
+
95
+ All of them now go through `keysetWalk` (`@objectstack/types`): order by a
96
+ unique key, and seek past the last one instead of counting from the start. A
97
+ row's key does not move when the row is updated, and cannot be shifted when
98
+ another is deleted, so the walk is stable under exactly the mutation these
99
+ functions perform. It is also O(n) rather than O(n²/page) — measured on
100
+ Postgres over 2M rows, deep pages cost ~1.1 s by offset against ~0.09 s by seek.
101
+
102
+ One deliberate non-conversion: the REST **export** stream keeps its offset. It
103
+ honors a caller-chosen sort, and a keyset walk would have to re-order the export
104
+ by `id` to seek — changing what the user asked for to fix a cost. Its pages are
105
+ already a partition since #4363; only the depth cost remains.
106
+
107
+ `keysetWalk` merges the cursor with `$and` rather than spreading it into the
108
+ caller's filter, so a walk whose own `where` constrains the key column
109
+ (`{ id: { $in: [...] } }`) keeps that constraint instead of having it silently
110
+ overwritten. When a `max` cap is set it reads one row beyond the cap to tell
111
+ "the cap stopped us" from "the source ended exactly there" — without that, a
112
+ walk that read everything still reports `truncated`, and a caller acting on it
113
+ goes looking for rows that were never withheld.
114
+
115
+ The storage suites' fake engines now **throw** on an `offset` instead of serving
116
+ one, so the conversion is pinned rather than merely passing.
117
+
118
+ - cc2de0e: chore(packaging): 20 packages stop publishing their sources, tests and build tooling (#4248)
119
+
120
+ These 20 packages declared no `files` field, so npm fell back to packing the
121
+ whole package directory. `npm pack --dry-run` on `@objectstack/plugin-webhooks`
122
+ listed **21 files** — 15 under `src/`, three of them unit tests
123
+ (`auto-enqueuer.test.ts`, `bootstrap-declared-webhooks.test.ts`, …), plus the
124
+ build-time `scripts/i18n-extract.config.ts`. `dist/` lands on top of that at
125
+ publish time rather than instead of it, so consumers were installing the
126
+ TypeScript sources and the test suite alongside the artifact they asked for.
127
+
128
+ Each now declares `"files": ["dist", "README.md"]`, matching the 29 packages
129
+ that already did. Nothing a consumer imports moves: every `main` / `types` /
130
+ `exports` target in all 20 already resolved inside `dist/`, which the new
131
+ `check:published-files` guard verifies rather than assumes. The visible change
132
+ is a smaller install and a smaller dependency-scanning surface — `npm pack` on
133
+ `@objectstack/plugin-webhooks` now yields 2 files plus `dist/`.
134
+
135
+ The other half of the fix is the gate. Half the packages declaring `files` and
136
+ half not was the #3786 shape — a hand-copied convention with nothing enforcing
137
+ it, where whoever forgets the line gets no signal at all. `check:published-files`
138
+ (new, wired into the always-required `lint` job) holds every non-private
139
+ workspace package to four invariants: `files` is **declared**; it is
140
+ **sufficient** (covers every entry point, so tightening a whitelist cannot ship
141
+ a package that fails to resolve); it is **minimal** (admits no test, test-harness
142
+ config or build script); and anything beyond `dist` + `README.md` is
143
+ **registered** with a reason, reconciled in both directions so a stale exemption
144
+ is an error rather than dead text. `@objectstack/spec` is the one package with
145
+ registered extras — its `.zod.ts` sources, JSON Schemas, liveness ledgers and
146
+ `CHANGELOG.md` are product, not build input.
147
+
148
+ This also closes an assumption #4206 was resting on. Excluding `<pkg>/scripts/**`
149
+ from the docs-drift implementation test is sound only while no package publishes
150
+ `scripts/` as runtime code; that held, but it held because someone read all three
151
+ offenders by hand. It is now checked on every PR.
152
+
153
+ - Updated dependencies [48fcf70]
154
+ - Updated dependencies [3ec8186]
155
+ - Updated dependencies [b1863a5]
156
+ - Updated dependencies [270650f]
157
+ - Updated dependencies [956e7f9]
158
+ - Updated dependencies [3aef718]
159
+ - Updated dependencies [ffb003c]
160
+ - Updated dependencies [32ccb23]
161
+ - Updated dependencies [2d3e255]
162
+ - Updated dependencies [7d7521f]
163
+ - Updated dependencies [8d895ff]
164
+ - Updated dependencies [2af1988]
165
+ - Updated dependencies [0af50a3]
166
+ - Updated dependencies [2e836de]
167
+ - Updated dependencies [c20b875]
168
+ - Updated dependencies [2a37694]
169
+ - Updated dependencies [3c628ce]
170
+ - Updated dependencies [ed77493]
171
+ - Updated dependencies [58a03d2]
172
+ - Updated dependencies [c39d713]
173
+ - Updated dependencies [91f4c78]
174
+ - Updated dependencies [45dc446]
175
+ - Updated dependencies [ab9fb5c]
176
+ - Updated dependencies [f985b3f]
177
+ - Updated dependencies [9881074]
178
+ - Updated dependencies [7777e8f]
179
+ - Updated dependencies [507b92a]
180
+ - Updated dependencies [39eb01b]
181
+ - Updated dependencies [55bbefc]
182
+ - Updated dependencies [7ce02eb]
183
+ - Updated dependencies [d13004a]
184
+ - Updated dependencies [be7360c]
185
+ - Updated dependencies [8675db6]
186
+ - Updated dependencies [b09d8d9]
187
+ - Updated dependencies [af2a095]
188
+ - Updated dependencies [bf478e1]
189
+ - Updated dependencies [77fadbf]
190
+ - Updated dependencies [5c13368]
191
+ - Updated dependencies [857a6cf]
192
+ - Updated dependencies [d5749d7]
193
+ - Updated dependencies [d92c72d]
194
+ - Updated dependencies [5d21a48]
195
+ - Updated dependencies [e4c2dc8]
196
+ - @objectstack/objectql@17.0.0-rc.1
197
+ - @objectstack/core@17.0.0-rc.1
198
+ - @objectstack/types@17.0.0-rc.1
199
+
3
200
  ## 17.0.0-rc.0
4
201
 
5
202
  ### Patch Changes
package/dist/index.js CHANGED
@@ -40,10 +40,11 @@ __export(index_exports, {
40
40
  module.exports = __toCommonJS(index_exports);
41
41
 
42
42
  // src/pinyin-search-plugin.ts
43
- var import_types = require("@objectstack/types");
43
+ var import_types2 = require("@objectstack/types");
44
44
 
45
45
  // src/companion-projection.ts
46
46
  var import_objectql2 = require("@objectstack/objectql");
47
+ var import_types = require("@objectstack/types");
47
48
 
48
49
  // src/pinyin.ts
49
50
  var import_objectql = require("@objectstack/objectql");
@@ -114,47 +115,44 @@ async function backfillSearchCompanion(engine, logger, options) {
114
115
  const sources = (0, import_objectql2.resolveSearchCompanionSources)(schema);
115
116
  if (sources.length === 0) continue;
116
117
  result.objects++;
117
- let offset = 0;
118
- for (; ; ) {
119
- let rows = [];
120
- try {
121
- rows = await engine.find(schema.name, {
122
- fields: ["id", ...sources, import_objectql2.SEARCH_COMPANION_FIELD],
123
- limit: batchSize,
124
- offset,
125
- context: SYSTEM_CTX
126
- });
127
- } catch (err) {
128
- logger?.warn?.("[pinyin-search] backfill scan failed", { object: schema.name, error: err?.message });
129
- break;
130
- }
131
- if (!rows?.length) break;
132
- result.scanned += rows.length;
133
- for (const row of rows) {
134
- if (row?.id == null) continue;
135
- const hasBlob = typeof row[import_objectql2.SEARCH_COMPANION_FIELD] === "string" && row[import_objectql2.SEARCH_COMPANION_FIELD] !== "";
136
- const hasCjkSource = sources.some((s) => (0, import_objectql2.containsCJK)(row[s]));
137
- if (!options?.force && (hasBlob || !hasCjkSource)) continue;
138
- try {
139
- const value = await computeSearchCompanionValue(sources.map((s) => row[s]));
140
- if (!options?.force && value == null) continue;
141
- if (value === row[import_objectql2.SEARCH_COMPANION_FIELD]) continue;
142
- await engine.update(
143
- schema.name,
144
- { id: row.id, [import_objectql2.SEARCH_COMPANION_FIELD]: value },
145
- { context: SYSTEM_CTX }
146
- );
147
- result.updated++;
148
- } catch (err) {
149
- logger?.warn?.("[pinyin-search] backfill row skipped", {
150
- object: schema.name,
151
- id: row.id,
152
- error: err?.message
153
- });
118
+ const walk = (0, import_types.keysetWalk)(
119
+ (q) => engine.find(schema.name, {
120
+ ...q,
121
+ fields: ["id", ...sources, import_objectql2.SEARCH_COMPANION_FIELD],
122
+ context: SYSTEM_CTX
123
+ }),
124
+ { pageSize: batchSize }
125
+ );
126
+ try {
127
+ for await (const rows of walk.pages()) {
128
+ result.scanned += rows.length;
129
+ for (const row of rows) {
130
+ if (row?.id == null) continue;
131
+ const hasBlob = typeof row[import_objectql2.SEARCH_COMPANION_FIELD] === "string" && row[import_objectql2.SEARCH_COMPANION_FIELD] !== "";
132
+ const hasCjkSource = sources.some((s) => (0, import_objectql2.containsCJK)(row[s]));
133
+ if (!options?.force && (hasBlob || !hasCjkSource)) continue;
134
+ try {
135
+ const value = await computeSearchCompanionValue(sources.map((s) => row[s]));
136
+ if (!options?.force && value == null) continue;
137
+ if (value === row[import_objectql2.SEARCH_COMPANION_FIELD]) continue;
138
+ await engine.update(
139
+ schema.name,
140
+ { id: row.id, [import_objectql2.SEARCH_COMPANION_FIELD]: value },
141
+ { context: SYSTEM_CTX }
142
+ );
143
+ result.updated++;
144
+ } catch (err) {
145
+ logger?.warn?.("[pinyin-search] backfill row skipped", {
146
+ object: schema.name,
147
+ id: row.id,
148
+ error: err?.message
149
+ });
150
+ }
154
151
  }
155
152
  }
156
- if (rows.length < batchSize) break;
157
- offset += batchSize;
153
+ } catch (err) {
154
+ logger?.warn?.("[pinyin-search] backfill scan failed", { object: schema.name, error: err?.message });
155
+ continue;
158
156
  }
159
157
  }
160
158
  if (result.updated > 0) {
@@ -176,7 +174,7 @@ var PinyinSearchPlugin = class {
176
174
  this.options = options;
177
175
  }
178
176
  get enabled() {
179
- return this.options.enabled ?? (0, import_types.resolveSearchPinyinEnabled)();
177
+ return this.options.enabled ?? (0, import_types2.resolveSearchPinyinEnabled)();
180
178
  }
181
179
  async init(_ctx) {
182
180
  }
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/pinyin-search-plugin.ts","../src/companion-projection.ts","../src/pinyin.ts"],"sourcesContent":["// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\nexport { PinyinSearchPlugin } from './pinyin-search-plugin.js';\nexport type { PinyinSearchPluginOptions } from './pinyin-search-plugin.js';\nexport {\n bindSearchCompanionHooks,\n backfillSearchCompanion,\n rebuildSearchCompanion,\n PINYIN_SEARCH_HOOK_PACKAGE,\n} from './companion-projection.js';\nexport type {\n CompanionBackfillOptions,\n CompanionBackfillResult,\n} from './companion-projection.js';\nexport { computeSearchCompanionValue } from './pinyin.js';\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * PinyinSearchPlugin (#2486) — pinyin recall for `$search`.\n *\n * Pure hook plugin: the `__search` companion column is declared at object\n * compile time by the SchemaRegistry (gated on the SAME\n * `OS_SEARCH_PINYIN_ENABLED` decision point), the engine ORs it into the\n * `$search` filter, and this plugin fills the value:\n *\n * - before-save hooks: recompute full pinyin + initials of the\n * display/name field when it changes;\n * - boot backfill (`kernel:bootstrapped`): fill rows that predate the\n * switch or arrived via hook-bypassing writes;\n * - `rebuildSearchCompanion` (exported): explicit reconcile/rebuild entry.\n *\n * When the flag is off the plugin is inert: no hooks, no backfill, and\n * `pinyin-pro` is never imported.\n */\n\nimport type { Plugin, PluginContext } from '@objectstack/core';\nimport { resolveSearchPinyinEnabled } from '@objectstack/types';\nimport {\n bindSearchCompanionHooks,\n backfillSearchCompanion,\n} from './companion-projection.js';\n\nexport interface PinyinSearchPluginOptions {\n /**\n * Force-enable/disable regardless of `OS_SEARCH_PINYIN_ENABLED` (tests /\n * embedders). Default: `resolveSearchPinyinEnabled()`.\n */\n enabled?: boolean;\n /** Skip the boot backfill (default: run it once per boot). */\n backfill?: boolean;\n}\n\nexport class PinyinSearchPlugin implements Plugin {\n name = 'com.objectstack.plugin.pinyin-search';\n version = '1.0.0';\n type = 'standard';\n dependencies = ['com.objectstack.engine.objectql'];\n\n private readonly options: PinyinSearchPluginOptions;\n\n constructor(options: PinyinSearchPluginOptions = {}) {\n this.options = options;\n }\n\n private get enabled(): boolean {\n return this.options.enabled ?? resolveSearchPinyinEnabled();\n }\n\n async init(_ctx: PluginContext): Promise<void> {\n // Nothing to register: the companion column is provisioned by the\n // SchemaRegistry's compile-time seam, not injected at runtime.\n }\n\n async start(ctx: PluginContext): Promise<void> {\n if (!this.enabled) {\n ctx.logger.debug?.('PinyinSearchPlugin: OS_SEARCH_PINYIN_ENABLED is off — inert');\n return;\n }\n\n ctx.hook('kernel:ready', async () => {\n const engine = this.resolveEngine(ctx);\n if (!engine) {\n ctx.logger.warn('PinyinSearchPlugin: no ObjectQL engine — companion hooks NOT bound');\n return;\n }\n try {\n bindSearchCompanionHooks(engine, ctx.logger as any);\n } catch (err: any) {\n ctx.logger.warn('PinyinSearchPlugin: companion hooks not bound', { error: err?.message });\n }\n });\n\n // Backfill AFTER boot settles (`kernel:bootstrapped` fires once every\n // `kernel:ready` hook — including seed loading — has completed), so\n // seeded rows written before/around hook binding are reconciled too.\n if (this.options.backfill !== false) {\n ctx.hook('kernel:bootstrapped', async () => {\n const engine = this.resolveEngine(ctx);\n if (!engine) return;\n try {\n await backfillSearchCompanion(engine, ctx.logger as any);\n } catch (err: any) {\n ctx.logger.warn('PinyinSearchPlugin: companion backfill failed', { error: err?.message });\n }\n });\n }\n }\n\n private resolveEngine(ctx: PluginContext): any {\n try {\n return ctx.getService<any>('objectql');\n } catch {\n try {\n return ctx.getService<any>('data');\n } catch {\n return null;\n }\n }\n }\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * `__search` companion-column projection (#2486).\n *\n * The column itself is DECLARED at object compile time by the SchemaRegistry\n * (`provisionSearchCompanion`, gated on `OS_SEARCH_PINYIN_ENABLED`); this\n * module only FILLS the value — the `plugin-sharing` primary-BU projection\n * pattern (column on the object, plugin maintains it via hooks).\n *\n * Write path: global `beforeInsert`/`beforeUpdate` hooks stamp\n * `data.__search` whenever a companion source field (the object's\n * display/name field) is present in the write — i.e. only when the source\n * actually changed, avoiding write amplification. Writes that bypass hooks\n * (bulk import, direct migration) leave the companion empty; the boot\n * backfill and the `rebuildSearchCompanion` reconcile entry cover that.\n */\n\nimport {\n SEARCH_COMPANION_FIELD,\n resolveSearchCompanionSources,\n containsCJK,\n} from '@objectstack/objectql';\nimport { computeSearchCompanionValue } from './pinyin.js';\n\nconst SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;\n\nexport const PINYIN_SEARCH_HOOK_PACKAGE = 'plugin-pinyin-search:companion';\n\ninterface MinimalEngine {\n registerHook(\n event: string,\n handler: (ctx: any) => any | Promise<any>,\n options?: { object?: string | string[]; priority?: number; packageId?: string },\n ): void;\n unregisterHooksByPackage(packageId: string): number;\n find(object: string, query?: any, options?: any): Promise<any[]>;\n update(object: string, data: any, options?: any): Promise<any>;\n registry?: {\n getObject(name: string): any;\n getAllObjects?(packageId?: string): any[];\n };\n}\n\ninterface MinimalLogger {\n info?: (msg: any, ...rest: any[]) => void;\n warn?: (msg: any, ...rest: any[]) => void;\n debug?: (msg: any, ...rest: any[]) => void;\n}\n\n/**\n * Stamp `data.__search` on a before-save hook context when a companion source\n * field is part of the write. Recomputes from the NEW value; a non-CJK new\n * value clears the companion (null) so stale pinyin never recalls a renamed\n * record. Never throws — a normalization failure must not fail the write.\n */\nasync function stampCompanion(engine: MinimalEngine, ctx: any, logger?: MinimalLogger): Promise<void> {\n const object = ctx?.object;\n if (!object) return;\n const schema = engine.registry?.getObject?.(object);\n if (!schema?.fields?.[SEARCH_COMPANION_FIELD]) return;\n\n const data = ctx?.input?.data;\n if (!data || typeof data !== 'object' || Array.isArray(data)) return;\n\n const sources = resolveSearchCompanionSources(schema);\n if (sources.length === 0) return;\n const touched = sources.filter((s) => Object.prototype.hasOwnProperty.call(data, s));\n if (touched.length === 0) return; // source unchanged → no recompute (no write amplification)\n\n try {\n data[SEARCH_COMPANION_FIELD] = await computeSearchCompanionValue(sources.map((s) => data[s]));\n } catch (err: any) {\n logger?.warn?.('[pinyin-search] companion normalization failed — write proceeds without it', {\n object,\n error: err?.message,\n });\n }\n}\n\n/**\n * Bind the global before-save hooks that keep `__search` in step. Idempotent\n * (unbinds the package first). Hooks are global (no object filter) with a\n * cheap early-out: objects without a provisioned companion column return\n * immediately. They run for system-context writes too — the projection must\n * stay correct regardless of who writes (seeds, imports, admin UI).\n */\nexport function bindSearchCompanionHooks(engine: MinimalEngine, logger?: MinimalLogger): void {\n if (typeof engine.registerHook !== 'function') return;\n if (typeof engine.unregisterHooksByPackage === 'function') {\n engine.unregisterHooksByPackage(PINYIN_SEARCH_HOOK_PACKAGE);\n }\n const opts = { packageId: PINYIN_SEARCH_HOOK_PACKAGE, priority: 150 };\n const handler = (ctx: any) => stampCompanion(engine, ctx, logger);\n engine.registerHook('beforeInsert', handler, opts);\n engine.registerHook('beforeUpdate', handler, opts);\n logger?.info?.('[pinyin-search] companion hooks bound (beforeInsert/beforeUpdate, all objects)');\n}\n\nexport interface CompanionBackfillOptions {\n /** Rows fetched per page during the scan. Default 1000. */\n batchSize?: number;\n /** Restrict to one object (reconcile entry); default: every provisioned object. */\n object?: string;\n /**\n * Recompute EVERY row's companion, not just missing ones — the periodic\n * reconcile/rebuild mode. Default false (backfill: only rows whose\n * companion is empty but whose source has CJK content).\n */\n force?: boolean;\n}\n\nexport interface CompanionBackfillResult {\n objects: number;\n scanned: number;\n updated: number;\n}\n\n/**\n * Backfill / reconcile the companion column.\n *\n * Denormalized-on-write columns go stale when writes bypass hooks (bulk\n * import, direct migration) and are empty for rows that predate the switch\n * being enabled. This scans every object that carries the companion column\n * (paged, system context) and fills the gaps; with `force: true` it\n * recomputes unconditionally (the periodic reconcile / rebuild entry).\n * Idempotent; per-row failures are skipped so one bad row never aborts the\n * pass.\n */\nexport async function backfillSearchCompanion(\n engine: MinimalEngine,\n logger?: MinimalLogger,\n options?: CompanionBackfillOptions,\n): Promise<CompanionBackfillResult> {\n const batchSize = Math.max(1, options?.batchSize ?? 1000);\n const all = options?.object\n ? [engine.registry?.getObject?.(options.object)].filter(Boolean)\n : engine.registry?.getAllObjects?.() ?? [];\n\n const result: CompanionBackfillResult = { objects: 0, scanned: 0, updated: 0 };\n\n for (const schema of all) {\n if (!schema?.name || !schema?.fields?.[SEARCH_COMPANION_FIELD]) continue;\n const sources = resolveSearchCompanionSources(schema);\n if (sources.length === 0) continue;\n result.objects++;\n\n let offset = 0;\n for (;;) {\n let rows: any[] = [];\n try {\n rows = await engine.find(schema.name, {\n fields: ['id', ...sources, SEARCH_COMPANION_FIELD],\n limit: batchSize,\n offset,\n context: SYSTEM_CTX,\n });\n } catch (err: any) {\n logger?.warn?.('[pinyin-search] backfill scan failed', { object: schema.name, error: err?.message });\n break;\n }\n if (!rows?.length) break;\n result.scanned += rows.length;\n\n for (const row of rows) {\n if (row?.id == null) continue;\n const hasBlob = typeof row[SEARCH_COMPANION_FIELD] === 'string' && row[SEARCH_COMPANION_FIELD] !== '';\n const hasCjkSource = sources.some((s) => containsCJK(row[s]));\n // Backfill mode: touch only rows missing a blob they should have.\n // Force mode: recompute everything (also clears stale blobs).\n if (!options?.force && (hasBlob || !hasCjkSource)) continue;\n try {\n const value = await computeSearchCompanionValue(sources.map((s) => row[s]));\n if (!options?.force && value == null) continue;\n if (value === row[SEARCH_COMPANION_FIELD]) continue;\n await engine.update(\n schema.name,\n { id: row.id, [SEARCH_COMPANION_FIELD]: value },\n { context: SYSTEM_CTX },\n );\n result.updated++;\n } catch (err: any) {\n logger?.warn?.('[pinyin-search] backfill row skipped', {\n object: schema.name,\n id: row.id,\n error: err?.message,\n });\n }\n }\n\n if (rows.length < batchSize) break;\n offset += batchSize;\n }\n }\n\n if (result.updated > 0) {\n logger?.info?.('[pinyin-search] companion backfill complete', result);\n }\n return result;\n}\n\n/**\n * Periodic reconcile / rebuild entry: recompute the companion for every row\n * (optionally one object). Alias for `backfillSearchCompanion` with\n * `force: true` — exposed under its own name so operators/jobs have an\n * explicit \"rebuild the pinyin index\" handle.\n */\nexport function rebuildSearchCompanion(\n engine: MinimalEngine,\n logger?: MinimalLogger,\n options?: Omit<CompanionBackfillOptions, 'force'>,\n): Promise<CompanionBackfillResult> {\n return backfillSearchCompanion(engine, logger, { ...options, force: true });\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * Pinyin normalization for the `__search` companion column (#2486).\n *\n * One normalized blob per record — full pinyin AND initials in the same\n * column — so a single `$contains` recalls every latin input shape:\n *\n * \"张伟\" → \"zhangwei zw\"\n * `zhang` / `wei` / `zhangwei` → substring of the full form\n * `zw` → substring of the initials form\n *\n * `pinyin-pro` is loaded lazily on first use: non-Chinese deployments (flag\n * off → hooks never bound) never import it and pay zero cost.\n *\n * Polyphones: pinyin-pro's default heuristics are accepted (issue #2486\n * \"待定\" — surname polyphone dictionaries are a P2 follow-up).\n */\n\nimport { containsCJK } from '@objectstack/objectql';\n\ntype PinyinFn = (text: string, options?: Record<string, unknown>) => string | string[];\n\nlet _pinyin: Promise<PinyinFn> | null = null;\n\n/** Lazy-load `pinyin-pro` (cached module-wide). */\nfunction loadPinyin(): Promise<PinyinFn> {\n _pinyin ??= import('pinyin-pro').then((m: any) => (m.pinyin ?? m.default?.pinyin) as PinyinFn);\n return _pinyin;\n}\n\n/** Lowercase and strip everything that is not a latin letter or digit. */\nfunction squash(syllables: string | string[]): string {\n const joined = Array.isArray(syllables) ? syllables.join('') : String(syllables ?? '');\n return joined.toLowerCase().replace(/[^a-z0-9]+/g, '');\n}\n\n/**\n * Compute the companion value for the given source-field values.\n *\n * Returns the normalized blob (`\"<full-pinyin> <initials>\"`, deduplicated)\n * when at least one value contains CJK characters, else `null` — a `null`\n * companion means \"nothing pinyin-searchable here\" and clears any stale blob\n * when a name is edited away from CJK. Non-CJK values need no companion:\n * their source column already matches latin input directly.\n */\nexport async function computeSearchCompanionValue(values: ReadonlyArray<unknown>): Promise<string | null> {\n const cjkValues = values.filter((v): v is string => containsCJK(v));\n if (cjkValues.length === 0) return null;\n\n const pinyin = await loadPinyin();\n const parts: string[] = [];\n for (const value of cjkValues) {\n // `nonZh: 'consecutive'` keeps latin/digit runs intact inside mixed\n // values (\"张伟2号\" → \"zhangwei2hao\"), so mixed names stay one token.\n const full = squash(pinyin(value, { toneType: 'none', type: 'array', nonZh: 'consecutive' }));\n const initials = squash(pinyin(value, { pattern: 'first', toneType: 'none', type: 'array', nonZh: 'consecutive' }));\n if (full) parts.push(full);\n if (initials && initials !== full) parts.push(initials);\n }\n if (parts.length === 0) return null;\n return [...new Set(parts)].join(' ');\n}\n"],"mappings":"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","names":["import_objectql"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/pinyin-search-plugin.ts","../src/companion-projection.ts","../src/pinyin.ts"],"sourcesContent":["// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\nexport { PinyinSearchPlugin } from './pinyin-search-plugin.js';\nexport type { PinyinSearchPluginOptions } from './pinyin-search-plugin.js';\nexport {\n bindSearchCompanionHooks,\n backfillSearchCompanion,\n rebuildSearchCompanion,\n PINYIN_SEARCH_HOOK_PACKAGE,\n} from './companion-projection.js';\nexport type {\n CompanionBackfillOptions,\n CompanionBackfillResult,\n} from './companion-projection.js';\nexport { computeSearchCompanionValue } from './pinyin.js';\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * PinyinSearchPlugin (#2486) — pinyin recall for `$search`.\n *\n * Pure hook plugin: the `__search` companion column is declared at object\n * compile time by the SchemaRegistry (gated on the SAME\n * `OS_SEARCH_PINYIN_ENABLED` decision point), the engine ORs it into the\n * `$search` filter, and this plugin fills the value:\n *\n * - before-save hooks: recompute full pinyin + initials of the\n * display/name field when it changes;\n * - boot backfill (`kernel:bootstrapped`): fill rows that predate the\n * switch or arrived via hook-bypassing writes;\n * - `rebuildSearchCompanion` (exported): explicit reconcile/rebuild entry.\n *\n * When the flag is off the plugin is inert: no hooks, no backfill, and\n * `pinyin-pro` is never imported.\n */\n\nimport type { Plugin, PluginContext } from '@objectstack/core';\nimport { resolveSearchPinyinEnabled } from '@objectstack/types';\nimport {\n bindSearchCompanionHooks,\n backfillSearchCompanion,\n} from './companion-projection.js';\n\nexport interface PinyinSearchPluginOptions {\n /**\n * Force-enable/disable regardless of `OS_SEARCH_PINYIN_ENABLED` (tests /\n * embedders). Default: `resolveSearchPinyinEnabled()`.\n */\n enabled?: boolean;\n /** Skip the boot backfill (default: run it once per boot). */\n backfill?: boolean;\n}\n\nexport class PinyinSearchPlugin implements Plugin {\n name = 'com.objectstack.plugin.pinyin-search';\n version = '1.0.0';\n type = 'standard';\n dependencies = ['com.objectstack.engine.objectql'];\n\n private readonly options: PinyinSearchPluginOptions;\n\n constructor(options: PinyinSearchPluginOptions = {}) {\n this.options = options;\n }\n\n private get enabled(): boolean {\n return this.options.enabled ?? resolveSearchPinyinEnabled();\n }\n\n async init(_ctx: PluginContext): Promise<void> {\n // Nothing to register: the companion column is provisioned by the\n // SchemaRegistry's compile-time seam, not injected at runtime.\n }\n\n async start(ctx: PluginContext): Promise<void> {\n if (!this.enabled) {\n ctx.logger.debug?.('PinyinSearchPlugin: OS_SEARCH_PINYIN_ENABLED is off — inert');\n return;\n }\n\n ctx.hook('kernel:ready', async () => {\n const engine = this.resolveEngine(ctx);\n if (!engine) {\n ctx.logger.warn('PinyinSearchPlugin: no ObjectQL engine — companion hooks NOT bound');\n return;\n }\n try {\n bindSearchCompanionHooks(engine, ctx.logger as any);\n } catch (err: any) {\n ctx.logger.warn('PinyinSearchPlugin: companion hooks not bound', { error: err?.message });\n }\n });\n\n // Backfill AFTER boot settles (`kernel:bootstrapped` fires once every\n // `kernel:ready` hook — including seed loading — has completed), so\n // seeded rows written before/around hook binding are reconciled too.\n if (this.options.backfill !== false) {\n ctx.hook('kernel:bootstrapped', async () => {\n const engine = this.resolveEngine(ctx);\n if (!engine) return;\n try {\n await backfillSearchCompanion(engine, ctx.logger as any);\n } catch (err: any) {\n ctx.logger.warn('PinyinSearchPlugin: companion backfill failed', { error: err?.message });\n }\n });\n }\n }\n\n private resolveEngine(ctx: PluginContext): any {\n try {\n return ctx.getService<any>('objectql');\n } catch {\n try {\n return ctx.getService<any>('data');\n } catch {\n return null;\n }\n }\n }\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * `__search` companion-column projection (#2486).\n *\n * The column itself is DECLARED at object compile time by the SchemaRegistry\n * (`provisionSearchCompanion`, gated on `OS_SEARCH_PINYIN_ENABLED`); this\n * module only FILLS the value — the `plugin-sharing` primary-BU projection\n * pattern (column on the object, plugin maintains it via hooks).\n *\n * Write path: global `beforeInsert`/`beforeUpdate` hooks stamp\n * `data.__search` whenever a companion source field (the object's\n * display/name field) is present in the write — i.e. only when the source\n * actually changed, avoiding write amplification. Writes that bypass hooks\n * (bulk import, direct migration) leave the companion empty; the boot\n * backfill and the `rebuildSearchCompanion` reconcile entry cover that.\n */\n\nimport {\n SEARCH_COMPANION_FIELD,\n resolveSearchCompanionSources,\n containsCJK,\n} from '@objectstack/objectql';\nimport { keysetWalk } from '@objectstack/types';\nimport { computeSearchCompanionValue } from './pinyin.js';\n\nconst SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;\n\nexport const PINYIN_SEARCH_HOOK_PACKAGE = 'plugin-pinyin-search:companion';\n\ninterface MinimalEngine {\n registerHook(\n event: string,\n handler: (ctx: any) => any | Promise<any>,\n options?: { object?: string | string[]; priority?: number; packageId?: string },\n ): void;\n unregisterHooksByPackage(packageId: string): number;\n find(object: string, query?: any, options?: any): Promise<any[]>;\n update(object: string, data: any, options?: any): Promise<any>;\n registry?: {\n getObject(name: string): any;\n getAllObjects?(packageId?: string): any[];\n };\n}\n\ninterface MinimalLogger {\n info?: (msg: any, ...rest: any[]) => void;\n warn?: (msg: any, ...rest: any[]) => void;\n debug?: (msg: any, ...rest: any[]) => void;\n}\n\n/**\n * Stamp `data.__search` on a before-save hook context when a companion source\n * field is part of the write. Recomputes from the NEW value; a non-CJK new\n * value clears the companion (null) so stale pinyin never recalls a renamed\n * record. Never throws — a normalization failure must not fail the write.\n */\nasync function stampCompanion(engine: MinimalEngine, ctx: any, logger?: MinimalLogger): Promise<void> {\n const object = ctx?.object;\n if (!object) return;\n const schema = engine.registry?.getObject?.(object);\n if (!schema?.fields?.[SEARCH_COMPANION_FIELD]) return;\n\n const data = ctx?.input?.data;\n if (!data || typeof data !== 'object' || Array.isArray(data)) return;\n\n const sources = resolveSearchCompanionSources(schema);\n if (sources.length === 0) return;\n const touched = sources.filter((s) => Object.prototype.hasOwnProperty.call(data, s));\n if (touched.length === 0) return; // source unchanged → no recompute (no write amplification)\n\n try {\n data[SEARCH_COMPANION_FIELD] = await computeSearchCompanionValue(sources.map((s) => data[s]));\n } catch (err: any) {\n logger?.warn?.('[pinyin-search] companion normalization failed — write proceeds without it', {\n object,\n error: err?.message,\n });\n }\n}\n\n/**\n * Bind the global before-save hooks that keep `__search` in step. Idempotent\n * (unbinds the package first). Hooks are global (no object filter) with a\n * cheap early-out: objects without a provisioned companion column return\n * immediately. They run for system-context writes too — the projection must\n * stay correct regardless of who writes (seeds, imports, admin UI).\n */\nexport function bindSearchCompanionHooks(engine: MinimalEngine, logger?: MinimalLogger): void {\n if (typeof engine.registerHook !== 'function') return;\n if (typeof engine.unregisterHooksByPackage === 'function') {\n engine.unregisterHooksByPackage(PINYIN_SEARCH_HOOK_PACKAGE);\n }\n const opts = { packageId: PINYIN_SEARCH_HOOK_PACKAGE, priority: 150 };\n const handler = (ctx: any) => stampCompanion(engine, ctx, logger);\n engine.registerHook('beforeInsert', handler, opts);\n engine.registerHook('beforeUpdate', handler, opts);\n logger?.info?.('[pinyin-search] companion hooks bound (beforeInsert/beforeUpdate, all objects)');\n}\n\nexport interface CompanionBackfillOptions {\n /** Rows fetched per page during the scan. Default 1000. */\n batchSize?: number;\n /** Restrict to one object (reconcile entry); default: every provisioned object. */\n object?: string;\n /**\n * Recompute EVERY row's companion, not just missing ones — the periodic\n * reconcile/rebuild mode. Default false (backfill: only rows whose\n * companion is empty but whose source has CJK content).\n */\n force?: boolean;\n}\n\nexport interface CompanionBackfillResult {\n objects: number;\n scanned: number;\n updated: number;\n}\n\n/**\n * Backfill / reconcile the companion column.\n *\n * Denormalized-on-write columns go stale when writes bypass hooks (bulk\n * import, direct migration) and are empty for rows that predate the switch\n * being enabled. This scans every object that carries the companion column\n * (paged, system context) and fills the gaps; with `force: true` it\n * recomputes unconditionally (the periodic reconcile / rebuild entry).\n * Idempotent; per-row failures are skipped so one bad row never aborts the\n * pass.\n */\nexport async function backfillSearchCompanion(\n engine: MinimalEngine,\n logger?: MinimalLogger,\n options?: CompanionBackfillOptions,\n): Promise<CompanionBackfillResult> {\n const batchSize = Math.max(1, options?.batchSize ?? 1000);\n const all = options?.object\n ? [engine.registry?.getObject?.(options.object)].filter(Boolean)\n : engine.registry?.getAllObjects?.() ?? [];\n\n const result: CompanionBackfillResult = { objects: 0, scanned: 0, updated: 0 };\n\n for (const schema of all) {\n if (!schema?.name || !schema?.fields?.[SEARCH_COMPANION_FIELD]) continue;\n const sources = resolveSearchCompanionSources(schema);\n if (sources.length === 0) continue;\n result.objects++;\n\n // Seek by `id` (#4363). This walk UPDATES the rows it reads, and an offset\n // counts into a set those writes are changing, so rows slide past the\n // cursor and never get their companion blob — a backfill that reports a\n // clean pass while leaving records unsearchable.\n const walk = keysetWalk<any>(\n (q) => engine.find(schema.name, {\n ...q,\n fields: ['id', ...sources, SEARCH_COMPANION_FIELD],\n context: SYSTEM_CTX,\n }),\n { pageSize: batchSize },\n );\n\n try {\n for await (const rows of walk.pages()) {\n result.scanned += rows.length;\n\n for (const row of rows) {\n if (row?.id == null) continue;\n const hasBlob = typeof row[SEARCH_COMPANION_FIELD] === 'string' && row[SEARCH_COMPANION_FIELD] !== '';\n const hasCjkSource = sources.some((s) => containsCJK(row[s]));\n // Backfill mode: touch only rows missing a blob they should have.\n // Force mode: recompute everything (also clears stale blobs).\n if (!options?.force && (hasBlob || !hasCjkSource)) continue;\n try {\n const value = await computeSearchCompanionValue(sources.map((s) => row[s]));\n if (!options?.force && value == null) continue;\n if (value === row[SEARCH_COMPANION_FIELD]) continue;\n await engine.update(\n schema.name,\n { id: row.id, [SEARCH_COMPANION_FIELD]: value },\n { context: SYSTEM_CTX },\n );\n result.updated++;\n } catch (err: any) {\n logger?.warn?.('[pinyin-search] backfill row skipped', {\n object: schema.name,\n id: row.id,\n error: err?.message,\n });\n }\n }\n }\n } catch (err: any) {\n logger?.warn?.('[pinyin-search] backfill scan failed', { object: schema.name, error: err?.message });\n continue;\n }\n }\n\n if (result.updated > 0) {\n logger?.info?.('[pinyin-search] companion backfill complete', result);\n }\n return result;\n}\n\n/**\n * Periodic reconcile / rebuild entry: recompute the companion for every row\n * (optionally one object). Alias for `backfillSearchCompanion` with\n * `force: true` — exposed under its own name so operators/jobs have an\n * explicit \"rebuild the pinyin index\" handle.\n */\nexport function rebuildSearchCompanion(\n engine: MinimalEngine,\n logger?: MinimalLogger,\n options?: Omit<CompanionBackfillOptions, 'force'>,\n): Promise<CompanionBackfillResult> {\n return backfillSearchCompanion(engine, logger, { ...options, force: true });\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * Pinyin normalization for the `__search` companion column (#2486).\n *\n * One normalized blob per record — full pinyin AND initials in the same\n * column — so a single `$contains` recalls every latin input shape:\n *\n * \"张伟\" → \"zhangwei zw\"\n * `zhang` / `wei` / `zhangwei` → substring of the full form\n * `zw` → substring of the initials form\n *\n * `pinyin-pro` is loaded lazily on first use: non-Chinese deployments (flag\n * off → hooks never bound) never import it and pay zero cost.\n *\n * Polyphones: pinyin-pro's default heuristics are accepted (issue #2486\n * \"待定\" — surname polyphone dictionaries are a P2 follow-up).\n */\n\nimport { containsCJK } from '@objectstack/objectql';\n\ntype PinyinFn = (text: string, options?: Record<string, unknown>) => string | string[];\n\nlet _pinyin: Promise<PinyinFn> | null = null;\n\n/** Lazy-load `pinyin-pro` (cached module-wide). */\nfunction loadPinyin(): Promise<PinyinFn> {\n _pinyin ??= import('pinyin-pro').then((m: any) => (m.pinyin ?? m.default?.pinyin) as PinyinFn);\n return _pinyin;\n}\n\n/** Lowercase and strip everything that is not a latin letter or digit. */\nfunction squash(syllables: string | string[]): string {\n const joined = Array.isArray(syllables) ? syllables.join('') : String(syllables ?? '');\n return joined.toLowerCase().replace(/[^a-z0-9]+/g, '');\n}\n\n/**\n * Compute the companion value for the given source-field values.\n *\n * Returns the normalized blob (`\"<full-pinyin> <initials>\"`, deduplicated)\n * when at least one value contains CJK characters, else `null` — a `null`\n * companion means \"nothing pinyin-searchable here\" and clears any stale blob\n * when a name is edited away from CJK. Non-CJK values need no companion:\n * their source column already matches latin input directly.\n */\nexport async function computeSearchCompanionValue(values: ReadonlyArray<unknown>): Promise<string | null> {\n const cjkValues = values.filter((v): v is string => containsCJK(v));\n if (cjkValues.length === 0) return null;\n\n const pinyin = await loadPinyin();\n const parts: string[] = [];\n for (const value of cjkValues) {\n // `nonZh: 'consecutive'` keeps latin/digit runs intact inside mixed\n // values (\"张伟2号\" → \"zhangwei2hao\"), so mixed names stay one token.\n const full = squash(pinyin(value, { toneType: 'none', type: 'array', nonZh: 'consecutive' }));\n const initials = squash(pinyin(value, { pattern: 'first', toneType: 'none', type: 'array', nonZh: 'consecutive' }));\n if (full) parts.push(full);\n if (initials && initials !== full) parts.push(initials);\n }\n if (parts.length === 0) return null;\n return [...new Set(parts)].join(' ');\n}\n"],"mappings":"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","names":["import_types","import_objectql"]}
package/dist/index.mjs CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  resolveSearchCompanionSources,
8
8
  containsCJK as containsCJK2
9
9
  } from "@objectstack/objectql";
10
+ import { keysetWalk } from "@objectstack/types";
10
11
 
11
12
  // src/pinyin.ts
12
13
  import { containsCJK } from "@objectstack/objectql";
@@ -77,47 +78,44 @@ async function backfillSearchCompanion(engine, logger, options) {
77
78
  const sources = resolveSearchCompanionSources(schema);
78
79
  if (sources.length === 0) continue;
79
80
  result.objects++;
80
- let offset = 0;
81
- for (; ; ) {
82
- let rows = [];
83
- try {
84
- rows = await engine.find(schema.name, {
85
- fields: ["id", ...sources, SEARCH_COMPANION_FIELD],
86
- limit: batchSize,
87
- offset,
88
- context: SYSTEM_CTX
89
- });
90
- } catch (err) {
91
- logger?.warn?.("[pinyin-search] backfill scan failed", { object: schema.name, error: err?.message });
92
- break;
93
- }
94
- if (!rows?.length) break;
95
- result.scanned += rows.length;
96
- for (const row of rows) {
97
- if (row?.id == null) continue;
98
- const hasBlob = typeof row[SEARCH_COMPANION_FIELD] === "string" && row[SEARCH_COMPANION_FIELD] !== "";
99
- const hasCjkSource = sources.some((s) => containsCJK2(row[s]));
100
- if (!options?.force && (hasBlob || !hasCjkSource)) continue;
101
- try {
102
- const value = await computeSearchCompanionValue(sources.map((s) => row[s]));
103
- if (!options?.force && value == null) continue;
104
- if (value === row[SEARCH_COMPANION_FIELD]) continue;
105
- await engine.update(
106
- schema.name,
107
- { id: row.id, [SEARCH_COMPANION_FIELD]: value },
108
- { context: SYSTEM_CTX }
109
- );
110
- result.updated++;
111
- } catch (err) {
112
- logger?.warn?.("[pinyin-search] backfill row skipped", {
113
- object: schema.name,
114
- id: row.id,
115
- error: err?.message
116
- });
81
+ const walk = keysetWalk(
82
+ (q) => engine.find(schema.name, {
83
+ ...q,
84
+ fields: ["id", ...sources, SEARCH_COMPANION_FIELD],
85
+ context: SYSTEM_CTX
86
+ }),
87
+ { pageSize: batchSize }
88
+ );
89
+ try {
90
+ for await (const rows of walk.pages()) {
91
+ result.scanned += rows.length;
92
+ for (const row of rows) {
93
+ if (row?.id == null) continue;
94
+ const hasBlob = typeof row[SEARCH_COMPANION_FIELD] === "string" && row[SEARCH_COMPANION_FIELD] !== "";
95
+ const hasCjkSource = sources.some((s) => containsCJK2(row[s]));
96
+ if (!options?.force && (hasBlob || !hasCjkSource)) continue;
97
+ try {
98
+ const value = await computeSearchCompanionValue(sources.map((s) => row[s]));
99
+ if (!options?.force && value == null) continue;
100
+ if (value === row[SEARCH_COMPANION_FIELD]) continue;
101
+ await engine.update(
102
+ schema.name,
103
+ { id: row.id, [SEARCH_COMPANION_FIELD]: value },
104
+ { context: SYSTEM_CTX }
105
+ );
106
+ result.updated++;
107
+ } catch (err) {
108
+ logger?.warn?.("[pinyin-search] backfill row skipped", {
109
+ object: schema.name,
110
+ id: row.id,
111
+ error: err?.message
112
+ });
113
+ }
117
114
  }
118
115
  }
119
- if (rows.length < batchSize) break;
120
- offset += batchSize;
116
+ } catch (err) {
117
+ logger?.warn?.("[pinyin-search] backfill scan failed", { object: schema.name, error: err?.message });
118
+ continue;
121
119
  }
122
120
  }
123
121
  if (result.updated > 0) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/pinyin-search-plugin.ts","../src/companion-projection.ts","../src/pinyin.ts"],"sourcesContent":["// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * PinyinSearchPlugin (#2486) — pinyin recall for `$search`.\n *\n * Pure hook plugin: the `__search` companion column is declared at object\n * compile time by the SchemaRegistry (gated on the SAME\n * `OS_SEARCH_PINYIN_ENABLED` decision point), the engine ORs it into the\n * `$search` filter, and this plugin fills the value:\n *\n * - before-save hooks: recompute full pinyin + initials of the\n * display/name field when it changes;\n * - boot backfill (`kernel:bootstrapped`): fill rows that predate the\n * switch or arrived via hook-bypassing writes;\n * - `rebuildSearchCompanion` (exported): explicit reconcile/rebuild entry.\n *\n * When the flag is off the plugin is inert: no hooks, no backfill, and\n * `pinyin-pro` is never imported.\n */\n\nimport type { Plugin, PluginContext } from '@objectstack/core';\nimport { resolveSearchPinyinEnabled } from '@objectstack/types';\nimport {\n bindSearchCompanionHooks,\n backfillSearchCompanion,\n} from './companion-projection.js';\n\nexport interface PinyinSearchPluginOptions {\n /**\n * Force-enable/disable regardless of `OS_SEARCH_PINYIN_ENABLED` (tests /\n * embedders). Default: `resolveSearchPinyinEnabled()`.\n */\n enabled?: boolean;\n /** Skip the boot backfill (default: run it once per boot). */\n backfill?: boolean;\n}\n\nexport class PinyinSearchPlugin implements Plugin {\n name = 'com.objectstack.plugin.pinyin-search';\n version = '1.0.0';\n type = 'standard';\n dependencies = ['com.objectstack.engine.objectql'];\n\n private readonly options: PinyinSearchPluginOptions;\n\n constructor(options: PinyinSearchPluginOptions = {}) {\n this.options = options;\n }\n\n private get enabled(): boolean {\n return this.options.enabled ?? resolveSearchPinyinEnabled();\n }\n\n async init(_ctx: PluginContext): Promise<void> {\n // Nothing to register: the companion column is provisioned by the\n // SchemaRegistry's compile-time seam, not injected at runtime.\n }\n\n async start(ctx: PluginContext): Promise<void> {\n if (!this.enabled) {\n ctx.logger.debug?.('PinyinSearchPlugin: OS_SEARCH_PINYIN_ENABLED is off — inert');\n return;\n }\n\n ctx.hook('kernel:ready', async () => {\n const engine = this.resolveEngine(ctx);\n if (!engine) {\n ctx.logger.warn('PinyinSearchPlugin: no ObjectQL engine — companion hooks NOT bound');\n return;\n }\n try {\n bindSearchCompanionHooks(engine, ctx.logger as any);\n } catch (err: any) {\n ctx.logger.warn('PinyinSearchPlugin: companion hooks not bound', { error: err?.message });\n }\n });\n\n // Backfill AFTER boot settles (`kernel:bootstrapped` fires once every\n // `kernel:ready` hook — including seed loading — has completed), so\n // seeded rows written before/around hook binding are reconciled too.\n if (this.options.backfill !== false) {\n ctx.hook('kernel:bootstrapped', async () => {\n const engine = this.resolveEngine(ctx);\n if (!engine) return;\n try {\n await backfillSearchCompanion(engine, ctx.logger as any);\n } catch (err: any) {\n ctx.logger.warn('PinyinSearchPlugin: companion backfill failed', { error: err?.message });\n }\n });\n }\n }\n\n private resolveEngine(ctx: PluginContext): any {\n try {\n return ctx.getService<any>('objectql');\n } catch {\n try {\n return ctx.getService<any>('data');\n } catch {\n return null;\n }\n }\n }\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * `__search` companion-column projection (#2486).\n *\n * The column itself is DECLARED at object compile time by the SchemaRegistry\n * (`provisionSearchCompanion`, gated on `OS_SEARCH_PINYIN_ENABLED`); this\n * module only FILLS the value — the `plugin-sharing` primary-BU projection\n * pattern (column on the object, plugin maintains it via hooks).\n *\n * Write path: global `beforeInsert`/`beforeUpdate` hooks stamp\n * `data.__search` whenever a companion source field (the object's\n * display/name field) is present in the write — i.e. only when the source\n * actually changed, avoiding write amplification. Writes that bypass hooks\n * (bulk import, direct migration) leave the companion empty; the boot\n * backfill and the `rebuildSearchCompanion` reconcile entry cover that.\n */\n\nimport {\n SEARCH_COMPANION_FIELD,\n resolveSearchCompanionSources,\n containsCJK,\n} from '@objectstack/objectql';\nimport { computeSearchCompanionValue } from './pinyin.js';\n\nconst SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;\n\nexport const PINYIN_SEARCH_HOOK_PACKAGE = 'plugin-pinyin-search:companion';\n\ninterface MinimalEngine {\n registerHook(\n event: string,\n handler: (ctx: any) => any | Promise<any>,\n options?: { object?: string | string[]; priority?: number; packageId?: string },\n ): void;\n unregisterHooksByPackage(packageId: string): number;\n find(object: string, query?: any, options?: any): Promise<any[]>;\n update(object: string, data: any, options?: any): Promise<any>;\n registry?: {\n getObject(name: string): any;\n getAllObjects?(packageId?: string): any[];\n };\n}\n\ninterface MinimalLogger {\n info?: (msg: any, ...rest: any[]) => void;\n warn?: (msg: any, ...rest: any[]) => void;\n debug?: (msg: any, ...rest: any[]) => void;\n}\n\n/**\n * Stamp `data.__search` on a before-save hook context when a companion source\n * field is part of the write. Recomputes from the NEW value; a non-CJK new\n * value clears the companion (null) so stale pinyin never recalls a renamed\n * record. Never throws — a normalization failure must not fail the write.\n */\nasync function stampCompanion(engine: MinimalEngine, ctx: any, logger?: MinimalLogger): Promise<void> {\n const object = ctx?.object;\n if (!object) return;\n const schema = engine.registry?.getObject?.(object);\n if (!schema?.fields?.[SEARCH_COMPANION_FIELD]) return;\n\n const data = ctx?.input?.data;\n if (!data || typeof data !== 'object' || Array.isArray(data)) return;\n\n const sources = resolveSearchCompanionSources(schema);\n if (sources.length === 0) return;\n const touched = sources.filter((s) => Object.prototype.hasOwnProperty.call(data, s));\n if (touched.length === 0) return; // source unchanged → no recompute (no write amplification)\n\n try {\n data[SEARCH_COMPANION_FIELD] = await computeSearchCompanionValue(sources.map((s) => data[s]));\n } catch (err: any) {\n logger?.warn?.('[pinyin-search] companion normalization failed — write proceeds without it', {\n object,\n error: err?.message,\n });\n }\n}\n\n/**\n * Bind the global before-save hooks that keep `__search` in step. Idempotent\n * (unbinds the package first). Hooks are global (no object filter) with a\n * cheap early-out: objects without a provisioned companion column return\n * immediately. They run for system-context writes too — the projection must\n * stay correct regardless of who writes (seeds, imports, admin UI).\n */\nexport function bindSearchCompanionHooks(engine: MinimalEngine, logger?: MinimalLogger): void {\n if (typeof engine.registerHook !== 'function') return;\n if (typeof engine.unregisterHooksByPackage === 'function') {\n engine.unregisterHooksByPackage(PINYIN_SEARCH_HOOK_PACKAGE);\n }\n const opts = { packageId: PINYIN_SEARCH_HOOK_PACKAGE, priority: 150 };\n const handler = (ctx: any) => stampCompanion(engine, ctx, logger);\n engine.registerHook('beforeInsert', handler, opts);\n engine.registerHook('beforeUpdate', handler, opts);\n logger?.info?.('[pinyin-search] companion hooks bound (beforeInsert/beforeUpdate, all objects)');\n}\n\nexport interface CompanionBackfillOptions {\n /** Rows fetched per page during the scan. Default 1000. */\n batchSize?: number;\n /** Restrict to one object (reconcile entry); default: every provisioned object. */\n object?: string;\n /**\n * Recompute EVERY row's companion, not just missing ones — the periodic\n * reconcile/rebuild mode. Default false (backfill: only rows whose\n * companion is empty but whose source has CJK content).\n */\n force?: boolean;\n}\n\nexport interface CompanionBackfillResult {\n objects: number;\n scanned: number;\n updated: number;\n}\n\n/**\n * Backfill / reconcile the companion column.\n *\n * Denormalized-on-write columns go stale when writes bypass hooks (bulk\n * import, direct migration) and are empty for rows that predate the switch\n * being enabled. This scans every object that carries the companion column\n * (paged, system context) and fills the gaps; with `force: true` it\n * recomputes unconditionally (the periodic reconcile / rebuild entry).\n * Idempotent; per-row failures are skipped so one bad row never aborts the\n * pass.\n */\nexport async function backfillSearchCompanion(\n engine: MinimalEngine,\n logger?: MinimalLogger,\n options?: CompanionBackfillOptions,\n): Promise<CompanionBackfillResult> {\n const batchSize = Math.max(1, options?.batchSize ?? 1000);\n const all = options?.object\n ? [engine.registry?.getObject?.(options.object)].filter(Boolean)\n : engine.registry?.getAllObjects?.() ?? [];\n\n const result: CompanionBackfillResult = { objects: 0, scanned: 0, updated: 0 };\n\n for (const schema of all) {\n if (!schema?.name || !schema?.fields?.[SEARCH_COMPANION_FIELD]) continue;\n const sources = resolveSearchCompanionSources(schema);\n if (sources.length === 0) continue;\n result.objects++;\n\n let offset = 0;\n for (;;) {\n let rows: any[] = [];\n try {\n rows = await engine.find(schema.name, {\n fields: ['id', ...sources, SEARCH_COMPANION_FIELD],\n limit: batchSize,\n offset,\n context: SYSTEM_CTX,\n });\n } catch (err: any) {\n logger?.warn?.('[pinyin-search] backfill scan failed', { object: schema.name, error: err?.message });\n break;\n }\n if (!rows?.length) break;\n result.scanned += rows.length;\n\n for (const row of rows) {\n if (row?.id == null) continue;\n const hasBlob = typeof row[SEARCH_COMPANION_FIELD] === 'string' && row[SEARCH_COMPANION_FIELD] !== '';\n const hasCjkSource = sources.some((s) => containsCJK(row[s]));\n // Backfill mode: touch only rows missing a blob they should have.\n // Force mode: recompute everything (also clears stale blobs).\n if (!options?.force && (hasBlob || !hasCjkSource)) continue;\n try {\n const value = await computeSearchCompanionValue(sources.map((s) => row[s]));\n if (!options?.force && value == null) continue;\n if (value === row[SEARCH_COMPANION_FIELD]) continue;\n await engine.update(\n schema.name,\n { id: row.id, [SEARCH_COMPANION_FIELD]: value },\n { context: SYSTEM_CTX },\n );\n result.updated++;\n } catch (err: any) {\n logger?.warn?.('[pinyin-search] backfill row skipped', {\n object: schema.name,\n id: row.id,\n error: err?.message,\n });\n }\n }\n\n if (rows.length < batchSize) break;\n offset += batchSize;\n }\n }\n\n if (result.updated > 0) {\n logger?.info?.('[pinyin-search] companion backfill complete', result);\n }\n return result;\n}\n\n/**\n * Periodic reconcile / rebuild entry: recompute the companion for every row\n * (optionally one object). Alias for `backfillSearchCompanion` with\n * `force: true` — exposed under its own name so operators/jobs have an\n * explicit \"rebuild the pinyin index\" handle.\n */\nexport function rebuildSearchCompanion(\n engine: MinimalEngine,\n logger?: MinimalLogger,\n options?: Omit<CompanionBackfillOptions, 'force'>,\n): Promise<CompanionBackfillResult> {\n return backfillSearchCompanion(engine, logger, { ...options, force: true });\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * Pinyin normalization for the `__search` companion column (#2486).\n *\n * One normalized blob per record — full pinyin AND initials in the same\n * column — so a single `$contains` recalls every latin input shape:\n *\n * \"张伟\" → \"zhangwei zw\"\n * `zhang` / `wei` / `zhangwei` → substring of the full form\n * `zw` → substring of the initials form\n *\n * `pinyin-pro` is loaded lazily on first use: non-Chinese deployments (flag\n * off → hooks never bound) never import it and pay zero cost.\n *\n * Polyphones: pinyin-pro's default heuristics are accepted (issue #2486\n * \"待定\" — surname polyphone dictionaries are a P2 follow-up).\n */\n\nimport { containsCJK } from '@objectstack/objectql';\n\ntype PinyinFn = (text: string, options?: Record<string, unknown>) => string | string[];\n\nlet _pinyin: Promise<PinyinFn> | null = null;\n\n/** Lazy-load `pinyin-pro` (cached module-wide). */\nfunction loadPinyin(): Promise<PinyinFn> {\n _pinyin ??= import('pinyin-pro').then((m: any) => (m.pinyin ?? m.default?.pinyin) as PinyinFn);\n return _pinyin;\n}\n\n/** Lowercase and strip everything that is not a latin letter or digit. */\nfunction squash(syllables: string | string[]): string {\n const joined = Array.isArray(syllables) ? syllables.join('') : String(syllables ?? '');\n return joined.toLowerCase().replace(/[^a-z0-9]+/g, '');\n}\n\n/**\n * Compute the companion value for the given source-field values.\n *\n * Returns the normalized blob (`\"<full-pinyin> <initials>\"`, deduplicated)\n * when at least one value contains CJK characters, else `null` — a `null`\n * companion means \"nothing pinyin-searchable here\" and clears any stale blob\n * when a name is edited away from CJK. Non-CJK values need no companion:\n * their source column already matches latin input directly.\n */\nexport async function computeSearchCompanionValue(values: ReadonlyArray<unknown>): Promise<string | null> {\n const cjkValues = values.filter((v): v is string => containsCJK(v));\n if (cjkValues.length === 0) return null;\n\n const pinyin = await loadPinyin();\n const parts: string[] = [];\n for (const value of cjkValues) {\n // `nonZh: 'consecutive'` keeps latin/digit runs intact inside mixed\n // values (\"张伟2号\" → \"zhangwei2hao\"), so mixed names stay one token.\n const full = squash(pinyin(value, { toneType: 'none', type: 'array', nonZh: 'consecutive' }));\n const initials = squash(pinyin(value, { pattern: 'first', toneType: 'none', type: 'array', nonZh: 'consecutive' }));\n if (full) parts.push(full);\n if (initials && initials !== full) parts.push(initials);\n }\n if (parts.length === 0) return null;\n return [...new Set(parts)].join(' ');\n}\n"],"mappings":"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","names":["containsCJK","containsCJK"]}
1
+ {"version":3,"sources":["../src/pinyin-search-plugin.ts","../src/companion-projection.ts","../src/pinyin.ts"],"sourcesContent":["// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * PinyinSearchPlugin (#2486) — pinyin recall for `$search`.\n *\n * Pure hook plugin: the `__search` companion column is declared at object\n * compile time by the SchemaRegistry (gated on the SAME\n * `OS_SEARCH_PINYIN_ENABLED` decision point), the engine ORs it into the\n * `$search` filter, and this plugin fills the value:\n *\n * - before-save hooks: recompute full pinyin + initials of the\n * display/name field when it changes;\n * - boot backfill (`kernel:bootstrapped`): fill rows that predate the\n * switch or arrived via hook-bypassing writes;\n * - `rebuildSearchCompanion` (exported): explicit reconcile/rebuild entry.\n *\n * When the flag is off the plugin is inert: no hooks, no backfill, and\n * `pinyin-pro` is never imported.\n */\n\nimport type { Plugin, PluginContext } from '@objectstack/core';\nimport { resolveSearchPinyinEnabled } from '@objectstack/types';\nimport {\n bindSearchCompanionHooks,\n backfillSearchCompanion,\n} from './companion-projection.js';\n\nexport interface PinyinSearchPluginOptions {\n /**\n * Force-enable/disable regardless of `OS_SEARCH_PINYIN_ENABLED` (tests /\n * embedders). Default: `resolveSearchPinyinEnabled()`.\n */\n enabled?: boolean;\n /** Skip the boot backfill (default: run it once per boot). */\n backfill?: boolean;\n}\n\nexport class PinyinSearchPlugin implements Plugin {\n name = 'com.objectstack.plugin.pinyin-search';\n version = '1.0.0';\n type = 'standard';\n dependencies = ['com.objectstack.engine.objectql'];\n\n private readonly options: PinyinSearchPluginOptions;\n\n constructor(options: PinyinSearchPluginOptions = {}) {\n this.options = options;\n }\n\n private get enabled(): boolean {\n return this.options.enabled ?? resolveSearchPinyinEnabled();\n }\n\n async init(_ctx: PluginContext): Promise<void> {\n // Nothing to register: the companion column is provisioned by the\n // SchemaRegistry's compile-time seam, not injected at runtime.\n }\n\n async start(ctx: PluginContext): Promise<void> {\n if (!this.enabled) {\n ctx.logger.debug?.('PinyinSearchPlugin: OS_SEARCH_PINYIN_ENABLED is off — inert');\n return;\n }\n\n ctx.hook('kernel:ready', async () => {\n const engine = this.resolveEngine(ctx);\n if (!engine) {\n ctx.logger.warn('PinyinSearchPlugin: no ObjectQL engine — companion hooks NOT bound');\n return;\n }\n try {\n bindSearchCompanionHooks(engine, ctx.logger as any);\n } catch (err: any) {\n ctx.logger.warn('PinyinSearchPlugin: companion hooks not bound', { error: err?.message });\n }\n });\n\n // Backfill AFTER boot settles (`kernel:bootstrapped` fires once every\n // `kernel:ready` hook — including seed loading — has completed), so\n // seeded rows written before/around hook binding are reconciled too.\n if (this.options.backfill !== false) {\n ctx.hook('kernel:bootstrapped', async () => {\n const engine = this.resolveEngine(ctx);\n if (!engine) return;\n try {\n await backfillSearchCompanion(engine, ctx.logger as any);\n } catch (err: any) {\n ctx.logger.warn('PinyinSearchPlugin: companion backfill failed', { error: err?.message });\n }\n });\n }\n }\n\n private resolveEngine(ctx: PluginContext): any {\n try {\n return ctx.getService<any>('objectql');\n } catch {\n try {\n return ctx.getService<any>('data');\n } catch {\n return null;\n }\n }\n }\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * `__search` companion-column projection (#2486).\n *\n * The column itself is DECLARED at object compile time by the SchemaRegistry\n * (`provisionSearchCompanion`, gated on `OS_SEARCH_PINYIN_ENABLED`); this\n * module only FILLS the value — the `plugin-sharing` primary-BU projection\n * pattern (column on the object, plugin maintains it via hooks).\n *\n * Write path: global `beforeInsert`/`beforeUpdate` hooks stamp\n * `data.__search` whenever a companion source field (the object's\n * display/name field) is present in the write — i.e. only when the source\n * actually changed, avoiding write amplification. Writes that bypass hooks\n * (bulk import, direct migration) leave the companion empty; the boot\n * backfill and the `rebuildSearchCompanion` reconcile entry cover that.\n */\n\nimport {\n SEARCH_COMPANION_FIELD,\n resolveSearchCompanionSources,\n containsCJK,\n} from '@objectstack/objectql';\nimport { keysetWalk } from '@objectstack/types';\nimport { computeSearchCompanionValue } from './pinyin.js';\n\nconst SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;\n\nexport const PINYIN_SEARCH_HOOK_PACKAGE = 'plugin-pinyin-search:companion';\n\ninterface MinimalEngine {\n registerHook(\n event: string,\n handler: (ctx: any) => any | Promise<any>,\n options?: { object?: string | string[]; priority?: number; packageId?: string },\n ): void;\n unregisterHooksByPackage(packageId: string): number;\n find(object: string, query?: any, options?: any): Promise<any[]>;\n update(object: string, data: any, options?: any): Promise<any>;\n registry?: {\n getObject(name: string): any;\n getAllObjects?(packageId?: string): any[];\n };\n}\n\ninterface MinimalLogger {\n info?: (msg: any, ...rest: any[]) => void;\n warn?: (msg: any, ...rest: any[]) => void;\n debug?: (msg: any, ...rest: any[]) => void;\n}\n\n/**\n * Stamp `data.__search` on a before-save hook context when a companion source\n * field is part of the write. Recomputes from the NEW value; a non-CJK new\n * value clears the companion (null) so stale pinyin never recalls a renamed\n * record. Never throws — a normalization failure must not fail the write.\n */\nasync function stampCompanion(engine: MinimalEngine, ctx: any, logger?: MinimalLogger): Promise<void> {\n const object = ctx?.object;\n if (!object) return;\n const schema = engine.registry?.getObject?.(object);\n if (!schema?.fields?.[SEARCH_COMPANION_FIELD]) return;\n\n const data = ctx?.input?.data;\n if (!data || typeof data !== 'object' || Array.isArray(data)) return;\n\n const sources = resolveSearchCompanionSources(schema);\n if (sources.length === 0) return;\n const touched = sources.filter((s) => Object.prototype.hasOwnProperty.call(data, s));\n if (touched.length === 0) return; // source unchanged → no recompute (no write amplification)\n\n try {\n data[SEARCH_COMPANION_FIELD] = await computeSearchCompanionValue(sources.map((s) => data[s]));\n } catch (err: any) {\n logger?.warn?.('[pinyin-search] companion normalization failed — write proceeds without it', {\n object,\n error: err?.message,\n });\n }\n}\n\n/**\n * Bind the global before-save hooks that keep `__search` in step. Idempotent\n * (unbinds the package first). Hooks are global (no object filter) with a\n * cheap early-out: objects without a provisioned companion column return\n * immediately. They run for system-context writes too — the projection must\n * stay correct regardless of who writes (seeds, imports, admin UI).\n */\nexport function bindSearchCompanionHooks(engine: MinimalEngine, logger?: MinimalLogger): void {\n if (typeof engine.registerHook !== 'function') return;\n if (typeof engine.unregisterHooksByPackage === 'function') {\n engine.unregisterHooksByPackage(PINYIN_SEARCH_HOOK_PACKAGE);\n }\n const opts = { packageId: PINYIN_SEARCH_HOOK_PACKAGE, priority: 150 };\n const handler = (ctx: any) => stampCompanion(engine, ctx, logger);\n engine.registerHook('beforeInsert', handler, opts);\n engine.registerHook('beforeUpdate', handler, opts);\n logger?.info?.('[pinyin-search] companion hooks bound (beforeInsert/beforeUpdate, all objects)');\n}\n\nexport interface CompanionBackfillOptions {\n /** Rows fetched per page during the scan. Default 1000. */\n batchSize?: number;\n /** Restrict to one object (reconcile entry); default: every provisioned object. */\n object?: string;\n /**\n * Recompute EVERY row's companion, not just missing ones — the periodic\n * reconcile/rebuild mode. Default false (backfill: only rows whose\n * companion is empty but whose source has CJK content).\n */\n force?: boolean;\n}\n\nexport interface CompanionBackfillResult {\n objects: number;\n scanned: number;\n updated: number;\n}\n\n/**\n * Backfill / reconcile the companion column.\n *\n * Denormalized-on-write columns go stale when writes bypass hooks (bulk\n * import, direct migration) and are empty for rows that predate the switch\n * being enabled. This scans every object that carries the companion column\n * (paged, system context) and fills the gaps; with `force: true` it\n * recomputes unconditionally (the periodic reconcile / rebuild entry).\n * Idempotent; per-row failures are skipped so one bad row never aborts the\n * pass.\n */\nexport async function backfillSearchCompanion(\n engine: MinimalEngine,\n logger?: MinimalLogger,\n options?: CompanionBackfillOptions,\n): Promise<CompanionBackfillResult> {\n const batchSize = Math.max(1, options?.batchSize ?? 1000);\n const all = options?.object\n ? [engine.registry?.getObject?.(options.object)].filter(Boolean)\n : engine.registry?.getAllObjects?.() ?? [];\n\n const result: CompanionBackfillResult = { objects: 0, scanned: 0, updated: 0 };\n\n for (const schema of all) {\n if (!schema?.name || !schema?.fields?.[SEARCH_COMPANION_FIELD]) continue;\n const sources = resolveSearchCompanionSources(schema);\n if (sources.length === 0) continue;\n result.objects++;\n\n // Seek by `id` (#4363). This walk UPDATES the rows it reads, and an offset\n // counts into a set those writes are changing, so rows slide past the\n // cursor and never get their companion blob — a backfill that reports a\n // clean pass while leaving records unsearchable.\n const walk = keysetWalk<any>(\n (q) => engine.find(schema.name, {\n ...q,\n fields: ['id', ...sources, SEARCH_COMPANION_FIELD],\n context: SYSTEM_CTX,\n }),\n { pageSize: batchSize },\n );\n\n try {\n for await (const rows of walk.pages()) {\n result.scanned += rows.length;\n\n for (const row of rows) {\n if (row?.id == null) continue;\n const hasBlob = typeof row[SEARCH_COMPANION_FIELD] === 'string' && row[SEARCH_COMPANION_FIELD] !== '';\n const hasCjkSource = sources.some((s) => containsCJK(row[s]));\n // Backfill mode: touch only rows missing a blob they should have.\n // Force mode: recompute everything (also clears stale blobs).\n if (!options?.force && (hasBlob || !hasCjkSource)) continue;\n try {\n const value = await computeSearchCompanionValue(sources.map((s) => row[s]));\n if (!options?.force && value == null) continue;\n if (value === row[SEARCH_COMPANION_FIELD]) continue;\n await engine.update(\n schema.name,\n { id: row.id, [SEARCH_COMPANION_FIELD]: value },\n { context: SYSTEM_CTX },\n );\n result.updated++;\n } catch (err: any) {\n logger?.warn?.('[pinyin-search] backfill row skipped', {\n object: schema.name,\n id: row.id,\n error: err?.message,\n });\n }\n }\n }\n } catch (err: any) {\n logger?.warn?.('[pinyin-search] backfill scan failed', { object: schema.name, error: err?.message });\n continue;\n }\n }\n\n if (result.updated > 0) {\n logger?.info?.('[pinyin-search] companion backfill complete', result);\n }\n return result;\n}\n\n/**\n * Periodic reconcile / rebuild entry: recompute the companion for every row\n * (optionally one object). Alias for `backfillSearchCompanion` with\n * `force: true` — exposed under its own name so operators/jobs have an\n * explicit \"rebuild the pinyin index\" handle.\n */\nexport function rebuildSearchCompanion(\n engine: MinimalEngine,\n logger?: MinimalLogger,\n options?: Omit<CompanionBackfillOptions, 'force'>,\n): Promise<CompanionBackfillResult> {\n return backfillSearchCompanion(engine, logger, { ...options, force: true });\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * Pinyin normalization for the `__search` companion column (#2486).\n *\n * One normalized blob per record — full pinyin AND initials in the same\n * column — so a single `$contains` recalls every latin input shape:\n *\n * \"张伟\" → \"zhangwei zw\"\n * `zhang` / `wei` / `zhangwei` → substring of the full form\n * `zw` → substring of the initials form\n *\n * `pinyin-pro` is loaded lazily on first use: non-Chinese deployments (flag\n * off → hooks never bound) never import it and pay zero cost.\n *\n * Polyphones: pinyin-pro's default heuristics are accepted (issue #2486\n * \"待定\" — surname polyphone dictionaries are a P2 follow-up).\n */\n\nimport { containsCJK } from '@objectstack/objectql';\n\ntype PinyinFn = (text: string, options?: Record<string, unknown>) => string | string[];\n\nlet _pinyin: Promise<PinyinFn> | null = null;\n\n/** Lazy-load `pinyin-pro` (cached module-wide). */\nfunction loadPinyin(): Promise<PinyinFn> {\n _pinyin ??= import('pinyin-pro').then((m: any) => (m.pinyin ?? m.default?.pinyin) as PinyinFn);\n return _pinyin;\n}\n\n/** Lowercase and strip everything that is not a latin letter or digit. */\nfunction squash(syllables: string | string[]): string {\n const joined = Array.isArray(syllables) ? syllables.join('') : String(syllables ?? '');\n return joined.toLowerCase().replace(/[^a-z0-9]+/g, '');\n}\n\n/**\n * Compute the companion value for the given source-field values.\n *\n * Returns the normalized blob (`\"<full-pinyin> <initials>\"`, deduplicated)\n * when at least one value contains CJK characters, else `null` — a `null`\n * companion means \"nothing pinyin-searchable here\" and clears any stale blob\n * when a name is edited away from CJK. Non-CJK values need no companion:\n * their source column already matches latin input directly.\n */\nexport async function computeSearchCompanionValue(values: ReadonlyArray<unknown>): Promise<string | null> {\n const cjkValues = values.filter((v): v is string => containsCJK(v));\n if (cjkValues.length === 0) return null;\n\n const pinyin = await loadPinyin();\n const parts: string[] = [];\n for (const value of cjkValues) {\n // `nonZh: 'consecutive'` keeps latin/digit runs intact inside mixed\n // values (\"张伟2号\" → \"zhangwei2hao\"), so mixed names stay one token.\n const full = squash(pinyin(value, { toneType: 'none', type: 'array', nonZh: 'consecutive' }));\n const initials = squash(pinyin(value, { pattern: 'first', toneType: 'none', type: 'array', nonZh: 'consecutive' }));\n if (full) parts.push(full);\n if (initials && initials !== full) parts.push(initials);\n }\n if (parts.length === 0) return null;\n return [...new Set(parts)].join(' ');\n}\n"],"mappings":"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","names":["containsCJK","containsCJK"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectstack/plugin-pinyin-search",
3
- "version": "17.0.0-rc.0",
3
+ "version": "17.0.0-rc.2",
4
4
  "license": "Apache-2.0",
5
5
  "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).",
6
6
  "main": "dist/index.js",
@@ -14,12 +14,12 @@
14
14
  },
15
15
  "dependencies": {
16
16
  "pinyin-pro": "^3.28.2",
17
- "@objectstack/core": "17.0.0-rc.0",
18
- "@objectstack/objectql": "17.0.0-rc.0",
19
- "@objectstack/types": "17.0.0-rc.0"
17
+ "@objectstack/core": "17.0.0-rc.2",
18
+ "@objectstack/objectql": "17.0.0-rc.2",
19
+ "@objectstack/types": "17.0.0-rc.2"
20
20
  },
21
21
  "devDependencies": {
22
- "@types/node": "^26.1.1",
22
+ "@types/node": "^26.1.2",
23
23
  "typescript": "^6.0.3",
24
24
  "vitest": "^4.1.10"
25
25
  },
@@ -30,8 +30,14 @@
30
30
  "pinyin",
31
31
  "i18n"
32
32
  ],
33
+ "files": [
34
+ "dist",
35
+ "README.md",
36
+ "CHANGELOG.md"
37
+ ],
33
38
  "scripts": {
34
39
  "build": "tsup --config ../../../tsup.config.ts",
35
- "test": "vitest run --passWithNoTests"
40
+ "test": "vitest run --passWithNoTests",
41
+ "typecheck": "tsc --noEmit"
36
42
  }
37
43
  }
@@ -1,22 +0,0 @@
1
-
2
- > @objectstack/plugin-pinyin-search@17.0.0-rc.0 build /home/runner/work/objectstack/objectstack/packages/plugins/plugin-pinyin-search
3
- > tsup --config ../../../tsup.config.ts
4
-
5
- CLI Building entry: src/index.ts
6
- CLI Using tsconfig: tsconfig.json
7
- CLI tsup v8.5.1
8
- CLI Using tsup config: /home/runner/work/objectstack/objectstack/tsup.config.ts
9
- CLI Target: es2020
10
- CLI Cleaning output folder
11
- ESM Build start
12
- CJS Build start
13
- CJS dist/index.js 8.92 KB
14
- CJS dist/index.js.map 19.68 KB
15
- CJS ⚡️ Build success in 54ms
16
- ESM dist/index.mjs 6.88 KB
17
- ESM dist/index.mjs.map 19.09 KB
18
- ESM ⚡️ Build success in 57ms
19
- DTS Build start
20
- DTS ⚡️ Build success in 8808ms
21
- DTS dist/index.d.mts 5.12 KB
22
- DTS dist/index.d.ts 5.12 KB
@@ -1,168 +0,0 @@
1
- // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- /**
4
- * #2486 — companion projection: before-save stamping, no-write-amplification
5
- * guard, backfill and rebuild reconcile. Uses a minimal fake engine (the
6
- * boot-backfill.test.ts pattern) with a stub registry.
7
- */
8
-
9
- import { describe, it, expect, beforeEach } from 'vitest';
10
- import {
11
- bindSearchCompanionHooks,
12
- backfillSearchCompanion,
13
- rebuildSearchCompanion,
14
- PINYIN_SEARCH_HOOK_PACKAGE,
15
- } from './companion-projection.js';
16
-
17
- interface Row { [k: string]: any }
18
-
19
- const contactSchema = {
20
- name: 'crm_contact',
21
- nameField: 'name',
22
- fields: {
23
- name: { type: 'text' },
24
- email: { type: 'email' },
25
- __search: { type: 'text', hidden: true, readonly: true, system: true },
26
- },
27
- };
28
-
29
- const plainSchema = {
30
- name: 'crm_note',
31
- fields: { title: { type: 'text' } }, // no companion provisioned
32
- };
33
-
34
- function makeEngine(schemas: any[]) {
35
- const tables: Record<string, Row[]> = {};
36
- const hooks: Record<string, Array<{ handler: (ctx: any) => any; opts: any }>> = {};
37
- const byName = new Map(schemas.map((s) => [s.name, s]));
38
- const engine = {
39
- _tables: tables,
40
- registry: {
41
- getObject: (n: string) => byName.get(n),
42
- getAllObjects: () => [...byName.values()],
43
- },
44
- registerHook(event: string, handler: any, opts?: any) {
45
- (hooks[event] ??= []).push({ handler, opts });
46
- },
47
- unregisterHooksByPackage(packageId: string) {
48
- let removed = 0;
49
- for (const [event, entries] of Object.entries(hooks)) {
50
- const kept = entries.filter((e) => e.opts?.packageId !== packageId);
51
- removed += entries.length - kept.length;
52
- hooks[event] = kept;
53
- }
54
- return removed;
55
- },
56
- async trigger(event: string, ctx: any) {
57
- for (const { handler } of hooks[event] ?? []) await handler(ctx);
58
- },
59
- async find(o: string, opts?: any) {
60
- const rows = tables[o] ?? [];
61
- const offset = opts?.offset ?? 0;
62
- return rows.slice(offset, offset + (opts?.limit ?? rows.length));
63
- },
64
- async insert(o: string, data: Row) {
65
- const ctx = { object: o, event: 'beforeInsert', input: { data } };
66
- await engine.trigger('beforeInsert', ctx);
67
- (tables[o] ??= []).push({ ...ctx.input.data });
68
- return ctx.input.data;
69
- },
70
- async update(o: string, data: Row) {
71
- const ctx = { object: o, event: 'beforeUpdate', input: { id: data.id, data } };
72
- await engine.trigger('beforeUpdate', ctx);
73
- const t = tables[o] ?? [];
74
- const i = t.findIndex((r) => r.id === data.id);
75
- if (i >= 0) t[i] = { ...t[i], ...ctx.input.data };
76
- return t[i];
77
- },
78
- _hooks: hooks,
79
- };
80
- return engine;
81
- }
82
-
83
- describe('bindSearchCompanionHooks', () => {
84
- let engine: ReturnType<typeof makeEngine>;
85
-
86
- beforeEach(() => {
87
- engine = makeEngine([contactSchema, plainSchema]);
88
- bindSearchCompanionHooks(engine as any);
89
- });
90
-
91
- it('binds beforeInsert + beforeUpdate globally and is idempotent (rebind-safe)', () => {
92
- bindSearchCompanionHooks(engine as any); // rebind
93
- expect(engine._hooks.beforeInsert).toHaveLength(1);
94
- expect(engine._hooks.beforeUpdate).toHaveLength(1);
95
- expect(engine._hooks.beforeInsert[0].opts.packageId).toBe(PINYIN_SEARCH_HOOK_PACKAGE);
96
- expect(engine._hooks.beforeInsert[0].opts.object).toBeUndefined(); // global
97
- });
98
-
99
- it('stamps __search on insert when the name field carries CJK', async () => {
100
- const row = await engine.insert('crm_contact', { id: 'c1', name: '张伟' });
101
- expect(row.__search).toBe('zhangwei zw');
102
- });
103
-
104
- it('leaves __search null for latin names (source column already matches)', async () => {
105
- const row = await engine.insert('crm_contact', { id: 'c2', name: 'Ada Lovelace' });
106
- expect(row.__search).toBe(null);
107
- });
108
-
109
- it('recomputes on update ONLY when the source field is in the patch', async () => {
110
- await engine.insert('crm_contact', { id: 'c3', name: '张伟' });
111
- // email-only patch: no recompute, no companion key added
112
- const patch: Row = { id: 'c3', email: 'zw@example.com' };
113
- await engine.update('crm_contact', patch);
114
- expect('__search' in patch).toBe(false);
115
- // name patch: recompute
116
- const updated = await engine.update('crm_contact', { id: 'c3', name: '王芳' });
117
- expect(updated.__search).toBe('wangfang wf');
118
- });
119
-
120
- it('clears the companion when a CJK name is renamed to latin (no stale recall)', async () => {
121
- await engine.insert('crm_contact', { id: 'c4', name: '张伟' });
122
- const updated = await engine.update('crm_contact', { id: 'c4', name: 'Victor Zhang' });
123
- expect(updated.__search).toBe(null);
124
- });
125
-
126
- it('ignores objects without a provisioned companion column', async () => {
127
- const row = await engine.insert('crm_note', { id: 'n1', title: '会议纪要' });
128
- expect('__search' in row).toBe(false);
129
- });
130
- });
131
-
132
- describe('backfillSearchCompanion / rebuildSearchCompanion', () => {
133
- it('fills rows missing a blob, in pages, and skips rows that need none', async () => {
134
- const engine = makeEngine([contactSchema]);
135
- engine._tables.crm_contact = [
136
- { id: 'a', name: '张伟', __search: null }, // hook-bypassing write → fill
137
- { id: 'b', name: 'Ada', __search: null }, // latin → skip
138
- { id: 'c', name: '王芳', __search: 'wangfang wf' }, // already filled → skip
139
- { id: 'd', name: '李雷', __search: null }, // fill (second page)
140
- ];
141
- const result = await backfillSearchCompanion(engine as any, undefined, { batchSize: 2 });
142
- expect(result).toEqual({ objects: 1, scanned: 4, updated: 2 });
143
- const rows = engine._tables.crm_contact;
144
- expect(rows.find((r) => r.id === 'a')!.__search).toBe('zhangwei zw');
145
- expect(rows.find((r) => r.id === 'b')!.__search).toBe(null);
146
- expect(rows.find((r) => r.id === 'd')!.__search).toBe('lilei ll');
147
- });
148
-
149
- it('is idempotent — a second pass updates nothing', async () => {
150
- const engine = makeEngine([contactSchema]);
151
- engine._tables.crm_contact = [{ id: 'a', name: '张伟', __search: null }];
152
- await backfillSearchCompanion(engine as any);
153
- const second = await backfillSearchCompanion(engine as any);
154
- expect(second.updated).toBe(0);
155
- });
156
-
157
- it('rebuild recomputes everything, clearing stale blobs (reconcile entry)', async () => {
158
- const engine = makeEngine([contactSchema]);
159
- engine._tables.crm_contact = [
160
- { id: 'a', name: 'Renamed To Latin', __search: 'zhangwei zw' }, // stale → cleared
161
- { id: 'b', name: '王芳', __search: 'wrongblob' }, // wrong → recomputed
162
- ];
163
- const result = await rebuildSearchCompanion(engine as any);
164
- expect(result.updated).toBe(2);
165
- expect(engine._tables.crm_contact[0].__search).toBe(null);
166
- expect(engine._tables.crm_contact[1].__search).toBe('wangfang wf');
167
- });
168
- });
@@ -1,214 +0,0 @@
1
- // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- /**
4
- * `__search` companion-column projection (#2486).
5
- *
6
- * The column itself is DECLARED at object compile time by the SchemaRegistry
7
- * (`provisionSearchCompanion`, gated on `OS_SEARCH_PINYIN_ENABLED`); this
8
- * module only FILLS the value — the `plugin-sharing` primary-BU projection
9
- * pattern (column on the object, plugin maintains it via hooks).
10
- *
11
- * Write path: global `beforeInsert`/`beforeUpdate` hooks stamp
12
- * `data.__search` whenever a companion source field (the object's
13
- * display/name field) is present in the write — i.e. only when the source
14
- * actually changed, avoiding write amplification. Writes that bypass hooks
15
- * (bulk import, direct migration) leave the companion empty; the boot
16
- * backfill and the `rebuildSearchCompanion` reconcile entry cover that.
17
- */
18
-
19
- import {
20
- SEARCH_COMPANION_FIELD,
21
- resolveSearchCompanionSources,
22
- containsCJK,
23
- } from '@objectstack/objectql';
24
- import { computeSearchCompanionValue } from './pinyin.js';
25
-
26
- const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;
27
-
28
- export const PINYIN_SEARCH_HOOK_PACKAGE = 'plugin-pinyin-search:companion';
29
-
30
- interface MinimalEngine {
31
- registerHook(
32
- event: string,
33
- handler: (ctx: any) => any | Promise<any>,
34
- options?: { object?: string | string[]; priority?: number; packageId?: string },
35
- ): void;
36
- unregisterHooksByPackage(packageId: string): number;
37
- find(object: string, query?: any, options?: any): Promise<any[]>;
38
- update(object: string, data: any, options?: any): Promise<any>;
39
- registry?: {
40
- getObject(name: string): any;
41
- getAllObjects?(packageId?: string): any[];
42
- };
43
- }
44
-
45
- interface MinimalLogger {
46
- info?: (msg: any, ...rest: any[]) => void;
47
- warn?: (msg: any, ...rest: any[]) => void;
48
- debug?: (msg: any, ...rest: any[]) => void;
49
- }
50
-
51
- /**
52
- * Stamp `data.__search` on a before-save hook context when a companion source
53
- * field is part of the write. Recomputes from the NEW value; a non-CJK new
54
- * value clears the companion (null) so stale pinyin never recalls a renamed
55
- * record. Never throws — a normalization failure must not fail the write.
56
- */
57
- async function stampCompanion(engine: MinimalEngine, ctx: any, logger?: MinimalLogger): Promise<void> {
58
- const object = ctx?.object;
59
- if (!object) return;
60
- const schema = engine.registry?.getObject?.(object);
61
- if (!schema?.fields?.[SEARCH_COMPANION_FIELD]) return;
62
-
63
- const data = ctx?.input?.data;
64
- if (!data || typeof data !== 'object' || Array.isArray(data)) return;
65
-
66
- const sources = resolveSearchCompanionSources(schema);
67
- if (sources.length === 0) return;
68
- const touched = sources.filter((s) => Object.prototype.hasOwnProperty.call(data, s));
69
- if (touched.length === 0) return; // source unchanged → no recompute (no write amplification)
70
-
71
- try {
72
- data[SEARCH_COMPANION_FIELD] = await computeSearchCompanionValue(sources.map((s) => data[s]));
73
- } catch (err: any) {
74
- logger?.warn?.('[pinyin-search] companion normalization failed — write proceeds without it', {
75
- object,
76
- error: err?.message,
77
- });
78
- }
79
- }
80
-
81
- /**
82
- * Bind the global before-save hooks that keep `__search` in step. Idempotent
83
- * (unbinds the package first). Hooks are global (no object filter) with a
84
- * cheap early-out: objects without a provisioned companion column return
85
- * immediately. They run for system-context writes too — the projection must
86
- * stay correct regardless of who writes (seeds, imports, admin UI).
87
- */
88
- export function bindSearchCompanionHooks(engine: MinimalEngine, logger?: MinimalLogger): void {
89
- if (typeof engine.registerHook !== 'function') return;
90
- if (typeof engine.unregisterHooksByPackage === 'function') {
91
- engine.unregisterHooksByPackage(PINYIN_SEARCH_HOOK_PACKAGE);
92
- }
93
- const opts = { packageId: PINYIN_SEARCH_HOOK_PACKAGE, priority: 150 };
94
- const handler = (ctx: any) => stampCompanion(engine, ctx, logger);
95
- engine.registerHook('beforeInsert', handler, opts);
96
- engine.registerHook('beforeUpdate', handler, opts);
97
- logger?.info?.('[pinyin-search] companion hooks bound (beforeInsert/beforeUpdate, all objects)');
98
- }
99
-
100
- export interface CompanionBackfillOptions {
101
- /** Rows fetched per page during the scan. Default 1000. */
102
- batchSize?: number;
103
- /** Restrict to one object (reconcile entry); default: every provisioned object. */
104
- object?: string;
105
- /**
106
- * Recompute EVERY row's companion, not just missing ones — the periodic
107
- * reconcile/rebuild mode. Default false (backfill: only rows whose
108
- * companion is empty but whose source has CJK content).
109
- */
110
- force?: boolean;
111
- }
112
-
113
- export interface CompanionBackfillResult {
114
- objects: number;
115
- scanned: number;
116
- updated: number;
117
- }
118
-
119
- /**
120
- * Backfill / reconcile the companion column.
121
- *
122
- * Denormalized-on-write columns go stale when writes bypass hooks (bulk
123
- * import, direct migration) and are empty for rows that predate the switch
124
- * being enabled. This scans every object that carries the companion column
125
- * (paged, system context) and fills the gaps; with `force: true` it
126
- * recomputes unconditionally (the periodic reconcile / rebuild entry).
127
- * Idempotent; per-row failures are skipped so one bad row never aborts the
128
- * pass.
129
- */
130
- export async function backfillSearchCompanion(
131
- engine: MinimalEngine,
132
- logger?: MinimalLogger,
133
- options?: CompanionBackfillOptions,
134
- ): Promise<CompanionBackfillResult> {
135
- const batchSize = Math.max(1, options?.batchSize ?? 1000);
136
- const all = options?.object
137
- ? [engine.registry?.getObject?.(options.object)].filter(Boolean)
138
- : engine.registry?.getAllObjects?.() ?? [];
139
-
140
- const result: CompanionBackfillResult = { objects: 0, scanned: 0, updated: 0 };
141
-
142
- for (const schema of all) {
143
- if (!schema?.name || !schema?.fields?.[SEARCH_COMPANION_FIELD]) continue;
144
- const sources = resolveSearchCompanionSources(schema);
145
- if (sources.length === 0) continue;
146
- result.objects++;
147
-
148
- let offset = 0;
149
- for (;;) {
150
- let rows: any[] = [];
151
- try {
152
- rows = await engine.find(schema.name, {
153
- fields: ['id', ...sources, SEARCH_COMPANION_FIELD],
154
- limit: batchSize,
155
- offset,
156
- context: SYSTEM_CTX,
157
- });
158
- } catch (err: any) {
159
- logger?.warn?.('[pinyin-search] backfill scan failed', { object: schema.name, error: err?.message });
160
- break;
161
- }
162
- if (!rows?.length) break;
163
- result.scanned += rows.length;
164
-
165
- for (const row of rows) {
166
- if (row?.id == null) continue;
167
- const hasBlob = typeof row[SEARCH_COMPANION_FIELD] === 'string' && row[SEARCH_COMPANION_FIELD] !== '';
168
- const hasCjkSource = sources.some((s) => containsCJK(row[s]));
169
- // Backfill mode: touch only rows missing a blob they should have.
170
- // Force mode: recompute everything (also clears stale blobs).
171
- if (!options?.force && (hasBlob || !hasCjkSource)) continue;
172
- try {
173
- const value = await computeSearchCompanionValue(sources.map((s) => row[s]));
174
- if (!options?.force && value == null) continue;
175
- if (value === row[SEARCH_COMPANION_FIELD]) continue;
176
- await engine.update(
177
- schema.name,
178
- { id: row.id, [SEARCH_COMPANION_FIELD]: value },
179
- { context: SYSTEM_CTX },
180
- );
181
- result.updated++;
182
- } catch (err: any) {
183
- logger?.warn?.('[pinyin-search] backfill row skipped', {
184
- object: schema.name,
185
- id: row.id,
186
- error: err?.message,
187
- });
188
- }
189
- }
190
-
191
- if (rows.length < batchSize) break;
192
- offset += batchSize;
193
- }
194
- }
195
-
196
- if (result.updated > 0) {
197
- logger?.info?.('[pinyin-search] companion backfill complete', result);
198
- }
199
- return result;
200
- }
201
-
202
- /**
203
- * Periodic reconcile / rebuild entry: recompute the companion for every row
204
- * (optionally one object). Alias for `backfillSearchCompanion` with
205
- * `force: true` — exposed under its own name so operators/jobs have an
206
- * explicit "rebuild the pinyin index" handle.
207
- */
208
- export function rebuildSearchCompanion(
209
- engine: MinimalEngine,
210
- logger?: MinimalLogger,
211
- options?: Omit<CompanionBackfillOptions, 'force'>,
212
- ): Promise<CompanionBackfillResult> {
213
- return backfillSearchCompanion(engine, logger, { ...options, force: true });
214
- }
package/src/index.ts DELETED
@@ -1,15 +0,0 @@
1
- // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- export { PinyinSearchPlugin } from './pinyin-search-plugin.js';
4
- export type { PinyinSearchPluginOptions } from './pinyin-search-plugin.js';
5
- export {
6
- bindSearchCompanionHooks,
7
- backfillSearchCompanion,
8
- rebuildSearchCompanion,
9
- PINYIN_SEARCH_HOOK_PACKAGE,
10
- } from './companion-projection.js';
11
- export type {
12
- CompanionBackfillOptions,
13
- CompanionBackfillResult,
14
- } from './companion-projection.js';
15
- export { computeSearchCompanionValue } from './pinyin.js';
@@ -1,105 +0,0 @@
1
- // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- /**
4
- * PinyinSearchPlugin (#2486) — pinyin recall for `$search`.
5
- *
6
- * Pure hook plugin: the `__search` companion column is declared at object
7
- * compile time by the SchemaRegistry (gated on the SAME
8
- * `OS_SEARCH_PINYIN_ENABLED` decision point), the engine ORs it into the
9
- * `$search` filter, and this plugin fills the value:
10
- *
11
- * - before-save hooks: recompute full pinyin + initials of the
12
- * display/name field when it changes;
13
- * - boot backfill (`kernel:bootstrapped`): fill rows that predate the
14
- * switch or arrived via hook-bypassing writes;
15
- * - `rebuildSearchCompanion` (exported): explicit reconcile/rebuild entry.
16
- *
17
- * When the flag is off the plugin is inert: no hooks, no backfill, and
18
- * `pinyin-pro` is never imported.
19
- */
20
-
21
- import type { Plugin, PluginContext } from '@objectstack/core';
22
- import { resolveSearchPinyinEnabled } from '@objectstack/types';
23
- import {
24
- bindSearchCompanionHooks,
25
- backfillSearchCompanion,
26
- } from './companion-projection.js';
27
-
28
- export interface PinyinSearchPluginOptions {
29
- /**
30
- * Force-enable/disable regardless of `OS_SEARCH_PINYIN_ENABLED` (tests /
31
- * embedders). Default: `resolveSearchPinyinEnabled()`.
32
- */
33
- enabled?: boolean;
34
- /** Skip the boot backfill (default: run it once per boot). */
35
- backfill?: boolean;
36
- }
37
-
38
- export class PinyinSearchPlugin implements Plugin {
39
- name = 'com.objectstack.plugin.pinyin-search';
40
- version = '1.0.0';
41
- type = 'standard';
42
- dependencies = ['com.objectstack.engine.objectql'];
43
-
44
- private readonly options: PinyinSearchPluginOptions;
45
-
46
- constructor(options: PinyinSearchPluginOptions = {}) {
47
- this.options = options;
48
- }
49
-
50
- private get enabled(): boolean {
51
- return this.options.enabled ?? resolveSearchPinyinEnabled();
52
- }
53
-
54
- async init(_ctx: PluginContext): Promise<void> {
55
- // Nothing to register: the companion column is provisioned by the
56
- // SchemaRegistry's compile-time seam, not injected at runtime.
57
- }
58
-
59
- async start(ctx: PluginContext): Promise<void> {
60
- if (!this.enabled) {
61
- ctx.logger.debug?.('PinyinSearchPlugin: OS_SEARCH_PINYIN_ENABLED is off — inert');
62
- return;
63
- }
64
-
65
- ctx.hook('kernel:ready', async () => {
66
- const engine = this.resolveEngine(ctx);
67
- if (!engine) {
68
- ctx.logger.warn('PinyinSearchPlugin: no ObjectQL engine — companion hooks NOT bound');
69
- return;
70
- }
71
- try {
72
- bindSearchCompanionHooks(engine, ctx.logger as any);
73
- } catch (err: any) {
74
- ctx.logger.warn('PinyinSearchPlugin: companion hooks not bound', { error: err?.message });
75
- }
76
- });
77
-
78
- // Backfill AFTER boot settles (`kernel:bootstrapped` fires once every
79
- // `kernel:ready` hook — including seed loading — has completed), so
80
- // seeded rows written before/around hook binding are reconciled too.
81
- if (this.options.backfill !== false) {
82
- ctx.hook('kernel:bootstrapped', async () => {
83
- const engine = this.resolveEngine(ctx);
84
- if (!engine) return;
85
- try {
86
- await backfillSearchCompanion(engine, ctx.logger as any);
87
- } catch (err: any) {
88
- ctx.logger.warn('PinyinSearchPlugin: companion backfill failed', { error: err?.message });
89
- }
90
- });
91
- }
92
- }
93
-
94
- private resolveEngine(ctx: PluginContext): any {
95
- try {
96
- return ctx.getService<any>('objectql');
97
- } catch {
98
- try {
99
- return ctx.getService<any>('data');
100
- } catch {
101
- return null;
102
- }
103
- }
104
- }
105
- }
@@ -1,39 +0,0 @@
1
- // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import { describe, it, expect } from 'vitest';
4
- import { computeSearchCompanionValue } from './pinyin.js';
5
-
6
- describe('computeSearchCompanionValue (#2486)', () => {
7
- it('stores full pinyin + initials in one blob ("张伟" → "zhangwei zw")', async () => {
8
- expect(await computeSearchCompanionValue(['张伟'])).toBe('zhangwei zw');
9
- });
10
-
11
- it('recalls every documented input shape as a substring of the blob', async () => {
12
- const blob = (await computeSearchCompanionValue(['张伟']))!;
13
- for (const typed of ['zhang', 'wei', 'zhangwei', 'zw']) {
14
- expect(blob.includes(typed)).toBe(true);
15
- }
16
- });
17
-
18
- it('handles multi-word and mixed CJK/latin values', async () => {
19
- const blob = (await computeSearchCompanionValue(['上海分公司']))!;
20
- expect(blob).toBe('shanghaifengongsi shfgs');
21
-
22
- const mixed = (await computeSearchCompanionValue(['张伟2号']))!;
23
- expect(mixed.includes('zhangwei2hao')).toBe(true);
24
- });
25
-
26
- it('returns null for non-CJK / empty / non-string values (companion cleared)', async () => {
27
- expect(await computeSearchCompanionValue(['Zhang Wei'])).toBe(null);
28
- expect(await computeSearchCompanionValue([''])).toBe(null);
29
- expect(await computeSearchCompanionValue([null, undefined, 42])).toBe(null);
30
- expect(await computeSearchCompanionValue([])).toBe(null);
31
- });
32
-
33
- it('deduplicates when initials equal the full form (single-char name)', async () => {
34
- const blob = (await computeSearchCompanionValue(['张']))!;
35
- expect(blob).toBe('zhang z');
36
- // no duplicated tokens
37
- expect(new Set(blob.split(' ')).size).toBe(blob.split(' ').length);
38
- });
39
- });
package/src/pinyin.ts DELETED
@@ -1,63 +0,0 @@
1
- // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- /**
4
- * Pinyin normalization for the `__search` companion column (#2486).
5
- *
6
- * One normalized blob per record — full pinyin AND initials in the same
7
- * column — so a single `$contains` recalls every latin input shape:
8
- *
9
- * "张伟" → "zhangwei zw"
10
- * `zhang` / `wei` / `zhangwei` → substring of the full form
11
- * `zw` → substring of the initials form
12
- *
13
- * `pinyin-pro` is loaded lazily on first use: non-Chinese deployments (flag
14
- * off → hooks never bound) never import it and pay zero cost.
15
- *
16
- * Polyphones: pinyin-pro's default heuristics are accepted (issue #2486
17
- * "待定" — surname polyphone dictionaries are a P2 follow-up).
18
- */
19
-
20
- import { containsCJK } from '@objectstack/objectql';
21
-
22
- type PinyinFn = (text: string, options?: Record<string, unknown>) => string | string[];
23
-
24
- let _pinyin: Promise<PinyinFn> | null = null;
25
-
26
- /** Lazy-load `pinyin-pro` (cached module-wide). */
27
- function loadPinyin(): Promise<PinyinFn> {
28
- _pinyin ??= import('pinyin-pro').then((m: any) => (m.pinyin ?? m.default?.pinyin) as PinyinFn);
29
- return _pinyin;
30
- }
31
-
32
- /** Lowercase and strip everything that is not a latin letter or digit. */
33
- function squash(syllables: string | string[]): string {
34
- const joined = Array.isArray(syllables) ? syllables.join('') : String(syllables ?? '');
35
- return joined.toLowerCase().replace(/[^a-z0-9]+/g, '');
36
- }
37
-
38
- /**
39
- * Compute the companion value for the given source-field values.
40
- *
41
- * Returns the normalized blob (`"<full-pinyin> <initials>"`, deduplicated)
42
- * when at least one value contains CJK characters, else `null` — a `null`
43
- * companion means "nothing pinyin-searchable here" and clears any stale blob
44
- * when a name is edited away from CJK. Non-CJK values need no companion:
45
- * their source column already matches latin input directly.
46
- */
47
- export async function computeSearchCompanionValue(values: ReadonlyArray<unknown>): Promise<string | null> {
48
- const cjkValues = values.filter((v): v is string => containsCJK(v));
49
- if (cjkValues.length === 0) return null;
50
-
51
- const pinyin = await loadPinyin();
52
- const parts: string[] = [];
53
- for (const value of cjkValues) {
54
- // `nonZh: 'consecutive'` keeps latin/digit runs intact inside mixed
55
- // values ("张伟2号" → "zhangwei2hao"), so mixed names stay one token.
56
- const full = squash(pinyin(value, { toneType: 'none', type: 'array', nonZh: 'consecutive' }));
57
- const initials = squash(pinyin(value, { pattern: 'first', toneType: 'none', type: 'array', nonZh: 'consecutive' }));
58
- if (full) parts.push(full);
59
- if (initials && initials !== full) parts.push(initials);
60
- }
61
- if (parts.length === 0) return null;
62
- return [...new Set(parts)].join(' ');
63
- }
package/tsconfig.json DELETED
@@ -1,10 +0,0 @@
1
- {
2
- "extends": "../../../tsconfig.json",
3
- "compilerOptions": {
4
- "outDir": "./dist",
5
- "rootDir": "./src",
6
- "types": ["node"]
7
- },
8
- "include": ["src/**/*"],
9
- "exclude": ["dist", "node_modules", "**/*.test.ts"]
10
- }