@open-mercato/core 0.8.1-develop.7206.1.45a3cee87f → 0.8.1-develop.7215.1.60786cf836

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.
Files changed (39) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/catalog/backend/catalog/products/[id]/page.js +6 -2
  3. package/dist/modules/catalog/backend/catalog/products/[id]/page.js.map +2 -2
  4. package/dist/modules/catalog/backend/catalog/products/create/page.js +7 -3
  5. package/dist/modules/catalog/backend/catalog/products/create/page.js.map +2 -2
  6. package/dist/modules/catalog/components/products/ProductUomSection.js +11 -22
  7. package/dist/modules/catalog/components/products/ProductUomSection.js.map +2 -2
  8. package/dist/modules/catalog/components/products/productForm.js +26 -1
  9. package/dist/modules/catalog/components/products/productForm.js.map +2 -2
  10. package/dist/modules/configs/lib/upgrade-actions.js +1 -0
  11. package/dist/modules/configs/lib/upgrade-actions.js.map +2 -2
  12. package/dist/modules/configs/services/upgradeActionsService.js +9 -2
  13. package/dist/modules/configs/services/upgradeActionsService.js.map +2 -2
  14. package/dist/modules/entities/lib/definition-scope-where.js +18 -0
  15. package/dist/modules/entities/lib/definition-scope-where.js.map +7 -0
  16. package/dist/modules/entities/lib/definition-scope.js +2 -8
  17. package/dist/modules/entities/lib/definition-scope.js.map +2 -2
  18. package/dist/modules/entities/lib/helpers.js +4 -30
  19. package/dist/modules/entities/lib/helpers.js.map +2 -2
  20. package/dist/modules/entities/lib/scoped-field-defs.js +5 -10
  21. package/dist/modules/entities/lib/scoped-field-defs.js.map +2 -2
  22. package/dist/modules/query_index/lib/engine.js.map +1 -1
  23. package/dist/modules/query_index/lib/search-tokens.js.map +2 -2
  24. package/dist/modules/wms/components/backend/AdjustInventoryDialog.js +15 -1
  25. package/dist/modules/wms/components/backend/AdjustInventoryDialog.js.map +2 -2
  26. package/package.json +7 -7
  27. package/src/modules/catalog/backend/catalog/products/[id]/page.tsx +6 -2
  28. package/src/modules/catalog/backend/catalog/products/create/page.tsx +7 -6
  29. package/src/modules/catalog/components/products/ProductUomSection.tsx +11 -31
  30. package/src/modules/catalog/components/products/productForm.ts +53 -0
  31. package/src/modules/configs/lib/upgrade-actions.ts +7 -0
  32. package/src/modules/configs/services/upgradeActionsService.ts +17 -2
  33. package/src/modules/entities/lib/definition-scope-where.ts +27 -0
  34. package/src/modules/entities/lib/definition-scope.ts +2 -10
  35. package/src/modules/entities/lib/helpers.ts +4 -33
  36. package/src/modules/entities/lib/scoped-field-defs.ts +6 -15
  37. package/src/modules/query_index/lib/engine.ts +4 -4
  38. package/src/modules/query_index/lib/search-tokens.ts +31 -0
  39. package/src/modules/wms/components/backend/AdjustInventoryDialog.tsx +21 -1
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/query_index/lib/search-tokens.ts"],
4
- "sourcesContent": ["import { type Kysely, type Transaction, sql } from 'kysely'\nimport {\n isSearchFieldBlocklisted,\n resolveSearchConfig,\n resolveSearchTokenLimits,\n type SearchConfig,\n} from '@open-mercato/shared/lib/search/config'\nimport { tokenizeText } from '@open-mercato/shared/lib/search/tokenize'\nimport { looksLikeEncryptedPayload } from '@open-mercato/shared/lib/encryption/aes'\nimport { createKmsService, resolveEncryptionMode, type KmsService } from '@open-mercato/shared/lib/encryption/kms'\nimport { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('query_index').child({ component: 'search-tokens' })\n\nconst INSERT_BATCH_SIZE = 500\n\nfunction chunk<T>(items: T[], size: number): T[][] {\n if (size <= 0) return [items]\n const out: T[][] = []\n for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))\n return out\n}\n\nexport type SearchTokenRow = {\n entity_type: string\n entity_id: string\n organization_id: string | null\n tenant_id: string | null\n field: string\n token_hash: string\n token?: string | null\n}\n\ntype BuildTokenOptions = {\n entityType: string\n recordId: string\n organizationId?: string | null\n tenantId?: string | null\n doc?: Record<string, unknown> | null\n config?: SearchConfig\n /** Resolved once per write by the exported entry points; see {@link shouldGuardCiphertext}. */\n guardCiphertext?: boolean\n}\n\nconst DEFAULT_SCOPE = { organizationId: null, tenantId: null }\ntype EntityFieldPair = [string, string]\ntype SearchTokenExecutor = Kysely<any> | Transaction<any>\n\nexport const isSearchDebugEnabled = (): boolean => {\n return parseBooleanToken(process.env.OM_SEARCH_DEBUG ?? '') === true\n}\n\nconst debug = (event: string, payload: Record<string, unknown>) => {\n if (!isSearchDebugEnabled()) return\n try {\n logger.debug('Search token event', { event, payload })\n } catch {\n // ignore\n }\n}\n\nfunction collectTextValues(value: unknown): string[] {\n if (typeof value === 'string') return [value]\n if (Array.isArray(value)) {\n const out: string[] = []\n for (const entry of value) {\n if (typeof entry === 'string') out.push(entry)\n }\n return out\n }\n return []\n}\n\nlet guardKmsService: KmsService | null = null\n\n/**\n * Whether the ciphertext guard below is allowed to run for this write.\n *\n * The guard recognises an envelope by its SHAPE, which is forgeable: `<16 b64>:<b64>:<24 b64>:v1`\n * is a string any user can type into a searchable field. `tenantDataEncryptionService` removed the\n * same structural test for that reason (#2720). So the guard may only run where the shape is the\n * ONLY test available -- which is exactly where no DEK is reachable:\n *\n * - `active` -- the indexer decrypted the document before handing it here, so a value still\n * shaped like an envelope is plaintext somebody typed. Indexing it is correct,\n * and skipping it would let that person freeze their own record's tokens at a\n * past state. The guard stays off, which also keeps it off the hot path of\n * every normal deployment.\n * - `disabled` -- `decryptIndexDocForSearch` is a no-op, so ciphertext arrives undecrypted.\n * - `unavailable` -- the decrypt was attempted and could not complete, same outcome.\n *\n * Resolved once per write rather than per document, over a KMS built once per process:\n * {@link createKmsService} logs when it falls back, and a reindex calls this once per record. The\n * toggle itself is still re-read every call by {@link resolveEncryptionMode}; only the KMS is\n * cached, and it already requires a restart to change, since DEK and map caches are in-process.\n */\nfunction shouldGuardCiphertext(): boolean {\n guardKmsService ??= createKmsService()\n return resolveEncryptionMode(guardKmsService) !== 'active'\n}\n\n/**\n * Fields whose value is an AES-GCM envelope rather than the text it is supposed to hold.\n *\n * Search tokens are hashes of PLAINTEXT: the indexer decrypts a document before tokenising it\n * (`indexer.ts` -> `decryptIndexDocForSearch`), which is what lets the token index survive\n * encryption being switched on or off. That decrypt step is a no-op once\n * `TENANT_DATA_ENCRYPTION=no`, so an operator who flips the toggle before running\n * `mercato entities decrypt-database` starts feeding ciphertext into the tokeniser. The tokens\n * that come out are hashes of base64 noise and match nothing, and because a write REPLACES a\n * record's tokens, the good plaintext tokens already in the table would be deleted to make room\n * for them -- turning a recoverable misordering into permanent search loss.\n *\n * Detecting the envelope by shape lets the write skip those fields and leave what is already\n * indexed alone. `guard` gates that detection; see {@link shouldGuardCiphertext} for why it is not\n * unconditional.\n */\nfunction ciphertextFieldsOf(\n doc: Record<string, unknown> | null | undefined,\n guard: boolean,\n): Set<string> {\n const fields = new Set<string>()\n if (!guard || !doc) return fields\n for (const [field, value] of Object.entries(doc)) {\n const values = collectTextValues(value)\n if (values.length && values.some((text) => looksLikeEncryptedPayload(text))) fields.add(field)\n }\n return fields\n}\n\nconst warnedCiphertextEntities = new Set<string>()\n\nfunction warnCiphertextSkipped(entityType: string, tenantId: string | null, fields: Set<string>): void {\n if (!fields.size) return\n // Once per entity type per tenant per process: a full reindex would otherwise emit this per\n // record, while keying on the entity type alone would let the first affected tenant in a shared\n // process consume the one warning every other tenant's operator needed.\n const key = `${entityType}|${tenantId ?? ''}`\n if (warnedCiphertextEntities.has(key)) return\n warnedCiphertextEntities.add(key)\n logger.warn(\n 'Search indexing skipped ciphertext fields and preserved their existing tokens. '\n + 'This means TENANT_DATA_ENCRYPTION was switched off while encrypted data was still at rest. '\n + 'Run `mercato entities decrypt-database` and reindex; until then these fields are not searchable.',\n { entityType, tenantId, fields: Array.from(fields).sort((left, right) => left.localeCompare(right)) },\n )\n}\n\n/** Test seam: both the warning above and the KMS behind the guard are once-per-process. */\nexport function resetCiphertextGuardState(): void {\n warnedCiphertextEntities.clear()\n guardKmsService = null\n}\n\nfunction shouldIndexField(\n field: string,\n value: unknown,\n config: SearchConfig,\n entityType: string | null,\n): boolean {\n if (typeof value !== 'string' && !Array.isArray(value)) return false\n const lower = field.toLowerCase()\n if (lower === 'id' || lower.endsWith('_id') || lower.endsWith('.id')) return false\n if (lower.endsWith('_at')) return false\n if (['created_at', 'updated_at', 'deleted_at', 'tenant_id', 'organization_id'].includes(lower)) return false\n if (isSearchFieldBlocklisted(field, entityType, config)) return false\n return collectTextValues(value).some((text) => text.length > 0)\n}\n\nexport function buildSearchTokenRows(params: BuildTokenOptions): SearchTokenRow[] {\n const config = params.config ?? resolveSearchConfig()\n if (!config.enabled) return []\n if (!params.doc) return []\n const tokens: SearchTokenRow[] = []\n const capturePairs = isSearchDebugEnabled() && params.entityType === 'customers:customer_deal'\n const debugPairs: Array<{ field: string; hash: string }> = []\n const scope = {\n organizationId: params.organizationId ?? DEFAULT_SCOPE.organizationId,\n tenantId: params.tenantId ?? DEFAULT_SCOPE.tenantId,\n }\n const limits = resolveSearchTokenLimits(config)\n const recordLimit = limits.maxTokensPerRecord > 0 ? limits.maxTokensPerRecord : Number.POSITIVE_INFINITY\n const fieldLimit = limits.maxTokensPerField > 0 ? limits.maxTokensPerField : Number.POSITIVE_INFINITY\n const ciphertextFields = ciphertextFieldsOf(params.doc, params.guardCiphertext ?? shouldGuardCiphertext())\n warnCiphertextSkipped(params.entityType, scope.tenantId, ciphertextFields)\n\n for (const [field, rawValue] of Object.entries(params.doc)) {\n if (tokens.length >= recordLimit) break\n if (ciphertextFields.has(field)) continue\n if (!shouldIndexField(field, rawValue, config, params.entityType)) continue\n const values = collectTextValues(rawValue)\n const seen = new Set<string>()\n let fieldTokenCount = 0\n for (const text of values) {\n if (tokens.length >= recordLimit || fieldTokenCount >= fieldLimit) break\n const remainingLimit = Math.min(recordLimit - tokens.length, fieldLimit - fieldTokenCount)\n const candidateLimit = fieldTokenCount + remainingLimit\n const tokenConfig = Number.isFinite(candidateLimit)\n ? { ...config, maxTokensPerField: candidateLimit }\n : config\n const { tokens: textTokens, hashes } = tokenizeText(text, tokenConfig)\n for (let i = 0; i < textTokens.length; i += 1) {\n if (tokens.length >= recordLimit || fieldTokenCount >= fieldLimit) break\n const token = textTokens[i]\n const hash = hashes[i]\n const dedupeKey = `${field}|${hash}`\n if (seen.has(dedupeKey)) continue\n seen.add(dedupeKey)\n fieldTokenCount += 1\n debug('token.generated', { entityType: params.entityType, recordId: params.recordId, field, hash })\n tokens.push({\n entity_type: params.entityType,\n entity_id: String(params.recordId),\n organization_id: scope.organizationId,\n tenant_id: scope.tenantId,\n field,\n token_hash: hash,\n token: config.storeRawTokens ? token : null,\n })\n if (capturePairs) {\n debugPairs.push({ field, hash })\n }\n }\n }\n }\n if (capturePairs) {\n debug('deal.tokens', {\n entityType: params.entityType,\n recordId: params.recordId,\n tokenCount: debugPairs.length,\n tokens: debugPairs,\n })\n }\n debug('doc.completed', { entityType: params.entityType, recordId: params.recordId, tokenCount: tokens.length })\n\n return tokens\n}\n\nfunction buildFieldPairs(\n recordId: string,\n doc?: Record<string, unknown> | null,\n skipFields?: Set<string>,\n): EntityFieldPair[] {\n if (!doc) return []\n const pairs: EntityFieldPair[] = []\n const dedupe = new Set<string>()\n for (const field of Object.keys(doc)) {\n // The delete below is scoped to these pairs, so omitting a field here is what preserves the\n // tokens already stored for it rather than merely declining to write new ones.\n if (skipFields?.has(field)) continue\n const key = `${recordId}|${field}`\n if (dedupe.has(key)) continue\n dedupe.add(key)\n pairs.push([recordId, field])\n }\n return pairs\n}\n\ntype TokenRowLike = { field?: unknown; token_hash?: unknown; token?: unknown }\n\n// NUL, not a printable separator: a field name may itself contain a space, so `a b` + hash `c`\n// would otherwise sign identically to field `a` + hash `b c`.\nconst SIGNATURE_SEPARATOR = String.fromCharCode(0)\n\n// Identifies one token row for comparison. `token` is NULL unless `storeRawTokens` is on, and a\n// stored NULL has to sign the same as the `null` a freshly built row carries \u2014 otherwise every\n// record compares as changed and the skip never fires.\nfunction tokenSignature(row: TokenRowLike): string {\n return [\n String(row.field ?? ''),\n String(row.token_hash ?? ''),\n row.token == null ? '' : String(row.token),\n ].join(SIGNATURE_SEPARATOR)\n}\n\n// Multiplicities, not sets: #4681 reports token rows duplicated by the concurrent-replacement\n// defect, and a set comparison reads such a record as already correct and preserves the duplicates\n// forever. Counting sends it through a full rewrite, which collapses them.\nfunction tallyOf(rows: Iterable<TokenRowLike>): Map<string, number> {\n const tally = new Map<string, number>()\n for (const row of rows) {\n const signature = tokenSignature(row)\n tally.set(signature, (tally.get(signature) ?? 0) + 1)\n }\n return tally\n}\n\nfunction tallyEquals(a: Map<string, number> | undefined, b: Map<string, number> | undefined): boolean {\n const left = a ?? new Map<string, number>()\n const right = b ?? new Map<string, number>()\n if (left.size !== right.size) return false\n for (const [key, count] of left.entries()) {\n if (right.get(key) !== count) return false\n }\n return true\n}\n\nfunction tallyTokenRows<TRow extends TokenRowLike>(\n rows: Iterable<TRow>,\n keyOf: (row: TRow) => string\n): Map<string, Map<string, number>> {\n const tallies = new Map<string, Map<string, number>>()\n for (const row of rows) {\n const key = keyOf(row)\n const tally = tallies.get(key) ?? new Map<string, number>()\n const signature = tokenSignature(row)\n tally.set(signature, (tally.get(signature) ?? 0) + 1)\n tallies.set(key, tally)\n }\n return tallies\n}\n\nexport async function replaceSearchTokensForRecord(\n db: Kysely<any>,\n params: BuildTokenOptions,\n options?: { trx?: SearchTokenExecutor },\n): Promise<void> {\n const guardCiphertext = params.guardCiphertext ?? shouldGuardCiphertext()\n const rows = buildSearchTokenRows({ ...params, guardCiphertext })\n const config = params.config ?? resolveSearchConfig()\n if (!config.enabled) return\n const organizationId = params.organizationId ?? null\n const tenantId = params.tenantId ?? null\n const ciphertextFields = ciphertextFieldsOf(params.doc, guardCiphertext)\n const fieldPairs = buildFieldPairs(String(params.recordId), params.doc, ciphertextFields)\n\n // An empty pair list normally means the document is gone, and the delete below then purges the\n // record wholesale. It can now also mean every field was skipped as ciphertext, where a purge\n // would destroy precisely the tokens the skip exists to protect. Distinguish the two.\n if (params.doc && ciphertextFields.size && !fieldPairs.length) {\n debug('record.preserve-ciphertext', { entityType: params.entityType, recordId: params.recordId })\n return\n }\n\n // Same comparison #5402 gave the batch path, over the scope this path actually writes: the\n // delete below is narrowed to the document's own `(entity_id, field)` pairs, so the comparison\n // has to be narrowed the same way. Reading wider would let a token row under a field this\n // document does not carry \u2014 the `cf_` twin the search module used to write, say \u2014 read as a\n // difference forever and defeat the skip on every write.\n const scopeTokenQuery = (query: any): any => {\n let scoped = query\n .where('entity_type' as any, '=', params.entityType)\n .where(sql<boolean>`organization_id is not distinct from ${organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${tenantId}`)\n .where('entity_id' as any, '=', String(params.recordId))\n if (fieldPairs.length) {\n scoped = scoped.where('field' as any, 'in', fieldPairs.map(([, field]) => field))\n }\n return scoped\n }\n\n // Read through the caller's transaction when there is one. A separate connection cannot see that\n // transaction's own uncommitted writes, so it could report rows a pending delete has already\n // removed and talk this call out of re-inserting them.\n const reader = options?.trx ?? db\n\n // Count probe first, as in the batch path: it returns one row whatever the table holds, so a\n // record whose stored rows have run away (#4681) is settled without materializing them.\n const storedCountRows = await scopeTokenQuery(\n reader.selectFrom('search_tokens' as any).select(sql<number>`count(*)`.as('token_count') as any),\n ).execute()\n const storedCount = Number((storedCountRows as any[])[0]?.token_count ?? 0)\n\n let unchanged = storedCount === rows.length\n if (unchanged && rows.length) {\n const stored = await scopeTokenQuery(\n reader.selectFrom('search_tokens' as any).select(['field' as any, 'token_hash' as any, 'token' as any]),\n )\n // Counts already match, so this cannot truncate. It bounds the read if a concurrent writer\n // inserts between the probe and here; a truncated read compares as changed, which costs a\n // rewrite rather than a wrong skip.\n .limit(rows.length)\n .execute()\n unchanged = tallyEquals(tallyOf(rows), tallyOf(stored as any[]))\n }\n if (unchanged) {\n debug('record.skip', { entityType: params.entityType, recordId: params.recordId, tokenCount: rows.length })\n return\n }\n\n const writeTokens = async (executor: SearchTokenExecutor): Promise<void> => {\n let deleteQuery = executor\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', params.entityType)\n .where(sql<boolean>`organization_id is not distinct from ${organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${tenantId}`)\n if (fieldPairs.length) {\n deleteQuery = deleteQuery.where((eb: any) => eb.or(\n fieldPairs.map(([rid, field]) => eb.and([\n eb('entity_id' as any, '=', rid),\n eb('field' as any, '=', field),\n ])),\n ))\n } else {\n deleteQuery = deleteQuery.where('entity_id' as any, '=', String(params.recordId))\n }\n await deleteQuery.execute()\n if (!rows.length) return\n const payloads = rows.map((row) => ({ ...row, created_at: sql`now()` }))\n for (const batch of chunk(payloads, INSERT_BATCH_SIZE)) {\n await executor.insertInto('search_tokens' as any).values(batch as any).execute()\n }\n }\n\n if (options?.trx) {\n await writeTokens(options.trx)\n return\n }\n\n await db.transaction().execute(writeTokens)\n}\n\nexport async function deleteSearchTokensForRecord(\n db: Kysely<any>,\n params: { entityType: string; recordId: string; organizationId?: string | null; tenantId?: string | null },\n options?: { trx?: SearchTokenExecutor },\n): Promise<void> {\n const organizationId = params.organizationId ?? null\n const tenantId = params.tenantId ?? null\n const executor = options?.trx ?? db\n await executor\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', params.entityType)\n .where('entity_id' as any, '=', String(params.recordId))\n .where(sql<boolean>`organization_id is not distinct from ${organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${tenantId}`)\n .execute()\n}\n\nexport async function replaceSearchTokensForBatch(\n db: Kysely<any>,\n allPayloads: Array<BuildTokenOptions & { doc: Record<string, unknown> }>\n): Promise<void> {\n if (!allPayloads.length) return\n const config = resolveSearchConfig()\n if (!config.enabled) return\n\n // A record carrying ciphertext drops out of the batch entirely, rather than being rewritten\n // without its encrypted fields. This path deletes by `entity_id` -- it cannot express \"replace\n // these fields and leave those alone\" the way the per-record path can -- so partial handling\n // here would still delete the tokens we are trying to protect. Skipping the record leaves every\n // one of its tokens, encrypted-field and plaintext-field alike, exactly as it was. The state is\n // transient by construction: `decrypt-database` followed by a reindex rebuilds all of it.\n const guardCiphertext = shouldGuardCiphertext()\n const preservedRecordIds = new Set<string>()\n const payloads = allPayloads.filter((payload) => {\n const ciphertextFields = ciphertextFieldsOf(payload.doc, guardCiphertext)\n if (!ciphertextFields.size) return true\n warnCiphertextSkipped(payload.entityType, payload.tenantId ?? null, ciphertextFields)\n preservedRecordIds.add(String(payload.recordId))\n return false\n })\n if (!payloads.length) return\n\n const rows = payloads.flatMap((payload) => buildSearchTokenRows({ ...payload, config, guardCiphertext }))\n if (!rows.length) {\n const entityType = payloads[0]?.entityType\n if (!entityType) return\n const ids = payloads.map((p) => String(p.recordId))\n await db\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', entityType)\n .where('entity_id' as any, 'in', ids)\n .execute()\n return\n }\n\n const scopeKey = (org: string | null, tenant: string | null) => `${org ?? '__null__'}|${tenant ?? '__null__'}`\n const scopeBuckets = new Map<string, { organizationId: string | null; tenantId: string | null; ids: Set<string> }>()\n\n for (const payload of payloads) {\n const org = payload.organizationId ?? null\n const tenant = payload.tenantId ?? null\n const key = scopeKey(org, tenant)\n const bucket = scopeBuckets.get(key) ?? { organizationId: org, tenantId: tenant, ids: new Set<string>() }\n bucket.ids.add(String(payload.recordId))\n scopeBuckets.set(key, bucket)\n }\n\n const recordKeyOf = (row: SearchTokenRow) =>\n `${scopeKey(row.organization_id ?? null, row.tenant_id ?? null)}|${String(row.entity_id)}`\n const builtTally = tallyTokenRows(rows, recordKeyOf)\n\n // Read outside the transaction, deliberately. The comparison decides only whether to skip a\n // rewrite, so a concurrent writer costs us at most a rewrite we declined \u2014 declined because the\n // table already held exactly the rows this call wanted to write. One ordering is worth naming\n // though: if the read matches and a concurrent writer then commits tokens built from a *staler*\n // doc, the unconditional rewrite this call used to perform would have overwritten them by\n // accident. It no longer does, so those stale rows survive until the record's next write. That\n // is a repair we lose, not a guarantee we break.\n const changedIdsByBucket = new Map<string, Set<string>>()\n for (const [key, bucket] of scopeBuckets.entries()) {\n const ids = Array.from(bucket.ids)\n const builtCountById = new Map<string, number>()\n for (const id of ids) {\n let total = 0\n const tally = builtTally.get(`${key}|${id}`)\n if (tally) for (const count of tally.values()) total += count\n builtCountById.set(id, total)\n }\n\n // Count probe first. Its result is one row per record in the batch, so it is bounded by the\n // batch size \u2014 unlike a bare row read, which would be bounded only by how many token rows the\n // table already holds for these ids, a quantity this function does not control and (per #4681)\n // has no reason to trust.\n const storedCounts = await db\n .selectFrom('search_tokens' as any)\n .select(['entity_id' as any, sql<number>`count(*)`.as('token_count') as any])\n .where('entity_type' as any, '=', payloads[0].entityType)\n .where(sql<boolean>`organization_id is not distinct from ${bucket.organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${bucket.tenantId}`)\n .where('entity_id' as any, 'in', ids)\n .groupBy('entity_id' as any)\n .execute()\n const storedCountById = new Map<string, number>()\n for (const row of storedCounts as any[]) {\n storedCountById.set(String(row.entity_id), Number(row.token_count))\n }\n\n const changed = new Set<string>()\n // A record whose stored row count already differs is changed, whatever the rows say \u2014 the\n // duplicate case from #4681 resolves here without ever materializing the duplicated rows.\n const contentCandidates = ids.filter((id) => {\n const builtCount = builtCountById.get(id) ?? 0\n if ((storedCountById.get(id) ?? 0) !== builtCount) {\n changed.add(id)\n return false\n }\n return builtCount > 0\n })\n\n if (contentCandidates.length) {\n const rowBudget = contentCandidates.reduce((sum, id) => sum + (builtCountById.get(id) ?? 0), 0)\n const stored = await db\n .selectFrom('search_tokens' as any)\n .select(['entity_id' as any, 'field' as any, 'token_hash' as any, 'token' as any])\n .where('entity_type' as any, '=', payloads[0].entityType)\n .where(sql<boolean>`organization_id is not distinct from ${bucket.organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${bucket.tenantId}`)\n .where('entity_id' as any, 'in', contentCandidates)\n // Counts already match, so this cannot truncate \u2014 it bounds the damage if a concurrent\n // writer inserts between the probe and this read. A truncated read compares as changed,\n // which costs a rewrite rather than a wrong skip.\n .limit(rowBudget)\n .execute()\n const storedTally = tallyTokenRows(stored as any[], (row) => String(row.entity_id))\n for (const id of contentCandidates) {\n if (!tallyEquals(builtTally.get(`${key}|${id}`), storedTally.get(id))) changed.add(id)\n }\n }\n changedIdsByBucket.set(key, changed)\n }\n\n const changedRecordKeys = new Set<string>()\n for (const [key, changed] of changedIdsByBucket.entries()) {\n for (const id of changed) changedRecordKeys.add(`${key}|${id}`)\n }\n debug('batch.skip', {\n entityType: payloads[0].entityType,\n recordCount: payloads.length,\n changedCount: changedRecordKeys.size,\n preservedCiphertextRecordCount: preservedRecordIds.size,\n })\n if (!changedRecordKeys.size) return\n\n await db.transaction().execute(async (trx) => {\n for (const [key, bucket] of scopeBuckets.entries()) {\n const changed = changedIdsByBucket.get(key)\n if (!changed?.size) continue\n // Delete by entity_id: a batch replaces all of a record's tokens, and a per-field OR over the\n // whole batch overflows the query compiler's call stack on large batches.\n const deleteQuery = trx\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', payloads[0].entityType)\n .where(sql<boolean>`organization_id is not distinct from ${bucket.organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${bucket.tenantId}`)\n .where('entity_id' as any, 'in', Array.from(changed))\n await deleteQuery.execute()\n }\n const payloadWithTimestamps = rows\n .filter((row) => changedRecordKeys.has(recordKeyOf(row)))\n .map((row) => ({ ...row, created_at: sql`now()` }))\n for (const batch of chunk(payloadWithTimestamps, INSERT_BATCH_SIZE)) {\n await trx.insertInto('search_tokens' as any).values(batch as any).execute()\n }\n })\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { type Kysely, type Transaction, sql } from 'kysely'\nimport {\n isSearchFieldBlocklisted,\n resolveSearchConfig,\n resolveSearchTokenLimits,\n type SearchConfig,\n} from '@open-mercato/shared/lib/search/config'\nimport { tokenizeText } from '@open-mercato/shared/lib/search/tokenize'\nimport { looksLikeEncryptedPayload } from '@open-mercato/shared/lib/encryption/aes'\nimport { createKmsService, resolveEncryptionMode, type KmsService } from '@open-mercato/shared/lib/encryption/kms'\nimport { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('query_index').child({ component: 'search-tokens' })\n\nconst INSERT_BATCH_SIZE = 500\n\nfunction chunk<T>(items: T[], size: number): T[][] {\n if (size <= 0) return [items]\n const out: T[][] = []\n for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))\n return out\n}\n\nexport type SearchTokenRow = {\n entity_type: string\n entity_id: string\n organization_id: string | null\n tenant_id: string | null\n field: string\n token_hash: string\n token?: string | null\n}\n\ntype BuildTokenOptions = {\n entityType: string\n recordId: string\n organizationId?: string | null\n tenantId?: string | null\n doc?: Record<string, unknown> | null\n config?: SearchConfig\n /** Resolved once per write by the exported entry points; see {@link shouldGuardCiphertext}. */\n guardCiphertext?: boolean\n}\n\nconst DEFAULT_SCOPE = { organizationId: null, tenantId: null }\ntype EntityFieldPair = [string, string]\ntype SearchTokenExecutor = Kysely<any> | Transaction<any>\n\nexport const isSearchDebugEnabled = (): boolean => {\n return parseBooleanToken(process.env.OM_SEARCH_DEBUG ?? '') === true\n}\n\nconst debug = (event: string, payload: Record<string, unknown>) => {\n if (!isSearchDebugEnabled()) return\n try {\n logger.debug('Search token event', { event, payload })\n } catch {\n // ignore\n }\n}\n\nfunction collectTextValues(value: unknown): string[] {\n if (typeof value === 'string') return [value]\n if (Array.isArray(value)) {\n const out: string[] = []\n for (const entry of value) {\n if (typeof entry === 'string') out.push(entry)\n }\n return out\n }\n return []\n}\n\nlet guardKmsService: KmsService | null = null\n\n/**\n * Whether the ciphertext guard below is allowed to run for this write.\n *\n * The guard recognises an envelope by its SHAPE, which is forgeable: `<16 b64>:<b64>:<24 b64>:v1`\n * is a string any user can type into a searchable field. `tenantDataEncryptionService` removed the\n * same structural test for that reason (#2720). So the guard may only run where the shape is the\n * ONLY test available -- which is exactly where no DEK is reachable:\n *\n * - `active` -- the indexer decrypted the document before handing it here, so a value still\n * shaped like an envelope is plaintext somebody typed. Indexing it is correct,\n * and skipping it would let that person freeze their own record's tokens at a\n * past state. The guard stays off, which also keeps it off the hot path of\n * every normal deployment.\n * - `disabled` -- `decryptIndexDocForSearch` is a no-op, so ciphertext arrives undecrypted.\n * - `unavailable` -- the decrypt was attempted and could not complete, same outcome.\n *\n * Resolved once per write rather than per document, over a KMS built once per process:\n * {@link createKmsService} logs when it falls back, and a reindex calls this once per record. The\n * toggle itself is still re-read every call by {@link resolveEncryptionMode}; only the KMS is\n * cached, and it already requires a restart to change, since DEK and map caches are in-process.\n */\nfunction shouldGuardCiphertext(): boolean {\n guardKmsService ??= createKmsService()\n return resolveEncryptionMode(guardKmsService) !== 'active'\n}\n\n/**\n * Fields whose value is an AES-GCM envelope rather than the text it is supposed to hold.\n *\n * Search tokens are hashes of PLAINTEXT: the indexer decrypts a document before tokenising it\n * (`indexer.ts` -> `decryptIndexDocForSearch`), which is what lets the token index survive\n * encryption being switched on or off. That decrypt step is a no-op once\n * `TENANT_DATA_ENCRYPTION=no`, so an operator who flips the toggle before running\n * `mercato entities decrypt-database` starts feeding ciphertext into the tokeniser. The tokens\n * that come out are hashes of base64 noise and match nothing, and because a write REPLACES a\n * record's tokens, the good plaintext tokens already in the table would be deleted to make room\n * for them -- turning a recoverable misordering into permanent search loss.\n *\n * Detecting the envelope by shape lets the write skip those fields and leave what is already\n * indexed alone. `guard` gates that detection; see {@link shouldGuardCiphertext} for why it is not\n * unconditional.\n */\nfunction ciphertextFieldsOf(\n doc: Record<string, unknown> | null | undefined,\n guard: boolean,\n): Set<string> {\n const fields = new Set<string>()\n if (!guard || !doc) return fields\n for (const [field, value] of Object.entries(doc)) {\n const values = collectTextValues(value)\n if (values.length && values.some((text) => looksLikeEncryptedPayload(text))) fields.add(field)\n }\n return fields\n}\n\nconst warnedCiphertextEntities = new Set<string>()\n\nfunction warnCiphertextSkipped(entityType: string, tenantId: string | null, fields: Set<string>): void {\n if (!fields.size) return\n // Once per entity type per tenant per process: a full reindex would otherwise emit this per\n // record, while keying on the entity type alone would let the first affected tenant in a shared\n // process consume the one warning every other tenant's operator needed.\n const key = `${entityType}|${tenantId ?? ''}`\n if (warnedCiphertextEntities.has(key)) return\n warnedCiphertextEntities.add(key)\n logger.warn(\n 'Search indexing skipped ciphertext fields and preserved their existing tokens. '\n + 'This means TENANT_DATA_ENCRYPTION was switched off while encrypted data was still at rest. '\n + 'Run `mercato entities decrypt-database` and reindex; until then these fields are not searchable.',\n { entityType, tenantId, fields: Array.from(fields).sort((left, right) => left.localeCompare(right)) },\n )\n}\n\n/** Test seam: both the warning above and the KMS behind the guard are once-per-process. */\nexport function resetCiphertextGuardState(): void {\n warnedCiphertextEntities.clear()\n guardKmsService = null\n}\n\nfunction shouldIndexField(\n field: string,\n value: unknown,\n config: SearchConfig,\n entityType: string | null,\n): boolean {\n if (typeof value !== 'string' && !Array.isArray(value)) return false\n const lower = field.toLowerCase()\n if (lower === 'id' || lower.endsWith('_id') || lower.endsWith('.id')) return false\n if (lower.endsWith('_at')) return false\n if (['created_at', 'updated_at', 'deleted_at', 'tenant_id', 'organization_id'].includes(lower)) return false\n if (isSearchFieldBlocklisted(field, entityType, config)) return false\n return collectTextValues(value).some((text) => text.length > 0)\n}\n\n/**\n * Builds the `search_tokens` rows for one document.\n *\n * `maxTokensPerRecord` is spent in the iteration order of `params.doc`'s own keys: fields are\n * tokenized one after another and the loop stops at the first field that exhausts the budget, so\n * on an over-budget record the surviving fields are whichever ones come first. That order is a\n * property of the object handed in, not of the entity, and the two write-path builders order it\n * differently: `buildIndexDocument` (`lib/document.ts`, the batch reindex path) appends `cf:*` keys\n * after the base columns and the aggregate `search_text` field last. `buildIndexDoc` (`lib/indexer.ts`,\n * the incremental single-record write path \u2014 `upsertIndexRow` \u2192 `reindexSearchTokensForRecord`) adds\n * `l10n:{locale}:{field}` translation keys between the `cf:*` keys and `search_text`. The budget is\n * spent front-to-back, so the fields nearest the end of that order are starved first: `search_text`\n * is last on both paths and starves first either way, but starves sooner on the incremental path,\n * because the `l10n:*` keys ahead of it there consume budget the batch path would have spent on it.\n * Those translation fields starve next, before any `cf:*` key or base column. The two paths can\n * therefore keep different fields searchable for an otherwise equivalent record.\n *\n * Every write path tokenizes an in-memory document it is about to write \u2014 `TokenSearchStrategy.index`\n * writes only `search_tokens` rows and never touches `entity_indexes.doc` \u2014 which keeps each path\n * self-consistent on its own. A document read back out of the `entity_indexes.doc` `jsonb` column is\n * not the same object: Postgres stores `jsonb` keys in a canonical order (by key length, then byte\n * order), not in insertion order. Re-tokenizing such a document can therefore truncate at a different\n * field than the write did, so code that verifies or recomputes a record's expected tokens must\n * rebuild the document through the builder that matches the write path being checked \u2014 `buildIndexDoc`\n * for incrementally-written records, `buildIndexDocument` for batch-reindexed ones \u2014 rather than\n * reading it back from the database.\n *\n * Making truncation reproducible from a `jsonb` read \u2014 by sorting fields before tokenization \u2014\n * would change which terms stay searchable on over-budget records, so it is a behavior decision\n * rather than a clarification (#5971).\n */\nexport function buildSearchTokenRows(params: BuildTokenOptions): SearchTokenRow[] {\n const config = params.config ?? resolveSearchConfig()\n if (!config.enabled) return []\n if (!params.doc) return []\n const tokens: SearchTokenRow[] = []\n const capturePairs = isSearchDebugEnabled() && params.entityType === 'customers:customer_deal'\n const debugPairs: Array<{ field: string; hash: string }> = []\n const scope = {\n organizationId: params.organizationId ?? DEFAULT_SCOPE.organizationId,\n tenantId: params.tenantId ?? DEFAULT_SCOPE.tenantId,\n }\n const limits = resolveSearchTokenLimits(config)\n const recordLimit = limits.maxTokensPerRecord > 0 ? limits.maxTokensPerRecord : Number.POSITIVE_INFINITY\n const fieldLimit = limits.maxTokensPerField > 0 ? limits.maxTokensPerField : Number.POSITIVE_INFINITY\n const ciphertextFields = ciphertextFieldsOf(params.doc, params.guardCiphertext ?? shouldGuardCiphertext())\n warnCiphertextSkipped(params.entityType, scope.tenantId, ciphertextFields)\n\n for (const [field, rawValue] of Object.entries(params.doc)) {\n if (tokens.length >= recordLimit) break\n if (ciphertextFields.has(field)) continue\n if (!shouldIndexField(field, rawValue, config, params.entityType)) continue\n const values = collectTextValues(rawValue)\n const seen = new Set<string>()\n let fieldTokenCount = 0\n for (const text of values) {\n if (tokens.length >= recordLimit || fieldTokenCount >= fieldLimit) break\n const remainingLimit = Math.min(recordLimit - tokens.length, fieldLimit - fieldTokenCount)\n const candidateLimit = fieldTokenCount + remainingLimit\n const tokenConfig = Number.isFinite(candidateLimit)\n ? { ...config, maxTokensPerField: candidateLimit }\n : config\n const { tokens: textTokens, hashes } = tokenizeText(text, tokenConfig)\n for (let i = 0; i < textTokens.length; i += 1) {\n if (tokens.length >= recordLimit || fieldTokenCount >= fieldLimit) break\n const token = textTokens[i]\n const hash = hashes[i]\n const dedupeKey = `${field}|${hash}`\n if (seen.has(dedupeKey)) continue\n seen.add(dedupeKey)\n fieldTokenCount += 1\n debug('token.generated', { entityType: params.entityType, recordId: params.recordId, field, hash })\n tokens.push({\n entity_type: params.entityType,\n entity_id: String(params.recordId),\n organization_id: scope.organizationId,\n tenant_id: scope.tenantId,\n field,\n token_hash: hash,\n token: config.storeRawTokens ? token : null,\n })\n if (capturePairs) {\n debugPairs.push({ field, hash })\n }\n }\n }\n }\n if (capturePairs) {\n debug('deal.tokens', {\n entityType: params.entityType,\n recordId: params.recordId,\n tokenCount: debugPairs.length,\n tokens: debugPairs,\n })\n }\n debug('doc.completed', { entityType: params.entityType, recordId: params.recordId, tokenCount: tokens.length })\n\n return tokens\n}\n\nfunction buildFieldPairs(\n recordId: string,\n doc?: Record<string, unknown> | null,\n skipFields?: Set<string>,\n): EntityFieldPair[] {\n if (!doc) return []\n const pairs: EntityFieldPair[] = []\n const dedupe = new Set<string>()\n for (const field of Object.keys(doc)) {\n // The delete below is scoped to these pairs, so omitting a field here is what preserves the\n // tokens already stored for it rather than merely declining to write new ones.\n if (skipFields?.has(field)) continue\n const key = `${recordId}|${field}`\n if (dedupe.has(key)) continue\n dedupe.add(key)\n pairs.push([recordId, field])\n }\n return pairs\n}\n\ntype TokenRowLike = { field?: unknown; token_hash?: unknown; token?: unknown }\n\n// NUL, not a printable separator: a field name may itself contain a space, so `a b` + hash `c`\n// would otherwise sign identically to field `a` + hash `b c`.\nconst SIGNATURE_SEPARATOR = String.fromCharCode(0)\n\n// Identifies one token row for comparison. `token` is NULL unless `storeRawTokens` is on, and a\n// stored NULL has to sign the same as the `null` a freshly built row carries \u2014 otherwise every\n// record compares as changed and the skip never fires.\nfunction tokenSignature(row: TokenRowLike): string {\n return [\n String(row.field ?? ''),\n String(row.token_hash ?? ''),\n row.token == null ? '' : String(row.token),\n ].join(SIGNATURE_SEPARATOR)\n}\n\n// Multiplicities, not sets: #4681 reports token rows duplicated by the concurrent-replacement\n// defect, and a set comparison reads such a record as already correct and preserves the duplicates\n// forever. Counting sends it through a full rewrite, which collapses them.\nfunction tallyOf(rows: Iterable<TokenRowLike>): Map<string, number> {\n const tally = new Map<string, number>()\n for (const row of rows) {\n const signature = tokenSignature(row)\n tally.set(signature, (tally.get(signature) ?? 0) + 1)\n }\n return tally\n}\n\nfunction tallyEquals(a: Map<string, number> | undefined, b: Map<string, number> | undefined): boolean {\n const left = a ?? new Map<string, number>()\n const right = b ?? new Map<string, number>()\n if (left.size !== right.size) return false\n for (const [key, count] of left.entries()) {\n if (right.get(key) !== count) return false\n }\n return true\n}\n\nfunction tallyTokenRows<TRow extends TokenRowLike>(\n rows: Iterable<TRow>,\n keyOf: (row: TRow) => string\n): Map<string, Map<string, number>> {\n const tallies = new Map<string, Map<string, number>>()\n for (const row of rows) {\n const key = keyOf(row)\n const tally = tallies.get(key) ?? new Map<string, number>()\n const signature = tokenSignature(row)\n tally.set(signature, (tally.get(signature) ?? 0) + 1)\n tallies.set(key, tally)\n }\n return tallies\n}\n\nexport async function replaceSearchTokensForRecord(\n db: Kysely<any>,\n params: BuildTokenOptions,\n options?: { trx?: SearchTokenExecutor },\n): Promise<void> {\n const guardCiphertext = params.guardCiphertext ?? shouldGuardCiphertext()\n const rows = buildSearchTokenRows({ ...params, guardCiphertext })\n const config = params.config ?? resolveSearchConfig()\n if (!config.enabled) return\n const organizationId = params.organizationId ?? null\n const tenantId = params.tenantId ?? null\n const ciphertextFields = ciphertextFieldsOf(params.doc, guardCiphertext)\n const fieldPairs = buildFieldPairs(String(params.recordId), params.doc, ciphertextFields)\n\n // An empty pair list normally means the document is gone, and the delete below then purges the\n // record wholesale. It can now also mean every field was skipped as ciphertext, where a purge\n // would destroy precisely the tokens the skip exists to protect. Distinguish the two.\n if (params.doc && ciphertextFields.size && !fieldPairs.length) {\n debug('record.preserve-ciphertext', { entityType: params.entityType, recordId: params.recordId })\n return\n }\n\n // Same comparison #5402 gave the batch path, over the scope this path actually writes: the\n // delete below is narrowed to the document's own `(entity_id, field)` pairs, so the comparison\n // has to be narrowed the same way. Reading wider would let a token row under a field this\n // document does not carry \u2014 the `cf_` twin the search module used to write, say \u2014 read as a\n // difference forever and defeat the skip on every write.\n const scopeTokenQuery = (query: any): any => {\n let scoped = query\n .where('entity_type' as any, '=', params.entityType)\n .where(sql<boolean>`organization_id is not distinct from ${organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${tenantId}`)\n .where('entity_id' as any, '=', String(params.recordId))\n if (fieldPairs.length) {\n scoped = scoped.where('field' as any, 'in', fieldPairs.map(([, field]) => field))\n }\n return scoped\n }\n\n // Read through the caller's transaction when there is one. A separate connection cannot see that\n // transaction's own uncommitted writes, so it could report rows a pending delete has already\n // removed and talk this call out of re-inserting them.\n const reader = options?.trx ?? db\n\n // Count probe first, as in the batch path: it returns one row whatever the table holds, so a\n // record whose stored rows have run away (#4681) is settled without materializing them.\n const storedCountRows = await scopeTokenQuery(\n reader.selectFrom('search_tokens' as any).select(sql<number>`count(*)`.as('token_count') as any),\n ).execute()\n const storedCount = Number((storedCountRows as any[])[0]?.token_count ?? 0)\n\n let unchanged = storedCount === rows.length\n if (unchanged && rows.length) {\n const stored = await scopeTokenQuery(\n reader.selectFrom('search_tokens' as any).select(['field' as any, 'token_hash' as any, 'token' as any]),\n )\n // Counts already match, so this cannot truncate. It bounds the read if a concurrent writer\n // inserts between the probe and here; a truncated read compares as changed, which costs a\n // rewrite rather than a wrong skip.\n .limit(rows.length)\n .execute()\n unchanged = tallyEquals(tallyOf(rows), tallyOf(stored as any[]))\n }\n if (unchanged) {\n debug('record.skip', { entityType: params.entityType, recordId: params.recordId, tokenCount: rows.length })\n return\n }\n\n const writeTokens = async (executor: SearchTokenExecutor): Promise<void> => {\n let deleteQuery = executor\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', params.entityType)\n .where(sql<boolean>`organization_id is not distinct from ${organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${tenantId}`)\n if (fieldPairs.length) {\n deleteQuery = deleteQuery.where((eb: any) => eb.or(\n fieldPairs.map(([rid, field]) => eb.and([\n eb('entity_id' as any, '=', rid),\n eb('field' as any, '=', field),\n ])),\n ))\n } else {\n deleteQuery = deleteQuery.where('entity_id' as any, '=', String(params.recordId))\n }\n await deleteQuery.execute()\n if (!rows.length) return\n const payloads = rows.map((row) => ({ ...row, created_at: sql`now()` }))\n for (const batch of chunk(payloads, INSERT_BATCH_SIZE)) {\n await executor.insertInto('search_tokens' as any).values(batch as any).execute()\n }\n }\n\n if (options?.trx) {\n await writeTokens(options.trx)\n return\n }\n\n await db.transaction().execute(writeTokens)\n}\n\nexport async function deleteSearchTokensForRecord(\n db: Kysely<any>,\n params: { entityType: string; recordId: string; organizationId?: string | null; tenantId?: string | null },\n options?: { trx?: SearchTokenExecutor },\n): Promise<void> {\n const organizationId = params.organizationId ?? null\n const tenantId = params.tenantId ?? null\n const executor = options?.trx ?? db\n await executor\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', params.entityType)\n .where('entity_id' as any, '=', String(params.recordId))\n .where(sql<boolean>`organization_id is not distinct from ${organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${tenantId}`)\n .execute()\n}\n\nexport async function replaceSearchTokensForBatch(\n db: Kysely<any>,\n allPayloads: Array<BuildTokenOptions & { doc: Record<string, unknown> }>\n): Promise<void> {\n if (!allPayloads.length) return\n const config = resolveSearchConfig()\n if (!config.enabled) return\n\n // A record carrying ciphertext drops out of the batch entirely, rather than being rewritten\n // without its encrypted fields. This path deletes by `entity_id` -- it cannot express \"replace\n // these fields and leave those alone\" the way the per-record path can -- so partial handling\n // here would still delete the tokens we are trying to protect. Skipping the record leaves every\n // one of its tokens, encrypted-field and plaintext-field alike, exactly as it was. The state is\n // transient by construction: `decrypt-database` followed by a reindex rebuilds all of it.\n const guardCiphertext = shouldGuardCiphertext()\n const preservedRecordIds = new Set<string>()\n const payloads = allPayloads.filter((payload) => {\n const ciphertextFields = ciphertextFieldsOf(payload.doc, guardCiphertext)\n if (!ciphertextFields.size) return true\n warnCiphertextSkipped(payload.entityType, payload.tenantId ?? null, ciphertextFields)\n preservedRecordIds.add(String(payload.recordId))\n return false\n })\n if (!payloads.length) return\n\n const rows = payloads.flatMap((payload) => buildSearchTokenRows({ ...payload, config, guardCiphertext }))\n if (!rows.length) {\n const entityType = payloads[0]?.entityType\n if (!entityType) return\n const ids = payloads.map((p) => String(p.recordId))\n await db\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', entityType)\n .where('entity_id' as any, 'in', ids)\n .execute()\n return\n }\n\n const scopeKey = (org: string | null, tenant: string | null) => `${org ?? '__null__'}|${tenant ?? '__null__'}`\n const scopeBuckets = new Map<string, { organizationId: string | null; tenantId: string | null; ids: Set<string> }>()\n\n for (const payload of payloads) {\n const org = payload.organizationId ?? null\n const tenant = payload.tenantId ?? null\n const key = scopeKey(org, tenant)\n const bucket = scopeBuckets.get(key) ?? { organizationId: org, tenantId: tenant, ids: new Set<string>() }\n bucket.ids.add(String(payload.recordId))\n scopeBuckets.set(key, bucket)\n }\n\n const recordKeyOf = (row: SearchTokenRow) =>\n `${scopeKey(row.organization_id ?? null, row.tenant_id ?? null)}|${String(row.entity_id)}`\n const builtTally = tallyTokenRows(rows, recordKeyOf)\n\n // Read outside the transaction, deliberately. The comparison decides only whether to skip a\n // rewrite, so a concurrent writer costs us at most a rewrite we declined \u2014 declined because the\n // table already held exactly the rows this call wanted to write. One ordering is worth naming\n // though: if the read matches and a concurrent writer then commits tokens built from a *staler*\n // doc, the unconditional rewrite this call used to perform would have overwritten them by\n // accident. It no longer does, so those stale rows survive until the record's next write. That\n // is a repair we lose, not a guarantee we break.\n const changedIdsByBucket = new Map<string, Set<string>>()\n for (const [key, bucket] of scopeBuckets.entries()) {\n const ids = Array.from(bucket.ids)\n const builtCountById = new Map<string, number>()\n for (const id of ids) {\n let total = 0\n const tally = builtTally.get(`${key}|${id}`)\n if (tally) for (const count of tally.values()) total += count\n builtCountById.set(id, total)\n }\n\n // Count probe first. Its result is one row per record in the batch, so it is bounded by the\n // batch size \u2014 unlike a bare row read, which would be bounded only by how many token rows the\n // table already holds for these ids, a quantity this function does not control and (per #4681)\n // has no reason to trust.\n const storedCounts = await db\n .selectFrom('search_tokens' as any)\n .select(['entity_id' as any, sql<number>`count(*)`.as('token_count') as any])\n .where('entity_type' as any, '=', payloads[0].entityType)\n .where(sql<boolean>`organization_id is not distinct from ${bucket.organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${bucket.tenantId}`)\n .where('entity_id' as any, 'in', ids)\n .groupBy('entity_id' as any)\n .execute()\n const storedCountById = new Map<string, number>()\n for (const row of storedCounts as any[]) {\n storedCountById.set(String(row.entity_id), Number(row.token_count))\n }\n\n const changed = new Set<string>()\n // A record whose stored row count already differs is changed, whatever the rows say \u2014 the\n // duplicate case from #4681 resolves here without ever materializing the duplicated rows.\n const contentCandidates = ids.filter((id) => {\n const builtCount = builtCountById.get(id) ?? 0\n if ((storedCountById.get(id) ?? 0) !== builtCount) {\n changed.add(id)\n return false\n }\n return builtCount > 0\n })\n\n if (contentCandidates.length) {\n const rowBudget = contentCandidates.reduce((sum, id) => sum + (builtCountById.get(id) ?? 0), 0)\n const stored = await db\n .selectFrom('search_tokens' as any)\n .select(['entity_id' as any, 'field' as any, 'token_hash' as any, 'token' as any])\n .where('entity_type' as any, '=', payloads[0].entityType)\n .where(sql<boolean>`organization_id is not distinct from ${bucket.organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${bucket.tenantId}`)\n .where('entity_id' as any, 'in', contentCandidates)\n // Counts already match, so this cannot truncate \u2014 it bounds the damage if a concurrent\n // writer inserts between the probe and this read. A truncated read compares as changed,\n // which costs a rewrite rather than a wrong skip.\n .limit(rowBudget)\n .execute()\n const storedTally = tallyTokenRows(stored as any[], (row) => String(row.entity_id))\n for (const id of contentCandidates) {\n if (!tallyEquals(builtTally.get(`${key}|${id}`), storedTally.get(id))) changed.add(id)\n }\n }\n changedIdsByBucket.set(key, changed)\n }\n\n const changedRecordKeys = new Set<string>()\n for (const [key, changed] of changedIdsByBucket.entries()) {\n for (const id of changed) changedRecordKeys.add(`${key}|${id}`)\n }\n debug('batch.skip', {\n entityType: payloads[0].entityType,\n recordCount: payloads.length,\n changedCount: changedRecordKeys.size,\n preservedCiphertextRecordCount: preservedRecordIds.size,\n })\n if (!changedRecordKeys.size) return\n\n await db.transaction().execute(async (trx) => {\n for (const [key, bucket] of scopeBuckets.entries()) {\n const changed = changedIdsByBucket.get(key)\n if (!changed?.size) continue\n // Delete by entity_id: a batch replaces all of a record's tokens, and a per-field OR over the\n // whole batch overflows the query compiler's call stack on large batches.\n const deleteQuery = trx\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', payloads[0].entityType)\n .where(sql<boolean>`organization_id is not distinct from ${bucket.organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${bucket.tenantId}`)\n .where('entity_id' as any, 'in', Array.from(changed))\n await deleteQuery.execute()\n }\n const payloadWithTimestamps = rows\n .filter((row) => changedRecordKeys.has(recordKeyOf(row)))\n .map((row) => ({ ...row, created_at: sql`now()` }))\n for (const batch of chunk(payloadWithTimestamps, INSERT_BATCH_SIZE)) {\n await trx.insertInto('search_tokens' as any).values(batch as any).execute()\n }\n })\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -109,6 +109,8 @@ function AdjustInventoryDialog({
109
109
  );
