From d78a22ce0b750c93d9e41494b617979691b72bc1 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:31:47 +0700 Subject: [PATCH 1/3] feat(shared-data): Item and Variant schemas, model_key helpers (#1149) Add the Item / variant key rules from the design doc (#1129, section 4): composeModelKey and splitModelKey (model_key = item_key:variant_key), isItemKey / isVariantKey / isModelKey / isSizedVariantKey, and the ItemSchema, VariantSchema and VariantSourceSchema zod contracts. The helpers are zod-free and exported from both the root entry and @repo/shared-data/rules. Tests round-trip every key in the importer fixtures. Nothing consumes them yet. Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/shared-data.test.ts | 232 +++++++++++++++++- packages/shared-data/src/index.ts | 3 + packages/shared-data/src/rules/index.ts | 6 +- packages/shared-data/src/rules/model-key.ts | 112 +++++++++ packages/shared-data/src/schemas/index.ts | 1 + .../shared-data/src/schemas/item.schema.ts | 119 +++++++++ 6 files changed, 464 insertions(+), 9 deletions(-) create mode 100644 packages/shared-data/src/rules/model-key.ts create mode 100644 packages/shared-data/src/schemas/item.schema.ts diff --git a/packages/shared-data/src/__tests__/shared-data.test.ts b/packages/shared-data/src/__tests__/shared-data.test.ts index 09c94fc63..d485472a8 100644 --- a/packages/shared-data/src/__tests__/shared-data.test.ts +++ b/packages/shared-data/src/__tests__/shared-data.test.ts @@ -823,29 +823,247 @@ function testPriceRules(): void { } } +function assertRoundTrip(modelKey: string): void { + const parts = sharedDataRoot.splitModelKey(modelKey); + assert.ok(parts, `splitModelKey must parse ${modelKey}`); + const segments = modelKey.split(':'); + assert.equal(parts.itemKey, `${segments[0]}:${segments[1]}`, modelKey); + assert.equal(parts.variantKey, segments[2], modelKey); + assert.equal( + sharedDataRoot.composeModelKey(parts.itemKey, parts.variantKey), + modelKey + ); + assert.equal(sharedDataRoot.isItemKey(parts.itemKey), true, modelKey); + assert.equal(sharedDataRoot.isVariantKey(parts.variantKey), true, modelKey); +} + +function testItemVariantKeys(): void { + const { + splitModelKey, + composeModelKey, + isModelKey, + isItemKey, + isVariantKey, + isSizedVariantKey, + UNKNOWN_VARIANT_KEY, + ItemSchema, + VariantSchema, + VariantSourceSchema, + ItemKeySchema, + VariantKeySchema, + ModelKeySchema, + } = sharedDataRoot; + + // Exported from the root entry and from the zod-free rules entry. + assert.equal(rules.splitModelKey, splitModelKey); + assert.equal(rules.composeModelKey, composeModelKey); + + // Every key form the normalizer emits today (design doc 4.2). + const forms: Array<[string, string, string]> = [ + ['apple:iphone-17-pro:256gb', 'apple:iphone-17-pro', '256gb'], + ['apple:iphone-17-pro-max:1024gb', 'apple:iphone-17-pro-max', '1024gb'], + // Apple screen / case size stays in the Item (#1113, #1124). + ['apple:ipad-pro-m5-11:256gb', 'apple:ipad-pro-m5-11', '256gb'], + ['apple:ipad-pro-m5-13:1024gb', 'apple:ipad-pro-m5-13', '1024gb'], + ['apple:watch-series-11-46mm:64gb', 'apple:watch-series-11-46mm', '64gb'], + // Storage not stated, and earbuds / sizeless watches (single variant). + ['apple:iphone-18-pro:unknown', 'apple:iphone-18-pro', 'unknown'], + ['apple:airpods-pro-2:unknown', 'apple:airpods-pro-2', 'unknown'], + ['samsung:galaxy-buds3-pro:unknown', 'samsung:galaxy-buds3-pro', 'unknown'], + ['garmin:fenix-8:unknown', 'garmin:fenix-8', 'unknown'], + // Literal legacy / future variant keys (design doc 4.2). + [ + 'samsung:galaxy-buds-3-pro:standard', + 'samsung:galaxy-buds-3-pro', + 'standard', + ], + [ + 'apple:ipad-air-m3-11:256gb-cellular', + 'apple:ipad-air-m3-11', + '256gb-cellular', + ], + [ + 'samsung:galaxy-s26-ultra:256gb-16gbram', + 'samsung:galaxy-s26-ultra', + '256gb-16gbram', + ], + ['xiaomi:redmi-note-14.5:128gb', 'xiaomi:redmi-note-14.5', '128gb'], + ]; + for (const [modelKey, itemKey, variantKey] of forms) { + assert.deepEqual(splitModelKey(modelKey), { itemKey, variantKey }); + assert.equal(composeModelKey(itemKey, variantKey), modelKey); + assertRoundTrip(modelKey); + } + + // Every three-part key literal in the importer's normalizer fixtures. + const fixtureDir = new URL( + '../../../../apps/importer/src/shopee/__tests__/', + import.meta.url + ); + const fixtureKeys = new Set(); + for (const file of readdirSync(fixtureDir)) { + if (!/\.(ts|mjs)$/.test(file)) continue; + const source = readFileSync(new URL(file, fixtureDir), 'utf8'); + for (const m of source.matchAll( + /['"`]([a-z0-9][a-z0-9.-]*:[a-z0-9][a-z0-9.-]*:[a-z0-9.-]+)['"`]/g + )) { + fixtureKeys.add(m[1] ?? ''); + } + } + assert.ok( + fixtureKeys.size >= 100, + `expected the importer fixtures to hold 100+ keys, found ${fixtureKeys.size}` + ); + for (const modelKey of fixtureKeys) assertRoundTrip(modelKey); + + // Not in the brand:model:variant shape: not parsed, not composed. + for (const bad of [ + '', + 'apple_iphone_16', + 'other:key', + 'khc:legacy', + 'a:b:c:d', + 'Apple:iphone-17:256gb', + 'apple::256gb', + ':iphone-17:256gb', + 'apple:iphone-17:', + 'apple:-iphone:256gb', + 'apple:iphone 17:256gb', + 'apple:iphone-17:256GB', + `apple:${'x'.repeat(200)}:256gb`, + ]) { + assert.equal(splitModelKey(bad), null, bad); + assert.equal(isModelKey(bad), false, bad); + assert.equal(ModelKeySchema.safeParse(bad).success, false, bad); + } + assert.equal(splitModelKey(undefined as unknown as string), null); + assert.throws(() => composeModelKey('apple', '256gb'), RangeError); + assert.throws( + () => composeModelKey('apple:iphone-17:256gb', '256gb'), + RangeError + ); + assert.throws(() => composeModelKey('apple:iphone-17', ''), RangeError); + assert.throws(() => composeModelKey('apple:iphone-17', '256:gb'), RangeError); + assert.throws( + () => composeModelKey(`apple:${'x'.repeat(190)}`, '1024gb'), + RangeError + ); + assert.equal(isItemKey('apple:ipad-pro-m5-11'), true); + assert.equal(isItemKey('apple:ipad-pro-m5-11:256gb'), false); + assert.equal(isVariantKey('256gb-cellular'), true); + assert.equal(isVariantKey('256gb:x'), false); + assert.equal(ItemKeySchema.safeParse('apple:ipad-pro-m5-11').success, true); + assert.equal(ItemKeySchema.safeParse('apple').success, false); + assert.equal(VariantKeySchema.safeParse('unknown').success, true); + assert.equal(VariantKeySchema.safeParse('').success, false); + + // Only a stated storage size makes an Item multi-size (design doc 4.2). + for (const sized of ['8gb', '64gb', '256gb', '1024gb', '1tb']) { + assert.equal(isSizedVariantKey(sized), true, sized); + } + for (const other of [ + UNKNOWN_VARIANT_KEY, + 'standard', + '256gb-cellular', + 'gb', + '', + ]) { + assert.equal(isSizedVariantKey(other), false, other); + } + assert.equal(UNKNOWN_VARIANT_KEY, 'unknown'); + + const id = '018f3b2c-1a2b-4c3d-8e4f-5a6b7c8d9e0f'; + const item = ItemSchema.parse({ + id, + item_key: 'apple:ipad-pro-m5-11', + slug: 'apple-ipad-pro-m5-11', + name: 'Apple iPad Pro M5 11', + brand: 'Apple', + model: 'iPad Pro M5 11', + }); + assert.equal(item.status, 'draft'); + assert.equal(item.min_price, 0); + assert.equal(item.unknown_variant_listing_count, 0); + assert.deepEqual(item.specifications, {}); + // Legacy keys still get an Item (design doc 4.3): no shape check on rows. + assert.equal( + ItemSchema.safeParse({ ...item, item_key: 'khc:legacy-thing' }).success, + true + ); + assert.equal( + ItemSchema.safeParse({ ...item, status: 'hidden' }).success, + false + ); + assert.equal( + ItemSchema.safeParse({ ...item, average_rating: 6 }).success, + false + ); + + const variant = { + id, + brand: 'Apple', + model: 'iPad Pro M5 11', + model_key: 'apple:ipad-pro-m5-11:256gb', + name: 'Apple iPad Pro M5 11 256GB', + slug: 'apple-ipad-pro-m5-11-256gb', + item_id: id, + variant_key: '256gb', + sort_order: 1, + }; + assert.equal(VariantSchema.parse(variant).variant_key, '256gb'); + // Before the backfill both links may be missing. + assert.equal( + VariantSchema.safeParse({ ...variant, item_id: null, variant_key: null }) + .success, + true + ); + // model_key = item_key:variant_key must hold. + assert.equal( + VariantSchema.safeParse({ ...variant, variant_key: '512gb' }).success, + false + ); + assert.equal( + VariantSchema.safeParse({ ...variant, sort_order: 40000 }).success, + false + ); + + assert.deepEqual(VariantSourceSchema.options, [ + 'title', + 'sku', + 'single_size', + 'single_variant', + 'manual', + 'legacy', + ]); +} + function main(): void { console.log('๐Ÿงช Running @repo/shared-data unit tests...'); testVersionAndZeroStrapiExports(); console.log( - ' โœ… [1/7] Version & Zero-Strapi root export isolation verified' + ' โœ… [1/8] Version & Zero-Strapi root export isolation verified' ); testRouteConstants(); - console.log(' โœ… [2/7] Route & REST API v1 constants verified'); + console.log(' โœ… [2/8] Route & REST API v1 constants verified'); testTenFieldGroupsSpecificationsSchema(); console.log( - ' โœ… [3/7] 10-Field-Groups ProductSpecificationsSchema verified' + ' โœ… [3/8] 10-Field-Groups ProductSpecificationsSchema verified' ); testMasterProductAndScrapedProductSchemas(); console.log( - ' โœ… [4/7] MasterProduct, ScrapedProduct & common schemas verified' + ' โœ… [4/8] MasterProduct, ScrapedProduct & common schemas verified' ); testSupabaseClientGuards(); - console.log(' โœ… [5/7] Supabase client fail-closed env guards verified'); + console.log(' โœ… [5/8] Supabase client fail-closed env guards verified'); testSeedingRiskPenalty(); - console.log(' โœ… [6/7] Shared seeding risk penalty verified'); + console.log(' โœ… [6/8] Shared seeding risk penalty verified'); testPriceRules(); console.log( - ' โœ… [7/7] Shared price rules (@repo/shared-data/rules) verified' + ' โœ… [7/8] Shared price rules (@repo/shared-data/rules) verified' + ); + testItemVariantKeys(); + console.log( + ' โœ… [8/8] Item / variant schemas and model_key helpers verified' ); console.log('๐ŸŽ‰ All @repo/shared-data tests passed!'); } diff --git a/packages/shared-data/src/index.ts b/packages/shared-data/src/index.ts index a1a9c407a..fd477b1ac 100644 --- a/packages/shared-data/src/index.ts +++ b/packages/shared-data/src/index.ts @@ -15,6 +15,9 @@ export * from './clients/index.js'; // Export constants export * from './constants/index.js'; +// Item / variant key helpers: model_key = item_key:variant_key (DOS/Rate#1149) +export * from './rules/model-key.js'; + // `rate` schema types generated from supabase/migrations (pnpm gen:db-types) export type { Database, Json } from './database.types.js'; diff --git a/packages/shared-data/src/rules/index.ts b/packages/shared-data/src/rules/index.ts index 3523dc373..8ea5fd486 100644 --- a/packages/shared-data/src/rules/index.ts +++ b/packages/shared-data/src/rules/index.ts @@ -1,9 +1,11 @@ /** - * Business rules about prices, shared by rate.vn, the importer and the - * crawlers (DOS/Rate#1089). Plain functions and constants only: no zod and + * Business rules about prices (DOS/Rate#1089) and catalog keys + * (DOS/Rate#1149), shared by rate.vn, the importer and the crawlers. + * Plain functions and constants only: no zod and * no Supabase client, so client components can import * `@repo/shared-data/rules` without pulling either into the bundle. */ export { MAX_VALID_VND_PRICE } from './price-limits.js'; export * from './offer-plausibility.js'; export * from './listing-price-floor.js'; +export * from './model-key.js'; diff --git a/packages/shared-data/src/rules/model-key.ts b/packages/shared-data/src/rules/model-key.ts new file mode 100644 index 000000000..5aba8dda4 --- /dev/null +++ b/packages/shared-data/src/rules/model-key.ts @@ -0,0 +1,112 @@ +/** + * Item / variant key rules (DOS/Rate#1149; design doc + * `Docs/Proposals/2026-10-27-item-variant-listing-model.md`, section 4). + * + * `model_key` = `item_key:variant_key`, the shape the normalizer emits today: + * - `item_key` = `brand:model`. An Apple screen or case size stays in the + * model segment, so `apple:ipad-pro-m5-11` and `apple:ipad-pro-m5-13` are + * different Items. + * - `variant_key` = the literal third segment: `256gb`, `1024gb`, `standard`, + * `unknown` (earbuds), and later suffixed forms such as `256gb-cellular`. + * Display labels (`1TB`, "Standard") are derived from it, never keys. + * + * Segment shapes follow the design doc's inventory check (section 7.3). A key + * outside them (legacy keys without three segments, upper case, empty + * segments) is not parsed: `splitModelKey` returns null for it. + */ + +const BRAND_SEGMENT = '[a-z0-9][a-z0-9-]*'; +const MODEL_SEGMENT = '[a-z0-9][a-z0-9.-]*'; +const VARIANT_SEGMENT = '[a-z0-9-]+'; + +export const ITEM_KEY_PATTERN = new RegExp( + `^${BRAND_SEGMENT}:${MODEL_SEGMENT}$` +); +export const VARIANT_KEY_PATTERN = new RegExp(`^${VARIANT_SEGMENT}$`); +export const MODEL_KEY_PATTERN = new RegExp( + `^(${BRAND_SEGMENT}:${MODEL_SEGMENT}):(${VARIANT_SEGMENT})$` +); + +/** + * A variant that states a storage size. An Item with a non-archived sized + * variant is multi-size, and there `unknown` means "size not stated". + */ +export const SIZED_VARIANT_KEY_PATTERN = /^\d+(gb|tb)$/; + +/** + * Storage not stated (multi-size Item) or not applicable (single-variant + * Item, e.g. earbuds). + */ +const UNKNOWN = 'unknown'; +export const UNKNOWN_VARIANT_KEY = UNKNOWN; + +/** Same 200-character cap as `master_products.model_key`. */ +export const MAX_MODEL_KEY_LENGTH = 200; + +const isString = (value: unknown): value is string => typeof value === 'string'; + +export interface ModelKeyParts { + itemKey: string; + variantKey: string; +} + +export function isItemKey(value: unknown): value is string { + return ( + isString(value) && + value.length <= MAX_MODEL_KEY_LENGTH && + ITEM_KEY_PATTERN.test(value) + ); +} + +export function isVariantKey(value: unknown): value is string { + return ( + isString(value) && + value.length <= MAX_MODEL_KEY_LENGTH && + VARIANT_KEY_PATTERN.test(value) + ); +} + +export function isModelKey(value: unknown): value is string { + return ( + isString(value) && + value.length <= MAX_MODEL_KEY_LENGTH && + MODEL_KEY_PATTERN.test(value) + ); +} + +export function isSizedVariantKey(value: unknown): value is string { + return isString(value) && SIZED_VARIANT_KEY_PATTERN.test(value); +} + +/** + * Splits a `model_key` into its Item and variant keys, or returns null when + * the key is not in the `brand:model:variant` shape. + * `composeModelKey(itemKey, variantKey)` gives the same key back. + */ +export function splitModelKey(modelKey: string): ModelKeyParts | null { + if (!isModelKey(modelKey)) return null; + const match = MODEL_KEY_PATTERN.exec(modelKey); + if (!match || match[1] === undefined || match[2] === undefined) return null; + return { itemKey: match[1], variantKey: match[2] }; +} + +/** + * Builds a `model_key` from an Item key and a variant key. Throws a + * RangeError when either part, or the result, is not a valid key, so a + * malformed key never reaches a writer. + */ +export function composeModelKey(itemKey: string, variantKey: string): string { + if (!isItemKey(itemKey)) { + throw new RangeError(`Invalid item_key: ${JSON.stringify(itemKey)}`); + } + if (!isVariantKey(variantKey)) { + throw new RangeError(`Invalid variant_key: ${JSON.stringify(variantKey)}`); + } + const modelKey = `${itemKey}:${variantKey}`; + if (!isModelKey(modelKey)) { + throw new RangeError( + `model_key longer than ${MAX_MODEL_KEY_LENGTH} characters: ${JSON.stringify(modelKey)}` + ); + } + return modelKey; +} diff --git a/packages/shared-data/src/schemas/index.ts b/packages/shared-data/src/schemas/index.ts index 37b17a032..db4fdd3b3 100644 --- a/packages/shared-data/src/schemas/index.ts +++ b/packages/shared-data/src/schemas/index.ts @@ -8,3 +8,4 @@ export * from './listing.schema.js'; export * from './review.schema.js'; export * from './common.schema.js'; export * from './specs.schema.js'; +export * from './item.schema.js'; diff --git a/packages/shared-data/src/schemas/item.schema.ts b/packages/shared-data/src/schemas/item.schema.ts new file mode 100644 index 000000000..76a0f3b6e --- /dev/null +++ b/packages/shared-data/src/schemas/item.schema.ts @@ -0,0 +1,119 @@ +import { z } from 'zod'; +import { MAX_VALID_VND_PRICE } from '../rules/price-limits.js'; +import { + ITEM_KEY_PATTERN, + MAX_MODEL_KEY_LENGTH, + MODEL_KEY_PATTERN, + VARIANT_KEY_PATTERN, + splitModelKey, +} from '../rules/model-key.js'; +import { HttpUrlSchema, StatusSchema } from './common.schema.js'; +import { + KeySpecsSchema, + MasterProductSchema, + ProductSpecificationsSchema, +} from './specs.schema.js'; + +/** + * Item / variant / listing model (DOS/Rate#1149; design doc + * `Docs/Proposals/2026-10-27-item-variant-listing-model.md`, section 4). + * One Item per product (`rate.items`); each storage size is a variant + * (`rate.master_products`); listings (`rate.products`) point at the Item and, + * when the size is known, at the variant (`cluster_id`). + */ + +/** Strict key shapes, for keys a writer builds. See `rules/model-key.ts`. */ +export const ItemKeySchema = z + .string() + .max(MAX_MODEL_KEY_LENGTH) + .regex(ITEM_KEY_PATTERN, { message: 'Invalid item_key' }); +export const VariantKeySchema = z + .string() + .max(MAX_MODEL_KEY_LENGTH) + .regex(VARIANT_KEY_PATTERN, { message: 'Invalid variant_key' }); +export const ModelKeySchema = z + .string() + .max(MAX_MODEL_KEY_LENGTH) + .regex(MODEL_KEY_PATTERN, { message: 'Invalid model_key' }); + +/** How a listing's variant was assigned (`products.variant_source`). */ +export const VariantSourceSchema = z.enum([ + 'title', + 'sku', + 'single_size', + 'single_variant', + 'manual', + 'legacy', +]); + +/** + * A `rate.items` row. `item_key` has no shape check here, as in the table: + * legacy keys still get an Item (design doc 4.3). + */ +export const ItemSchema = z.object({ + id: z.string().uuid(), + item_key: z.string().min(1).max(MAX_MODEL_KEY_LENGTH), + slug: z.string().min(1).max(200), + name: z.string().min(1).max(300), + brand: z.string().min(1).max(120), + model: z.string().min(1).max(200), + category_id: z.string().uuid().nullable().optional(), + status: StatusSchema.default('draft'), + entity_type: z.enum(['product', 'service']).default('product'), + description: z.string().max(50_000).nullable().optional(), + specifications: ProductSpecificationsSchema.default({}), + key_specs: KeySpecsSchema.default({}), + spec_provenance: z.record(z.string(), z.unknown()).default({}), + images: z.array(HttpUrlSchema).max(100).default([]), + thumbnail: HttpUrlSchema.nullable().optional(), + default_variant_id: z.string().uuid().nullable().optional(), + min_price: z.number().nonnegative().max(MAX_VALID_VND_PRICE).default(0), + max_price: z.number().nonnegative().max(MAX_VALID_VND_PRICE).default(0), + price_variant_count: z.number().int().nonnegative().default(0), + merchant_count: z.number().int().nonnegative().default(0), + listing_count: z.number().int().nonnegative().default(0), + unknown_variant_listing_count: z.number().int().nonnegative().default(0), + total_reviews: z.number().int().nonnegative().default(0), + average_rating: z.number().min(0).max(5).default(0), + user_rating_weighted: z.number().min(0).max(5).nullable().optional(), + authentic_review_count: z.number().int().nonnegative().default(0), + expert_rating: z.number().min(0).max(5).nullable().optional(), + expert_review_summary: z.string().max(10_000).nullable().optional(), + expert_criteria_scores: z.record(z.string(), z.unknown()).default({}), + metadata: z.record(z.string(), z.unknown()).default({}), + created_at: z.string().max(64).optional(), + updated_at: z.string().max(64).optional(), +}); + +/** + * A variant: a `rate.master_products` row with its Item link. `item_id` and + * `variant_key` stay nullable until the backfill and the M4 constraints. + * When the `model_key` is well formed, `variant_key` must be its third + * segment, so `model_key = item_key:variant_key` holds. + */ +export const VariantSchema = MasterProductSchema.extend({ + item_id: z.string().uuid().nullable().optional(), + variant_key: z + .string() + .min(1) + .max(MAX_MODEL_KEY_LENGTH) + .nullable() + .optional(), + sort_order: z.number().int().min(-32768).max(32767).nullable().optional(), +}).superRefine((variant, ctx) => { + if (variant.variant_key == null) return; + const parts = splitModelKey(variant.model_key); + if (parts && parts.variantKey !== variant.variant_key) { + ctx.addIssue({ + code: 'custom', + path: ['variant_key'], + message: `variant_key must be the model_key's third segment (${parts.variantKey})`, + }); + } +}); + +export type ItemKey = z.infer; +export type VariantKey = z.infer; +export type VariantSource = z.infer; +export type Item = z.infer; +export type Variant = z.infer; From f39ed8fb3902b68c766ef803500c560c8ad857ec Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:34:33 +0700 Subject: [PATCH 2/3] test(shared-data): stricter fixture key scan, note legacy variant_key rule (#1149) Co-Authored-By: Claude Opus 5.5 --- packages/shared-data/src/__tests__/shared-data.test.ts | 7 ++++++- packages/shared-data/src/schemas/item.schema.ts | 4 +++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/shared-data/src/__tests__/shared-data.test.ts b/packages/shared-data/src/__tests__/shared-data.test.ts index d485472a8..0846d823f 100644 --- a/packages/shared-data/src/__tests__/shared-data.test.ts +++ b/packages/shared-data/src/__tests__/shared-data.test.ts @@ -887,6 +887,8 @@ function testItemVariantKeys(): void { 'samsung:galaxy-s26-ultra', '256gb-16gbram', ], + // A dot is allowed in the model segment (design doc 7.3), although + // today's slugify strips it. ['xiaomi:redmi-note-14.5:128gb', 'xiaomi:redmi-note-14.5', '128gb'], ]; for (const [modelKey, itemKey, variantKey] of forms) { @@ -905,7 +907,10 @@ function testItemVariantKeys(): void { if (!/\.(ts|mjs)$/.test(file)) continue; const source = readFileSync(new URL(file, fixtureDir), 'utf8'); for (const m of source.matchAll( - /['"`]([a-z0-9][a-z0-9.-]*:[a-z0-9][a-z0-9.-]*:[a-z0-9.-]+)['"`]/g + // Wider than the key shape (upper case, `_`), so a fixture key the + // helpers would reject fails here instead of being skipped. The brand + // must start with a letter, which leaves out ISO timestamps. + /['"`]([A-Za-z_][\w-]*:[\w.-]+:[\w.-]+)['"`]/g )) { fixtureKeys.add(m[1] ?? ''); } diff --git a/packages/shared-data/src/schemas/item.schema.ts b/packages/shared-data/src/schemas/item.schema.ts index 76a0f3b6e..38b7cb23e 100644 --- a/packages/shared-data/src/schemas/item.schema.ts +++ b/packages/shared-data/src/schemas/item.schema.ts @@ -89,7 +89,9 @@ export const ItemSchema = z.object({ * A variant: a `rate.master_products` row with its Item link. `item_id` and * `variant_key` stay nullable until the backfill and the M4 constraints. * When the `model_key` is well formed, `variant_key` must be its third - * segment, so `model_key = item_key:variant_key` holds. + * segment, so `model_key = item_key:variant_key` holds. A legacy key outside + * that shape is not checked; the DB derives its `variant_key` with + * `split_part(model_key, ':', 3)` (design doc 4.3, 7.1). */ export const VariantSchema = MasterProductSchema.extend({ item_id: z.string().uuid().nullable().optional(), From 3eb34cc06b4d47a979b39dcc116da25d4e8ede29 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:36:06 +0700 Subject: [PATCH 3/3] feat(shared-data): allow _ in model_key segments, as slugify emits (#1149) PO decision: the key shape accepts '_' in all three segments, because the normalizer's slugify keeps it and real keys carry it. Empty segments (e.g. samsung::unknown) stay rejected. The design doc's 7.3 inventory regex now matches the helpers. Co-Authored-By: Claude Opus 5.5 --- .../2026-10-27-item-variant-listing-model.md | 3 ++- .../shared-data/src/__tests__/shared-data.test.ts | 12 +++++++++--- packages/shared-data/src/rules/model-key.ts | 13 +++++++------ 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/Docs/Proposals/2026-10-27-item-variant-listing-model.md b/Docs/Proposals/2026-10-27-item-variant-listing-model.md index 866d6792b..1e4c52df5 100644 --- a/Docs/Proposals/2026-10-27-item-variant-listing-model.md +++ b/Docs/Proposals/2026-10-27-item-variant-listing-model.md @@ -594,7 +594,8 @@ WHERE mp.id = b.old_cluster_id; ```sql -- Inventory (M0) -SELECT count(*) FILTER (WHERE model_key !~ '^[a-z0-9][a-z0-9-]*:[a-z0-9][a-z0-9.-]*:[a-z0-9-]+$') AS bad_shape, +-- Key shape = the shared-data helpers (splitModelKey). `_` is allowed because slugify keeps it (#1149). +SELECT count(*) FILTER (WHERE model_key !~ '^[a-z0-9_][a-z0-9_-]*:[a-z0-9_][a-z0-9_.-]*:[a-z0-9_-]+$') AS bad_shape, count(*) FILTER (WHERE model_key LIKE 'khc:%') AS khc_keys, count(*) FILTER (WHERE split_part(model_key, ':', 3) = 'unknown') AS unknown_masters, count(*) FILTER (WHERE status IS NULL) AS null_status, diff --git a/packages/shared-data/src/__tests__/shared-data.test.ts b/packages/shared-data/src/__tests__/shared-data.test.ts index 0846d823f..19a5a29a1 100644 --- a/packages/shared-data/src/__tests__/shared-data.test.ts +++ b/packages/shared-data/src/__tests__/shared-data.test.ts @@ -890,6 +890,9 @@ function testItemVariantKeys(): void { // A dot is allowed in the model segment (design doc 7.3), although // today's slugify strips it. ['xiaomi:redmi-note-14.5:128gb', 'xiaomi:redmi-note-14.5', '128gb'], + // slugify keeps `_` (it strips [^\w-]), including at the start. + ['asus:vivobook-x1504_va:512gb', 'asus:vivobook-x1504_va', '512gb'], + ['samsung:_galaxy-s25:256gb', 'samsung:_galaxy-s25', '256gb'], ]; for (const [modelKey, itemKey, variantKey] of forms) { assert.deepEqual(splitModelKey(modelKey), { itemKey, variantKey }); @@ -907,9 +910,9 @@ function testItemVariantKeys(): void { if (!/\.(ts|mjs)$/.test(file)) continue; const source = readFileSync(new URL(file, fixtureDir), 'utf8'); for (const m of source.matchAll( - // Wider than the key shape (upper case, `_`), so a fixture key the - // helpers would reject fails here instead of being skipped. The brand - // must start with a letter, which leaves out ISO timestamps. + // Wider than the key shape (upper case), so a fixture key the helpers + // would reject fails here instead of being skipped. The brand must + // start with a letter or `_`, which leaves out ISO timestamps. /['"`]([A-Za-z_][\w-]*:[\w.-]+:[\w.-]+)['"`]/g )) { fixtureKeys.add(m[1] ?? ''); @@ -934,6 +937,9 @@ function testItemVariantKeys(): void { 'apple:iphone-17:', 'apple:-iphone:256gb', 'apple:iphone 17:256gb', + // Empty model segment, as the normalizer emits for a title with no model. + 'samsung::unknown', + 'khac::256gb', 'apple:iphone-17:256GB', `apple:${'x'.repeat(200)}:256gb`, ]) { diff --git a/packages/shared-data/src/rules/model-key.ts b/packages/shared-data/src/rules/model-key.ts index 5aba8dda4..5830bfa18 100644 --- a/packages/shared-data/src/rules/model-key.ts +++ b/packages/shared-data/src/rules/model-key.ts @@ -10,14 +10,15 @@ * `unknown` (earbuds), and later suffixed forms such as `256gb-cellular`. * Display labels (`1TB`, "Standard") are derived from it, never keys. * - * Segment shapes follow the design doc's inventory check (section 7.3). A key - * outside them (legacy keys without three segments, upper case, empty - * segments) is not parsed: `splitModelKey` returns null for it. + * Segment shapes follow the design doc's inventory check (section 7.3). They + * allow `_`, which the normalizer's slugify keeps (`x1504_va`). A key outside + * them (legacy keys without three segments, upper case, empty segments such + * as `samsung::unknown`) is not parsed: `splitModelKey` returns null for it. */ -const BRAND_SEGMENT = '[a-z0-9][a-z0-9-]*'; -const MODEL_SEGMENT = '[a-z0-9][a-z0-9.-]*'; -const VARIANT_SEGMENT = '[a-z0-9-]+'; +const BRAND_SEGMENT = '[a-z0-9_][a-z0-9_-]*'; +const MODEL_SEGMENT = '[a-z0-9_][a-z0-9_.-]*'; +const VARIANT_SEGMENT = '[a-z0-9_-]+'; export const ITEM_KEY_PATTERN = new RegExp( `^${BRAND_SEGMENT}:${MODEL_SEGMENT}$`