Files
clawhub/convex/skillSearchDigestFirstTokens.runtime.test.ts
Yiğit ERDOĞAN cd09e33877 fix: Japanese searches skip the category and summary result tiers (#3363)
* fix: Japanese searches skip the category and summary result tiers

The pre-split in tokenize() treats U+30FC (ー) and U+3005 (々) as separators, so
a katakana word is torn into fragments before Intl.Segmenter can segment it:
"データベース" tokenizes as ["デ", "タベ", "ス"]. Two consequences follow.

Exploratory search requires every query token to be at least three characters
(EXPLORATORY_SEARCH_MIN_TOKEN_LENGTH in search.ts, skills.ts and packages.ts),
so katakana queries never reach the category, topic and summary tiers. And
getFirstSearchToken feeds normalizedDisplayNameFirstToken, an indexed range-scan
bound, which collapses to the single character "デ".

detectCJKLanguage in the same file already counts ー as katakana when it picks a
segmenter; the pre-split now agrees with it.

* fix: resynchronize digest first tokens and keep marks in the fallback

Widening the CJK class moves the first token of any name containing a
prolonged sound mark or an iteration mark. skillSearchDigest rows recompute
that field only when their skill is written, so already-stored rows keep the
old one-character token while search uses the new longer token as a range
index bound - the row stays on disk and out of recall.

Add a cursor-paginated resynchronization next to the existing digest backfills
in maintenance.ts, and stop the no-Segmenter fallback from emitting those two
marks as standalone tokens that exploratory matching discards.

* fix: space the search digest backfill batches apart

The catalog search page subscribes to skillSearchDigest, so a backfill that
reschedules itself with no delay drives reactive re-reads back to back for the
whole run. .agents/skills/clawhub-convex/SKILL.md asks for a delay between
backfill batches that write reactively subscribed tables.

The delay is an optional argument clamped the same way the batch size is, and it
follows repairLegacyPublisherOwnershipForUserHandler, which is the one backfill
in this file that already spaces its batches.

* fix: reindex the mirrored catalog's first tokens too

The skills.sh mirror persists its own normalizedSlugFirstToken and
normalizedDisplayNameFirstToken, derived through the same tokenizer, and external
candidate search range-scans both. Widening the katakana class therefore strands
mirrored rows exactly the way it stranded native digest rows, and the previous
backfill only paged skillSearchDigest.

skillsShMirror.ts had its own copy of the first-token rule. Both callers now share
getMirrorFirstSearchToken so the two cannot drift apart again.

* fix: require confirmation before the first-token backfills write

Both backfills defaulted dryRun to false, and their public admin actions
forward omitted arguments straight through. A bare
`npx convex run maintenance:backfillSkillSearchDigestFirstTokens` therefore
patched skillSearchDigest and scheduled every remaining page, against a table
catalog search subscribes to. An operator typo was an immediate production
apply rather than a preview.

Both now follow the contract the plugin catalog-digest resync already uses:
preview unless dryRun is explicitly false, reject an apply whose confirm token
does not match, and carry that token into the scheduled continuation so the
run does not stall on its own guard after the first page. The native and
mirror paths take separate tokens, so neither unlocks the other.
2026-08-05 16:30:28 -07:00

154 lines
5.5 KiB
TypeScript

/// <reference types="vite/client" />
/* @vitest-environment edge-runtime */
import { convexTest } from "convex-test";
import { describe, expect, it } from "vitest";
import { internal } from "./_generated/api";
import { getFirstSearchToken } from "./lib/skillSearchDigest";
import schema from "./schema";
const modules = import.meta.glob("./**/*.ts");
// What a row written before ー joined the CJK class holds for this display name.
const STALE_FIRST_TOKEN = "デ";
const DISPLAY_NAME = "データベース管理";
const SLUG = "database-kanri";
// The backfill previews unless an apply is confirmed, so the runtime cases that expect
// writes have to opt in the same way an operator does.
const APPLY = {
dryRun: false,
confirm: "backfill-skill-search-digest-first-tokens",
} as const;
async function insertDigestWithStaleFirstToken(t: ReturnType<typeof convexTest>) {
return await t.run(async (ctx) => {
const now = Date.now();
const userId = await ctx.db.insert("users", {
handle: "patrick",
displayName: "Patrick",
createdAt: now,
updatedAt: now,
});
const skillId = await ctx.db.insert("skills", {
slug: SLUG,
displayName: DISPLAY_NAME,
ownerUserId: userId,
tags: {},
stats: { downloads: 0, stars: 0, versions: 1, comments: 0 },
createdAt: now,
updatedAt: now,
});
const versionId = await ctx.db.insert("skillVersions", {
skillId,
version: "1.0.0",
changelog: "Initial",
files: [],
parsed: { frontmatter: {} },
createdBy: userId,
createdAt: now,
});
const digestId = await ctx.db.insert("skillSearchDigest", {
skillId,
slug: SLUG,
displayName: DISPLAY_NAME,
normalizedSlug: SLUG,
normalizedSlugFirstToken: "database",
normalizedDisplayName: DISPLAY_NAME,
normalizedDisplayNameFirstToken: STALE_FIRST_TOKEN,
ownerUserId: userId,
ownerHandle: "patrick",
ownerKind: "user",
ownerName: "patrick",
ownerDisplayName: "Patrick",
latestVersionId: versionId,
latestVersionSkillId: skillId,
publicVersion: { status: "available", versionId },
tags: {},
stats: { downloads: 0, stars: 0, versions: 1, comments: 0 },
createdAt: now,
updatedAt: now,
});
return { digestId, skillId };
});
}
describe("skillSearchDigest first-token resynchronization", () => {
it("repairs a pre-existing row the current tokenizer no longer agrees with", async () => {
const t = convexTest(schema, modules);
const { digestId } = await insertDigestWithStaleFirstToken(t);
const expected = getFirstSearchToken(DISPLAY_NAME);
expect(expected).not.toBe(STALE_FIRST_TOKEN);
const before = await t.run(async (ctx) => await ctx.db.get(digestId));
expect(before?.normalizedDisplayNameFirstToken).toBe(STALE_FIRST_TOKEN);
// An unconfirmed call reports the same repair without performing it.
const preview = await t.mutation(
internal.maintenance.backfillSkillSearchDigestFirstTokensInternal,
{},
);
expect(preview.patched).toBe(1);
expect(preview.dryRun).toBe(true);
const afterPreview = await t.run(async (ctx) => await ctx.db.get(digestId));
expect(afterPreview?.normalizedDisplayNameFirstToken).toBe(STALE_FIRST_TOKEN);
const result = await t.mutation(
internal.maintenance.backfillSkillSearchDigestFirstTokensInternal,
APPLY,
);
expect(result.patched).toBe(1);
expect(result.missingSkills).toBe(0);
const after = await t.run(async (ctx) => await ctx.db.get(digestId));
expect(after?.normalizedDisplayNameFirstToken).toBe(expected);
expect(after?.normalizedSlugFirstToken).toBe(getFirstSearchToken(SLUG));
});
it("makes the row reachable again through the index the search actually queries", async () => {
const t = convexTest(schema, modules);
await insertDigestWithStaleFirstToken(t);
const token = getFirstSearchToken(DISPLAY_NAME) as string;
const upperBound =
token.slice(0, -1) + String.fromCharCode(token.charCodeAt(token.length - 1) + 1);
const recall = async () =>
await t.run(
async (ctx) =>
await ctx.db
.query("skillSearchDigest")
.withIndex("by_active_normalized_display_name_first_token", (q) =>
q
.eq("softDeletedAt", undefined)
.gte("normalizedDisplayNameFirstToken", token)
.lt("normalizedDisplayNameFirstToken", upperBound),
)
.collect(),
);
// The row is on disk and matches the query the user typed, but the stored token
// predates the tokenizer, so the range bound the search computes never reaches it.
expect(await recall()).toHaveLength(0);
await t.mutation(internal.maintenance.backfillSkillSearchDigestFirstTokensInternal, APPLY);
expect(await recall()).toHaveLength(1);
});
it("leaves an already-current row untouched", async () => {
const t = convexTest(schema, modules);
const { digestId } = await insertDigestWithStaleFirstToken(t);
await t.mutation(internal.maintenance.backfillSkillSearchDigestFirstTokensInternal, APPLY);
const second = await t.mutation(
internal.maintenance.backfillSkillSearchDigestFirstTokensInternal,
APPLY,
);
expect(second.scanned).toBe(1);
expect(second.patched).toBe(0);
const row = await t.run(async (ctx) => await ctx.db.get(digestId));
expect(row?.normalizedDisplayNameFirstToken).toBe(getFirstSearchToken(DISPLAY_NAME));
});
});