110
110
  const [submitting, setSubmitting] = React.useState(false);
111
111
  const [form, setForm] = React.useState(EMPTY_FORM);
112
+ const [deltaInputText, setDeltaInputText] = React.useState(String(EMPTY_FORM.delta));
113
+ const deltaInputFocusedRef = React.useRef(false);
112
114
  const [fieldErrors, setFieldErrors] = React.useState({});
113
115
  const [onHand, setOnHand] = React.useState(null);
114
116
  const [previewError, setPreviewError] = React.useState(null);
@@ -153,6 +155,7 @@ function AdjustInventoryDialog({
153
155
  );
154
156
  const resetDialog = React.useCallback(() => {
155
157
  setForm(EMPTY_FORM);
158
+ setDeltaInputText(String(EMPTY_FORM.delta));
156
159
  setFieldErrors({});
157
160
  setOnHand(null);
158
161
  setPreviewError(null);
@@ -169,6 +172,9 @@ function AdjustInventoryDialog({
169
172
  setForm((current) => ({ ...current, ...patch }));
170
173
  setFieldErrors({});
171
174
  }, []);
175
+ React.useEffect(() => {
176
+ if (!deltaInputFocusedRef.current) setDeltaInputText(String(form.delta));
177
+ }, [form.delta]);
172
178
  React.useEffect(() => {
173
179
  if (!open) return;
174
180
  const catalogVariantId = initialCatalogVariantId?.trim();
@@ -644,12 +650,20 @@ function AdjustInventoryDialog({
644
650
  {
645
651
  type: "text",
646
652
  inputMode: "decimal",
647
- value: String(form.delta),
653
+ value: deltaInputText,
648
654
  onChange: (event) => {
655
+ setDeltaInputText(event.target.value);
649
656
  const parsed = parseDeltaInput(event.target.value, locale);
650
657
  if (parsed == null) return;
651
658
  patchForm({ delta: parsed });
652
659
  },
660
+ onFocus: () => {
661
+ deltaInputFocusedRef.current = true;
662
+ },
663
+ onBlur: () => {
664
+ deltaInputFocusedRef.current = false;
665
+ setDeltaInputText(String(form.delta));
666
+ },
653
667
  className: "h-8 w-auto min-w-0 flex-1 border-0 bg-transparent p-0 shadow-none focus-visible:ring-0",
654
668
  inputClassName: "text-center [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none",
655
669
  disabled: submitting
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../src/modules/wms/components/backend/AdjustInventoryDialog.tsx"],
4
- "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { z } from 'zod'\nimport { useQueryClient } from '@tanstack/react-query'\nimport { Minus, Package, Plus } from 'lucide-react'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { flashMutationError } from '../../lib/flashMutationError'\nimport { ComboboxInput } from '@open-mercato/ui/backend/inputs/ComboboxInput'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { raiseCrudError } from '@open-mercato/ui/backend/utils/serverErrors'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from '@open-mercato/ui/primitives/dialog'\nimport { FormField } from '@open-mercato/ui/primitives/form-field'\nimport { IconButton } from '@open-mercato/ui/primitives/icon-button'\nimport { Input } from '@open-mercato/ui/primitives/input'\nimport { KbdShortcut } from '@open-mercato/ui/primitives/kbd'\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '@open-mercato/ui/primitives/select'\nimport { StatusBadge } from '@open-mercato/ui/primitives/status-badge'\nimport { Textarea } from '@open-mercato/ui/primitives/textarea'\nimport { useT, useLocale } from '@open-mercato/shared/lib/i18n/context'\nimport { parseLocaleNumber } from '@open-mercato/shared/lib/number'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { buildInventoryMutationReferenceId } from '../../lib/inventoryMutationUi'\nimport {\n BalanceLookupError,\n ensureLotIdForInventoryMutation,\n fetchBalanceOnHand,\n fetchVariantReorderPoint,\n findLotIdByNumber,\n InventoryLotMutationError,\n loadCatalogVariantOptions,\n loadLocationOptions,\n loadLotNumberOptions,\n loadWarehouseOptions,\n resolveCatalogVariantLabel,\n resolveLocationLabel,\n resolveLotNumberFromId,\n resolveWarehouseLabel,\n} from './inventoryMutationLoaders'\nimport type { useWmsInventoryMutationAccess } from './useWmsInventoryMutationAccess'\n\nconst logger = createLogger('wms')\n\nconst ADJUST_REASON_CODES = ['damaged', 'shrinkage', 'found', 'correction', 'other'] as const\n\ntype AdjustReasonCode = (typeof ADJUST_REASON_CODES)[number]\n\ntype AdjustFormValues = {\n catalogVariantId: string\n warehouseId: string\n locationId: string\n lotNumber: string\n delta: number\n reasonCode: AdjustReasonCode | ''\n notes: string\n serialNumber: string\n}\n\ntype AdjustInventoryDialogProps = {\n open: boolean\n onOpenChange: (open: boolean) => void\n access: ReturnType<typeof useWmsInventoryMutationAccess>\n initialCatalogVariantId?: string\n initialWarehouseId?: string\n initialLocationId?: string\n initialLotId?: string\n}\n\nconst EMPTY_FORM: AdjustFormValues = {\n catalogVariantId: '',\n warehouseId: '',\n locationId: '',\n lotNumber: '',\n delta: 1,\n reasonCode: '',\n notes: '',\n serialNumber: '',\n}\n\nexport function parseDeltaInput(value: string, locale?: string): number | null {\n const trimmed = value.trim()\n if (!trimmed || trimmed === '-' || trimmed === '+') return null\n return parseLocaleNumber(trimmed, locale)\n}\n\nexport function AdjustInventoryDialog({\n open,\n onOpenChange,\n access,\n initialCatalogVariantId,\n initialWarehouseId,\n initialLocationId,\n initialLotId,\n}: AdjustInventoryDialogProps) {\n const t = useT()\n const locale = useLocale()\n const queryClient = useQueryClient()\n const formRef = React.useRef<HTMLFormElement>(null)\n const { runMutation, retryLastMutation } = useGuardedMutation<{\n retryLastMutation: () => Promise<boolean>\n }>({\n contextId: 'wms-inventory-adjust',\n })\n const mutationContext = React.useMemo(\n () => ({ retryLastMutation }),\n [retryLastMutation],\n )\n const adjustFormSchema = React.useMemo(\n () =>\n z.object({\n catalogVariantId: z.string().uuid(),\n warehouseId: z.string().uuid(),\n locationId: z.string().uuid(),\n lotNumber: z.string().trim().max(120).optional(),\n delta: z.coerce.number().refine((value) => value !== 0, {\n message: t(\n 'wms.backend.inventory.adjust.errors.deltaZero',\n 'Inventory delta must be non-zero.',\n ),\n }),\n reasonCode: z.enum(ADJUST_REASON_CODES),\n notes: z.string().trim().max(500).optional(),\n serialNumber: z.string().trim().max(120).optional(),\n }),\n [t],\n )\n const [submitting, setSubmitting] = React.useState(false)\n const [form, setForm] = React.useState<AdjustFormValues>(EMPTY_FORM)\n const [fieldErrors, setFieldErrors] = React.useState<Record<string, string>>({})\n const [onHand, setOnHand] = React.useState<number | null>(null)\n const [previewError, setPreviewError] = React.useState<string | null>(null)\n const [reorderPoint, setReorderPoint] = React.useState(0)\n const [loadingPreview, setLoadingPreview] = React.useState(false)\n const [optionLabelByValue, setOptionLabelByValue] = React.useState<Record<string, string>>({})\n\n const registerOptionLabels = React.useCallback(\n (options: Array<{ value: string; label: string }>) => {\n setOptionLabelByValue((current) => {\n let changed = false\n const next = { ...current }\n for (const option of options) {\n const value = option.value.trim()\n const label = option.label.trim()\n if (!value || !label || next[value] === label) continue\n next[value] = label\n changed = true\n }\n return changed ? next : current\n })\n },\n [],\n )\n\n const resolveOptionLabel = React.useCallback(\n (value: string) => optionLabelByValue[value] ?? value,\n [optionLabelByValue],\n )\n const optionLabelByValueRef = React.useRef(optionLabelByValue)\n optionLabelByValueRef.current = optionLabelByValue\n\n const reasonLabel = React.useCallback(\n (code: AdjustReasonCode) => {\n const fallbacks: Record<AdjustReasonCode, string> = {\n damaged: 'Damaged',\n shrinkage: 'Shrinkage',\n found: 'Found stock',\n correction: 'Correction',\n other: 'Other',\n }\n return t(`wms.backend.inventory.adjust.reasons.${code}`, fallbacks[code])\n },\n [t],\n )\n\n const resetDialog = React.useCallback(() => {\n setForm(EMPTY_FORM)\n setFieldErrors({})\n setOnHand(null)\n setPreviewError(null)\n setReorderPoint(0)\n setLoadingPreview(false)\n setSubmitting(false)\n setOptionLabelByValue({})\n }, [])\n\n const closeDialog = React.useCallback(() => {\n onOpenChange(false)\n resetDialog()\n }, [onOpenChange, resetDialog])\n\n const patchForm = React.useCallback((patch: Partial<AdjustFormValues>) => {\n setForm((current) => ({ ...current, ...patch }))\n setFieldErrors({})\n }, [])\n\n React.useEffect(() => {\n if (!open) return\n const catalogVariantId = initialCatalogVariantId?.trim()\n const warehouseId = initialWarehouseId?.trim()\n const locationId = initialLocationId?.trim()\n const lotId = initialLotId?.trim()\n if (!catalogVariantId && !warehouseId && !locationId && !lotId) return\n setForm((current) => ({\n ...current,\n ...(catalogVariantId ? { catalogVariantId } : {}),\n ...(warehouseId ? { warehouseId, locationId: locationId ?? '' } : {}),\n ...(locationId ? { locationId } : {}),\n }))\n if (!lotId) return\n let cancelled = false\n void resolveLotNumberFromId(lotId).then((lotNumber) => {\n if (cancelled || !lotNumber) return\n setForm((current) => ({ ...current, lotNumber }))\n registerOptionLabels([{ value: lotNumber, label: lotNumber }])\n })\n return () => {\n cancelled = true\n }\n }, [\n initialCatalogVariantId,\n initialLocationId,\n initialLotId,\n initialWarehouseId,\n open,\n registerOptionLabels,\n ])\n\n React.useEffect(() => {\n if (!open) return\n let cancelled = false\n\n const ensureLabel = async (value: string, resolve: (id: string) => Promise<string | null>) => {\n const id = value.trim()\n if (!id || optionLabelByValueRef.current[id]) return\n const label = await resolve(id)\n if (cancelled || !label) return\n registerOptionLabels([{ value: id, label }])\n }\n\n void Promise.all([\n ensureLabel(form.catalogVariantId, resolveCatalogVariantLabel),\n ensureLabel(form.warehouseId, resolveWarehouseLabel),\n ensureLabel(form.locationId, resolveLocationLabel),\n ])\n\n return () => {\n cancelled = true\n }\n }, [\n form.catalogVariantId,\n form.locationId,\n form.warehouseId,\n open,\n registerOptionLabels,\n ])\n\n React.useEffect(() => {\n if (!open) return\n const variantId = form.catalogVariantId.trim()\n if (!variantId) {\n setReorderPoint(0)\n return\n }\n let cancelled = false\n void fetchVariantReorderPoint(variantId).then((value) => {\n if (!cancelled) setReorderPoint(value)\n })\n return () => {\n cancelled = true\n }\n }, [form.catalogVariantId, open])\n\n const previewContextReady = Boolean(\n form.catalogVariantId.trim() &&\n form.warehouseId.trim() &&\n form.locationId.trim(),\n )\n\n React.useEffect(() => {\n if (!open) return\n const warehouseId = form.warehouseId.trim()\n const locationId = form.locationId.trim()\n const catalogVariantId = form.catalogVariantId.trim()\n const lotNumber = form.lotNumber.trim()\n if (!warehouseId || !locationId || !catalogVariantId) {\n setOnHand(null)\n setPreviewError(null)\n setLoadingPreview(false)\n return\n }\n let cancelled = false\n setLoadingPreview(true)\n setPreviewError(null)\n void (async () => {\n try {\n const lotId = lotNumber\n ? await findLotIdByNumber(catalogVariantId, lotNumber)\n : null\n if (cancelled) return\n const value = await fetchBalanceOnHand({\n warehouseId,\n locationId,\n catalogVariantId,\n lotId,\n })\n if (!cancelled) {\n setOnHand(value)\n setPreviewError(null)\n }\n } catch (error: unknown) {\n if (cancelled) return\n setOnHand(null)\n if (error instanceof BalanceLookupError) {\n setPreviewError(\n t(\n 'wms.backend.inventory.adjust.errors.previewBalance',\n 'Failed to load balance preview.',\n ),\n )\n return\n }\n logger.error('fetchBalanceOnHand failed', { component: 'AdjustInventoryDialog', err: error })\n setPreviewError(\n t(\n 'wms.backend.inventory.adjust.errors.previewBalance',\n 'Failed to load balance preview.',\n ),\n )\n } finally {\n if (!cancelled) setLoadingPreview(false)\n }\n })()\n return () => {\n cancelled = true\n }\n }, [\n form.catalogVariantId,\n form.locationId,\n form.lotNumber,\n form.warehouseId,\n open,\n t,\n ])\n\n const projectedOnHand =\n onHand != null && form.delta !== 0 ? onHand + form.delta : null\n const showBelowReorder =\n projectedOnHand != null && reorderPoint > 0 && projectedOnHand <= reorderPoint\n\n const handleSubmit = React.useCallback(\n async (event?: React.FormEvent) => {\n event?.preventDefault()\n const parsed = adjustFormSchema.safeParse({\n ...form,\n lotNumber: form.lotNumber.trim() || undefined,\n notes: form.notes.trim() || undefined,\n serialNumber: form.serialNumber.trim() || undefined,\n })\n if (!parsed.success) {\n const nextErrors: Record<string, string> = {}\n for (const issue of parsed.error.issues) {\n const key = String(issue.path[0] ?? 'form')\n if (!nextErrors[key]) nextErrors[key] = issue.message\n }\n setFieldErrors(nextErrors)\n return\n }\n\n if (!access.scopeReady || !access.organizationId || !access.tenantId || !access.userId) {\n flash(\n t(\n 'wms.backend.inventory.mutations.errors.scope',\n 'Select an organization and sign in before posting inventory changes.',\n ),\n 'error',\n )\n return\n }\n\n setSubmitting(true)\n try {\n // Persist the stable reason code (not a locale-rendered label) so activity\n // feeds can re-translate for the viewer's language later.\n const reason = parsed.data.reasonCode\n const notes = parsed.data.notes?.trim()\n const lotNumber = parsed.data.lotNumber?.trim()\n let lotId: string | undefined\n if (lotNumber) {\n try {\n lotId = await ensureLotIdForInventoryMutation({\n catalogVariantId: parsed.data.catalogVariantId,\n lotNumber,\n organizationId: access.organizationId,\n tenantId: access.tenantId,\n })\n } catch (error: unknown) {\n const message =\n error instanceof InventoryLotMutationError\n ? error.message\n : t(\n 'wms.backend.inventory.adjust.errors.lot',\n 'Failed to resolve inventory lot.',\n )\n setFieldErrors({ lotNumber: message })\n return\n }\n }\n const payload: Record<string, unknown> = {\n organizationId: access.organizationId,\n tenantId: access.tenantId,\n warehouseId: parsed.data.warehouseId,\n locationId: parsed.data.locationId,\n catalogVariantId: parsed.data.catalogVariantId,\n delta: parsed.data.delta,\n reason,\n reasonCode: parsed.data.reasonCode,\n referenceType: 'manual',\n referenceId: buildInventoryMutationReferenceId(),\n performedBy: access.userId,\n }\n if (lotId) payload.lotId = lotId\n const serial = parsed.data.serialNumber?.trim()\n if (serial) payload.serialNumber = serial\n if (notes) payload.metadata = { notes }\n\n await runMutation({\n operation: async () => {\n const call = await apiCall<{ ok?: boolean; movementId?: string }>(\n '/api/wms/inventory/adjust',\n {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(payload),\n },\n )\n if (!call.ok) {\n await raiseCrudError(\n call.response,\n t('wms.backend.inventory.adjust.errors.submit', 'Failed to adjust inventory.'),\n )\n }\n return call.result ?? {}\n },\n context: mutationContext,\n mutationPayload: payload,\n })\n\n flash(t('wms.backend.inventory.adjust.flash.success', 'Inventory adjusted'), 'success')\n await queryClient.invalidateQueries({ queryKey: ['wms-inventory-console'] })\n await queryClient.invalidateQueries({ queryKey: ['wms-sku-detail'] })\n closeDialog()\n } catch (error) {\n flashMutationError(error, t('wms.backend.inventory.adjust.errors.submit', 'Failed to adjust inventory.'), t)\n } finally {\n setSubmitting(false)\n }\n },\n [\n access,\n adjustFormSchema,\n closeDialog,\n form,\n mutationContext,\n queryClient,\n runMutation,\n t,\n ],\n )\n\n const handleDialogKeyDown = React.useCallback(\n (event: React.KeyboardEvent<HTMLDivElement>) => {\n if (event.key === 'Escape') {\n event.preventDefault()\n closeDialog()\n return\n }\n if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {\n event.preventDefault()\n if (!submitting) void handleSubmit()\n }\n },\n [closeDialog, handleSubmit, submitting],\n )\n\n const adjustDelta = React.useCallback((step: -1 | 1) => {\n setForm((current) => {\n const next = current.delta + step\n return { ...current, delta: next === 0 ? step : next }\n })\n setFieldErrors({})\n }, [])\n\n return (\n <Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : closeDialog())}>\n <DialogContent\n className=\"max-w-lg gap-0 overflow-hidden p-0\"\n onKeyDown={handleDialogKeyDown}\n >\n <div className=\"border-b px-6 py-4 pr-12\">\n <DialogHeader className=\"space-y-1 text-left\">\n <DialogTitle>\n {t('wms.backend.inventory.adjust.dialog.title', 'Adjust inventory')}\n </DialogTitle>\n <DialogDescription>\n {t(\n 'wms.backend.inventory.adjust.dialog.description',\n 'Append a signed delta to the ledger',\n )}\n </DialogDescription>\n </DialogHeader>\n </div>\n\n <form ref={formRef} onSubmit={handleSubmit} className=\"flex min-h-0 flex-1 flex-col\">\n <div className=\"flex flex-col gap-5 overflow-y-auto px-6 py-6\">\n <FormField\n label={t('wms.backend.inventory.adjust.form.variant', 'Variant')}\n required\n error={fieldErrors.catalogVariantId}\n >\n <div className=\"relative [&_input]:pl-9\">\n <Package\n className=\"pointer-events-none absolute left-3 top-1/2 z-10 size-4 -translate-y-1/2 text-muted-foreground\"\n aria-hidden=\"true\"\n />\n <ComboboxInput\n value={form.catalogVariantId}\n onChange={(next) => {\n patchForm({\n catalogVariantId: next.trim(),\n lotNumber: '',\n })\n }}\n loadSuggestions={async (query) => {\n const options = await loadCatalogVariantOptions(query)\n registerOptionLabels(options)\n return options.map((option) => ({\n value: option.value,\n label: option.label,\n description: option.description,\n }))\n }}\n resolveLabel={resolveOptionLabel}\n placeholder={t(\n 'wms.backend.inventory.adjust.form.variantPlaceholder',\n 'Search variant or SKU',\n )}\n allowCustomValues={false}\n disabled={submitting}\n />\n </div>\n </FormField>\n\n <div className=\"grid gap-5 sm:grid-cols-2\">\n <FormField\n label={t('wms.backend.inventory.adjust.form.warehouse', 'Warehouse')}\n required\n error={fieldErrors.warehouseId}\n >\n <ComboboxInput\n value={form.warehouseId}\n onChange={(next) => {\n patchForm({\n warehouseId: next.trim(),\n locationId: '',\n })\n }}\n loadSuggestions={async (query) => {\n const options = await loadWarehouseOptions(query)\n registerOptionLabels(options)\n return options.map((option) => ({\n value: option.value,\n label: option.label,\n }))\n }}\n resolveLabel={resolveOptionLabel}\n placeholder={t(\n 'wms.backend.inventory.adjust.form.warehousePlaceholder',\n 'Select warehouse',\n )}\n allowCustomValues={false}\n disabled={submitting}\n />\n </FormField>\n\n <FormField\n label={t('wms.backend.inventory.adjust.form.location', 'Location')}\n required\n error={fieldErrors.locationId}\n >\n <ComboboxInput\n value={form.locationId}\n onChange={(next) => patchForm({ locationId: next.trim() })}\n loadSuggestions={async (query) => {\n const options = await loadLocationOptions(form.warehouseId, query)\n registerOptionLabels(options)\n return options.map((option) => ({\n value: option.value,\n label: option.label,\n }))\n }}\n resolveLabel={resolveOptionLabel}\n placeholder={t(\n 'wms.backend.inventory.adjust.form.locationPlaceholder',\n 'Select location',\n )}\n allowCustomValues={false}\n disabled={submitting || !form.warehouseId}\n />\n </FormField>\n </div>\n\n <FormField\n label={t('wms.backend.inventory.adjust.form.lot', 'Lot')}\n error={fieldErrors.lotNumber}\n >\n <ComboboxInput\n value={form.lotNumber}\n onChange={(next) => patchForm({ lotNumber: next.trim() })}\n loadSuggestions={async (query) => {\n const options = await loadLotNumberOptions(form.catalogVariantId, query)\n registerOptionLabels(options)\n return options.map((option) => ({\n value: option.value,\n label: option.label,\n }))\n }}\n resolveLabel={resolveOptionLabel}\n placeholder={t(\n 'wms.backend.inventory.adjust.form.lotPlaceholder',\n 'Select lot (optional)',\n )}\n allowCustomValues\n disabled={submitting || !form.catalogVariantId}\n />\n </FormField>\n\n <FormField\n label={t('wms.backend.inventory.adjust.form.serial', 'Serial number')}\n error={fieldErrors.serialNumber}\n >\n <Input\n value={form.serialNumber}\n onChange={(event) => patchForm({ serialNumber: event.target.value })}\n placeholder={t(\n 'wms.backend.inventory.adjust.form.serialPlaceholder',\n 'Optional \u2014 for serial-tracked variants',\n )}\n disabled={submitting}\n />\n <p className=\"mt-1 text-xs text-muted-foreground\">\n {t(\n 'wms.backend.inventory.adjust.form.serialHelp',\n 'Use when the variant profile tracks serials.',\n )}\n </p>\n </FormField>\n\n <FormField\n label={t('wms.backend.inventory.adjust.form.delta', 'Adjustment')}\n required\n error={fieldErrors.delta}\n >\n <div className=\"flex w-32 items-center gap-2 rounded-md border bg-background p-2 shadow-xs\">\n <IconButton\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n aria-label={t('wms.backend.inventory.adjust.form.decrease', 'Decrease quantity')}\n onClick={() => adjustDelta(-1)}\n disabled={submitting}\n >\n <Minus className=\"size-4\" />\n </IconButton>\n <Input\n type=\"text\"\n inputMode=\"decimal\"\n value={String(form.delta)}\n onChange={(event) => {\n const parsed = parseDeltaInput(event.target.value, locale)\n if (parsed == null) return\n patchForm({ delta: parsed })\n }}\n className=\"h-8 w-auto min-w-0 flex-1 border-0 bg-transparent p-0 shadow-none focus-visible:ring-0\"\n inputClassName=\"text-center [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none\"\n disabled={submitting}\n />\n <IconButton\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n aria-label={t('wms.backend.inventory.adjust.form.increase', 'Increase quantity')}\n onClick={() => adjustDelta(1)}\n disabled={submitting}\n >\n <Plus className=\"size-4\" />\n </IconButton>\n </div>\n </FormField>\n\n <FormField\n label={t('wms.backend.inventory.adjust.form.reason', 'Reason')}\n required\n error={fieldErrors.reasonCode}\n >\n <Select\n value={form.reasonCode || undefined}\n onValueChange={(next) =>\n patchForm({ reasonCode: next as AdjustReasonCode })\n }\n disabled={submitting}\n >\n <SelectTrigger>\n <SelectValue\n placeholder={t(\n 'wms.backend.inventory.adjust.form.reasonPlaceholder',\n 'Select reason',\n )}\n />\n </SelectTrigger>\n <SelectContent>\n {ADJUST_REASON_CODES.map((code) => (\n <SelectItem key={code} value={code}>\n {reasonLabel(code)}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </FormField>\n\n <FormField label={t('wms.backend.inventory.adjust.form.notes', 'Notes')}>\n <Textarea\n value={form.notes}\n onChange={(event) => patchForm({ notes: event.target.value })}\n placeholder={t(\n 'wms.backend.inventory.adjust.form.notesPlaceholder',\n 'Optional \u2014 context for auditors',\n )}\n rows={3}\n disabled={submitting}\n />\n </FormField>\n\n {previewContextReady ? (\n <div className=\"rounded-lg border bg-muted/40 px-4 py-3.5\">\n <p className=\"text-xs font-semibold uppercase tracking-wide text-muted-foreground\">\n {t('wms.backend.inventory.adjust.preview.title', 'Balance preview')}\n </p>\n <div className=\"mt-2 flex items-center justify-between gap-3\">\n {previewError ? (\n <p className=\"text-sm text-status-warning-fg\">{previewError}</p>\n ) : loadingPreview && onHand == null ? (\n <p className=\"text-sm text-muted-foreground\">\n {t('wms.backend.inventory.adjust.preview.loading', 'Refreshing balance\u2026')}\n </p>\n ) : onHand != null && projectedOnHand != null ? (\n <p className=\"text-sm font-semibold tabular-nums text-foreground\">\n {t('wms.backend.inventory.adjust.preview.onHandLabel', 'On hand')}{' '}\n {onHand}\n {' \u2192 '}\n {projectedOnHand}\n </p>\n ) : onHand != null ? (\n <p className=\"text-sm font-semibold tabular-nums text-foreground\">\n {t('wms.backend.inventory.adjust.preview.onHandLabel', 'On hand')}{' '}\n {onHand}\n </p>\n ) : null}\n {showBelowReorder ? (\n <StatusBadge variant=\"warning\">\n {t(\n 'wms.backend.inventory.adjust.preview.belowReorder',\n 'Below reorder',\n )}\n </StatusBadge>\n ) : null}\n </div>\n {loadingPreview && onHand != null ? (\n <p className=\"mt-1 text-xs text-muted-foreground\">\n {t('wms.backend.inventory.adjust.preview.loading', 'Refreshing balance\u2026')}\n </p>\n ) : null}\n </div>\n ) : null}\n </div>\n\n <DialogFooter bordered={false} className=\"border-t px-6 py-4 sm:justify-between\">\n <p className=\"hidden text-xs text-muted-foreground sm:inline-flex sm:items-center sm:gap-1.5\">\n <KbdShortcut keys={['\u2318', 'Enter']} />\n <span>/</span>\n <KbdShortcut keys={['Ctrl', 'Enter']} />\n <span>{t('wms.backend.inventory.adjust.dialog.shortcutSave', 'to save')}</span>\n </p>\n <div className=\"flex w-full flex-col-reverse gap-2 sm:w-auto sm:flex-row\">\n <Button type=\"button\" variant=\"outline\" onClick={closeDialog} disabled={submitting}>\n {t('common.cancel', 'Cancel')}\n </Button>\n <Button type=\"submit\" disabled={submitting}>\n {t('wms.backend.inventory.adjust.dialog.submit', 'Save adjustment')}\n </Button>\n </div>\n </DialogFooter>\n </form>\n </DialogContent>\n </Dialog>\n )\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { z } from 'zod'\nimport { useQueryClient } from '@tanstack/react-query'\nimport { Minus, Package, Plus } from 'lucide-react'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { flashMutationError } from '../../lib/flashMutationError'\nimport { ComboboxInput } from '@open-mercato/ui/backend/inputs/ComboboxInput'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { raiseCrudError } from '@open-mercato/ui/backend/utils/serverErrors'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from '@open-mercato/ui/primitives/dialog'\nimport { FormField } from '@open-mercato/ui/primitives/form-field'\nimport { IconButton } from '@open-mercato/ui/primitives/icon-button'\nimport { Input } from '@open-mercato/ui/primitives/input'\nimport { KbdShortcut } from '@open-mercato/ui/primitives/kbd'\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '@open-mercato/ui/primitives/select'\nimport { StatusBadge } from '@open-mercato/ui/primitives/status-badge'\nimport { Textarea } from '@open-mercato/ui/primitives/textarea'\nimport { useT, useLocale } from '@open-mercato/shared/lib/i18n/context'\nimport { parseLocaleNumber } from '@open-mercato/shared/lib/number'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { buildInventoryMutationReferenceId } from '../../lib/inventoryMutationUi'\nimport {\n BalanceLookupError,\n ensureLotIdForInventoryMutation,\n fetchBalanceOnHand,\n fetchVariantReorderPoint,\n findLotIdByNumber,\n InventoryLotMutationError,\n loadCatalogVariantOptions,\n loadLocationOptions,\n loadLotNumberOptions,\n loadWarehouseOptions,\n resolveCatalogVariantLabel,\n resolveLocationLabel,\n resolveLotNumberFromId,\n resolveWarehouseLabel,\n} from './inventoryMutationLoaders'\nimport type { useWmsInventoryMutationAccess } from './useWmsInventoryMutationAccess'\n\nconst logger = createLogger('wms')\n\nconst ADJUST_REASON_CODES = ['damaged', 'shrinkage', 'found', 'correction', 'other'] as const\n\ntype AdjustReasonCode = (typeof ADJUST_REASON_CODES)[number]\n\ntype AdjustFormValues = {\n catalogVariantId: string\n warehouseId: string\n locationId: string\n lotNumber: string\n delta: number\n reasonCode: AdjustReasonCode | ''\n notes: string\n serialNumber: string\n}\n\ntype AdjustInventoryDialogProps = {\n open: boolean\n onOpenChange: (open: boolean) => void\n access: ReturnType<typeof useWmsInventoryMutationAccess>\n initialCatalogVariantId?: string\n initialWarehouseId?: string\n initialLocationId?: string\n initialLotId?: string\n}\n\nconst EMPTY_FORM: AdjustFormValues = {\n catalogVariantId: '',\n warehouseId: '',\n locationId: '',\n lotNumber: '',\n delta: 1,\n reasonCode: '',\n notes: '',\n serialNumber: '',\n}\n\nexport function parseDeltaInput(value: string, locale?: string): number | null {\n const trimmed = value.trim()\n if (!trimmed || trimmed === '-' || trimmed === '+') return null\n return parseLocaleNumber(trimmed, locale)\n}\n\nexport function AdjustInventoryDialog({\n open,\n onOpenChange,\n access,\n initialCatalogVariantId,\n initialWarehouseId,\n initialLocationId,\n initialLotId,\n}: AdjustInventoryDialogProps) {\n const t = useT()\n const locale = useLocale()\n const queryClient = useQueryClient()\n const formRef = React.useRef<HTMLFormElement>(null)\n const { runMutation, retryLastMutation } = useGuardedMutation<{\n retryLastMutation: () => Promise<boolean>\n }>({\n contextId: 'wms-inventory-adjust',\n })\n const mutationContext = React.useMemo(\n () => ({ retryLastMutation }),\n [retryLastMutation],\n )\n const adjustFormSchema = React.useMemo(\n () =>\n z.object({\n catalogVariantId: z.string().uuid(),\n warehouseId: z.string().uuid(),\n locationId: z.string().uuid(),\n lotNumber: z.string().trim().max(120).optional(),\n delta: z.coerce.number().refine((value) => value !== 0, {\n message: t(\n 'wms.backend.inventory.adjust.errors.deltaZero',\n 'Inventory delta must be non-zero.',\n ),\n }),\n reasonCode: z.enum(ADJUST_REASON_CODES),\n notes: z.string().trim().max(500).optional(),\n serialNumber: z.string().trim().max(120).optional(),\n }),\n [t],\n )\n const [submitting, setSubmitting] = React.useState(false)\n const [form, setForm] = React.useState<AdjustFormValues>(EMPTY_FORM)\n // Raw text the user is typing into the delta field, decoupled from `form.delta` (issue\n // #5828): re-deriving the input's display from the committed number on every keystroke\n // drops an in-progress decimal separator the instant it's typed (`2,5` collapses to `2`\n // then the next digit appends, producing `25`). Synced from `form.delta` only while the\n // field is not focused, mirroring `CrudForm.NumberInput`'s buffer.\n const [deltaInputText, setDeltaInputText] = React.useState<string>(String(EMPTY_FORM.delta))\n const deltaInputFocusedRef = React.useRef(false)\n const [fieldErrors, setFieldErrors] = React.useState<Record<string, string>>({})\n const [onHand, setOnHand] = React.useState<number | null>(null)\n const [previewError, setPreviewError] = React.useState<string | null>(null)\n const [reorderPoint, setReorderPoint] = React.useState(0)\n const [loadingPreview, setLoadingPreview] = React.useState(false)\n const [optionLabelByValue, setOptionLabelByValue] = React.useState<Record<string, string>>({})\n\n const registerOptionLabels = React.useCallback(\n (options: Array<{ value: string; label: string }>) => {\n setOptionLabelByValue((current) => {\n let changed = false\n const next = { ...current }\n for (const option of options) {\n const value = option.value.trim()\n const label = option.label.trim()\n if (!value || !label || next[value] === label) continue\n next[value] = label\n changed = true\n }\n return changed ? next : current\n })\n },\n [],\n )\n\n const resolveOptionLabel = React.useCallback(\n (value: string) => optionLabelByValue[value] ?? value,\n [optionLabelByValue],\n )\n const optionLabelByValueRef = React.useRef(optionLabelByValue)\n optionLabelByValueRef.current = optionLabelByValue\n\n const reasonLabel = React.useCallback(\n (code: AdjustReasonCode) => {\n const fallbacks: Record<AdjustReasonCode, string> = {\n damaged: 'Damaged',\n shrinkage: 'Shrinkage',\n found: 'Found stock',\n correction: 'Correction',\n other: 'Other',\n }\n return t(`wms.backend.inventory.adjust.reasons.${code}`, fallbacks[code])\n },\n [t],\n )\n\n const resetDialog = React.useCallback(() => {\n setForm(EMPTY_FORM)\n setDeltaInputText(String(EMPTY_FORM.delta))\n setFieldErrors({})\n setOnHand(null)\n setPreviewError(null)\n setReorderPoint(0)\n setLoadingPreview(false)\n setSubmitting(false)\n setOptionLabelByValue({})\n }, [])\n\n const closeDialog = React.useCallback(() => {\n onOpenChange(false)\n resetDialog()\n }, [onOpenChange, resetDialog])\n\n const patchForm = React.useCallback((patch: Partial<AdjustFormValues>) => {\n setForm((current) => ({ ...current, ...patch }))\n setFieldErrors({})\n }, [])\n\n React.useEffect(() => {\n if (!deltaInputFocusedRef.current) setDeltaInputText(String(form.delta))\n }, [form.delta])\n\n React.useEffect(() => {\n if (!open) return\n const catalogVariantId = initialCatalogVariantId?.trim()\n const warehouseId = initialWarehouseId?.trim()\n const locationId = initialLocationId?.trim()\n const lotId = initialLotId?.trim()\n if (!catalogVariantId && !warehouseId && !locationId && !lotId) return\n setForm((current) => ({\n ...current,\n ...(catalogVariantId ? { catalogVariantId } : {}),\n ...(warehouseId ? { warehouseId, locationId: locationId ?? '' } : {}),\n ...(locationId ? { locationId } : {}),\n }))\n if (!lotId) return\n let cancelled = false\n void resolveLotNumberFromId(lotId).then((lotNumber) => {\n if (cancelled || !lotNumber) return\n setForm((current) => ({ ...current, lotNumber }))\n registerOptionLabels([{ value: lotNumber, label: lotNumber }])\n })\n return () => {\n cancelled = true\n }\n }, [\n initialCatalogVariantId,\n initialLocationId,\n initialLotId,\n initialWarehouseId,\n open,\n registerOptionLabels,\n ])\n\n React.useEffect(() => {\n if (!open) return\n let cancelled = false\n\n const ensureLabel = async (value: string, resolve: (id: string) => Promise<string | null>) => {\n const id = value.trim()\n if (!id || optionLabelByValueRef.current[id]) return\n const label = await resolve(id)\n if (cancelled || !label) return\n registerOptionLabels([{ value: id, label }])\n }\n\n void Promise.all([\n ensureLabel(form.catalogVariantId, resolveCatalogVariantLabel),\n ensureLabel(form.warehouseId, resolveWarehouseLabel),\n ensureLabel(form.locationId, resolveLocationLabel),\n ])\n\n return () => {\n cancelled = true\n }\n }, [\n form.catalogVariantId,\n form.locationId,\n form.warehouseId,\n open,\n registerOptionLabels,\n ])\n\n React.useEffect(() => {\n if (!open) return\n const variantId = form.catalogVariantId.trim()\n if (!variantId) {\n setReorderPoint(0)\n return\n }\n let cancelled = false\n void fetchVariantReorderPoint(variantId).then((value) => {\n if (!cancelled) setReorderPoint(value)\n })\n return () => {\n cancelled = true\n }\n }, [form.catalogVariantId, open])\n\n const previewContextReady = Boolean(\n form.catalogVariantId.trim() &&\n form.warehouseId.trim() &&\n form.locationId.trim(),\n )\n\n React.useEffect(() => {\n if (!open) return\n const warehouseId = form.warehouseId.trim()\n const locationId = form.locationId.trim()\n const catalogVariantId = form.catalogVariantId.trim()\n const lotNumber = form.lotNumber.trim()\n if (!warehouseId || !locationId || !catalogVariantId) {\n setOnHand(null)\n setPreviewError(null)\n setLoadingPreview(false)\n return\n }\n let cancelled = false\n setLoadingPreview(true)\n setPreviewError(null)\n void (async () => {\n try {\n const lotId = lotNumber\n ? await findLotIdByNumber(catalogVariantId, lotNumber)\n : null\n if (cancelled) return\n const value = await fetchBalanceOnHand({\n warehouseId,\n locationId,\n catalogVariantId,\n lotId,\n })\n if (!cancelled) {\n setOnHand(value)\n setPreviewError(null)\n }\n } catch (error: unknown) {\n if (cancelled) return\n setOnHand(null)\n if (error instanceof BalanceLookupError) {\n setPreviewError(\n t(\n 'wms.backend.inventory.adjust.errors.previewBalance',\n 'Failed to load balance preview.',\n ),\n )\n return\n }\n logger.error('fetchBalanceOnHand failed', { component: 'AdjustInventoryDialog', err: error })\n setPreviewError(\n t(\n 'wms.backend.inventory.adjust.errors.previewBalance',\n 'Failed to load balance preview.',\n ),\n )\n } finally {\n if (!cancelled) setLoadingPreview(false)\n }\n })()\n return () => {\n cancelled = true\n }\n }, [\n form.catalogVariantId,\n form.locationId,\n form.lotNumber,\n form.warehouseId,\n open,\n t,\n ])\n\n const projectedOnHand =\n onHand != null && form.delta !== 0 ? onHand + form.delta : null\n const showBelowReorder =\n projectedOnHand != null && reorderPoint > 0 && projectedOnHand <= reorderPoint\n\n const handleSubmit = React.useCallback(\n async (event?: React.FormEvent) => {\n event?.preventDefault()\n const parsed = adjustFormSchema.safeParse({\n ...form,\n lotNumber: form.lotNumber.trim() || undefined,\n notes: form.notes.trim() || undefined,\n serialNumber: form.serialNumber.trim() || undefined,\n })\n if (!parsed.success) {\n const nextErrors: Record<string, string> = {}\n for (const issue of parsed.error.issues) {\n const key = String(issue.path[0] ?? 'form')\n if (!nextErrors[key]) nextErrors[key] = issue.message\n }\n setFieldErrors(nextErrors)\n return\n }\n\n if (!access.scopeReady || !access.organizationId || !access.tenantId || !access.userId) {\n flash(\n t(\n 'wms.backend.inventory.mutations.errors.scope',\n 'Select an organization and sign in before posting inventory changes.',\n ),\n 'error',\n )\n return\n }\n\n setSubmitting(true)\n try {\n // Persist the stable reason code (not a locale-rendered label) so activity\n // feeds can re-translate for the viewer's language later.\n const reason = parsed.data.reasonCode\n const notes = parsed.data.notes?.trim()\n const lotNumber = parsed.data.lotNumber?.trim()\n let lotId: string | undefined\n if (lotNumber) {\n try {\n lotId = await ensureLotIdForInventoryMutation({\n catalogVariantId: parsed.data.catalogVariantId,\n lotNumber,\n organizationId: access.organizationId,\n tenantId: access.tenantId,\n })\n } catch (error: unknown) {\n const message =\n error instanceof InventoryLotMutationError\n ? error.message\n : t(\n 'wms.backend.inventory.adjust.errors.lot',\n 'Failed to resolve inventory lot.',\n )\n setFieldErrors({ lotNumber: message })\n return\n }\n }\n const payload: Record<string, unknown> = {\n organizationId: access.organizationId,\n tenantId: access.tenantId,\n warehouseId: parsed.data.warehouseId,\n locationId: parsed.data.locationId,\n catalogVariantId: parsed.data.catalogVariantId,\n delta: parsed.data.delta,\n reason,\n reasonCode: parsed.data.reasonCode,\n referenceType: 'manual',\n referenceId: buildInventoryMutationReferenceId(),\n performedBy: access.userId,\n }\n if (lotId) payload.lotId = lotId\n const serial = parsed.data.serialNumber?.trim()\n if (serial) payload.serialNumber = serial\n if (notes) payload.metadata = { notes }\n\n await runMutation({\n operation: async () => {\n const call = await apiCall<{ ok?: boolean; movementId?: string }>(\n '/api/wms/inventory/adjust',\n {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(payload),\n },\n )\n if (!call.ok) {\n await raiseCrudError(\n call.response,\n t('wms.backend.inventory.adjust.errors.submit', 'Failed to adjust inventory.'),\n )\n }\n return call.result ?? {}\n },\n context: mutationContext,\n mutationPayload: payload,\n })\n\n flash(t('wms.backend.inventory.adjust.flash.success', 'Inventory adjusted'), 'success')\n await queryClient.invalidateQueries({ queryKey: ['wms-inventory-console'] })\n await queryClient.invalidateQueries({ queryKey: ['wms-sku-detail'] })\n closeDialog()\n } catch (error) {\n flashMutationError(error, t('wms.backend.inventory.adjust.errors.submit', 'Failed to adjust inventory.'), t)\n } finally {\n setSubmitting(false)\n }\n },\n [\n access,\n adjustFormSchema,\n closeDialog,\n form,\n mutationContext,\n queryClient,\n runMutation,\n t,\n ],\n )\n\n const handleDialogKeyDown = React.useCallback(\n (event: React.KeyboardEvent<HTMLDivElement>) => {\n if (event.key === 'Escape') {\n event.preventDefault()\n closeDialog()\n return\n }\n if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {\n event.preventDefault()\n if (!submitting) void handleSubmit()\n }\n },\n [closeDialog, handleSubmit, submitting],\n )\n\n const adjustDelta = React.useCallback((step: -1 | 1) => {\n setForm((current) => {\n const next = current.delta + step\n return { ...current, delta: next === 0 ? step : next }\n })\n setFieldErrors({})\n }, [])\n\n return (\n <Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : closeDialog())}>\n <DialogContent\n className=\"max-w-lg gap-0 overflow-hidden p-0\"\n onKeyDown={handleDialogKeyDown}\n >\n <div className=\"border-b px-6 py-4 pr-12\">\n <DialogHeader className=\"space-y-1 text-left\">\n <DialogTitle>\n {t('wms.backend.inventory.adjust.dialog.title', 'Adjust inventory')}\n </DialogTitle>\n <DialogDescription>\n {t(\n 'wms.backend.inventory.adjust.dialog.description',\n 'Append a signed delta to the ledger',\n )}\n </DialogDescription>\n </DialogHeader>\n </div>\n\n <form ref={formRef} onSubmit={handleSubmit} className=\"flex min-h-0 flex-1 flex-col\">\n <div className=\"flex flex-col gap-5 overflow-y-auto px-6 py-6\">\n <FormField\n label={t('wms.backend.inventory.adjust.form.variant', 'Variant')}\n required\n error={fieldErrors.catalogVariantId}\n >\n <div className=\"relative [&_input]:pl-9\">\n <Package\n className=\"pointer-events-none absolute left-3 top-1/2 z-10 size-4 -translate-y-1/2 text-muted-foreground\"\n aria-hidden=\"true\"\n />\n <ComboboxInput\n value={form.catalogVariantId}\n onChange={(next) => {\n patchForm({\n catalogVariantId: next.trim(),\n lotNumber: '',\n })\n }}\n loadSuggestions={async (query) => {\n const options = await loadCatalogVariantOptions(query)\n registerOptionLabels(options)\n return options.map((option) => ({\n value: option.value,\n label: option.label,\n description: option.description,\n }))\n }}\n resolveLabel={resolveOptionLabel}\n placeholder={t(\n 'wms.backend.inventory.adjust.form.variantPlaceholder',\n 'Search variant or SKU',\n )}\n allowCustomValues={false}\n disabled={submitting}\n />\n </div>\n </FormField>\n\n <div className=\"grid gap-5 sm:grid-cols-2\">\n <FormField\n label={t('wms.backend.inventory.adjust.form.warehouse', 'Warehouse')}\n required\n error={fieldErrors.warehouseId}\n >\n <ComboboxInput\n value={form.warehouseId}\n onChange={(next) => {\n patchForm({\n warehouseId: next.trim(),\n locationId: '',\n })\n }}\n loadSuggestions={async (query) => {\n const options = await loadWarehouseOptions(query)\n registerOptionLabels(options)\n return options.map((option) => ({\n value: option.value,\n label: option.label,\n }))\n }}\n resolveLabel={resolveOptionLabel}\n placeholder={t(\n 'wms.backend.inventory.adjust.form.warehousePlaceholder',\n 'Select warehouse',\n )}\n allowCustomValues={false}\n disabled={submitting}\n />\n </FormField>\n\n <FormField\n label={t('wms.backend.inventory.adjust.form.location', 'Location')}\n required\n error={fieldErrors.locationId}\n >\n <ComboboxInput\n value={form.locationId}\n onChange={(next) => patchForm({ locationId: next.trim() })}\n loadSuggestions={async (query) => {\n const options = await loadLocationOptions(form.warehouseId, query)\n registerOptionLabels(options)\n return options.map((option) => ({\n value: option.value,\n label: option.label,\n }))\n }}\n resolveLabel={resolveOptionLabel}\n placeholder={t(\n 'wms.backend.inventory.adjust.form.locationPlaceholder',\n 'Select location',\n )}\n allowCustomValues={false}\n disabled={submitting || !form.warehouseId}\n />\n </FormField>\n </div>\n\n <FormField\n label={t('wms.backend.inventory.adjust.form.lot', 'Lot')}\n error={fieldErrors.lotNumber}\n >\n <ComboboxInput\n value={form.lotNumber}\n onChange={(next) => patchForm({ lotNumber: next.trim() })}\n loadSuggestions={async (query) => {\n const options = await loadLotNumberOptions(form.catalogVariantId, query)\n registerOptionLabels(options)\n return options.map((option) => ({\n value: option.value,\n label: option.label,\n }))\n }}\n resolveLabel={resolveOptionLabel}\n placeholder={t(\n 'wms.backend.inventory.adjust.form.lotPlaceholder',\n 'Select lot (optional)',\n )}\n allowCustomValues\n disabled={submitting || !form.catalogVariantId}\n />\n </FormField>\n\n <FormField\n label={t('wms.backend.inventory.adjust.form.serial', 'Serial number')}\n error={fieldErrors.serialNumber}\n >\n <Input\n value={form.serialNumber}\n onChange={(event) => patchForm({ serialNumber: event.target.value })}\n placeholder={t(\n 'wms.backend.inventory.adjust.form.serialPlaceholder',\n 'Optional \u2014 for serial-tracked variants',\n )}\n disabled={submitting}\n />\n <p className=\"mt-1 text-xs text-muted-foreground\">\n {t(\n 'wms.backend.inventory.adjust.form.serialHelp',\n 'Use when the variant profile tracks serials.',\n )}\n </p>\n </FormField>\n\n <FormField\n label={t('wms.backend.inventory.adjust.form.delta', 'Adjustment')}\n required\n error={fieldErrors.delta}\n >\n <div className=\"flex w-32 items-center gap-2 rounded-md border bg-background p-2 shadow-xs\">\n <IconButton\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n aria-label={t('wms.backend.inventory.adjust.form.decrease', 'Decrease quantity')}\n onClick={() => adjustDelta(-1)}\n disabled={submitting}\n >\n <Minus className=\"size-4\" />\n </IconButton>\n <Input\n type=\"text\"\n inputMode=\"decimal\"\n value={deltaInputText}\n onChange={(event) => {\n setDeltaInputText(event.target.value)\n const parsed = parseDeltaInput(event.target.value, locale)\n if (parsed == null) return\n patchForm({ delta: parsed })\n }}\n onFocus={() => {\n deltaInputFocusedRef.current = true\n }}\n onBlur={() => {\n deltaInputFocusedRef.current = false\n setDeltaInputText(String(form.delta))\n }}\n className=\"h-8 w-auto min-w-0 flex-1 border-0 bg-transparent p-0 shadow-none focus-visible:ring-0\"\n inputClassName=\"text-center [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none\"\n disabled={submitting}\n />\n <IconButton\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n aria-label={t('wms.backend.inventory.adjust.form.increase', 'Increase quantity')}\n onClick={() => adjustDelta(1)}\n disabled={submitting}\n >\n <Plus className=\"size-4\" />\n </IconButton>\n </div>\n </FormField>\n\n <FormField\n label={t('wms.backend.inventory.adjust.form.reason', 'Reason')}\n required\n error={fieldErrors.reasonCode}\n >\n <Select\n value={form.reasonCode || undefined}\n onValueChange={(next) =>\n patchForm({ reasonCode: next as AdjustReasonCode })\n }\n disabled={submitting}\n >\n <SelectTrigger>\n <SelectValue\n placeholder={t(\n 'wms.backend.inventory.adjust.form.reasonPlaceholder',\n 'Select reason',\n )}\n />\n </SelectTrigger>\n <SelectContent>\n {ADJUST_REASON_CODES.map((code) => (\n <SelectItem key={code} value={code}>\n {reasonLabel(code)}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </FormField>\n\n <FormField label={t('wms.backend.inventory.adjust.form.notes', 'Notes')}>\n <Textarea\n value={form.notes}\n onChange={(event) => patchForm({ notes: event.target.value })}\n placeholder={t(\n 'wms.backend.inventory.adjust.form.notesPlaceholder',\n 'Optional \u2014 context for auditors',\n )}\n rows={3}\n disabled={submitting}\n />\n </FormField>\n\n {previewContextReady ? (\n <div className=\"rounded-lg border bg-muted/40 px-4 py-3.5\">\n <p className=\"text-xs font-semibold uppercase tracking-wide text-muted-foreground\">\n {t('wms.backend.inventory.adjust.preview.title', 'Balance preview')}\n </p>\n <div className=\"mt-2 flex items-center justify-between gap-3\">\n {previewError ? (\n <p className=\"text-sm text-status-warning-fg\">{previewError}</p>\n ) : loadingPreview && onHand == null ? (\n <p className=\"text-sm text-muted-foreground\">\n {t('wms.backend.inventory.adjust.preview.loading', 'Refreshing balance\u2026')}\n </p>\n ) : onHand != null && projectedOnHand != null ? (\n <p className=\"text-sm font-semibold tabular-nums text-foreground\">\n {t('wms.backend.inventory.adjust.preview.onHandLabel', 'On hand')}{' '}\n {onHand}\n {' \u2192 '}\n {projectedOnHand}\n </p>\n ) : onHand != null ? (\n <p className=\"text-sm font-semibold tabular-nums text-foreground\">\n {t('wms.backend.inventory.adjust.preview.onHandLabel', 'On hand')}{' '}\n {onHand}\n </p>\n ) : null}\n {showBelowReorder ? (\n <StatusBadge variant=\"warning\">\n {t(\n 'wms.backend.inventory.adjust.preview.belowReorder',\n 'Below reorder',\n )}\n </StatusBadge>\n ) : null}\n </div>\n {loadingPreview && onHand != null ? (\n <p className=\"mt-1 text-xs text-muted-foreground\">\n {t('wms.backend.inventory.adjust.preview.loading', 'Refreshing balance\u2026')}\n </p>\n ) : null}\n </div>\n ) : null}\n </div>\n\n <DialogFooter bordered={false} className=\"border-t px-6 py-4 sm:justify-between\">\n <p className=\"hidden text-xs text-muted-foreground sm:inline-flex sm:items-center sm:gap-1.5\">\n <KbdShortcut keys={['\u2318', 'Enter']} />\n <span>/</span>\n <KbdShortcut keys={['Ctrl', 'Enter']} />\n <span>{t('wms.backend.inventory.adjust.dialog.shortcutSave', 'to save')}</span>\n </p>\n <div className=\"flex w-full flex-col-reverse gap-2 sm:w-auto sm:flex-row\">\n <Button type=\"button\" variant=\"outline\" onClick={closeDialog} disabled={submitting}>\n {t('common.cancel', 'Cancel')}\n </Button>\n <Button type=\"submit\" disabled={submitting}>\n {t('wms.backend.inventory.adjust.dialog.submit', 'Save adjustment')}\n </Button>\n </div>\n </DialogFooter>\n </form>\n </DialogContent>\n </Dialog>\n )\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.8.1-develop.7206.1.45a3cee87f",
3
+ "version": "0.8.1-develop.7215.1.60786cf836",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -253,16 +253,16 @@
253
253
  "zod": "^4.4.3"
254
254
  },
255
255
  "peerDependencies": {
256
- "@open-mercato/ai-assistant": "0.8.1-develop.7206.1.45a3cee87f",
257
- "@open-mercato/shared": "0.8.1-develop.7206.1.45a3cee87f",
258
- "@open-mercato/ui": "0.8.1-develop.7206.1.45a3cee87f",
256
+ "@open-mercato/ai-assistant": "0.8.1-develop.7215.1.60786cf836",
257
+ "@open-mercato/shared": "0.8.1-develop.7215.1.60786cf836",
258
+ "@open-mercato/ui": "0.8.1-develop.7215.1.60786cf836",
259
259
  "react": "^19.0.0",
260
260
  "react-dom": "^19.0.0"
261
261
  },
262
262
  "devDependencies": {
263
- "@open-mercato/ai-assistant": "0.8.1-develop.7206.1.45a3cee87f",
264
- "@open-mercato/shared": "0.8.1-develop.7206.1.45a3cee87f",
265
- "@open-mercato/ui": "0.8.1-develop.7206.1.45a3cee87f",
263
+ "@open-mercato/ai-assistant": "0.8.1-develop.7215.1.60786cf836",
264
+ "@open-mercato/shared": "0.8.1-develop.7215.1.60786cf836",
265
+ "@open-mercato/ui": "0.8.1-develop.7215.1.60786cf836",
266
266
  "@testing-library/dom": "^10.4.1",
267
267
  "@testing-library/jest-dom": "^7.0.1",
268
268
  "@testing-library/react": "^16.3.3",
@@ -47,7 +47,7 @@ import {
47
47
  buildRecordInjectionContext,
48
48
  useSetCurrentRecordInjectionContext,
49
49
  } from "@open-mercato/ui/backend/injection/recordContext";
50
- import { useT } from "@open-mercato/shared/lib/i18n/context";
50
+ import { useT, useLocale } from "@open-mercato/shared/lib/i18n/context";
51
51
  import { useConfirmDialog } from "@open-mercato/ui/backend/confirm-dialog";
52
52
  import { E } from "#generated/entities.ids.generated";
53
53
  import {
@@ -73,6 +73,7 @@ import {
73
73
  type ProductUnitPriceReferenceUnit,
74
74
  type ProductUnitRoundingMode,
75
75
  productFormSchema,
76
+ withCanonicalUomFields,
76
77
  BASE_INITIAL_VALUES,
77
78
  createLocalId,
78
79
  slugify,
@@ -323,6 +324,7 @@ export default function EditCatalogProductPage({
323
324
  }) {
324
325
  const productId = params?.id ? String(params.id) : null;
325
326
  const t = useT();
327
+ const locale = useLocale();
326
328
  const pathname = usePathname();
327
329
  const productSubpathPrefix = productId
328
330
  ? `/backend/catalog/products/${productId}/`
@@ -1049,7 +1051,9 @@ export default function EditCatalogProductPage({
1049
1051
  ),
1050
1052
  );
1051
1053
  }
1052
- const parsed = productFormSchema.safeParse(formValues);
1054
+ const parsed = productFormSchema.safeParse(
1055
+ withCanonicalUomFields(formValues, locale),
1056
+ );
1053
1057
  if (!parsed.success) {
1054
1058
  const issues = parsed.error.issues;
1055
1059
  const fieldErrors: Record<string, string> = {};
@@ -3,7 +3,6 @@
3
3
  import * as React from "react";
4
4
  import { extensionPoints } from "@open-mercato/core/modules/catalog/extension-points";
5
5
  import { useRouter, useSearchParams } from "next/navigation";
6
- import type { ZodType } from "zod";
7
6
  import { Page, PageBody } from "@open-mercato/ui/backend/Page";
8
7
  import {
9
8
  CrudForm,
@@ -41,7 +40,7 @@ import {
41
40
  apiCall,
42
41
  readApiResultOrThrow,
43
42
  } from "@open-mercato/ui/backend/utils/apiCall";
44
- import { useT } from "@open-mercato/shared/lib/i18n/context";
43
+ import { useT, useLocale } from "@open-mercato/shared/lib/i18n/context";
45
44
  import { E } from "#generated/entities.ids.generated";
46
45
  import {
47
46
  ProductMediaManager,
@@ -60,7 +59,7 @@ import {
60
59
  type ProductUnitConversionDraft,
61
60
  type ProductUnitPriceReferenceUnit,
62
61
  type ProductUnitRoundingMode,
63
- productFormSchema,
62
+ buildLocaleAwareProductFormSchema,
64
63
  createInitialProductFormValues,
65
64
  createVariantDraft,
66
65
  buildOptionValuesKey,
@@ -101,9 +100,6 @@ import { createLogger } from '@open-mercato/shared/lib/logger'
101
100
 
102
101
  const logger = createLogger('catalog')
103
102
 
104
- const productFormTypedSchema =
105
- productFormSchema as unknown as ZodType<ProductFormValues>;
106
-
107
103
  type VariantPriceRequest = {
108
104
  variantDraftId: string;
109
105
  priceKindId: string;
@@ -233,6 +229,11 @@ function readInboxProductDraft(): InboxProductDraft | null {
233
229
 
234
230
  export default function CreateCatalogProductPage() {
235
231
  const t = useT();
232
+ const locale = useLocale();
233
+ const productFormTypedSchema = React.useMemo(
234
+ () => buildLocaleAwareProductFormSchema(locale),
235
+ [locale],
236
+ );
236
237
  const router = useRouter();
237
238
  const searchParams = useSearchParams();
238
239
  const fromInboxAction = searchParams.get("fromInboxAction");