@open-mercato/core 0.10.1-develop.7932.1.ead5057eae → 0.10.1-develop.7937.1.cf4cfe1a1f
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/attachments/lib/ocrQueue.js +4 -1
- package/dist/modules/attachments/lib/ocrQueue.js.map +2 -2
- package/dist/modules/entities/lib/helpers.js +2 -2
- package/dist/modules/entities/lib/helpers.js.map +3 -3
- package/package.json +7 -7
- package/src/modules/attachments/lib/ocrQueue.ts +4 -2
- package/src/modules/entities/lib/helpers.ts +3 -2
package/.turbo/turbo-build.log
CHANGED
|
@@ -119,11 +119,14 @@ async function requestOcrProcessing(em, attachment, driver, storagePath) {
|
|
|
119
119
|
}
|
|
120
120
|
const workerEm = em.fork();
|
|
121
121
|
const maxWaitQueue = resolveOcrMaxWaitQueue();
|
|
122
|
-
|
|
122
|
+
const freeOcrSlots = Math.max(0, resolveOcrMaxConcurrency() - activeOcrJobs);
|
|
123
|
+
const pendingWaiters = Math.max(0, pendingOcrJobs - freeOcrSlots);
|
|
124
|
+
if (ocrWaitQueue.length + pendingWaiters >= maxWaitQueue) {
|
|
123
125
|
logger.warn("OCR wait queue full; falling back to inline text extraction", {
|
|
124
126
|
attachmentId: attachment.id,
|
|
125
127
|
waiting: ocrWaitQueue.length,
|
|
126
128
|
pending: pendingOcrJobs,
|
|
129
|
+
pendingWaiters,
|
|
127
130
|
maxWaitQueue
|
|
128
131
|
});
|
|
129
132
|
await persistInlineTextExtractionFallback(workerEm, payload, driver);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/attachments/lib/ocrQueue.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { Attachment, AttachmentPartition } from '../data/entities'\nimport { OcrService } from './ocrService'\nimport type { StorageDriver } from './drivers/types'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { resolveOcrMaxConcurrency, resolveOcrMaxWaitQueue } from './ocrLimits'\nimport { extractAttachmentContent } from './textExtraction'\n\nconst logger = createLogger('attachments').child({ component: 'ocr' })\n\nexport type OcrRequestedEvent = {\n attachmentId: string\n storagePath: string\n mimeType: string\n partitionCode: string\n organizationId: string | null\n tenantId: string | null\n}\n\n/** Outcome of `requestOcrProcessing` \u2014 background LLM OCR vs inline text fallback. */\nexport type OcrProcessingDispatch = 'queued' | 'inline_fallback'\n\nlet activeOcrJobs = 0\nconst ocrWaitQueue: Array<() => void> = []\n// Jobs scheduled with setImmediate that have not yet entered a slot or the wait queue.\
|
|
5
|
-
"mappings": "AACA,SAAS,YAAY,2BAA2B;AAChD,SAAS,kBAAkB;AAE3B,SAAS,oBAAoB;AAC7B,SAAS,0BAA0B,8BAA8B;AACjE,SAAS,gCAAgC;AAEzC,MAAM,SAAS,aAAa,aAAa,EAAE,MAAM,EAAE,WAAW,MAAM,CAAC;AAcrE,IAAI,gBAAgB;AACpB,MAAM,eAAkC,CAAC;
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { Attachment, AttachmentPartition } from '../data/entities'\nimport { OcrService } from './ocrService'\nimport type { StorageDriver } from './drivers/types'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { resolveOcrMaxConcurrency, resolveOcrMaxWaitQueue } from './ocrLimits'\nimport { extractAttachmentContent } from './textExtraction'\n\nconst logger = createLogger('attachments').child({ component: 'ocr' })\n\nexport type OcrRequestedEvent = {\n attachmentId: string\n storagePath: string\n mimeType: string\n partitionCode: string\n organizationId: string | null\n tenantId: string | null\n}\n\n/** Outcome of `requestOcrProcessing` \u2014 background LLM OCR vs inline text fallback. */\nexport type OcrProcessingDispatch = 'queued' | 'inline_fallback'\n\nlet activeOcrJobs = 0\nconst ocrWaitQueue: Array<() => void> = []\n// Jobs scheduled with setImmediate that have not yet entered a slot or the wait queue.\nlet pendingOcrJobs = 0\n\n/** Test-only: reset in-process OCR concurrency bookkeeping. */\nexport function resetOcrConcurrencyStateForTests(): void {\n activeOcrJobs = 0\n ocrWaitQueue.length = 0\n pendingOcrJobs = 0\n}\n\n/** Test-only: inspect in-process OCR concurrency counters. */\nexport function getOcrConcurrencyStateForTests(): { active: number; waiting: number } {\n return { active: activeOcrJobs, waiting: ocrWaitQueue.length }\n}\n\nexport async function withOcrConcurrencySlot<T>(run: () => Promise<T>): Promise<T> {\n const maxConcurrency = resolveOcrMaxConcurrency()\n // Re-check after wake: multiple waiters can resume when one slot frees (barging).\n while (activeOcrJobs >= maxConcurrency) {\n await new Promise<void>((resolve) => {\n ocrWaitQueue.push(resolve)\n })\n }\n activeOcrJobs += 1\n try {\n return await run()\n } finally {\n activeOcrJobs -= 1\n const next = ocrWaitQueue.shift()\n if (next) next()\n }\n}\n\nexport async function processAttachmentOcr(\n em: EntityManager,\n payload: OcrRequestedEvent,\n driver: StorageDriver,\n): Promise<void> {\n const { attachmentId, storagePath, mimeType, partitionCode } = payload\n\n logger.info('Processing started', { attachmentId })\n const startTime = Date.now()\n\n const { filePath, cleanup } = await driver.toLocalPath(partitionCode, storagePath)\n try {\n const partition = await em.findOne(AttachmentPartition, { code: partitionCode })\n const resolvedModel = partition?.ocrModel ?? process.env.OCR_MODEL ?? 'gpt-4o'\n\n const ocrService = new OcrService()\n\n if (!ocrService.available) {\n logger.warn('OPENAI_API_KEY not configured, skipping OCR', { attachmentId })\n return\n }\n\n const result = await ocrService.processFile({\n filePath,\n mimeType,\n model: resolvedModel,\n })\n\n if (!result) {\n logger.info('No content extracted', { attachmentId })\n return\n }\n\n const attachment = await em.findOne(Attachment, { id: attachmentId })\n if (!attachment) {\n logger.error('Attachment not found', { attachmentId })\n return\n }\n\n attachment.content = result.content\n await em.persist(attachment).flush()\n\n logger.info('Processing completed', {\n attachmentId,\n pageCount: result.pageCount,\n contentLength: result.content.length,\n timeMs: result.processingTimeMs,\n totalTimeMs: Date.now() - startTime,\n })\n } catch (error) {\n logger.error('Processing failed', { attachmentId, err: error })\n } finally {\n await cleanup().catch((cleanupError) => {\n logger.warn('Temp file cleanup failed', { err: cleanupError })\n })\n }\n}\n\n/**\n * When the in-process OCR wait queue is full, extract plain text/PDF/DOCX content\n * inline (no LLM) so `attachment.content` is not left NULL forever.\n */\nasync function persistInlineTextExtractionFallback(\n em: EntityManager,\n payload: OcrRequestedEvent,\n driver: StorageDriver,\n): Promise<void> {\n const { attachmentId, storagePath, mimeType, partitionCode } = payload\n const { filePath, cleanup } = await driver.toLocalPath(partitionCode, storagePath)\n try {\n const content = await extractAttachmentContent({ filePath, mimeType })\n if (!content) {\n logger.info('OCR wait queue full; inline extraction produced no content', { attachmentId })\n return\n }\n const row = await em.findOne(Attachment, { id: attachmentId })\n if (!row) {\n logger.error('Attachment not found during OCR overflow fallback', { attachmentId })\n return\n }\n row.content = content\n await em.persist(row).flush()\n logger.info('OCR wait queue full; stored inline text extraction', {\n attachmentId,\n contentLength: content.length,\n })\n } catch (error) {\n logger.error('OCR wait queue overflow inline extraction failed', { attachmentId, err: error })\n } finally {\n await cleanup().catch((cleanupError) => {\n logger.warn('Temp file cleanup failed after OCR overflow fallback', { err: cleanupError })\n })\n }\n}\n\nexport async function requestOcrProcessing(\n em: EntityManager,\n attachment: Attachment,\n driver: StorageDriver,\n storagePath: string,\n): Promise<OcrProcessingDispatch> {\n const payload: OcrRequestedEvent = {\n attachmentId: attachment.id,\n storagePath,\n mimeType: attachment.mimeType,\n partitionCode: attachment.partitionCode,\n organizationId: attachment.organizationId ?? null,\n tenantId: attachment.tenantId ?? null,\n }\n\n if (typeof (em as { fork?: unknown })?.fork !== 'function') {\n throw new Error(\n '[internal] attachments OCR background processing requires an EntityManager that exposes fork(); ' +\n 'reusing the request-scoped EntityManager in the async worker would race with later request mutations.',\n )\n }\n\n const workerEm = em.fork()\n\n const maxWaitQueue = resolveOcrMaxWaitQueue()\n const freeOcrSlots = Math.max(0, resolveOcrMaxConcurrency() - activeOcrJobs)\n const pendingWaiters = Math.max(0, pendingOcrJobs - freeOcrSlots)\n if (ocrWaitQueue.length + pendingWaiters >= maxWaitQueue) {\n logger.warn('OCR wait queue full; falling back to inline text extraction', {\n attachmentId: attachment.id,\n waiting: ocrWaitQueue.length,\n pending: pendingOcrJobs,\n pendingWaiters,\n maxWaitQueue,\n })\n await persistInlineTextExtractionFallback(workerEm, payload, driver)\n return 'inline_fallback'\n }\n\n pendingOcrJobs += 1\n setImmediate(() => {\n pendingOcrJobs -= 1\n withOcrConcurrencySlot(() => processAttachmentOcr(workerEm, payload, driver)).catch((error) => {\n logger.error('Background processing error', { err: error })\n })\n })\n return 'queued'\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -102,9 +102,9 @@ async function setRecordCustomFields(em, opts) {
|
|
|
102
102
|
const def = defsByKey?.get(fieldKey);
|
|
103
103
|
const encrypted = Boolean(def?.configJson && def.configJson?.encrypted);
|
|
104
104
|
const collapseToSingle = Array.isArray(raw) && Boolean(def) && def.configJson?.multi !== true;
|
|
105
|
-
const isArray = Array.isArray(raw) && !collapseToSingle;
|
|
105
|
+
const isArray = Array.isArray(raw) && !collapseToSingle || raw === null && def?.configJson?.multi === true;
|
|
106
106
|
if (isArray) {
|
|
107
|
-
const arr = raw;
|
|
107
|
+
const arr = Array.isArray(raw) ? raw.filter((value2) => value2 != null) : [];
|
|
108
108
|
await em.nativeDelete(CustomFieldValue, { entityId, recordId, fieldKey, ...reconcileScopeFilter });
|
|
109
109
|
for (const val of arr) {
|
|
110
110
|
const col = encrypted ? "valueText" : def ? columnFromKind(def.kind) : columnFromJsValue(val);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/entities/lib/helpers.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/core'\nimport type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'\nimport { decryptCustomFieldValue, encryptCustomFieldValue, resolveTenantEncryptionService } from '@open-mercato/shared/lib/encryption/customFieldValues'\nimport {\n MAX_CUSTOM_FIELD_KEYS_PER_RECORD,\n TOO_MANY_CUSTOM_FIELDS_ERROR,\n} from '@open-mercato/shared/modules/entities/validation'\nimport { CustomFieldDef, CustomFieldValue } from '../data/entities'\nimport { loadScopedCustomFieldDefs } from './scoped-field-defs'\nimport { encryptCustomEntityDocument } from '@open-mercato/shared/lib/encryption/customEntityDoc'\nimport { isTenantDataEncryptionEnabled } from '@open-mercato/shared/lib/encryption/toggles'\n\n/** @internal */\nexport async function encryptCustomEntityRecordDocument(\n em: EntityManager,\n opts: { entityId: string; organizationId: string | null; tenantId: string | null; doc: Record<string, unknown>; encryptionService?: TenantDataEncryptionService | null },\n): Promise<Record<string, unknown>> {\n if (!isTenantDataEncryptionEnabled() || Object.keys(opts.doc).every((key) => key === 'id')) return opts.doc\n const definitions = await loadScopedCustomFieldDefs(em, opts)\n const fields = Array.from(definitions.values(), (definition) => ({\n key: definition.key,\n kind: definition.kind,\n encrypted: Boolean(definition.configJson?.encrypted),\n }))\n return encryptCustomEntityDocument(opts.doc, fields, opts.tenantId, resolveTenantEncryptionService(em, opts.encryptionService))\n}\n\ntype Primitive = string | number | boolean | null | undefined\ntype PrimitiveOrArray = Primitive | Primitive[]\n\nexport type SetRecordCustomFieldsOptions = {\n entityId: string\n recordId: string\n organizationId?: string | null\n tenantId?: string | null\n values: Record<string, PrimitiveOrArray>\n // When true (default), try to use field definitions to decide storage column\n preferDefs?: boolean\n // Optional: notify external systems (e.g., indexing) when values changed\n onChanged?: (payload: { entityId: string; recordId: string; organizationId: string | null; tenantId: string | null }) => Promise<void> | void\n // Optional: re-use an existing tenant encryption service instance\n encryptionService?: TenantDataEncryptionService | null\n // When true, additionally scope the reconciling delete/lookup by organizationId,\n // restoring the pre-#5970 organization pin. Default false relies on `recordId`\n // being unique across organizations for a given entityId \u2014 true for every\n // ORM-backed entity, where recordId is the record's own primary key. That\n // invariant does not hold for `custom_entities_storage` records written under\n // `ENTITIES_BACKCOMPAT_EAV_FOR_CUSTOM`: the table upserts on\n // `(entity_type, entity_id, organization_id)`, so the same id can be a live\n // record in two organizations of one tenant, and an organization-agnostic\n // delete could remove another organization's values (#6034 review). Callers\n // writing those records must pass pinOrganizationId: true.\n pinOrganizationId?: boolean\n}\n\nfunction columnFromKind(kind: string): keyof CustomFieldValue {\n switch (kind) {\n case 'text':\n case 'select':\n case 'currency':\n case 'dictionary':\n case 'phone':\n return 'valueText'\n case 'multiline':\n return 'valueMultiline'\n case 'integer':\n return 'valueInt'\n case 'float':\n return 'valueFloat'\n case 'boolean':\n return 'valueBool'\n default:\n return 'valueText'\n }\n}\n\nfunction columnFromJsValue(v: Primitive): keyof CustomFieldValue {\n if (v === null || v === undefined) return 'valueText'\n if (typeof v === 'boolean') return 'valueBool'\n if (typeof v === 'number') return Number.isInteger(v) ? 'valueInt' : 'valueFloat'\n return 'valueText'\n}\n\n// Clears all value columns to avoid leftovers on update\nfunction clearValueColumns(cf: CustomFieldValue) {\n cf.valueText = null\n cf.valueMultiline = null\n cf.valueInt = null\n cf.valueFloat = null\n cf.valueBool = null\n}\n\nfunction valueFromRow(row: CustomFieldValue): Primitive {\n if (row.valueMultiline != null) return row.valueMultiline\n if (row.valueText != null) return row.valueText\n if (row.valueInt != null) return row.valueInt\n if (row.valueFloat != null) return row.valueFloat\n if (row.valueBool != null) return row.valueBool\n return null\n}\n\n// Keeps the value the caller's own scope already holds when the echoed array contains it,\n// so a save that never touched the field does not swap in another organization's value.\nfunction pickSingleValue(candidates: Primitive[], currentValue: unknown): Primitive {\n if (currentValue != null) {\n const kept = candidates.find((candidate) => candidate != null && String(candidate) === String(currentValue))\n if (kept !== undefined) return kept\n }\n const present = candidates.filter((candidate) => candidate != null)\n return present.length ? present[present.length - 1] : null\n}\n\nexport async function setRecordCustomFields(\n em: EntityManager,\n opts: SetRecordCustomFieldsOptions,\n): Promise<void> {\n const { entityId, recordId, values } = opts\n const organizationId = opts.organizationId ?? null\n const tenantId = opts.tenantId ?? null\n const preferDefs = opts.preferDefs !== false\n\n let defsByKey: Map<string, CustomFieldDef> | undefined\n if (preferDefs) {\n defsByKey = await loadScopedCustomFieldDefs(em, { entityId, organizationId, tenantId })\n }\n\n const toPersist: CustomFieldValue[] = []\n let encryptionService: TenantDataEncryptionService | null | undefined\n const encryptionCache = new Map<string | null, string | null>()\n const getEncryptionService = () => {\n if (encryptionService !== undefined) return encryptionService\n encryptionService = resolveTenantEncryptionService(em as any, opts.encryptionService)\n return encryptionService\n }\n // A write owns every value row a reader in ITS OWN tenant scope could return for this\n // record: same logical key (entityId, recordId, fieldKey), any organization, and either\n // the caller's tenant or the instance-global NULL tenant \u2014 exactly what the Query Engine\n // matches. Organization is left out because the Query Engine's value join does not filter\n // it (engine.ts, the cf join and the EXISTS subquery), so a row from a scope the record\n // has left is indistinguishable from the live one (#5970). The query-index reader does\n // scope organization_id, but it never sees another organization's row for a record it is\n // indexing, so dropping the column here cannot widen what it returns.\n //\n // This relies on recordId being unique across organizations and tenants for a given\n // entityId \u2014 true of every ORM-backed caller, which passes the record's own primary key.\n // A caller that passed a per-organization natural key \u2014 or a recordId that is only unique\n // WITHIN an organization, as custom_entities_storage's EAV backcompat writes are \u2014 would\n // have one organization's write delete another's live rows. Those callers opt into\n // pinOrganizationId (see SetRecordCustomFieldsOptions) to restore the organization pin.\n //\n // Scope of the delete, precisely: another TENANT's own rows are never touched. Rows with a\n // NULL tenant are, deliberately \u2014 they are instance-global and answer every tenant's reads,\n // so leaving one behind would reproduce the very duplicate this reconciles. Spelled as an\n // explicit $or rather than `$in: [tenantId, null]` because SQL `IN (NULL)` never matches a\n // NULL row.\n const tenantScopeFilter = { $or: [{ tenantId }, { tenantId: null }] }\n const pinOrganizationId = opts.pinOrganizationId === true\n const reconcileScopeFilter = pinOrganizationId\n ? { organizationId, ...tenantScopeFilter }\n : tenantScopeFilter\n const keys = Object.keys(values)\n const presentKeyCount = keys.filter((key) => values[key] !== undefined).length\n if (preferDefs && presentKeyCount > MAX_CUSTOM_FIELD_KEYS_PER_RECORD) {\n throw new Error(TOO_MANY_CUSTOM_FIELDS_ERROR)\n }\n\n // Run the per-key delete+insert work inside ONE database transaction so a\n // multi-value replacement is atomic and isolated. The array branch deletes the\n // existing rows for a key and inserts the replacements; without an enclosing\n // transaction those can land in separate commit boundaries under MikroORM's\n // FlushMode.AUTO (a query elsewhere in the unit auto-flushes part of the work),\n // which intermittently left the field with the delete applied but the inserts\n // missing \u2014 the multi-select EDIT reverted to []. The single commit below makes\n // it all-or-nothing. We only open our own transaction when the caller has not\n // already started one (commands fork the request em and may run setCustomFields\n // outside their own withAtomicFlush tx); join an ambient transaction otherwise.\n const txEm = em as {\n begin?: () => Promise<void>\n commit?: () => Promise<void>\n rollback?: () => Promise<void>\n isInTransaction?: () => boolean\n }\n const txCapable =\n typeof txEm.begin === 'function' &&\n typeof txEm.commit === 'function' &&\n typeof txEm.rollback === 'function' &&\n typeof txEm.isInTransaction === 'function'\n const ownCustomFieldTransaction = txCapable && !txEm.isInTransaction!()\n if (ownCustomFieldTransaction) await txEm.begin!()\n try {\n for (const fieldKey of keys) {\n const raw = values[fieldKey]\n if (raw === undefined) continue\n\n const def = defsByKey?.get(fieldKey)\n const encrypted = Boolean(def?.configJson && (def as any).configJson?.encrypted)\n // A single-value definition never stores an array. One arrives when the detail read\n // returned the duplicate rows #5970 left behind as an array and a form echoed it back\n // unchanged; treating it as a multi-value replacement re-creates every duplicate under\n // the caller's organization (#6468). Collapse it to one value so the scalar branch\n // below reconciles the duplicates away.\n const collapseToSingle = Array.isArray(raw) && Boolean(def) && (def as any).configJson?.multi !== true\n const isArray = Array.isArray(raw) && !collapseToSingle\n // When array (multi-value): replace all existing rows for the key. Delete\n // first, then create replacements, all inside the transaction opened above.\n // Creating rows before a native delete can auto-flush and delete the new\n // values; mixing em.remove(stale) with new rows for the same EAV scope was\n // observed to commit an empty set under MikroORM v7. The explicit order keeps\n // the replacement atomic without letting old-row cleanup target new rows.\n if (isArray) {\n const arr = raw as Primitive[]\n // Reconciling delete (see tenantScopeFilter/reconcileScopeFilter): pinning\n // organizationId here left the rows written under the record's PREVIOUS\n // organization alive next to the replacements, and the Query Engine's value join\n // does not filter organization_id, so both generations answered the same lookup\n // (#5970) \u2014 unless the caller opted into pinOrganizationId because its recordId\n // is not unique across organizations.\n await em.nativeDelete(CustomFieldValue, { entityId, recordId, fieldKey, ...reconcileScopeFilter })\n for (const val of arr) {\n const col: keyof CustomFieldValue = encrypted ? 'valueText' : def ? columnFromKind(def.kind) : columnFromJsValue(val)\n const cf = em.create(CustomFieldValue, { entityId, recordId, organizationId, tenantId, fieldKey, createdAt: new Date() })\n clearValueColumns(cf)\n const stored = encrypted\n ? await encryptCustomFieldValue(val, tenantId, getEncryptionService(), encryptionCache, { entityId, fieldKey })\n : val\n switch (col) {\n case 'valueText': cf.valueText = stored == null ? null : String(stored); break\n case 'valueMultiline': cf.valueMultiline = stored == null ? null : String(stored); break\n case 'valueInt': cf.valueInt = stored == null ? null : Number(stored); break\n case 'valueFloat': cf.valueFloat = stored == null ? null : Number(stored); break\n case 'valueBool': cf.valueBool = stored == null ? null : Boolean(stored); break\n default: cf.valueText = stored == null ? null : String(stored); break\n }\n toPersist.push(cf)\n }\n continue\n }\n\n // Same reconciliation as the multi-value branch (see tenantScopeFilter): load every\n // reachable row for the logical key, reuse the one already in the caller's exact\n // scope and drop the rest, so a row left behind by a previous organization \u2014 and\n // same-scope duplicates from two writes that both missed the lookup \u2014 cannot\n // survive (#5970). Picking the survivor in JS rather than narrowing the query keeps\n // NULL organizations out of SQL three-valued logic, where an inequality predicate\n // would silently spare exactly the stale rows this is meant to remove.\n const existingRows = await em.find(CustomFieldValue, { entityId, recordId, fieldKey, ...reconcileScopeFilter })\n // A soft-deleted row is never the survivor: writing the new value onto it would leave\n // deleted_at set, hiding the field from loadCustomFieldValues while the live row it\n // replaced goes into staleIds. Tombstones still get deleted as stale \u2014 the Query\n // Engine's join does not filter deleted_at, so one left behind would answer reads.\n let cf = existingRows.find((row) => !row.deletedAt\n && (row.organizationId ?? null) === organizationId\n && (row.tenantId ?? null) === tenantId) ?? null\n const value: Primitive = collapseToSingle\n ? pickSingleValue(raw as Primitive[], cf ? await decryptCustomFieldValue(valueFromRow(cf), tenantId, encrypted ? getEncryptionService() : null, encryptionCache, { kind: def?.kind ?? null }) : undefined)\n : raw as Primitive\n const column: keyof CustomFieldValue = encrypted ? 'valueText' : def ? columnFromKind(def.kind) : columnFromJsValue(value)\n const storedValue = encrypted\n ? await encryptCustomFieldValue(value, tenantId, getEncryptionService(), encryptionCache, { entityId, fieldKey })\n : value\n const staleIds = existingRows.filter((row) => row !== cf).map((row) => row.id)\n if (staleIds.length) await em.nativeDelete(CustomFieldValue, { id: { $in: staleIds } })\n if (!cf) {\n cf = em.create(CustomFieldValue, { entityId, recordId, organizationId, tenantId, fieldKey, createdAt: new Date() })\n toPersist.push(cf)\n }\n clearValueColumns(cf)\n switch (column) {\n case 'valueText':\n cf.valueText = (storedValue as Primitive) == null ? null : String(storedValue as Primitive)\n break\n case 'valueMultiline':\n cf.valueMultiline = (storedValue as Primitive) == null ? null : String(storedValue as Primitive)\n break\n case 'valueInt':\n cf.valueInt = (storedValue as Primitive) == null ? null : Number(storedValue as Primitive)\n break\n case 'valueFloat':\n cf.valueFloat = (storedValue as Primitive) == null ? null : Number(storedValue as Primitive)\n break\n case 'valueBool':\n cf.valueBool = (storedValue as Primitive) == null ? null : Boolean(storedValue as Primitive)\n break\n default:\n cf.valueText = (storedValue as Primitive) == null ? null : String(storedValue as Primitive)\n break\n }\n }\n\n if (toPersist.length) em.persist(toPersist)\n await em.flush()\n if (ownCustomFieldTransaction) await txEm.commit!()\n } catch (err) {\n if (ownCustomFieldTransaction) {\n try { await txEm.rollback!() } catch { /* surface the original error, not a rollback failure */ }\n }\n throw err\n }\n // Emit hook for indexing if requested (outside CRUD flows). Runs AFTER the\n // transaction commits so consumers observe the persisted rows.\n try {\n if (typeof opts.onChanged === 'function') {\n await opts.onChanged({ entityId, recordId, organizationId, tenantId })\n }\n } catch {\n // Non-blocking\n }\n}\n"],
|
|
5
|
-
"mappings": "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
|
|
6
|
-
"names": ["cf"]
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/core'\nimport type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'\nimport { decryptCustomFieldValue, encryptCustomFieldValue, resolveTenantEncryptionService } from '@open-mercato/shared/lib/encryption/customFieldValues'\nimport {\n MAX_CUSTOM_FIELD_KEYS_PER_RECORD,\n TOO_MANY_CUSTOM_FIELDS_ERROR,\n} from '@open-mercato/shared/modules/entities/validation'\nimport { CustomFieldDef, CustomFieldValue } from '../data/entities'\nimport { loadScopedCustomFieldDefs } from './scoped-field-defs'\nimport { encryptCustomEntityDocument } from '@open-mercato/shared/lib/encryption/customEntityDoc'\nimport { isTenantDataEncryptionEnabled } from '@open-mercato/shared/lib/encryption/toggles'\n\n/** @internal */\nexport async function encryptCustomEntityRecordDocument(\n em: EntityManager,\n opts: { entityId: string; organizationId: string | null; tenantId: string | null; doc: Record<string, unknown>; encryptionService?: TenantDataEncryptionService | null },\n): Promise<Record<string, unknown>> {\n if (!isTenantDataEncryptionEnabled() || Object.keys(opts.doc).every((key) => key === 'id')) return opts.doc\n const definitions = await loadScopedCustomFieldDefs(em, opts)\n const fields = Array.from(definitions.values(), (definition) => ({\n key: definition.key,\n kind: definition.kind,\n encrypted: Boolean(definition.configJson?.encrypted),\n }))\n return encryptCustomEntityDocument(opts.doc, fields, opts.tenantId, resolveTenantEncryptionService(em, opts.encryptionService))\n}\n\ntype Primitive = string | number | boolean | null | undefined\ntype PrimitiveOrArray = Primitive | Primitive[]\n\nexport type SetRecordCustomFieldsOptions = {\n entityId: string\n recordId: string\n organizationId?: string | null\n tenantId?: string | null\n values: Record<string, PrimitiveOrArray>\n // When true (default), try to use field definitions to decide storage column\n preferDefs?: boolean\n // Optional: notify external systems (e.g., indexing) when values changed\n onChanged?: (payload: { entityId: string; recordId: string; organizationId: string | null; tenantId: string | null }) => Promise<void> | void\n // Optional: re-use an existing tenant encryption service instance\n encryptionService?: TenantDataEncryptionService | null\n // When true, additionally scope the reconciling delete/lookup by organizationId,\n // restoring the pre-#5970 organization pin. Default false relies on `recordId`\n // being unique across organizations for a given entityId \u2014 true for every\n // ORM-backed entity, where recordId is the record's own primary key. That\n // invariant does not hold for `custom_entities_storage` records written under\n // `ENTITIES_BACKCOMPAT_EAV_FOR_CUSTOM`: the table upserts on\n // `(entity_type, entity_id, organization_id)`, so the same id can be a live\n // record in two organizations of one tenant, and an organization-agnostic\n // delete could remove another organization's values (#6034 review). Callers\n // writing those records must pass pinOrganizationId: true.\n pinOrganizationId?: boolean\n}\n\nfunction columnFromKind(kind: string): keyof CustomFieldValue {\n switch (kind) {\n case 'text':\n case 'select':\n case 'currency':\n case 'dictionary':\n case 'phone':\n return 'valueText'\n case 'multiline':\n return 'valueMultiline'\n case 'integer':\n return 'valueInt'\n case 'float':\n return 'valueFloat'\n case 'boolean':\n return 'valueBool'\n default:\n return 'valueText'\n }\n}\n\nfunction columnFromJsValue(v: Primitive): keyof CustomFieldValue {\n if (v === null || v === undefined) return 'valueText'\n if (typeof v === 'boolean') return 'valueBool'\n if (typeof v === 'number') return Number.isInteger(v) ? 'valueInt' : 'valueFloat'\n return 'valueText'\n}\n\n// Clears all value columns to avoid leftovers on update\nfunction clearValueColumns(cf: CustomFieldValue) {\n cf.valueText = null\n cf.valueMultiline = null\n cf.valueInt = null\n cf.valueFloat = null\n cf.valueBool = null\n}\n\nfunction valueFromRow(row: CustomFieldValue): Primitive {\n if (row.valueMultiline != null) return row.valueMultiline\n if (row.valueText != null) return row.valueText\n if (row.valueInt != null) return row.valueInt\n if (row.valueFloat != null) return row.valueFloat\n if (row.valueBool != null) return row.valueBool\n return null\n}\n\n// Keeps the value the caller's own scope already holds when the echoed array contains it,\n// so a save that never touched the field does not swap in another organization's value.\nfunction pickSingleValue(candidates: Primitive[], currentValue: unknown): Primitive {\n if (currentValue != null) {\n const kept = candidates.find((candidate) => candidate != null && String(candidate) === String(currentValue))\n if (kept !== undefined) return kept\n }\n const present = candidates.filter((candidate) => candidate != null)\n return present.length ? present[present.length - 1] : null\n}\n\nexport async function setRecordCustomFields(\n em: EntityManager,\n opts: SetRecordCustomFieldsOptions,\n): Promise<void> {\n const { entityId, recordId, values } = opts\n const organizationId = opts.organizationId ?? null\n const tenantId = opts.tenantId ?? null\n const preferDefs = opts.preferDefs !== false\n\n let defsByKey: Map<string, CustomFieldDef> | undefined\n if (preferDefs) {\n defsByKey = await loadScopedCustomFieldDefs(em, { entityId, organizationId, tenantId })\n }\n\n const toPersist: CustomFieldValue[] = []\n let encryptionService: TenantDataEncryptionService | null | undefined\n const encryptionCache = new Map<string | null, string | null>()\n const getEncryptionService = () => {\n if (encryptionService !== undefined) return encryptionService\n encryptionService = resolveTenantEncryptionService(em as any, opts.encryptionService)\n return encryptionService\n }\n // A write owns every value row a reader in ITS OWN tenant scope could return for this\n // record: same logical key (entityId, recordId, fieldKey), any organization, and either\n // the caller's tenant or the instance-global NULL tenant \u2014 exactly what the Query Engine\n // matches. Organization is left out because the Query Engine's value join does not filter\n // it (engine.ts, the cf join and the EXISTS subquery), so a row from a scope the record\n // has left is indistinguishable from the live one (#5970). The query-index reader does\n // scope organization_id, but it never sees another organization's row for a record it is\n // indexing, so dropping the column here cannot widen what it returns.\n //\n // This relies on recordId being unique across organizations and tenants for a given\n // entityId \u2014 true of every ORM-backed caller, which passes the record's own primary key.\n // A caller that passed a per-organization natural key \u2014 or a recordId that is only unique\n // WITHIN an organization, as custom_entities_storage's EAV backcompat writes are \u2014 would\n // have one organization's write delete another's live rows. Those callers opt into\n // pinOrganizationId (see SetRecordCustomFieldsOptions) to restore the organization pin.\n //\n // Scope of the delete, precisely: another TENANT's own rows are never touched. Rows with a\n // NULL tenant are, deliberately \u2014 they are instance-global and answer every tenant's reads,\n // so leaving one behind would reproduce the very duplicate this reconciles. Spelled as an\n // explicit $or rather than `$in: [tenantId, null]` because SQL `IN (NULL)` never matches a\n // NULL row.\n const tenantScopeFilter = { $or: [{ tenantId }, { tenantId: null }] }\n const pinOrganizationId = opts.pinOrganizationId === true\n const reconcileScopeFilter = pinOrganizationId\n ? { organizationId, ...tenantScopeFilter }\n : tenantScopeFilter\n const keys = Object.keys(values)\n const presentKeyCount = keys.filter((key) => values[key] !== undefined).length\n if (preferDefs && presentKeyCount > MAX_CUSTOM_FIELD_KEYS_PER_RECORD) {\n throw new Error(TOO_MANY_CUSTOM_FIELDS_ERROR)\n }\n\n // Run the per-key delete+insert work inside ONE database transaction so a\n // multi-value replacement is atomic and isolated. The array branch deletes the\n // existing rows for a key and inserts the replacements; without an enclosing\n // transaction those can land in separate commit boundaries under MikroORM's\n // FlushMode.AUTO (a query elsewhere in the unit auto-flushes part of the work),\n // which intermittently left the field with the delete applied but the inserts\n // missing \u2014 the multi-select EDIT reverted to []. The single commit below makes\n // it all-or-nothing. We only open our own transaction when the caller has not\n // already started one (commands fork the request em and may run setCustomFields\n // outside their own withAtomicFlush tx); join an ambient transaction otherwise.\n const txEm = em as {\n begin?: () => Promise<void>\n commit?: () => Promise<void>\n rollback?: () => Promise<void>\n isInTransaction?: () => boolean\n }\n const txCapable =\n typeof txEm.begin === 'function' &&\n typeof txEm.commit === 'function' &&\n typeof txEm.rollback === 'function' &&\n typeof txEm.isInTransaction === 'function'\n const ownCustomFieldTransaction = txCapable && !txEm.isInTransaction!()\n if (ownCustomFieldTransaction) await txEm.begin!()\n try {\n for (const fieldKey of keys) {\n const raw = values[fieldKey]\n if (raw === undefined) continue\n\n const def = defsByKey?.get(fieldKey)\n const encrypted = Boolean(def?.configJson && (def as any).configJson?.encrypted)\n // A single-value definition never stores an array. One arrives when the detail read\n // returned the duplicate rows #5970 left behind as an array and a form echoed it back\n // unchanged; treating it as a multi-value replacement re-creates every duplicate under\n // the caller's organization (#6468). Collapse it to one value so the scalar branch\n // below reconciles the duplicates away.\n const collapseToSingle = Array.isArray(raw) && Boolean(def) && (def as any).configJson?.multi !== true\n const isArray = (Array.isArray(raw) && !collapseToSingle)\n || (raw === null && def?.configJson?.multi === true)\n // When array (multi-value): replace all existing rows for the key. Delete\n // first, then create replacements, all inside the transaction opened above.\n // Creating rows before a native delete can auto-flush and delete the new\n // values; mixing em.remove(stale) with new rows for the same EAV scope was\n // observed to commit an empty set under MikroORM v7. The explicit order keeps\n // the replacement atomic without letting old-row cleanup target new rows.\n if (isArray) {\n const arr = Array.isArray(raw) ? raw.filter((value) => value != null) : []\n // Reconciling delete (see tenantScopeFilter/reconcileScopeFilter): pinning\n // organizationId here left the rows written under the record's PREVIOUS\n // organization alive next to the replacements, and the Query Engine's value join\n // does not filter organization_id, so both generations answered the same lookup\n // (#5970) \u2014 unless the caller opted into pinOrganizationId because its recordId\n // is not unique across organizations.\n await em.nativeDelete(CustomFieldValue, { entityId, recordId, fieldKey, ...reconcileScopeFilter })\n for (const val of arr) {\n const col: keyof CustomFieldValue = encrypted ? 'valueText' : def ? columnFromKind(def.kind) : columnFromJsValue(val)\n const cf = em.create(CustomFieldValue, { entityId, recordId, organizationId, tenantId, fieldKey, createdAt: new Date() })\n clearValueColumns(cf)\n const stored = encrypted\n ? await encryptCustomFieldValue(val, tenantId, getEncryptionService(), encryptionCache, { entityId, fieldKey })\n : val\n switch (col) {\n case 'valueText': cf.valueText = stored == null ? null : String(stored); break\n case 'valueMultiline': cf.valueMultiline = stored == null ? null : String(stored); break\n case 'valueInt': cf.valueInt = stored == null ? null : Number(stored); break\n case 'valueFloat': cf.valueFloat = stored == null ? null : Number(stored); break\n case 'valueBool': cf.valueBool = stored == null ? null : Boolean(stored); break\n default: cf.valueText = stored == null ? null : String(stored); break\n }\n toPersist.push(cf)\n }\n continue\n }\n\n // Same reconciliation as the multi-value branch (see tenantScopeFilter): load every\n // reachable row for the logical key, reuse the one already in the caller's exact\n // scope and drop the rest, so a row left behind by a previous organization \u2014 and\n // same-scope duplicates from two writes that both missed the lookup \u2014 cannot\n // survive (#5970). Picking the survivor in JS rather than narrowing the query keeps\n // NULL organizations out of SQL three-valued logic, where an inequality predicate\n // would silently spare exactly the stale rows this is meant to remove.\n const existingRows = await em.find(CustomFieldValue, { entityId, recordId, fieldKey, ...reconcileScopeFilter })\n // A soft-deleted row is never the survivor: writing the new value onto it would leave\n // deleted_at set, hiding the field from loadCustomFieldValues while the live row it\n // replaced goes into staleIds. Tombstones still get deleted as stale \u2014 the Query\n // Engine's join does not filter deleted_at, so one left behind would answer reads.\n let cf = existingRows.find((row) => !row.deletedAt\n && (row.organizationId ?? null) === organizationId\n && (row.tenantId ?? null) === tenantId) ?? null\n const value: Primitive = collapseToSingle\n ? pickSingleValue(raw as Primitive[], cf ? await decryptCustomFieldValue(valueFromRow(cf), tenantId, encrypted ? getEncryptionService() : null, encryptionCache, { kind: def?.kind ?? null }) : undefined)\n : raw as Primitive\n const column: keyof CustomFieldValue = encrypted ? 'valueText' : def ? columnFromKind(def.kind) : columnFromJsValue(value)\n const storedValue = encrypted\n ? await encryptCustomFieldValue(value, tenantId, getEncryptionService(), encryptionCache, { entityId, fieldKey })\n : value\n const staleIds = existingRows.filter((row) => row !== cf).map((row) => row.id)\n if (staleIds.length) await em.nativeDelete(CustomFieldValue, { id: { $in: staleIds } })\n if (!cf) {\n cf = em.create(CustomFieldValue, { entityId, recordId, organizationId, tenantId, fieldKey, createdAt: new Date() })\n toPersist.push(cf)\n }\n clearValueColumns(cf)\n switch (column) {\n case 'valueText':\n cf.valueText = (storedValue as Primitive) == null ? null : String(storedValue as Primitive)\n break\n case 'valueMultiline':\n cf.valueMultiline = (storedValue as Primitive) == null ? null : String(storedValue as Primitive)\n break\n case 'valueInt':\n cf.valueInt = (storedValue as Primitive) == null ? null : Number(storedValue as Primitive)\n break\n case 'valueFloat':\n cf.valueFloat = (storedValue as Primitive) == null ? null : Number(storedValue as Primitive)\n break\n case 'valueBool':\n cf.valueBool = (storedValue as Primitive) == null ? null : Boolean(storedValue as Primitive)\n break\n default:\n cf.valueText = (storedValue as Primitive) == null ? null : String(storedValue as Primitive)\n break\n }\n }\n\n if (toPersist.length) em.persist(toPersist)\n await em.flush()\n if (ownCustomFieldTransaction) await txEm.commit!()\n } catch (err) {\n if (ownCustomFieldTransaction) {\n try { await txEm.rollback!() } catch { /* surface the original error, not a rollback failure */ }\n }\n throw err\n }\n // Emit hook for indexing if requested (outside CRUD flows). Runs AFTER the\n // transaction commits so consumers observe the persisted rows.\n try {\n if (typeof opts.onChanged === 'function') {\n await opts.onChanged({ entityId, recordId, organizationId, tenantId })\n }\n } catch {\n // Non-blocking\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": ["value", "cf"]
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.10.1-develop.
|
|
3
|
+
"version": "0.10.1-develop.7937.1.cf4cfe1a1f",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -266,16 +266,16 @@
|
|
|
266
266
|
"zod": "^4.6.6"
|
|
267
267
|
},
|
|
268
268
|
"peerDependencies": {
|
|
269
|
-
"@open-mercato/ai-assistant": "0.10.1-develop.
|
|
270
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
271
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
269
|
+
"@open-mercato/ai-assistant": "0.10.1-develop.7937.1.cf4cfe1a1f",
|
|
270
|
+
"@open-mercato/shared": "0.10.1-develop.7937.1.cf4cfe1a1f",
|
|
271
|
+
"@open-mercato/ui": "0.10.1-develop.7937.1.cf4cfe1a1f",
|
|
272
272
|
"react": "^19.0.0",
|
|
273
273
|
"react-dom": "^19.0.0"
|
|
274
274
|
},
|
|
275
275
|
"devDependencies": {
|
|
276
|
-
"@open-mercato/ai-assistant": "0.10.1-develop.
|
|
277
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
278
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
276
|
+
"@open-mercato/ai-assistant": "0.10.1-develop.7937.1.cf4cfe1a1f",
|
|
277
|
+
"@open-mercato/shared": "0.10.1-develop.7937.1.cf4cfe1a1f",
|
|
278
|
+
"@open-mercato/ui": "0.10.1-develop.7937.1.cf4cfe1a1f",
|
|
279
279
|
"@testing-library/dom": "^10.4.1",
|
|
280
280
|
"@testing-library/jest-dom": "^7.0.1",
|
|
281
281
|
"@testing-library/react": "^16.3.3",
|
|
@@ -23,7 +23,6 @@ export type OcrProcessingDispatch = 'queued' | 'inline_fallback'
|
|
|
23
23
|
let activeOcrJobs = 0
|
|
24
24
|
const ocrWaitQueue: Array<() => void> = []
|
|
25
25
|
// Jobs scheduled with setImmediate that have not yet entered a slot or the wait queue.
|
|
26
|
-
// Counted toward the wait-queue cap so a burst in one event-loop turn cannot pass the check together.
|
|
27
26
|
let pendingOcrJobs = 0
|
|
28
27
|
|
|
29
28
|
/** Test-only: reset in-process OCR concurrency bookkeeping. */
|
|
@@ -176,11 +175,14 @@ export async function requestOcrProcessing(
|
|
|
176
175
|
const workerEm = em.fork()
|
|
177
176
|
|
|
178
177
|
const maxWaitQueue = resolveOcrMaxWaitQueue()
|
|
179
|
-
|
|
178
|
+
const freeOcrSlots = Math.max(0, resolveOcrMaxConcurrency() - activeOcrJobs)
|
|
179
|
+
const pendingWaiters = Math.max(0, pendingOcrJobs - freeOcrSlots)
|
|
180
|
+
if (ocrWaitQueue.length + pendingWaiters >= maxWaitQueue) {
|
|
180
181
|
logger.warn('OCR wait queue full; falling back to inline text extraction', {
|
|
181
182
|
attachmentId: attachment.id,
|
|
182
183
|
waiting: ocrWaitQueue.length,
|
|
183
184
|
pending: pendingOcrJobs,
|
|
185
|
+
pendingWaiters,
|
|
184
186
|
maxWaitQueue,
|
|
185
187
|
})
|
|
186
188
|
await persistInlineTextExtractionFallback(workerEm, payload, driver)
|
|
@@ -200,7 +200,8 @@ export async function setRecordCustomFields(
|
|
|
200
200
|
// the caller's organization (#6468). Collapse it to one value so the scalar branch
|
|
201
201
|
// below reconciles the duplicates away.
|
|
202
202
|
const collapseToSingle = Array.isArray(raw) && Boolean(def) && (def as any).configJson?.multi !== true
|
|
203
|
-
const isArray = Array.isArray(raw) && !collapseToSingle
|
|
203
|
+
const isArray = (Array.isArray(raw) && !collapseToSingle)
|
|
204
|
+
|| (raw === null && def?.configJson?.multi === true)
|
|
204
205
|
// When array (multi-value): replace all existing rows for the key. Delete
|
|
205
206
|
// first, then create replacements, all inside the transaction opened above.
|
|
206
207
|
// Creating rows before a native delete can auto-flush and delete the new
|
|
@@ -208,7 +209,7 @@ export async function setRecordCustomFields(
|
|
|
208
209
|
// observed to commit an empty set under MikroORM v7. The explicit order keeps
|
|
209
210
|
// the replacement atomic without letting old-row cleanup target new rows.
|
|
210
211
|
if (isArray) {
|
|
211
|
-
const arr = raw
|
|
212
|
+
const arr = Array.isArray(raw) ? raw.filter((value) => value != null) : []
|
|
212
213
|
// Reconciling delete (see tenantScopeFilter/reconcileScopeFilter): pinning
|
|
213
214
|
// organizationId here left the rows written under the record's PREVIOUS
|
|
214
215
|
// organization alive next to the replacements, and the Query Engine's value join
|