@open-mercato/shared 0.8.1-develop.7225.1.73190a4a95 → 0.8.1-develop.7228.1.b7ea09d2d3

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.
@@ -1,2 +1,2 @@
1
- [build:shared] found 292 entry points
1
+ [build:shared] found 293 entry points
2
2
  [build:shared] built successfully
@@ -13,6 +13,7 @@ import { parseBooleanToken } from "../boolean.js";
13
13
  import { isReadProjectionAlwaysConsistent } from "./consistency.js";
14
14
  import { isEventDeclared } from "../../modules/events/index.js";
15
15
  import { createLogger } from "../logger/index.js";
16
+ import { createBoundedTtlMemo } from "../query/bounded-ttl-memo.js";
16
17
  const logger = createLogger("shared").child({ component: "data-engine" });
17
18
  const undeclaredEventWarned = /* @__PURE__ */ new Set();
18
19
  function warnIfUndeclaredEvent(eventName, context) {
@@ -24,6 +25,14 @@ function warnIfUndeclaredEvent(eventName, context) {
24
25
  function __resetUndeclaredEventWarningsForTests() {
25
26
  undeclaredEventWarned.clear();
26
27
  }
28
+ const tableExistsCache = createBoundedTtlMemo({
29
+ ttlEnv: "OM_DATA_ENGINE_TABLE_EXISTS_CACHE_MS",
30
+ defaultTtlMs: 36e5,
31
+ defaultMaxEntries: 1e3
32
+ });
33
+ function clearDataEngineTableExistsCache() {
34
+ tableExistsCache.clear();
35
+ }
27
36
  const COVERAGE_REFRESH_INTERVAL_MS = 5 * 60 * 1e3;
28
37
  const coverageRefreshTracker = /* @__PURE__ */ new Map();
29
38
  function shouldTriggerCoverageRefresh(entityType, tenantId) {
@@ -151,11 +160,14 @@ class DefaultDataEngine {
151
160
  return this.em.getKysely();
152
161
  }
153
162
  async ensureStorageTableExists() {
163
+ const cacheKey = "custom_entities_storage";
164
+ if (tableExistsCache.get(cacheKey)) return;
154
165
  const db = this.getKysely();
155
166
  const exists = await db.selectFrom("information_schema.tables").select(sql`1`.as("present")).where("table_name", "=", "custom_entities_storage").executeTakeFirst();
156
167
  if (!exists) {
157
168
  throw new Error("custom_entities_storage table is missing. Run migrations (yarn db:migrate).");
158
169
  }
170
+ tableExistsCache.set(cacheKey, true);
159
171
  }
160
172
  normalizeValuesForValidation(values) {
161
173
  if (!values) return {};
@@ -574,6 +586,7 @@ export {
574
586
  SYSTEM_ENTITY_RECORDS_BLOCKED_CODE,
575
587
  __resetUndeclaredEventWarningsForTests,
576
588
  assertCustomEntityStorageEntityId,
589
+ clearDataEngineTableExistsCache,
577
590
  isOrmBackedSystemEntityId
578
591
  };
579
592
  //# sourceMappingURL=engine.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/data/engine.ts"],
4
- "sourcesContent": ["import type { EntityData, EntityName, FilterQuery, RequiredEntityData } from '@mikro-orm/core'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { AwilixContainer } from 'awilix'\nimport { type Kysely, sql } from 'kysely'\nimport { setRecordCustomFields } from '@open-mercato/core/modules/entities/lib/helpers'\nimport { validateCustomFieldValuesServer } from '@open-mercato/core/modules/entities/lib/validation'\nimport { sanitizeCustomFieldHtmlRichTextValuesServer } from '@open-mercato/core/modules/entities/lib/htmlRichTextSanitizer'\nimport type { EventBus } from '@open-mercato/events/types'\nimport {\n CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY,\n type CrudEventAction,\n type CrudEventsConfig,\n type CrudIndexerConfig,\n type CrudEntityIdentifiers,\n} from '../crud/types'\nimport type { BulkImportSuppression } from '../commands/types'\nimport { CrudHttpError } from '../crud/errors'\nimport { resolveRegisteredEntityTableName } from '../query/engine'\nimport { getEntityIds } from '../encryption/entityIds'\nimport { normalizeCustomFieldValues } from '../custom-fields/normalize'\nimport { parseBooleanToken } from '../boolean'\nimport { isReadProjectionAlwaysConsistent } from './consistency'\nimport { isEventDeclared } from '../../modules/events'\nimport { createLogger } from '../logger'\n\nconst logger = createLogger('shared').child({ component: 'data-engine' })\n\nconst undeclaredEventWarned = new Set<string>()\n\nfunction warnIfUndeclaredEvent(eventName: string, context: string): void {\n if (isEventDeclared(eventName)) return\n if (undeclaredEventWarned.has(eventName)) return\n undeclaredEventWarned.add(eventName)\n logger.warn('Emitting undeclared event \u2014 declare it in the owning module events.ts (createModuleEvents) so the event registry stays authoritative', { context, eventName })\n}\n\n/** Internal: clear the undeclared-event warning cache. Exposed for tests. */\nexport function __resetUndeclaredEventWarningsForTests(): void {\n undeclaredEventWarned.clear()\n}\n\nconst COVERAGE_REFRESH_INTERVAL_MS = 5 * 60 * 1000\nconst coverageRefreshTracker = new Map<string, number>()\n\nfunction shouldTriggerCoverageRefresh(entityType: string | undefined, tenantId: string | null): boolean {\n if (!entityType) return false\n const key = `${entityType}|${tenantId ?? '__null__'}`\n const now = Date.now()\n const last = coverageRefreshTracker.get(key) ?? 0\n if (now - last < COVERAGE_REFRESH_INTERVAL_MS) return false\n coverageRefreshTracker.set(key, now)\n return true\n}\n\ntype CustomEntityValues = Record<string, unknown>\n\ntype QueuedCrudSideEffect = {\n action: CrudEventAction\n entity: unknown\n identifiers: CrudEntityIdentifiers\n syncOrigin?: string | null\n actorUserId?: string | null\n events?: CrudEventsConfig<unknown>\n indexer?: CrudIndexerConfig<unknown>\n}\n\n/**\n * A `makeCrudRoute` route-level `indexer:` declaration, handed to the data engine for the\n * duration of one command-bus write. Command handlers own the side-effect mark on the\n * `actions.*` path, and most of them mark `events:` only \u2014 without this the route's\n * declaration would reach no code at all. `entityClass` scopes the default to the route's\n * own ORM entity so a handler that also marks a sibling entity in the same request (a tag\n * assignment alongside a tag, say) is never indexed under the route's `entityType`.\n */\nexport type DefaultCrudIndexerConfig = {\n indexer: CrudIndexerConfig<unknown>\n entityClass: abstract new (...args: never[]) => object\n}\n\nexport interface DataEngine {\n setCustomFields(opts: {\n entityId: string\n recordId: string\n organizationId?: string | null\n tenantId?: string | null\n values: Record<string, string | number | boolean | null | undefined | Array<string | number | boolean | null | undefined>>\n notify?: boolean // default true -> emit '<module>.<entity>.updated'\n }): Promise<void>\n\n // Storage for user-defined entities (doc-based)\n createCustomEntityRecord(opts: {\n entityId: string // '<module>:<entity>'\n recordId?: string // optional; auto-generate if not provided\n organizationId?: string | null\n tenantId?: string | null\n values: CustomEntityValues\n notify?: boolean // keep event emitting as it is via setCustomFields (updated)\n }): Promise<{ id: string }>\n\n updateCustomEntityRecord(opts: {\n entityId: string\n recordId: string\n organizationId?: string | null\n tenantId?: string | null\n values: CustomEntityValues\n notify?: boolean // keep event emitting as it is via setCustomFields (updated)\n }): Promise<void>\n\n deleteCustomEntityRecord(opts: {\n entityId: string\n recordId: string\n organizationId?: string | null\n tenantId?: string | null\n soft?: boolean // default true: sets deleted_at\n notify?: boolean // keep event emitting as it is (no extra events here)\n }): Promise<void>\n\n // Generic ORM-backed entity operations used by CrudFactory\n createOrmEntity<T extends object>(opts: {\n entity: EntityName<T>\n data: EntityData<T>\n }): Promise<T>\n\n updateOrmEntity<T extends object>(opts: {\n entity: EntityName<T>\n where: FilterQuery<T>\n apply: (current: T) => Promise<void> | void\n }): Promise<T | null>\n\n deleteOrmEntity<T extends object>(opts: {\n entity: EntityName<T>\n where: FilterQuery<T>\n soft?: boolean\n softDeleteField?: keyof T & string\n }): Promise<T | null>\n\n emitOrmEntityEvent<T>(opts: {\n action: CrudEventAction\n entity: T\n events?: CrudEventsConfig<T>\n indexer?: CrudIndexerConfig<T>\n identifiers: CrudEntityIdentifiers\n syncOrigin?: string | null\n actorUserId?: string | null\n /** Bulk-import deferral: skip the domain event and/or inline reindex for this emit. */\n suppress?: BulkImportSuppression\n }): Promise<void>\n\n markOrmEntityChange<T>(opts: {\n action: CrudEventAction\n entity: T | null | undefined\n events?: CrudEventsConfig<T>\n indexer?: CrudIndexerConfig<T>\n identifiers: CrudEntityIdentifiers\n syncOrigin?: string | null\n actorUserId?: string | null\n }): void\n\n /**\n * Drain queued side effects. When `suppress` is passed (a bulk-import backfill), the\n * flagged per-record events / reindex are skipped for every drained entry; the caller\n * is responsible for rebuilding the `query_index` afterwards.\n */\n flushOrmEntityChanges(suppress?: BulkImportSuppression): Promise<void>\n\n /**\n * Declare the indexer a CRUD route configured, for marks made during one command-bus\n * write that do not carry an indexer of their own. Pass `null` to clear it. Optional so\n * third-party `DataEngine` implementations stay valid; callers invoke it with `?.`.\n */\n setDefaultIndexerConfig?(config: DefaultCrudIndexerConfig | null): void\n\n /**\n * Whether any side effect drained since the current default was declared carried an\n * indexer for that default's entity class \u2014 false means the declared query-index\n * obligation was discharged by nobody. Optional for the same reason as the setter.\n */\n hasIndexedDefaultEntityClass?(): boolean\n}\n\nexport const SYSTEM_ENTITY_RECORDS_BLOCKED_CODE = 'system_entity_records_blocked'\n\n/**\n * A system entity for doc-storage purposes is an id that modules declare in the\n * generated entity-id registry AND that resolves to a registered ORM table. Both\n * conditions matter: `resolveRegisteredEntityTableName` matches class-name candidates\n * from the entity segment alone, so a runtime-registered custom entity whose name\n * happens to collide with some ORM class (e.g. `user:todo` vs the example module's\n * `Todo`) must never be classified as system. When the registry is not populated\n * (exotic bootstraps, unit harnesses) the check conservatively falls back to the\n * ORM-table match alone so the #2939 protection never switches off.\n */\nexport function isOrmBackedSystemEntityId(em: EntityManager, entityId: string): boolean {\n const registry = getEntityIds(false)\n const moduleIds = Object.values(registry).flatMap((moduleEntities) => Object.values(moduleEntities ?? {}))\n if (moduleIds.length > 0 && !moduleIds.includes(entityId)) return false\n return resolveRegisteredEntityTableName(em, entityId) !== null\n}\n\n/**\n * Doc storage (`custom_entities_storage`) is for custom entities only. A system\n * entity's records live in its own module tables/APIs \u2014 writing doc rows for it\n * poisons read-path classification (#2939) and must be rejected at the deepest\n * seam so no caller (API, AI tool, workflow) can do it.\n */\nexport function assertCustomEntityStorageEntityId(em: EntityManager, entityId: string): void {\n if (isOrmBackedSystemEntityId(em, entityId)) {\n throw new CrudHttpError(400, {\n error: 'Records are available for custom entities only',\n code: SYSTEM_ENTITY_RECORDS_BLOCKED_CODE,\n entityId,\n })\n }\n}\n\nexport class DefaultDataEngine implements DataEngine {\n private pendingSideEffects = new Map<string, QueuedCrudSideEffect>()\n private defaultIndexer: DefaultCrudIndexerConfig | null = null\n private indexedDefaultEntityClass = false\n constructor(private em: EntityManager, private container: AwilixContainer) {}\n\n /**\n * Per-command state, deliberately held on the engine instance rather than threaded through\n * `CommandRuntimeContext` the way the bulk-import flags are. That is sound only because\n * `createRequestContainer()` registers `dataEngine` per request (`lib/di/container.ts`), so\n * one engine instance never spans two requests, and no `makeCrudRoute` verb runs two commands\n * concurrently against it. An application that re-registers `dataEngine` as a transient would\n * break both assumptions: the command would mark on a different instance than the route\n * declared on, so nothing is indexed and every write logs the undischarged-declaration warning.\n */\n setDefaultIndexerConfig(config: DefaultCrudIndexerConfig | null): void {\n this.defaultIndexer = config\n this.indexedDefaultEntityClass = false\n }\n\n hasIndexedDefaultEntityClass(): boolean {\n return this.indexedDefaultEntityClass\n }\n\n private matchesDefaultEntityClass(entity: unknown): boolean {\n const declared = this.defaultIndexer?.entityClass\n // `OrmEntityConfig.entity` is `any` and this repository treats `EntitySchema` instances as a\n // first-class entity shape (`lib/bootstrap/types.ts`). An `EntitySchema` is an object rather\n // than a constructor, so `instanceof` against it throws \u2014 and it would throw inside\n // `markOrmEntityChange`, outside the best-effort try/catch that guards the flush, turning\n // every write on such a route into a 500.\n if (typeof declared !== 'function') return false\n return entity instanceof declared\n }\n\n private resolveDefaultIndexer(entity: unknown): CrudIndexerConfig<unknown> | undefined {\n return this.matchesDefaultEntityClass(entity) ? this.defaultIndexer?.indexer : undefined\n }\n\n async setCustomFields(opts: Parameters<DataEngine['setCustomFields']>[0]): Promise<void> {\n const { entityId, recordId, organizationId = null, tenantId = null, values } = opts\n const sanitizedValues = await sanitizeCustomFieldHtmlRichTextValuesServer(this.em, {\n entityId,\n organizationId,\n tenantId,\n values,\n })\n await this.validateCustomFieldValues(entityId, organizationId, tenantId, sanitizedValues as Record<string, unknown>)\n let encryptionService: any = null\n try {\n encryptionService = this.container.resolve('tenantEncryptionService') as any\n } catch {\n encryptionService = null\n }\n await setRecordCustomFields(this.em, {\n entityId,\n recordId,\n organizationId,\n tenantId,\n values: sanitizedValues,\n encryptionService,\n })\n if (opts.notify !== false) {\n let bus: EventBus | null = null\n try {\n bus = (this.container.resolve('eventBus') as EventBus)\n } catch {\n bus = null\n }\n if (bus) {\n const [mod, ent] = (entityId || '').split(':')\n if (mod && ent) {\n const eventName = `${mod}.${ent}.updated`\n warnIfUndeclaredEvent(eventName, 'setCustomFields')\n try {\n await bus.emitEvent(eventName, { id: recordId, organizationId, tenantId }, {\n persistent: true,\n tenantId,\n organizationId,\n emitterModuleId: mod,\n })\n } catch {\n // non-blocking\n }\n }\n }\n }\n }\n\n private normalizeDocValues(values: CustomEntityValues): CustomEntityValues {\n const out: CustomEntityValues = {}\n for (const [k, v] of Object.entries(values || {})) {\n // Never allow callers to override reserved identifiers in the doc\n if (k === 'id' || k === 'entity_id' || k === 'entityId') continue\n // Accept both 'cf_<key>' and 'cf:<key>' inputs and normalize to 'cf:<key>'\n if (k.startsWith('cf_')) out[`cf:${k.slice(3)}`] = v\n else out[k] = v\n }\n return out\n }\n\n private backcompatEavEnabled(): boolean {\n try {\n return parseBooleanToken(process.env.ENTITIES_BACKCOMPAT_EAV_FOR_CUSTOM ?? '') === true\n } catch { return false }\n }\n\n private getKysely(): Kysely<any> {\n return this.em.getKysely<any>()\n }\n\n private async ensureStorageTableExists(): Promise<void> {\n const db = this.getKysely()\n const exists = await db\n .selectFrom('information_schema.tables' as any)\n .select(sql`1`.as('present'))\n .where('table_name' as any, '=', 'custom_entities_storage')\n .executeTakeFirst()\n if (!exists) {\n throw new Error('custom_entities_storage table is missing. Run migrations (yarn db:migrate).')\n }\n }\n\n private normalizeValuesForValidation(values: Record<string, unknown> | undefined | null): Record<string, unknown> {\n if (!values) return {}\n const out: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(values)) {\n if (value === undefined) continue\n if (key.startsWith('cf_') || key.startsWith('cf:')) {\n const normalized = key.slice(3)\n if (normalized) out[normalized] = value\n continue\n }\n out[key] = value\n }\n return out\n }\n\n private async validateCustomFieldValues(\n entityId: string,\n organizationId: string | null,\n tenantId: string | null,\n values: Record<string, unknown> | undefined | null,\n ): Promise<void> {\n const prepared = this.normalizeValuesForValidation(values)\n if (!entityId || Object.keys(prepared).length === 0) return\n const result = await validateCustomFieldValuesServer(this.em, {\n entityId,\n organizationId,\n tenantId,\n values: prepared,\n })\n if (!result.ok) {\n throw new CrudHttpError(400, { error: 'Validation failed', fields: result.fieldErrors })\n }\n }\n\n async createCustomEntityRecord(opts: Parameters<DataEngine['createCustomEntityRecord']>[0]): Promise<{ id: string }> {\n assertCustomEntityStorageEntityId(this.em, opts.entityId)\n const db = this.getKysely()\n await this.ensureStorageTableExists()\n const sanitizedValues = await sanitizeCustomFieldHtmlRichTextValuesServer(this.em, {\n entityId: opts.entityId,\n organizationId: opts.organizationId ?? null,\n tenantId: opts.tenantId ?? null,\n values: opts.values || {},\n })\n await this.validateCustomFieldValues(opts.entityId, opts.organizationId ?? null, opts.tenantId ?? null, sanitizedValues)\n const rawId = String(opts.recordId ?? '').trim()\n const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(rawId)\n const sentinel = rawId.toLowerCase()\n const shouldGenerate = !rawId || !isUuid || sentinel === 'create' || sentinel === 'new' || sentinel === 'null' || sentinel === 'undefined'\n const id = shouldGenerate ? ((): string => {\n const g = globalThis as { crypto?: { randomUUID?: () => string } }\n if (g.crypto?.randomUUID) return g.crypto.randomUUID()\n // Fallback UUIDv4 generator\n return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {\n const r = (Math.random() * 16) | 0\n const v = c === 'x' ? r : (r & 0x3) | 0x8\n return v.toString(16)\n })\n })() : rawId\n const orgId = opts.organizationId ?? null\n const tenantId = opts.tenantId ?? null\n const doc: Record<string, unknown> = { id, ...this.normalizeDocValues(sanitizedValues || {}) }\n\n const now = sql`now()`\n const payload = {\n entity_type: opts.entityId,\n entity_id: id,\n organization_id: orgId,\n tenant_id: tenantId,\n doc: sql`${JSON.stringify(doc)}::jsonb`,\n updated_at: now,\n created_at: now,\n deleted_at: null,\n }\n\n // Upsert by scoped uniqueness\n try {\n await db\n .insertInto('custom_entities_storage' as any)\n .values(payload as any)\n .onConflict((oc) => oc\n .columns(['entity_type', 'entity_id', 'organization_id'])\n .doUpdateSet({\n doc: sql`${JSON.stringify(doc)}::jsonb`,\n updated_at: sql`now()`,\n deleted_at: null,\n } as any))\n .execute()\n } catch {\n // Fallback for global scope uniqueness\n try {\n const updated = await db\n .updateTable('custom_entities_storage' as any)\n .set({\n doc: sql`${JSON.stringify(doc)}::jsonb`,\n updated_at: sql`now()`,\n deleted_at: null,\n } as any)\n .where('entity_type' as any, '=', opts.entityId)\n .where('entity_id' as any, '=', id)\n .where('organization_id' as any, orgId === null ? 'is' : '=', orgId as any)\n .where('tenant_id' as any, tenantId === null ? 'is' : '=', tenantId as any)\n .executeTakeFirst()\n if (!updated || Number(updated.numUpdatedRows ?? 0) === 0) {\n await db.insertInto('custom_entities_storage' as any).values(payload as any).execute()\n }\n } catch (err) {\n // Surface a clear error so it doesn't silently fall back only to EAV\n throw err\n }\n }\n\n // Optional EAV backward compatibility (disabled by default)\n if (this.backcompatEavEnabled() && sanitizedValues && Object.keys(sanitizedValues).length > 0) {\n await this.setCustomFields({\n entityId: opts.entityId,\n recordId: id,\n organizationId: orgId,\n tenantId: tenantId,\n values: normalizeCustomFieldValues(sanitizedValues),\n notify: opts.notify, // defaults to true\n })\n }\n\n return { id }\n }\n\n async updateCustomEntityRecord(opts: Parameters<DataEngine['updateCustomEntityRecord']>[0]): Promise<void> {\n assertCustomEntityStorageEntityId(this.em, opts.entityId)\n const db = this.getKysely()\n const sanitizedValues = await sanitizeCustomFieldHtmlRichTextValuesServer(this.em, {\n entityId: opts.entityId,\n organizationId: opts.organizationId ?? null,\n tenantId: opts.tenantId ?? null,\n values: opts.values || {},\n })\n await this.validateCustomFieldValues(opts.entityId, opts.organizationId ?? null, opts.tenantId ?? null, sanitizedValues)\n const id = String(opts.recordId)\n const orgId = opts.organizationId ?? null\n const tenantId = opts.tenantId ?? null\n\n // Merge doc shallowly: load existing doc and overlay\n await this.ensureStorageTableExists()\n const applyScope = <T extends { where: (col: any, op: any, val?: any) => T }>(q: T) => {\n let chain = q.where('entity_type' as any, '=', opts.entityId)\n chain = chain.where('entity_id' as any, '=', id)\n chain = orgId === null\n ? chain.where('organization_id' as any, 'is', null as any)\n : chain.where('organization_id' as any, '=', orgId)\n chain = tenantId === null\n ? chain.where('tenant_id' as any, 'is', null as any)\n : chain.where('tenant_id' as any, '=', tenantId)\n return chain\n }\n const row = await applyScope(\n db.selectFrom('custom_entities_storage' as any).select(['doc' as any])\n ).executeTakeFirst()\n const prevDoc: Record<string, unknown> = (row as any)?.doc || { id }\n const nextDoc: Record<string, unknown> = { ...prevDoc, ...this.normalizeDocValues(sanitizedValues || {}), id }\n try {\n const updated = await applyScope(\n db.updateTable('custom_entities_storage' as any).set({\n doc: sql`${JSON.stringify(nextDoc)}::jsonb`,\n updated_at: sql`now()`,\n deleted_at: null,\n } as any) as any\n ).executeTakeFirst()\n if (!updated || Number((updated as any).numUpdatedRows ?? 0) === 0) {\n await db.insertInto('custom_entities_storage' as any).values({\n entity_type: opts.entityId,\n entity_id: id,\n organization_id: orgId,\n tenant_id: tenantId,\n doc: sql`${JSON.stringify(nextDoc)}::jsonb`,\n created_at: sql`now()`,\n updated_at: sql`now()`,\n deleted_at: null,\n } as any).execute()\n }\n } catch (err) {\n throw err\n }\n\n // Optional EAV backward compatibility (disabled by default)\n if (this.backcompatEavEnabled() && sanitizedValues && Object.keys(sanitizedValues).length > 0) {\n await this.setCustomFields({\n entityId: opts.entityId,\n recordId: id,\n organizationId: orgId,\n tenantId: tenantId,\n values: normalizeCustomFieldValues(sanitizedValues),\n notify: opts.notify, // defaults to true\n })\n }\n }\n\n async deleteCustomEntityRecord(opts: Parameters<DataEngine['deleteCustomEntityRecord']>[0]): Promise<void> {\n assertCustomEntityStorageEntityId(this.em, opts.entityId)\n const db = this.getKysely()\n const id = String(opts.recordId)\n const orgId = opts.organizationId ?? null\n const tenantId = opts.tenantId ?? null\n const soft = opts.soft !== false\n\n const applyScope = <T extends { where: (col: any, op: any, val?: any) => T }>(q: T) => {\n let chain = q.where('entity_type' as any, '=', opts.entityId)\n chain = chain.where('entity_id' as any, '=', id)\n chain = orgId === null\n ? chain.where('organization_id' as any, 'is', null as any)\n : chain.where('organization_id' as any, '=', orgId)\n chain = tenantId === null\n ? chain.where('tenant_id' as any, 'is', null as any)\n : chain.where('tenant_id' as any, '=', tenantId)\n return chain\n }\n\n if (soft) {\n await applyScope(\n db.updateTable('custom_entities_storage' as any).set({\n deleted_at: sql`now()`,\n updated_at: sql`now()`,\n } as any) as any\n ).execute()\n } else {\n await applyScope(db.deleteFrom('custom_entities_storage' as any) as any).execute()\n }\n\n // Soft-delete EAV values to preserve current behavior\n try {\n const { CustomFieldValue } = await import('@open-mercato/core/modules/entities/data/entities')\n const values = await this.em.find(CustomFieldValue, {\n entityId: opts.entityId,\n recordId: id,\n organizationId: orgId,\n tenantId: opts.tenantId ?? null,\n })\n const now = new Date()\n const mutated = values.filter((record) => {\n if (record.deletedAt) return false\n record.deletedAt = now\n return true\n })\n if (mutated.length) {\n for (const record of values) this.em.persist(record)\n await this.em.flush()\n }\n } catch { /* non-blocking */ }\n }\n\n async createOrmEntity<T extends object>(opts: { entity: EntityName<T>; data: EntityData<T> }): Promise<T> {\n const entity = this.em.create(\n opts.entity as EntityName<T>,\n opts.data as unknown as RequiredEntityData<T>\n )\n await this.em.persist(entity).flush()\n return entity\n }\n\n async updateOrmEntity<T extends object>(opts: {\n entity: EntityName<T>\n where: FilterQuery<T>\n apply: (current: T) => Promise<void> | void\n }): Promise<T | null> {\n const current = await this.em.findOne(opts.entity as EntityName<T>, opts.where as FilterQuery<NoInfer<T>>)\n if (!current) return null\n await opts.apply(current)\n await this.em.persist(current).flush()\n return current\n }\n\n async deleteOrmEntity<T extends object>(opts: {\n entity: EntityName<T>\n where: FilterQuery<T>\n soft?: boolean\n softDeleteField?: keyof T & string\n }): Promise<T | null> {\n const current = await this.em.findOne(opts.entity as EntityName<T>, opts.where as FilterQuery<NoInfer<T>>)\n if (!current) return null\n if (opts.soft !== false) {\n const field = opts.softDeleteField || ('deletedAt' as keyof T & string)\n if (typeof current === 'object' && current !== null) {\n ;(current as Record<string, unknown>)[field] = new Date()\n await this.em.persist(current).flush()\n }\n } else {\n await this.em.remove(current).flush()\n }\n return current\n }\n\n async emitOrmEntityEvent<T>(opts: {\n action: CrudEventAction\n entity: T\n events?: CrudEventsConfig<T>\n indexer?: CrudIndexerConfig<T>\n identifiers: CrudEntityIdentifiers\n syncOrigin?: string | null\n actorUserId?: string | null\n suppress?: BulkImportSuppression\n }): Promise<void> {\n const { action, entity, events, indexer, identifiers, syncOrigin, suppress } = opts\n // Bulk-import deferral: an entry may suppress its domain event and/or inline reindex. When both\n // the config is absent AND (for the present one) suppressed, there is nothing left to do.\n const emitEvents = !!events && !suppress?.skipEvents\n const runIndexer = !!indexer && !suppress?.skipReindex\n if (!emitEvents && !runIndexer) return\n if (!identifiers?.id) return\n\n let bus: EventBus | null = null\n try {\n bus = (this.container.resolve('eventBus') as EventBus)\n } catch {\n bus = null\n }\n if (!bus) return\n\n const ctx = {\n action,\n entity,\n identifiers: {\n id: identifiers.id,\n organizationId: identifiers.organizationId ?? null,\n tenantId: identifiers.tenantId ?? null,\n },\n syncOrigin: syncOrigin ?? null,\n actorUserId: opts.actorUserId ?? null,\n }\n\n if (events && !suppress?.skipEvents) {\n const eventName = `${events.module}.${events.entity}.${action}`\n warnIfUndeclaredEvent(eventName, 'emitOrmEntityEvent')\n const builtPayload = events.buildPayload\n ? events.buildPayload(ctx)\n : {\n id: ctx.identifiers.id,\n organizationId: ctx.identifiers.organizationId,\n tenantId: ctx.identifiers.tenantId,\n ...(ctx.syncOrigin ? { syncOrigin: ctx.syncOrigin } : {}),\n }\n // A configured indexer means this data-engine call owns the query-index\n // decision, including an explicit skipReindex suppression. Mark object\n // payloads so the legacy domain-event bridge does not enqueue the same\n // record a second time. Keep the marker non-enumerable so client broadcasts\n // and persisted domain payloads retain their existing public shape.\n // Primitive custom payloads cannot be bridged in any case because they do\n // not expose the record id.\n const payload = indexer && builtPayload && typeof builtPayload === 'object' && !Array.isArray(builtPayload)\n ? Object.defineProperty(\n { ...(builtPayload as Record<string, unknown>) },\n CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY,\n { value: true, enumerable: false },\n )\n : builtPayload\n try {\n await bus.emitEvent(eventName, payload, {\n persistent: !!events.persistent,\n tenantId: ctx.identifiers.tenantId ?? null,\n organizationId: ctx.identifiers.organizationId ?? null,\n emitterModuleId: events.module,\n })\n } catch {\n // non-blocking\n }\n }\n\n if (indexer && !suppress?.skipReindex) {\n const alwaysConsistent = isReadProjectionAlwaysConsistent()\n const resolveCoverageBaseDelta = (): number | undefined => {\n if (action === 'created') return 1\n if (action === 'deleted') return -1\n return undefined\n }\n const coverageBaseDelta = resolveCoverageBaseDelta()\n\n if (action === 'deleted') {\n const payload = indexer.buildDeletePayload\n ? indexer.buildDeletePayload(ctx)\n : {\n entityType: indexer.entityType,\n recordId: ctx.identifiers.id,\n organizationId: ctx.identifiers.organizationId,\n tenantId: ctx.identifiers.tenantId,\n }\n const enrichedPayload = payload as Record<string, unknown>\n enrichedPayload.crudAction = action\n if (coverageBaseDelta !== undefined) enrichedPayload.coverageBaseDelta = coverageBaseDelta\n if (ctx.syncOrigin) enrichedPayload.syncOrigin = ctx.syncOrigin\n // Await the index update so query-index reads (the `customValues`/scalar\n // projection that list endpoints serve) are consistent the moment the write\n // returns. The subscriber removes the projection row + tokens synchronously and\n // defers the coverage recompute + fulltext delete, so this stays bounded.\n // Errors are logged, not thrown \u2014 index drift never fails the originating write.\n // Always-consistent mode rethrows so drift is loud and retryable.\n if (alwaysConsistent) {\n await bus.emitEvent('query_index.delete_one', enrichedPayload, { rethrowHandlerErrors: true })\n } else {\n await bus.emitEvent('query_index.delete_one', enrichedPayload).catch((err: unknown) => {\n logger.error('query_index.delete_one emit failed', { err })\n })\n }\n } else {\n const payload = indexer.buildUpsertPayload\n ? indexer.buildUpsertPayload(ctx)\n : {\n entityType: indexer.entityType,\n recordId: ctx.identifiers.id,\n organizationId: ctx.identifiers.organizationId,\n tenantId: ctx.identifiers.tenantId,\n }\n const enrichedPayload = payload as Record<string, unknown>\n enrichedPayload.crudAction = action\n if (coverageBaseDelta !== undefined) enrichedPayload.coverageBaseDelta = coverageBaseDelta\n if (ctx.syncOrigin) enrichedPayload.syncOrigin = ctx.syncOrigin\n // Await the projection upsert so list reads observe the new doc immediately\n // (see delete_one above). The subscriber updates `entity_indexes` synchronously\n // and defers the heavy token-reindex pipeline (build doc + encrypt + decrypt +\n // tokenize + DELETE + chunked INSERT) so write latency stays bounded.\n if (alwaysConsistent) {\n await bus.emitEvent('query_index.upsert_one', enrichedPayload, { rethrowHandlerErrors: true })\n } else {\n await bus.emitEvent('query_index.upsert_one', enrichedPayload).catch((err: unknown) => {\n logger.error('query_index.upsert_one emit failed', { err })\n })\n }\n }\n\n if (alwaysConsistent || shouldTriggerCoverageRefresh(indexer.entityType, ctx.identifiers.tenantId ?? null)) {\n const coveragePayload = {\n entityType: indexer.entityType,\n tenantId: ctx.identifiers.tenantId ?? null,\n organizationId: null,\n delayMs: 0,\n }\n if (alwaysConsistent) {\n await bus.emitEvent('query_index.coverage.refresh', coveragePayload, { rethrowHandlerErrors: true })\n } else {\n void bus.emitEvent('query_index.coverage.refresh', coveragePayload).catch(() => undefined)\n }\n }\n }\n }\n\n markOrmEntityChange<T>(opts: {\n action: CrudEventAction\n entity: T | null | undefined\n events?: CrudEventsConfig<T>\n indexer?: CrudIndexerConfig<T>\n identifiers: CrudEntityIdentifiers\n syncOrigin?: string | null\n actorUserId?: string | null\n }): void {\n const { entity, identifiers } = opts\n if (!entity) return\n if (!identifiers?.id) return\n // A command handler that marks `events:` only still discharges the route's declared\n // query-index obligation \u2014 the route hands its `indexer:` down as the default so the\n // handler's own entity and identifiers (the accurate ones) drive the projection write.\n const indexer = opts.indexer ?? this.resolveDefaultIndexer(entity)\n const key = this.buildSideEffectKey(opts.action, identifiers)\n const existing = this.pendingSideEffects.get(key)\n if (existing) {\n existing.entity = entity\n existing.identifiers = {\n id: identifiers.id,\n organizationId: identifiers.organizationId ?? null,\n tenantId: identifiers.tenantId ?? null,\n }\n existing.syncOrigin = opts.syncOrigin ?? null\n existing.actorUserId = opts.actorUserId ?? null\n if (opts.events) existing.events = opts.events as CrudEventsConfig<unknown>\n // Explicit always wins, on the merge branch too: a second `events:`-only mark on the same\n // key must not let the route default overwrite the `indexer:` an earlier mark installed,\n // which would silently drop that handler's own `buildUpsertPayload`.\n if (opts.indexer) existing.indexer = opts.indexer as CrudIndexerConfig<unknown>\n else if (!existing.indexer && indexer) existing.indexer = indexer\n this.pendingSideEffects.set(key, existing)\n return\n }\n const entry: QueuedCrudSideEffect = {\n action: opts.action,\n entity,\n identifiers: {\n id: identifiers.id,\n organizationId: identifiers.organizationId ?? null,\n tenantId: identifiers.tenantId ?? null,\n },\n syncOrigin: opts.syncOrigin ?? null,\n actorUserId: opts.actorUserId ?? null,\n }\n if (opts.events) entry.events = opts.events as CrudEventsConfig<unknown>\n if (indexer) entry.indexer = indexer\n this.pendingSideEffects.set(key, entry)\n }\n\n async flushOrmEntityChanges(suppress?: BulkImportSuppression): Promise<void> {\n if (!this.pendingSideEffects.size) return\n const entries = Array.from(this.pendingSideEffects.values())\n this.pendingSideEffects.clear()\n for (const entry of entries) {\n if (entry.indexer && !suppress?.skipReindex && this.matchesDefaultEntityClass(entry.entity)) {\n this.indexedDefaultEntityClass = true\n }\n try {\n await this.emitOrmEntityEvent({\n action: entry.action,\n entity: entry.entity,\n identifiers: entry.identifiers,\n syncOrigin: entry.syncOrigin ?? null,\n actorUserId: entry.actorUserId ?? null,\n events: entry.events as CrudEventsConfig<unknown>,\n indexer: entry.indexer as CrudIndexerConfig<unknown>,\n suppress,\n })\n } catch (error) {\n if (isReadProjectionAlwaysConsistent()) {\n throw error\n }\n // best-effort; continue with remaining side effects\n }\n }\n }\n\n private buildSideEffectKey(action: CrudEventAction, identifiers: CrudEntityIdentifiers): string {\n const id = identifiers.id ?? ''\n const org = identifiers.organizationId ?? ''\n const tenant = identifiers.tenantId ?? ''\n return [action, id, org, tenant].join('|')\n }\n}\n"],
5
- "mappings": "AAGA,SAAsB,WAAW;AACjC,SAAS,6BAA6B;AACtC,SAAS,uCAAuC;AAChD,SAAS,mDAAmD;AAE5D;AAAA,EACE;AAAA,OAKK;AAEP,SAAS,qBAAqB;AAC9B,SAAS,wCAAwC;AACjD,SAAS,oBAAoB;AAC7B,SAAS,kCAAkC;AAC3C,SAAS,yBAAyB;AAClC,SAAS,wCAAwC;AACjD,SAAS,uBAAuB;AAChC,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,QAAQ,EAAE,MAAM,EAAE,WAAW,cAAc,CAAC;AAExE,MAAM,wBAAwB,oBAAI,IAAY;AAE9C,SAAS,sBAAsB,WAAmB,SAAuB;AACvE,MAAI,gBAAgB,SAAS,EAAG;AAChC,MAAI,sBAAsB,IAAI,SAAS,EAAG;AAC1C,wBAAsB,IAAI,SAAS;AACnC,SAAO,KAAK,6IAAwI,EAAE,SAAS,UAAU,CAAC;AAC5K;AAGO,SAAS,yCAA+C;AAC7D,wBAAsB,MAAM;AAC9B;AAEA,MAAM,+BAA+B,IAAI,KAAK;AAC9C,MAAM,yBAAyB,oBAAI,IAAoB;AAEvD,SAAS,6BAA6B,YAAgC,UAAkC;AACtG,MAAI,CAAC,WAAY,QAAO;AACxB,QAAM,MAAM,GAAG,UAAU,IAAI,YAAY,UAAU;AACnD,QAAM,MAAM,KAAK,IAAI;AACrB,QAAM,OAAO,uBAAuB,IAAI,GAAG,KAAK;AAChD,MAAI,MAAM,OAAO,6BAA8B,QAAO;AACtD,yBAAuB,IAAI,KAAK,GAAG;AACnC,SAAO;AACT;AAgIO,MAAM,qCAAqC;AAY3C,SAAS,0BAA0B,IAAmB,UAA2B;AACtF,QAAM,WAAW,aAAa,KAAK;AACnC,QAAM,YAAY,OAAO,OAAO,QAAQ,EAAE,QAAQ,CAAC,mBAAmB,OAAO,OAAO,kBAAkB,CAAC,CAAC,CAAC;AACzG,MAAI,UAAU,SAAS,KAAK,CAAC,UAAU,SAAS,QAAQ,EAAG,QAAO;AAClE,SAAO,iCAAiC,IAAI,QAAQ,MAAM;AAC5D;AAQO,SAAS,kCAAkC,IAAmB,UAAwB;AAC3F,MAAI,0BAA0B,IAAI,QAAQ,GAAG;AAC3C,UAAM,IAAI,cAAc,KAAK;AAAA,MAC3B,OAAO;AAAA,MACP,MAAM;AAAA,MACN;AAAA,IACF,CAAC;AAAA,EACH;AACF;AAEO,MAAM,kBAAwC;AAAA,EAInD,YAAoB,IAA2B,WAA4B;AAAvD;AAA2B;AAH/C,SAAQ,qBAAqB,oBAAI,IAAkC;AACnE,SAAQ,iBAAkD;AAC1D,SAAQ,4BAA4B;AAAA,EACwC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAW5E,wBAAwB,QAA+C;AACrE,SAAK,iBAAiB;AACtB,SAAK,4BAA4B;AAAA,EACnC;AAAA,EAEA,+BAAwC;AACtC,WAAO,KAAK;AAAA,EACd;AAAA,EAEQ,0BAA0B,QAA0B;AAC1D,UAAM,WAAW,KAAK,gBAAgB;AAMtC,QAAI,OAAO,aAAa,WAAY,QAAO;AAC3C,WAAO,kBAAkB;AAAA,EAC3B;AAAA,EAEQ,sBAAsB,QAAyD;AACrF,WAAO,KAAK,0BAA0B,MAAM,IAAI,KAAK,gBAAgB,UAAU;AAAA,EACjF;AAAA,EAEA,MAAM,gBAAgB,MAAmE;AACvF,UAAM,EAAE,UAAU,UAAU,iBAAiB,MAAM,WAAW,MAAM,OAAO,IAAI;AAC/E,UAAM,kBAAkB,MAAM,4CAA4C,KAAK,IAAI;AAAA,MACjF;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF,CAAC;AACD,UAAM,KAAK,0BAA0B,UAAU,gBAAgB,UAAU,eAA0C;AACnH,QAAI,oBAAyB;AAC7B,QAAI;AACF,0BAAoB,KAAK,UAAU,QAAQ,yBAAyB;AAAA,IACtE,QAAQ;AACN,0BAAoB;AAAA,IACtB;AACA,UAAM,sBAAsB,KAAK,IAAI;AAAA,MACnC;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA,QAAQ;AAAA,MACR;AAAA,IACF,CAAC;AACD,QAAI,KAAK,WAAW,OAAO;AACzB,UAAI,MAAuB;AAC3B,UAAI;AACF,cAAO,KAAK,UAAU,QAAQ,UAAU;AAAA,MAC1C,QAAQ;AACN,cAAM;AAAA,MACR;AACA,UAAI,KAAK;AACP,cAAM,CAAC,KAAK,GAAG,KAAK,YAAY,IAAI,MAAM,GAAG;AAC7C,YAAI,OAAO,KAAK;AACd,gBAAM,YAAY,GAAG,GAAG,IAAI,GAAG;AAC/B,gCAAsB,WAAW,iBAAiB;AAClD,cAAI;AACF,kBAAM,IAAI,UAAU,WAAW,EAAE,IAAI,UAAU,gBAAgB,SAAS,GAAG;AAAA,cACzE,YAAY;AAAA,cACZ;AAAA,cACA;AAAA,cACA,iBAAiB;AAAA,YACnB,CAAC;AAAA,UACH,QAAQ;AAAA,UAER;AAAA,QACF;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAAA,EAEQ,mBAAmB,QAAgD;AACzE,UAAM,MAA0B,CAAC;AACjC,eAAW,CAAC,GAAG,CAAC,KAAK,OAAO,QAAQ,UAAU,CAAC,CAAC,GAAG;AAEjD,UAAI,MAAM,QAAQ,MAAM,eAAe,MAAM,WAAY;AAEzD,UAAI,EAAE,WAAW,KAAK,EAAG,KAAI,MAAM,EAAE,MAAM,CAAC,CAAC,EAAE,IAAI;AAAA,UAC9C,KAAI,CAAC,IAAI;AAAA,IAChB;AACA,WAAO;AAAA,EACT;AAAA,EAEQ,uBAAgC;AACtC,QAAI;AACF,aAAO,kBAAkB,QAAQ,IAAI,sCAAsC,EAAE,MAAM;AAAA,IACrF,QAAQ;AAAE,aAAO;AAAA,IAAM;AAAA,EACzB;AAAA,EAEQ,YAAyB;AAC/B,WAAO,KAAK,GAAG,UAAe;AAAA,EAChC;AAAA,EAEA,MAAc,2BAA0C;AACtD,UAAM,KAAK,KAAK,UAAU;AAC1B,UAAM,SAAS,MAAM,GAClB,WAAW,2BAAkC,EAC7C,OAAO,OAAO,GAAG,SAAS,CAAC,EAC3B,MAAM,cAAqB,KAAK,yBAAyB,EACzD,iBAAiB;AACpB,QAAI,CAAC,QAAQ;AACX,YAAM,IAAI,MAAM,6EAA6E;AAAA,IAC/F;AAAA,EACF;AAAA,EAEQ,6BAA6B,QAA6E;AAChH,QAAI,CAAC,OAAQ,QAAO,CAAC;AACrB,UAAM,MAA+B,CAAC;AACtC,eAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,MAAM,GAAG;AACjD,UAAI,UAAU,OAAW;AACzB,UAAI,IAAI,WAAW,KAAK,KAAK,IAAI,WAAW,KAAK,GAAG;AAClD,cAAM,aAAa,IAAI,MAAM,CAAC;AAC9B,YAAI,WAAY,KAAI,UAAU,IAAI;AAClC;AAAA,MACF;AACA,UAAI,GAAG,IAAI;AAAA,IACb;AACA,WAAO;AAAA,EACT;AAAA,EAEA,MAAc,0BACZ,UACA,gBACA,UACA,QACe;AACf,UAAM,WAAW,KAAK,6BAA6B,MAAM;AACzD,QAAI,CAAC,YAAY,OAAO,KAAK,QAAQ,EAAE,WAAW,EAAG;AACrD,UAAM,SAAS,MAAM,gCAAgC,KAAK,IAAI;AAAA,MAC5D;AAAA,MACA;AAAA,MACA;AAAA,MACA,QAAQ;AAAA,IACV,CAAC;AACD,QAAI,CAAC,OAAO,IAAI;AACd,YAAM,IAAI,cAAc,KAAK,EAAE,OAAO,qBAAqB,QAAQ,OAAO,YAAY,CAAC;AAAA,IACzF;AAAA,EACF;AAAA,EAEA,MAAM,yBAAyB,MAAsF;AACnH,sCAAkC,KAAK,IAAI,KAAK,QAAQ;AACxD,UAAM,KAAK,KAAK,UAAU;AAC1B,UAAM,KAAK,yBAAyB;AACpC,UAAM,kBAAkB,MAAM,4CAA4C,KAAK,IAAI;AAAA,MACjF,UAAU,KAAK;AAAA,MACf,gBAAgB,KAAK,kBAAkB;AAAA,MACvC,UAAU,KAAK,YAAY;AAAA,MAC3B,QAAQ,KAAK,UAAU,CAAC;AAAA,IAC1B,CAAC;AACD,UAAM,KAAK,0BAA0B,KAAK,UAAU,KAAK,kBAAkB,MAAM,KAAK,YAAY,MAAM,eAAe;AACvH,UAAM,QAAQ,OAAO,KAAK,YAAY,EAAE,EAAE,KAAK;AAC/C,UAAM,SAAS,6EAA6E,KAAK,KAAK;AACtG,UAAM,WAAW,MAAM,YAAY;AACnC,UAAM,iBAAiB,CAAC,SAAS,CAAC,UAAU,aAAa,YAAY,aAAa,SAAS,aAAa,UAAU,aAAa;AAC/H,UAAM,KAAK,kBAAkB,MAAc;AACzC,YAAM,IAAI;AACV,UAAI,EAAE,QAAQ,WAAY,QAAO,EAAE,OAAO,WAAW;AAErD,aAAO,uCAAuC,QAAQ,SAAS,CAAC,MAAM;AACpE,cAAM,IAAK,KAAK,OAAO,IAAI,KAAM;AACjC,cAAM,IAAI,MAAM,MAAM,IAAK,IAAI,IAAO;AACtC,eAAO,EAAE,SAAS,EAAE;AAAA,MACtB,CAAC;AAAA,IACH,GAAG,IAAI;AACP,UAAM,QAAQ,KAAK,kBAAkB;AACrC,UAAM,WAAW,KAAK,YAAY;AAClC,UAAM,MAA+B,EAAE,IAAI,GAAG,KAAK,mBAAmB,mBAAmB,CAAC,CAAC,EAAE;AAE7F,UAAM,MAAM;AACZ,UAAM,UAAU;AAAA,MACd,aAAa,KAAK;AAAA,MAClB,WAAW;AAAA,MACX,iBAAiB;AAAA,MACjB,WAAW;AAAA,MACX,KAAK,MAAM,KAAK,UAAU,GAAG,CAAC;AAAA,MAC9B,YAAY;AAAA,MACZ,YAAY;AAAA,MACZ,YAAY;AAAA,IACd;AAGA,QAAI;AACF,YAAM,GACH,WAAW,yBAAgC,EAC3C,OAAO,OAAc,EACrB,WAAW,CAAC,OAAO,GACjB,QAAQ,CAAC,eAAe,aAAa,iBAAiB,CAAC,EACvD,YAAY;AAAA,QACX,KAAK,MAAM,KAAK,UAAU,GAAG,CAAC;AAAA,QAC9B,YAAY;AAAA,QACZ,YAAY;AAAA,MACd,CAAQ,CAAC,EACV,QAAQ;AAAA,IACb,QAAQ;AAEN,UAAI;AACF,cAAM,UAAU,MAAM,GACnB,YAAY,yBAAgC,EAC5C,IAAI;AAAA,UACH,KAAK,MAAM,KAAK,UAAU,GAAG,CAAC;AAAA,UAC9B,YAAY;AAAA,UACZ,YAAY;AAAA,QACd,CAAQ,EACP,MAAM,eAAsB,KAAK,KAAK,QAAQ,EAC9C,MAAM,aAAoB,KAAK,EAAE,EACjC,MAAM,mBAA0B,UAAU,OAAO,OAAO,KAAK,KAAY,EACzE,MAAM,aAAoB,aAAa,OAAO,OAAO,KAAK,QAAe,EACzE,iBAAiB;AACpB,YAAI,CAAC,WAAW,OAAO,QAAQ,kBAAkB,CAAC,MAAM,GAAG;AACzD,gBAAM,GAAG,WAAW,yBAAgC,EAAE,OAAO,OAAc,EAAE,QAAQ;AAAA,QACvF;AAAA,MACF,SAAS,KAAK;AAEZ,cAAM;AAAA,MACR;AAAA,IACF;AAGA,QAAI,KAAK,qBAAqB,KAAK,mBAAmB,OAAO,KAAK,eAAe,EAAE,SAAS,GAAG;AAC7F,YAAM,KAAK,gBAAgB;AAAA,QACzB,UAAU,KAAK;AAAA,QACf,UAAU;AAAA,QACV,gBAAgB;AAAA,QAChB;AAAA,QACA,QAAQ,2BAA2B,eAAe;AAAA,QAClD,QAAQ,KAAK;AAAA;AAAA,MACf,CAAC;AAAA,IACH;AAEA,WAAO,EAAE,GAAG;AAAA,EACd;AAAA,EAEA,MAAM,yBAAyB,MAA4E;AACzG,sCAAkC,KAAK,IAAI,KAAK,QAAQ;AACxD,UAAM,KAAK,KAAK,UAAU;AAC1B,UAAM,kBAAkB,MAAM,4CAA4C,KAAK,IAAI;AAAA,MACjF,UAAU,KAAK;AAAA,MACf,gBAAgB,KAAK,kBAAkB;AAAA,MACvC,UAAU,KAAK,YAAY;AAAA,MAC3B,QAAQ,KAAK,UAAU,CAAC;AAAA,IAC1B,CAAC;AACD,UAAM,KAAK,0BAA0B,KAAK,UAAU,KAAK,kBAAkB,MAAM,KAAK,YAAY,MAAM,eAAe;AACvH,UAAM,KAAK,OAAO,KAAK,QAAQ;AAC/B,UAAM,QAAQ,KAAK,kBAAkB;AACrC,UAAM,WAAW,KAAK,YAAY;AAGlC,UAAM,KAAK,yBAAyB;AACpC,UAAM,aAAa,CAA2D,MAAS;AACrF,UAAI,QAAQ,EAAE,MAAM,eAAsB,KAAK,KAAK,QAAQ;AAC5D,cAAQ,MAAM,MAAM,aAAoB,KAAK,EAAE;AAC/C,cAAQ,UAAU,OACd,MAAM,MAAM,mBAA0B,MAAM,IAAW,IACvD,MAAM,MAAM,mBAA0B,KAAK,KAAK;AACpD,cAAQ,aAAa,OACjB,MAAM,MAAM,aAAoB,MAAM,IAAW,IACjD,MAAM,MAAM,aAAoB,KAAK,QAAQ;AACjD,aAAO;AAAA,IACT;AACA,UAAM,MAAM,MAAM;AAAA,MAChB,GAAG,WAAW,yBAAgC,EAAE,OAAO,CAAC,KAAY,CAAC;AAAA,IACvE,EAAE,iBAAiB;AACnB,UAAM,UAAoC,KAAa,OAAO,EAAE,GAAG;AACnE,UAAM,UAAmC,EAAE,GAAG,SAAS,GAAG,KAAK,mBAAmB,mBAAmB,CAAC,CAAC,GAAG,GAAG;AAC7G,QAAI;AACF,YAAM,UAAU,MAAM;AAAA,QACpB,GAAG,YAAY,yBAAgC,EAAE,IAAI;AAAA,UACnD,KAAK,MAAM,KAAK,UAAU,OAAO,CAAC;AAAA,UAClC,YAAY;AAAA,UACZ,YAAY;AAAA,QACd,CAAQ;AAAA,MACV,EAAE,iBAAiB;AACnB,UAAI,CAAC,WAAW,OAAQ,QAAgB,kBAAkB,CAAC,MAAM,GAAG;AAClE,cAAM,GAAG,WAAW,yBAAgC,EAAE,OAAO;AAAA,UAC3D,aAAa,KAAK;AAAA,UAClB,WAAW;AAAA,UACX,iBAAiB;AAAA,UACjB,WAAW;AAAA,UACX,KAAK,MAAM,KAAK,UAAU,OAAO,CAAC;AAAA,UAClC,YAAY;AAAA,UACZ,YAAY;AAAA,UACZ,YAAY;AAAA,QACd,CAAQ,EAAE,QAAQ;AAAA,MACpB;AAAA,IACF,SAAS,KAAK;AACZ,YAAM;AAAA,IACR;AAGA,QAAI,KAAK,qBAAqB,KAAK,mBAAmB,OAAO,KAAK,eAAe,EAAE,SAAS,GAAG;AAC7F,YAAM,KAAK,gBAAgB;AAAA,QACzB,UAAU,KAAK;AAAA,QACf,UAAU;AAAA,QACV,gBAAgB;AAAA,QAChB;AAAA,QACA,QAAQ,2BAA2B,eAAe;AAAA,QAClD,QAAQ,KAAK;AAAA;AAAA,MACf,CAAC;AAAA,IACH;AAAA,EACF;AAAA,EAEA,MAAM,yBAAyB,MAA4E;AACzG,sCAAkC,KAAK,IAAI,KAAK,QAAQ;AACxD,UAAM,KAAK,KAAK,UAAU;AAC1B,UAAM,KAAK,OAAO,KAAK,QAAQ;AAC/B,UAAM,QAAQ,KAAK,kBAAkB;AACrC,UAAM,WAAW,KAAK,YAAY;AAClC,UAAM,OAAO,KAAK,SAAS;AAE3B,UAAM,aAAa,CAA2D,MAAS;AACrF,UAAI,QAAQ,EAAE,MAAM,eAAsB,KAAK,KAAK,QAAQ;AAC5D,cAAQ,MAAM,MAAM,aAAoB,KAAK,EAAE;AAC/C,cAAQ,UAAU,OACd,MAAM,MAAM,mBAA0B,MAAM,IAAW,IACvD,MAAM,MAAM,mBAA0B,KAAK,KAAK;AACpD,cAAQ,aAAa,OACjB,MAAM,MAAM,aAAoB,MAAM,IAAW,IACjD,MAAM,MAAM,aAAoB,KAAK,QAAQ;AACjD,aAAO;AAAA,IACT;AAEA,QAAI,MAAM;AACR,YAAM;AAAA,QACJ,GAAG,YAAY,yBAAgC,EAAE,IAAI;AAAA,UACnD,YAAY;AAAA,UACZ,YAAY;AAAA,QACd,CAAQ;AAAA,MACV,EAAE,QAAQ;AAAA,IACZ,OAAO;AACL,YAAM,WAAW,GAAG,WAAW,yBAAgC,CAAQ,EAAE,QAAQ;AAAA,IACnF;AAGA,QAAI;AACF,YAAM,EAAE,iBAAiB,IAAI,MAAM,OAAO,mDAAmD;AAC7F,YAAM,SAAS,MAAM,KAAK,GAAG,KAAK,kBAAkB;AAAA,QAClD,UAAU,KAAK;AAAA,QACf,UAAU;AAAA,QACV,gBAAgB;AAAA,QAChB,UAAU,KAAK,YAAY;AAAA,MAC7B,CAAC;AACD,YAAM,MAAM,oBAAI,KAAK;AACrB,YAAM,UAAU,OAAO,OAAO,CAAC,WAAW;AACxC,YAAI,OAAO,UAAW,QAAO;AAC7B,eAAO,YAAY;AACnB,eAAO;AAAA,MACT,CAAC;AACD,UAAI,QAAQ,QAAQ;AAClB,mBAAW,UAAU,OAAQ,MAAK,GAAG,QAAQ,MAAM;AACnD,cAAM,KAAK,GAAG,MAAM;AAAA,MACtB;AAAA,IACF,QAAQ;AAAA,IAAqB;AAAA,EAC/B;AAAA,EAEA,MAAM,gBAAkC,MAAkE;AACxG,UAAM,SAAS,KAAK,GAAG;AAAA,MACrB,KAAK;AAAA,MACL,KAAK;AAAA,IACP;AACA,UAAM,KAAK,GAAG,QAAQ,MAAM,EAAE,MAAM;AACpC,WAAO;AAAA,EACT;AAAA,EAEA,MAAM,gBAAkC,MAIlB;AACpB,UAAM,UAAU,MAAM,KAAK,GAAG,QAAQ,KAAK,QAAyB,KAAK,KAAgC;AACzG,QAAI,CAAC,QAAS,QAAO;AACrB,UAAM,KAAK,MAAM,OAAO;AACxB,UAAM,KAAK,GAAG,QAAQ,OAAO,EAAE,MAAM;AACrC,WAAO;AAAA,EACT;AAAA,EAEA,MAAM,gBAAkC,MAKlB;AACpB,UAAM,UAAU,MAAM,KAAK,GAAG,QAAQ,KAAK,QAAyB,KAAK,KAAgC;AACzG,QAAI,CAAC,QAAS,QAAO;AACrB,QAAI,KAAK,SAAS,OAAO;AACvB,YAAM,QAAQ,KAAK,mBAAoB;AACvC,UAAI,OAAO,YAAY,YAAY,YAAY,MAAM;AACnD;AAAC,QAAC,QAAoC,KAAK,IAAI,oBAAI,KAAK;AACxD,cAAM,KAAK,GAAG,QAAQ,OAAO,EAAE,MAAM;AAAA,MACvC;AAAA,IACF,OAAO;AACL,YAAM,KAAK,GAAG,OAAO,OAAO,EAAE,MAAM;AAAA,IACtC;AACA,WAAO;AAAA,EACT;AAAA,EAEA,MAAM,mBAAsB,MASV;AAChB,UAAM,EAAE,QAAQ,QAAQ,QAAQ,SAAS,aAAa,YAAY,SAAS,IAAI;AAG/E,UAAM,aAAa,CAAC,CAAC,UAAU,CAAC,UAAU;AAC1C,UAAM,aAAa,CAAC,CAAC,WAAW,CAAC,UAAU;AAC3C,QAAI,CAAC,cAAc,CAAC,WAAY;AAChC,QAAI,CAAC,aAAa,GAAI;AAEtB,QAAI,MAAuB;AAC3B,QAAI;AACF,YAAO,KAAK,UAAU,QAAQ,UAAU;AAAA,IAC1C,QAAQ;AACN,YAAM;AAAA,IACR;AACA,QAAI,CAAC,IAAK;AAEV,UAAM,MAAM;AAAA,MACV;AAAA,MACA;AAAA,MACA,aAAa;AAAA,QACX,IAAI,YAAY;AAAA,QAChB,gBAAgB,YAAY,kBAAkB;AAAA,QAC9C,UAAU,YAAY,YAAY;AAAA,MACpC;AAAA,MACA,YAAY,cAAc;AAAA,MAC1B,aAAa,KAAK,eAAe;AAAA,IACnC;AAEA,QAAI,UAAU,CAAC,UAAU,YAAY;AACnC,YAAM,YAAY,GAAG,OAAO,MAAM,IAAI,OAAO,MAAM,IAAI,MAAM;AAC7D,4BAAsB,WAAW,oBAAoB;AACrD,YAAM,eAAe,OAAO,eACxB,OAAO,aAAa,GAAG,IACvB;AAAA,QACE,IAAI,IAAI,YAAY;AAAA,QACpB,gBAAgB,IAAI,YAAY;AAAA,QAChC,UAAU,IAAI,YAAY;AAAA,QAC1B,GAAI,IAAI,aAAa,EAAE,YAAY,IAAI,WAAW,IAAI,CAAC;AAAA,MACzD;AAQJ,YAAM,UAAU,WAAW,gBAAgB,OAAO,iBAAiB,YAAY,CAAC,MAAM,QAAQ,YAAY,IACtG,OAAO;AAAA,QACL,EAAE,GAAI,aAAyC;AAAA,QAC/C;AAAA,QACA,EAAE,OAAO,MAAM,YAAY,MAAM;AAAA,MACnC,IACA;AACJ,UAAI;AACF,cAAM,IAAI,UAAU,WAAW,SAAS;AAAA,UACtC,YAAY,CAAC,CAAC,OAAO;AAAA,UACrB,UAAU,IAAI,YAAY,YAAY;AAAA,UACtC,gBAAgB,IAAI,YAAY,kBAAkB;AAAA,UAClD,iBAAiB,OAAO;AAAA,QAC1B,CAAC;AAAA,MACH,QAAQ;AAAA,MAER;AAAA,IACF;AAEA,QAAI,WAAW,CAAC,UAAU,aAAa;AACrC,YAAM,mBAAmB,iCAAiC;AAC1D,YAAM,2BAA2B,MAA0B;AACzD,YAAI,WAAW,UAAW,QAAO;AACjC,YAAI,WAAW,UAAW,QAAO;AACjC,eAAO;AAAA,MACT;AACA,YAAM,oBAAoB,yBAAyB;AAEnD,UAAI,WAAW,WAAW;AACxB,cAAM,UAAU,QAAQ,qBACpB,QAAQ,mBAAmB,GAAG,IAC9B;AAAA,UACE,YAAY,QAAQ;AAAA,UACpB,UAAU,IAAI,YAAY;AAAA,UAC1B,gBAAgB,IAAI,YAAY;AAAA,UAChC,UAAU,IAAI,YAAY;AAAA,QAC5B;AACJ,cAAM,kBAAkB;AACxB,wBAAgB,aAAa;AAC7B,YAAI,sBAAsB,OAAW,iBAAgB,oBAAoB;AACzE,YAAI,IAAI,WAAY,iBAAgB,aAAa,IAAI;AAOrD,YAAI,kBAAkB;AACpB,gBAAM,IAAI,UAAU,0BAA0B,iBAAiB,EAAE,sBAAsB,KAAK,CAAC;AAAA,QAC/F,OAAO;AACL,gBAAM,IAAI,UAAU,0BAA0B,eAAe,EAAE,MAAM,CAAC,QAAiB;AACrF,mBAAO,MAAM,sCAAsC,EAAE,IAAI,CAAC;AAAA,UAC5D,CAAC;AAAA,QACH;AAAA,MACF,OAAO;AACL,cAAM,UAAU,QAAQ,qBACpB,QAAQ,mBAAmB,GAAG,IAC9B;AAAA,UACE,YAAY,QAAQ;AAAA,UACpB,UAAU,IAAI,YAAY;AAAA,UAC1B,gBAAgB,IAAI,YAAY;AAAA,UAChC,UAAU,IAAI,YAAY;AAAA,QAC5B;AACJ,cAAM,kBAAkB;AACxB,wBAAgB,aAAa;AAC7B,YAAI,sBAAsB,OAAW,iBAAgB,oBAAoB;AACzE,YAAI,IAAI,WAAY,iBAAgB,aAAa,IAAI;AAKrD,YAAI,kBAAkB;AACpB,gBAAM,IAAI,UAAU,0BAA0B,iBAAiB,EAAE,sBAAsB,KAAK,CAAC;AAAA,QAC/F,OAAO;AACL,gBAAM,IAAI,UAAU,0BAA0B,eAAe,EAAE,MAAM,CAAC,QAAiB;AACrF,mBAAO,MAAM,sCAAsC,EAAE,IAAI,CAAC;AAAA,UAC5D,CAAC;AAAA,QACH;AAAA,MACF;AAEA,UAAI,oBAAoB,6BAA6B,QAAQ,YAAY,IAAI,YAAY,YAAY,IAAI,GAAG;AAC1G,cAAM,kBAAkB;AAAA,UACtB,YAAY,QAAQ;AAAA,UACpB,UAAU,IAAI,YAAY,YAAY;AAAA,UACtC,gBAAgB;AAAA,UAChB,SAAS;AAAA,QACX;AACA,YAAI,kBAAkB;AACpB,gBAAM,IAAI,UAAU,gCAAgC,iBAAiB,EAAE,sBAAsB,KAAK,CAAC;AAAA,QACrG,OAAO;AACL,eAAK,IAAI,UAAU,gCAAgC,eAAe,EAAE,MAAM,MAAM,MAAS;AAAA,QAC3F;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAAA,EAEA,oBAAuB,MAQd;AACP,UAAM,EAAE,QAAQ,YAAY,IAAI;AAChC,QAAI,CAAC,OAAQ;AACb,QAAI,CAAC,aAAa,GAAI;AAItB,UAAM,UAAU,KAAK,WAAW,KAAK,sBAAsB,MAAM;AACjE,UAAM,MAAM,KAAK,mBAAmB,KAAK,QAAQ,WAAW;AAC5D,UAAM,WAAW,KAAK,mBAAmB,IAAI,GAAG;AAChD,QAAI,UAAU;AACZ,eAAS,SAAS;AAClB,eAAS,cAAc;AAAA,QACrB,IAAI,YAAY;AAAA,QAChB,gBAAgB,YAAY,kBAAkB;AAAA,QAC9C,UAAU,YAAY,YAAY;AAAA,MACpC;AACA,eAAS,aAAa,KAAK,cAAc;AACzC,eAAS,cAAc,KAAK,eAAe;AAC3C,UAAI,KAAK,OAAQ,UAAS,SAAS,KAAK;AAIxC,UAAI,KAAK,QAAS,UAAS,UAAU,KAAK;AAAA,eACjC,CAAC,SAAS,WAAW,QAAS,UAAS,UAAU;AAC1D,WAAK,mBAAmB,IAAI,KAAK,QAAQ;AACzC;AAAA,IACF;AACA,UAAM,QAA8B;AAAA,MAClC,QAAQ,KAAK;AAAA,MACb;AAAA,MACA,aAAa;AAAA,QACX,IAAI,YAAY;AAAA,QAChB,gBAAgB,YAAY,kBAAkB;AAAA,QAC9C,UAAU,YAAY,YAAY;AAAA,MACpC;AAAA,MACA,YAAY,KAAK,cAAc;AAAA,MAC/B,aAAa,KAAK,eAAe;AAAA,IACnC;AACA,QAAI,KAAK,OAAQ,OAAM,SAAS,KAAK;AACrC,QAAI,QAAS,OAAM,UAAU;AAC7B,SAAK,mBAAmB,IAAI,KAAK,KAAK;AAAA,EACxC;AAAA,EAEA,MAAM,sBAAsB,UAAiD;AAC3E,QAAI,CAAC,KAAK,mBAAmB,KAAM;AACnC,UAAM,UAAU,MAAM,KAAK,KAAK,mBAAmB,OAAO,CAAC;AAC3D,SAAK,mBAAmB,MAAM;AAC9B,eAAW,SAAS,SAAS;AAC3B,UAAI,MAAM,WAAW,CAAC,UAAU,eAAe,KAAK,0BAA0B,MAAM,MAAM,GAAG;AAC3F,aAAK,4BAA4B;AAAA,MACnC;AACA,UAAI;AACF,cAAM,KAAK,mBAAmB;AAAA,UAC5B,QAAQ,MAAM;AAAA,UACd,QAAQ,MAAM;AAAA,UACd,aAAa,MAAM;AAAA,UACnB,YAAY,MAAM,cAAc;AAAA,UAChC,aAAa,MAAM,eAAe;AAAA,UAClC,QAAQ,MAAM;AAAA,UACd,SAAS,MAAM;AAAA,UACf;AAAA,QACF,CAAC;AAAA,MACH,SAAS,OAAO;AACd,YAAI,iCAAiC,GAAG;AACtC,gBAAM;AAAA,QACR;AAAA,MAEF;AAAA,IACF;AAAA,EACF;AAAA,EAEQ,mBAAmB,QAAyB,aAA4C;AAC9F,UAAM,KAAK,YAAY,MAAM;AAC7B,UAAM,MAAM,YAAY,kBAAkB;AAC1C,UAAM,SAAS,YAAY,YAAY;AACvC,WAAO,CAAC,QAAQ,IAAI,KAAK,MAAM,EAAE,KAAK,GAAG;AAAA,EAC3C;AACF;",
4
+ "sourcesContent": ["import type { EntityData, EntityName, FilterQuery, RequiredEntityData } from '@mikro-orm/core'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { AwilixContainer } from 'awilix'\nimport { type Kysely, sql } from 'kysely'\nimport { setRecordCustomFields } from '@open-mercato/core/modules/entities/lib/helpers'\nimport { validateCustomFieldValuesServer } from '@open-mercato/core/modules/entities/lib/validation'\nimport { sanitizeCustomFieldHtmlRichTextValuesServer } from '@open-mercato/core/modules/entities/lib/htmlRichTextSanitizer'\nimport type { EventBus } from '@open-mercato/events/types'\nimport {\n CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY,\n type CrudEventAction,\n type CrudEventsConfig,\n type CrudIndexerConfig,\n type CrudEntityIdentifiers,\n} from '../crud/types'\nimport type { BulkImportSuppression } from '../commands/types'\nimport { CrudHttpError } from '../crud/errors'\nimport { resolveRegisteredEntityTableName } from '../query/engine'\nimport { getEntityIds } from '../encryption/entityIds'\nimport { normalizeCustomFieldValues } from '../custom-fields/normalize'\nimport { parseBooleanToken } from '../boolean'\nimport { isReadProjectionAlwaysConsistent } from './consistency'\nimport { isEventDeclared } from '../../modules/events'\nimport { createLogger } from '../logger'\nimport { createBoundedTtlMemo } from '../query/bounded-ttl-memo'\n\nconst logger = createLogger('shared').child({ component: 'data-engine' })\n\nconst undeclaredEventWarned = new Set<string>()\n\nfunction warnIfUndeclaredEvent(eventName: string, context: string): void {\n if (isEventDeclared(eventName)) return\n if (undeclaredEventWarned.has(eventName)) return\n undeclaredEventWarned.add(eventName)\n logger.warn('Emitting undeclared event \u2014 declare it in the owning module events.ts (createModuleEvents) so the event registry stays authoritative', { context, eventName })\n}\n\n/** Internal: clear the undeclared-event warning cache. Exposed for tests. */\nexport function __resetUndeclaredEventWarningsForTests(): void {\n undeclaredEventWarned.clear()\n}\n\n/** Only `true` is cached: a cached `false` would keep this failing after a migration creates the table. */\nconst tableExistsCache = createBoundedTtlMemo<true>({\n ttlEnv: 'OM_DATA_ENGINE_TABLE_EXISTS_CACHE_MS',\n defaultTtlMs: 3_600_000,\n defaultMaxEntries: 1_000,\n})\n\n/** Test-only: the module-scoped memo would otherwise leak state across specs. */\nexport function clearDataEngineTableExistsCache(): void {\n tableExistsCache.clear()\n}\n\nconst COVERAGE_REFRESH_INTERVAL_MS = 5 * 60 * 1000\nconst coverageRefreshTracker = new Map<string, number>()\n\nfunction shouldTriggerCoverageRefresh(entityType: string | undefined, tenantId: string | null): boolean {\n if (!entityType) return false\n const key = `${entityType}|${tenantId ?? '__null__'}`\n const now = Date.now()\n const last = coverageRefreshTracker.get(key) ?? 0\n if (now - last < COVERAGE_REFRESH_INTERVAL_MS) return false\n coverageRefreshTracker.set(key, now)\n return true\n}\n\ntype CustomEntityValues = Record<string, unknown>\n\ntype QueuedCrudSideEffect = {\n action: CrudEventAction\n entity: unknown\n identifiers: CrudEntityIdentifiers\n syncOrigin?: string | null\n actorUserId?: string | null\n events?: CrudEventsConfig<unknown>\n indexer?: CrudIndexerConfig<unknown>\n}\n\n/**\n * A `makeCrudRoute` route-level `indexer:` declaration, handed to the data engine for the\n * duration of one command-bus write. Command handlers own the side-effect mark on the\n * `actions.*` path, and most of them mark `events:` only \u2014 without this the route's\n * declaration would reach no code at all. `entityClass` scopes the default to the route's\n * own ORM entity so a handler that also marks a sibling entity in the same request (a tag\n * assignment alongside a tag, say) is never indexed under the route's `entityType`.\n */\nexport type DefaultCrudIndexerConfig = {\n indexer: CrudIndexerConfig<unknown>\n entityClass: abstract new (...args: never[]) => object\n}\n\nexport interface DataEngine {\n setCustomFields(opts: {\n entityId: string\n recordId: string\n organizationId?: string | null\n tenantId?: string | null\n values: Record<string, string | number | boolean | null | undefined | Array<string | number | boolean | null | undefined>>\n notify?: boolean // default true -> emit '<module>.<entity>.updated'\n }): Promise<void>\n\n // Storage for user-defined entities (doc-based)\n createCustomEntityRecord(opts: {\n entityId: string // '<module>:<entity>'\n recordId?: string // optional; auto-generate if not provided\n organizationId?: string | null\n tenantId?: string | null\n values: CustomEntityValues\n notify?: boolean // keep event emitting as it is via setCustomFields (updated)\n }): Promise<{ id: string }>\n\n updateCustomEntityRecord(opts: {\n entityId: string\n recordId: string\n organizationId?: string | null\n tenantId?: string | null\n values: CustomEntityValues\n notify?: boolean // keep event emitting as it is via setCustomFields (updated)\n }): Promise<void>\n\n deleteCustomEntityRecord(opts: {\n entityId: string\n recordId: string\n organizationId?: string | null\n tenantId?: string | null\n soft?: boolean // default true: sets deleted_at\n notify?: boolean // keep event emitting as it is (no extra events here)\n }): Promise<void>\n\n // Generic ORM-backed entity operations used by CrudFactory\n createOrmEntity<T extends object>(opts: {\n entity: EntityName<T>\n data: EntityData<T>\n }): Promise<T>\n\n updateOrmEntity<T extends object>(opts: {\n entity: EntityName<T>\n where: FilterQuery<T>\n apply: (current: T) => Promise<void> | void\n }): Promise<T | null>\n\n deleteOrmEntity<T extends object>(opts: {\n entity: EntityName<T>\n where: FilterQuery<T>\n soft?: boolean\n softDeleteField?: keyof T & string\n }): Promise<T | null>\n\n emitOrmEntityEvent<T>(opts: {\n action: CrudEventAction\n entity: T\n events?: CrudEventsConfig<T>\n indexer?: CrudIndexerConfig<T>\n identifiers: CrudEntityIdentifiers\n syncOrigin?: string | null\n actorUserId?: string | null\n /** Bulk-import deferral: skip the domain event and/or inline reindex for this emit. */\n suppress?: BulkImportSuppression\n }): Promise<void>\n\n markOrmEntityChange<T>(opts: {\n action: CrudEventAction\n entity: T | null | undefined\n events?: CrudEventsConfig<T>\n indexer?: CrudIndexerConfig<T>\n identifiers: CrudEntityIdentifiers\n syncOrigin?: string | null\n actorUserId?: string | null\n }): void\n\n /**\n * Drain queued side effects. When `suppress` is passed (a bulk-import backfill), the\n * flagged per-record events / reindex are skipped for every drained entry; the caller\n * is responsible for rebuilding the `query_index` afterwards.\n */\n flushOrmEntityChanges(suppress?: BulkImportSuppression): Promise<void>\n\n /**\n * Declare the indexer a CRUD route configured, for marks made during one command-bus\n * write that do not carry an indexer of their own. Pass `null` to clear it. Optional so\n * third-party `DataEngine` implementations stay valid; callers invoke it with `?.`.\n */\n setDefaultIndexerConfig?(config: DefaultCrudIndexerConfig | null): void\n\n /**\n * Whether any side effect drained since the current default was declared carried an\n * indexer for that default's entity class \u2014 false means the declared query-index\n * obligation was discharged by nobody. Optional for the same reason as the setter.\n */\n hasIndexedDefaultEntityClass?(): boolean\n}\n\nexport const SYSTEM_ENTITY_RECORDS_BLOCKED_CODE = 'system_entity_records_blocked'\n\n/**\n * A system entity for doc-storage purposes is an id that modules declare in the\n * generated entity-id registry AND that resolves to a registered ORM table. Both\n * conditions matter: `resolveRegisteredEntityTableName` matches class-name candidates\n * from the entity segment alone, so a runtime-registered custom entity whose name\n * happens to collide with some ORM class (e.g. `user:todo` vs the example module's\n * `Todo`) must never be classified as system. When the registry is not populated\n * (exotic bootstraps, unit harnesses) the check conservatively falls back to the\n * ORM-table match alone so the #2939 protection never switches off.\n */\nexport function isOrmBackedSystemEntityId(em: EntityManager, entityId: string): boolean {\n const registry = getEntityIds(false)\n const moduleIds = Object.values(registry).flatMap((moduleEntities) => Object.values(moduleEntities ?? {}))\n if (moduleIds.length > 0 && !moduleIds.includes(entityId)) return false\n return resolveRegisteredEntityTableName(em, entityId) !== null\n}\n\n/**\n * Doc storage (`custom_entities_storage`) is for custom entities only. A system\n * entity's records live in its own module tables/APIs \u2014 writing doc rows for it\n * poisons read-path classification (#2939) and must be rejected at the deepest\n * seam so no caller (API, AI tool, workflow) can do it.\n */\nexport function assertCustomEntityStorageEntityId(em: EntityManager, entityId: string): void {\n if (isOrmBackedSystemEntityId(em, entityId)) {\n throw new CrudHttpError(400, {\n error: 'Records are available for custom entities only',\n code: SYSTEM_ENTITY_RECORDS_BLOCKED_CODE,\n entityId,\n })\n }\n}\n\nexport class DefaultDataEngine implements DataEngine {\n private pendingSideEffects = new Map<string, QueuedCrudSideEffect>()\n private defaultIndexer: DefaultCrudIndexerConfig | null = null\n private indexedDefaultEntityClass = false\n constructor(private em: EntityManager, private container: AwilixContainer) {}\n\n /**\n * Per-command state, deliberately held on the engine instance rather than threaded through\n * `CommandRuntimeContext` the way the bulk-import flags are. That is sound only because\n * `createRequestContainer()` registers `dataEngine` per request (`lib/di/container.ts`), so\n * one engine instance never spans two requests, and no `makeCrudRoute` verb runs two commands\n * concurrently against it. An application that re-registers `dataEngine` as a transient would\n * break both assumptions: the command would mark on a different instance than the route\n * declared on, so nothing is indexed and every write logs the undischarged-declaration warning.\n */\n setDefaultIndexerConfig(config: DefaultCrudIndexerConfig | null): void {\n this.defaultIndexer = config\n this.indexedDefaultEntityClass = false\n }\n\n hasIndexedDefaultEntityClass(): boolean {\n return this.indexedDefaultEntityClass\n }\n\n private matchesDefaultEntityClass(entity: unknown): boolean {\n const declared = this.defaultIndexer?.entityClass\n // `OrmEntityConfig.entity` is `any` and this repository treats `EntitySchema` instances as a\n // first-class entity shape (`lib/bootstrap/types.ts`). An `EntitySchema` is an object rather\n // than a constructor, so `instanceof` against it throws \u2014 and it would throw inside\n // `markOrmEntityChange`, outside the best-effort try/catch that guards the flush, turning\n // every write on such a route into a 500.\n if (typeof declared !== 'function') return false\n return entity instanceof declared\n }\n\n private resolveDefaultIndexer(entity: unknown): CrudIndexerConfig<unknown> | undefined {\n return this.matchesDefaultEntityClass(entity) ? this.defaultIndexer?.indexer : undefined\n }\n\n async setCustomFields(opts: Parameters<DataEngine['setCustomFields']>[0]): Promise<void> {\n const { entityId, recordId, organizationId = null, tenantId = null, values } = opts\n const sanitizedValues = await sanitizeCustomFieldHtmlRichTextValuesServer(this.em, {\n entityId,\n organizationId,\n tenantId,\n values,\n })\n await this.validateCustomFieldValues(entityId, organizationId, tenantId, sanitizedValues as Record<string, unknown>)\n let encryptionService: any = null\n try {\n encryptionService = this.container.resolve('tenantEncryptionService') as any\n } catch {\n encryptionService = null\n }\n await setRecordCustomFields(this.em, {\n entityId,\n recordId,\n organizationId,\n tenantId,\n values: sanitizedValues,\n encryptionService,\n })\n if (opts.notify !== false) {\n let bus: EventBus | null = null\n try {\n bus = (this.container.resolve('eventBus') as EventBus)\n } catch {\n bus = null\n }\n if (bus) {\n const [mod, ent] = (entityId || '').split(':')\n if (mod && ent) {\n const eventName = `${mod}.${ent}.updated`\n warnIfUndeclaredEvent(eventName, 'setCustomFields')\n try {\n await bus.emitEvent(eventName, { id: recordId, organizationId, tenantId }, {\n persistent: true,\n tenantId,\n organizationId,\n emitterModuleId: mod,\n })\n } catch {\n // non-blocking\n }\n }\n }\n }\n }\n\n private normalizeDocValues(values: CustomEntityValues): CustomEntityValues {\n const out: CustomEntityValues = {}\n for (const [k, v] of Object.entries(values || {})) {\n // Never allow callers to override reserved identifiers in the doc\n if (k === 'id' || k === 'entity_id' || k === 'entityId') continue\n // Accept both 'cf_<key>' and 'cf:<key>' inputs and normalize to 'cf:<key>'\n if (k.startsWith('cf_')) out[`cf:${k.slice(3)}`] = v\n else out[k] = v\n }\n return out\n }\n\n private backcompatEavEnabled(): boolean {\n try {\n return parseBooleanToken(process.env.ENTITIES_BACKCOMPAT_EAV_FOR_CUSTOM ?? '') === true\n } catch { return false }\n }\n\n private getKysely(): Kysely<any> {\n return this.em.getKysely<any>()\n }\n\n private async ensureStorageTableExists(): Promise<void> {\n const cacheKey = 'custom_entities_storage'\n if (tableExistsCache.get(cacheKey)) return\n const db = this.getKysely()\n const exists = await db\n .selectFrom('information_schema.tables' as any)\n .select(sql`1`.as('present'))\n .where('table_name' as any, '=', 'custom_entities_storage')\n .executeTakeFirst()\n if (!exists) {\n throw new Error('custom_entities_storage table is missing. Run migrations (yarn db:migrate).')\n }\n tableExistsCache.set(cacheKey, true)\n }\n\n private normalizeValuesForValidation(values: Record<string, unknown> | undefined | null): Record<string, unknown> {\n if (!values) return {}\n const out: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(values)) {\n if (value === undefined) continue\n if (key.startsWith('cf_') || key.startsWith('cf:')) {\n const normalized = key.slice(3)\n if (normalized) out[normalized] = value\n continue\n }\n out[key] = value\n }\n return out\n }\n\n private async validateCustomFieldValues(\n entityId: string,\n organizationId: string | null,\n tenantId: string | null,\n values: Record<string, unknown> | undefined | null,\n ): Promise<void> {\n const prepared = this.normalizeValuesForValidation(values)\n if (!entityId || Object.keys(prepared).length === 0) return\n const result = await validateCustomFieldValuesServer(this.em, {\n entityId,\n organizationId,\n tenantId,\n values: prepared,\n })\n if (!result.ok) {\n throw new CrudHttpError(400, { error: 'Validation failed', fields: result.fieldErrors })\n }\n }\n\n async createCustomEntityRecord(opts: Parameters<DataEngine['createCustomEntityRecord']>[0]): Promise<{ id: string }> {\n assertCustomEntityStorageEntityId(this.em, opts.entityId)\n const db = this.getKysely()\n await this.ensureStorageTableExists()\n const sanitizedValues = await sanitizeCustomFieldHtmlRichTextValuesServer(this.em, {\n entityId: opts.entityId,\n organizationId: opts.organizationId ?? null,\n tenantId: opts.tenantId ?? null,\n values: opts.values || {},\n })\n await this.validateCustomFieldValues(opts.entityId, opts.organizationId ?? null, opts.tenantId ?? null, sanitizedValues)\n const rawId = String(opts.recordId ?? '').trim()\n const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(rawId)\n const sentinel = rawId.toLowerCase()\n const shouldGenerate = !rawId || !isUuid || sentinel === 'create' || sentinel === 'new' || sentinel === 'null' || sentinel === 'undefined'\n const id = shouldGenerate ? ((): string => {\n const g = globalThis as { crypto?: { randomUUID?: () => string } }\n if (g.crypto?.randomUUID) return g.crypto.randomUUID()\n // Fallback UUIDv4 generator\n return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {\n const r = (Math.random() * 16) | 0\n const v = c === 'x' ? r : (r & 0x3) | 0x8\n return v.toString(16)\n })\n })() : rawId\n const orgId = opts.organizationId ?? null\n const tenantId = opts.tenantId ?? null\n const doc: Record<string, unknown> = { id, ...this.normalizeDocValues(sanitizedValues || {}) }\n\n const now = sql`now()`\n const payload = {\n entity_type: opts.entityId,\n entity_id: id,\n organization_id: orgId,\n tenant_id: tenantId,\n doc: sql`${JSON.stringify(doc)}::jsonb`,\n updated_at: now,\n created_at: now,\n deleted_at: null,\n }\n\n // Upsert by scoped uniqueness\n try {\n await db\n .insertInto('custom_entities_storage' as any)\n .values(payload as any)\n .onConflict((oc) => oc\n .columns(['entity_type', 'entity_id', 'organization_id'])\n .doUpdateSet({\n doc: sql`${JSON.stringify(doc)}::jsonb`,\n updated_at: sql`now()`,\n deleted_at: null,\n } as any))\n .execute()\n } catch {\n // Fallback for global scope uniqueness\n try {\n const updated = await db\n .updateTable('custom_entities_storage' as any)\n .set({\n doc: sql`${JSON.stringify(doc)}::jsonb`,\n updated_at: sql`now()`,\n deleted_at: null,\n } as any)\n .where('entity_type' as any, '=', opts.entityId)\n .where('entity_id' as any, '=', id)\n .where('organization_id' as any, orgId === null ? 'is' : '=', orgId as any)\n .where('tenant_id' as any, tenantId === null ? 'is' : '=', tenantId as any)\n .executeTakeFirst()\n if (!updated || Number(updated.numUpdatedRows ?? 0) === 0) {\n await db.insertInto('custom_entities_storage' as any).values(payload as any).execute()\n }\n } catch (err) {\n // Surface a clear error so it doesn't silently fall back only to EAV\n throw err\n }\n }\n\n // Optional EAV backward compatibility (disabled by default)\n if (this.backcompatEavEnabled() && sanitizedValues && Object.keys(sanitizedValues).length > 0) {\n await this.setCustomFields({\n entityId: opts.entityId,\n recordId: id,\n organizationId: orgId,\n tenantId: tenantId,\n values: normalizeCustomFieldValues(sanitizedValues),\n notify: opts.notify, // defaults to true\n })\n }\n\n return { id }\n }\n\n async updateCustomEntityRecord(opts: Parameters<DataEngine['updateCustomEntityRecord']>[0]): Promise<void> {\n assertCustomEntityStorageEntityId(this.em, opts.entityId)\n const db = this.getKysely()\n const sanitizedValues = await sanitizeCustomFieldHtmlRichTextValuesServer(this.em, {\n entityId: opts.entityId,\n organizationId: opts.organizationId ?? null,\n tenantId: opts.tenantId ?? null,\n values: opts.values || {},\n })\n await this.validateCustomFieldValues(opts.entityId, opts.organizationId ?? null, opts.tenantId ?? null, sanitizedValues)\n const id = String(opts.recordId)\n const orgId = opts.organizationId ?? null\n const tenantId = opts.tenantId ?? null\n\n // Merge doc shallowly: load existing doc and overlay\n await this.ensureStorageTableExists()\n const applyScope = <T extends { where: (col: any, op: any, val?: any) => T }>(q: T) => {\n let chain = q.where('entity_type' as any, '=', opts.entityId)\n chain = chain.where('entity_id' as any, '=', id)\n chain = orgId === null\n ? chain.where('organization_id' as any, 'is', null as any)\n : chain.where('organization_id' as any, '=', orgId)\n chain = tenantId === null\n ? chain.where('tenant_id' as any, 'is', null as any)\n : chain.where('tenant_id' as any, '=', tenantId)\n return chain\n }\n const row = await applyScope(\n db.selectFrom('custom_entities_storage' as any).select(['doc' as any])\n ).executeTakeFirst()\n const prevDoc: Record<string, unknown> = (row as any)?.doc || { id }\n const nextDoc: Record<string, unknown> = { ...prevDoc, ...this.normalizeDocValues(sanitizedValues || {}), id }\n try {\n const updated = await applyScope(\n db.updateTable('custom_entities_storage' as any).set({\n doc: sql`${JSON.stringify(nextDoc)}::jsonb`,\n updated_at: sql`now()`,\n deleted_at: null,\n } as any) as any\n ).executeTakeFirst()\n if (!updated || Number((updated as any).numUpdatedRows ?? 0) === 0) {\n await db.insertInto('custom_entities_storage' as any).values({\n entity_type: opts.entityId,\n entity_id: id,\n organization_id: orgId,\n tenant_id: tenantId,\n doc: sql`${JSON.stringify(nextDoc)}::jsonb`,\n created_at: sql`now()`,\n updated_at: sql`now()`,\n deleted_at: null,\n } as any).execute()\n }\n } catch (err) {\n throw err\n }\n\n // Optional EAV backward compatibility (disabled by default)\n if (this.backcompatEavEnabled() && sanitizedValues && Object.keys(sanitizedValues).length > 0) {\n await this.setCustomFields({\n entityId: opts.entityId,\n recordId: id,\n organizationId: orgId,\n tenantId: tenantId,\n values: normalizeCustomFieldValues(sanitizedValues),\n notify: opts.notify, // defaults to true\n })\n }\n }\n\n async deleteCustomEntityRecord(opts: Parameters<DataEngine['deleteCustomEntityRecord']>[0]): Promise<void> {\n assertCustomEntityStorageEntityId(this.em, opts.entityId)\n const db = this.getKysely()\n const id = String(opts.recordId)\n const orgId = opts.organizationId ?? null\n const tenantId = opts.tenantId ?? null\n const soft = opts.soft !== false\n\n const applyScope = <T extends { where: (col: any, op: any, val?: any) => T }>(q: T) => {\n let chain = q.where('entity_type' as any, '=', opts.entityId)\n chain = chain.where('entity_id' as any, '=', id)\n chain = orgId === null\n ? chain.where('organization_id' as any, 'is', null as any)\n : chain.where('organization_id' as any, '=', orgId)\n chain = tenantId === null\n ? chain.where('tenant_id' as any, 'is', null as any)\n : chain.where('tenant_id' as any, '=', tenantId)\n return chain\n }\n\n if (soft) {\n await applyScope(\n db.updateTable('custom_entities_storage' as any).set({\n deleted_at: sql`now()`,\n updated_at: sql`now()`,\n } as any) as any\n ).execute()\n } else {\n await applyScope(db.deleteFrom('custom_entities_storage' as any) as any).execute()\n }\n\n // Soft-delete EAV values to preserve current behavior\n try {\n const { CustomFieldValue } = await import('@open-mercato/core/modules/entities/data/entities')\n const values = await this.em.find(CustomFieldValue, {\n entityId: opts.entityId,\n recordId: id,\n organizationId: orgId,\n tenantId: opts.tenantId ?? null,\n })\n const now = new Date()\n const mutated = values.filter((record) => {\n if (record.deletedAt) return false\n record.deletedAt = now\n return true\n })\n if (mutated.length) {\n for (const record of values) this.em.persist(record)\n await this.em.flush()\n }\n } catch { /* non-blocking */ }\n }\n\n async createOrmEntity<T extends object>(opts: { entity: EntityName<T>; data: EntityData<T> }): Promise<T> {\n const entity = this.em.create(\n opts.entity as EntityName<T>,\n opts.data as unknown as RequiredEntityData<T>\n )\n await this.em.persist(entity).flush()\n return entity\n }\n\n async updateOrmEntity<T extends object>(opts: {\n entity: EntityName<T>\n where: FilterQuery<T>\n apply: (current: T) => Promise<void> | void\n }): Promise<T | null> {\n const current = await this.em.findOne(opts.entity as EntityName<T>, opts.where as FilterQuery<NoInfer<T>>)\n if (!current) return null\n await opts.apply(current)\n await this.em.persist(current).flush()\n return current\n }\n\n async deleteOrmEntity<T extends object>(opts: {\n entity: EntityName<T>\n where: FilterQuery<T>\n soft?: boolean\n softDeleteField?: keyof T & string\n }): Promise<T | null> {\n const current = await this.em.findOne(opts.entity as EntityName<T>, opts.where as FilterQuery<NoInfer<T>>)\n if (!current) return null\n if (opts.soft !== false) {\n const field = opts.softDeleteField || ('deletedAt' as keyof T & string)\n if (typeof current === 'object' && current !== null) {\n ;(current as Record<string, unknown>)[field] = new Date()\n await this.em.persist(current).flush()\n }\n } else {\n await this.em.remove(current).flush()\n }\n return current\n }\n\n async emitOrmEntityEvent<T>(opts: {\n action: CrudEventAction\n entity: T\n events?: CrudEventsConfig<T>\n indexer?: CrudIndexerConfig<T>\n identifiers: CrudEntityIdentifiers\n syncOrigin?: string | null\n actorUserId?: string | null\n suppress?: BulkImportSuppression\n }): Promise<void> {\n const { action, entity, events, indexer, identifiers, syncOrigin, suppress } = opts\n // Bulk-import deferral: an entry may suppress its domain event and/or inline reindex. When both\n // the config is absent AND (for the present one) suppressed, there is nothing left to do.\n const emitEvents = !!events && !suppress?.skipEvents\n const runIndexer = !!indexer && !suppress?.skipReindex\n if (!emitEvents && !runIndexer) return\n if (!identifiers?.id) return\n\n let bus: EventBus | null = null\n try {\n bus = (this.container.resolve('eventBus') as EventBus)\n } catch {\n bus = null\n }\n if (!bus) return\n\n const ctx = {\n action,\n entity,\n identifiers: {\n id: identifiers.id,\n organizationId: identifiers.organizationId ?? null,\n tenantId: identifiers.tenantId ?? null,\n },\n syncOrigin: syncOrigin ?? null,\n actorUserId: opts.actorUserId ?? null,\n }\n\n if (events && !suppress?.skipEvents) {\n const eventName = `${events.module}.${events.entity}.${action}`\n warnIfUndeclaredEvent(eventName, 'emitOrmEntityEvent')\n const builtPayload = events.buildPayload\n ? events.buildPayload(ctx)\n : {\n id: ctx.identifiers.id,\n organizationId: ctx.identifiers.organizationId,\n tenantId: ctx.identifiers.tenantId,\n ...(ctx.syncOrigin ? { syncOrigin: ctx.syncOrigin } : {}),\n }\n // A configured indexer means this data-engine call owns the query-index\n // decision, including an explicit skipReindex suppression. Mark object\n // payloads so the legacy domain-event bridge does not enqueue the same\n // record a second time. Keep the marker non-enumerable so client broadcasts\n // and persisted domain payloads retain their existing public shape.\n // Primitive custom payloads cannot be bridged in any case because they do\n // not expose the record id.\n const payload = indexer && builtPayload && typeof builtPayload === 'object' && !Array.isArray(builtPayload)\n ? Object.defineProperty(\n { ...(builtPayload as Record<string, unknown>) },\n CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY,\n { value: true, enumerable: false },\n )\n : builtPayload\n try {\n await bus.emitEvent(eventName, payload, {\n persistent: !!events.persistent,\n tenantId: ctx.identifiers.tenantId ?? null,\n organizationId: ctx.identifiers.organizationId ?? null,\n emitterModuleId: events.module,\n })\n } catch {\n // non-blocking\n }\n }\n\n if (indexer && !suppress?.skipReindex) {\n const alwaysConsistent = isReadProjectionAlwaysConsistent()\n const resolveCoverageBaseDelta = (): number | undefined => {\n if (action === 'created') return 1\n if (action === 'deleted') return -1\n return undefined\n }\n const coverageBaseDelta = resolveCoverageBaseDelta()\n\n if (action === 'deleted') {\n const payload = indexer.buildDeletePayload\n ? indexer.buildDeletePayload(ctx)\n : {\n entityType: indexer.entityType,\n recordId: ctx.identifiers.id,\n organizationId: ctx.identifiers.organizationId,\n tenantId: ctx.identifiers.tenantId,\n }\n const enrichedPayload = payload as Record<string, unknown>\n enrichedPayload.crudAction = action\n if (coverageBaseDelta !== undefined) enrichedPayload.coverageBaseDelta = coverageBaseDelta\n if (ctx.syncOrigin) enrichedPayload.syncOrigin = ctx.syncOrigin\n // Await the index update so query-index reads (the `customValues`/scalar\n // projection that list endpoints serve) are consistent the moment the write\n // returns. The subscriber removes the projection row + tokens synchronously and\n // defers the coverage recompute + fulltext delete, so this stays bounded.\n // Errors are logged, not thrown \u2014 index drift never fails the originating write.\n // Always-consistent mode rethrows so drift is loud and retryable.\n if (alwaysConsistent) {\n await bus.emitEvent('query_index.delete_one', enrichedPayload, { rethrowHandlerErrors: true })\n } else {\n await bus.emitEvent('query_index.delete_one', enrichedPayload).catch((err: unknown) => {\n logger.error('query_index.delete_one emit failed', { err })\n })\n }\n } else {\n const payload = indexer.buildUpsertPayload\n ? indexer.buildUpsertPayload(ctx)\n : {\n entityType: indexer.entityType,\n recordId: ctx.identifiers.id,\n organizationId: ctx.identifiers.organizationId,\n tenantId: ctx.identifiers.tenantId,\n }\n const enrichedPayload = payload as Record<string, unknown>\n enrichedPayload.crudAction = action\n if (coverageBaseDelta !== undefined) enrichedPayload.coverageBaseDelta = coverageBaseDelta\n if (ctx.syncOrigin) enrichedPayload.syncOrigin = ctx.syncOrigin\n // Await the projection upsert so list reads observe the new doc immediately\n // (see delete_one above). The subscriber updates `entity_indexes` synchronously\n // and defers the heavy token-reindex pipeline (build doc + encrypt + decrypt +\n // tokenize + DELETE + chunked INSERT) so write latency stays bounded.\n if (alwaysConsistent) {\n await bus.emitEvent('query_index.upsert_one', enrichedPayload, { rethrowHandlerErrors: true })\n } else {\n await bus.emitEvent('query_index.upsert_one', enrichedPayload).catch((err: unknown) => {\n logger.error('query_index.upsert_one emit failed', { err })\n })\n }\n }\n\n if (alwaysConsistent || shouldTriggerCoverageRefresh(indexer.entityType, ctx.identifiers.tenantId ?? null)) {\n const coveragePayload = {\n entityType: indexer.entityType,\n tenantId: ctx.identifiers.tenantId ?? null,\n organizationId: null,\n delayMs: 0,\n }\n if (alwaysConsistent) {\n await bus.emitEvent('query_index.coverage.refresh', coveragePayload, { rethrowHandlerErrors: true })\n } else {\n void bus.emitEvent('query_index.coverage.refresh', coveragePayload).catch(() => undefined)\n }\n }\n }\n }\n\n markOrmEntityChange<T>(opts: {\n action: CrudEventAction\n entity: T | null | undefined\n events?: CrudEventsConfig<T>\n indexer?: CrudIndexerConfig<T>\n identifiers: CrudEntityIdentifiers\n syncOrigin?: string | null\n actorUserId?: string | null\n }): void {\n const { entity, identifiers } = opts\n if (!entity) return\n if (!identifiers?.id) return\n // A command handler that marks `events:` only still discharges the route's declared\n // query-index obligation \u2014 the route hands its `indexer:` down as the default so the\n // handler's own entity and identifiers (the accurate ones) drive the projection write.\n const indexer = opts.indexer ?? this.resolveDefaultIndexer(entity)\n const key = this.buildSideEffectKey(opts.action, identifiers)\n const existing = this.pendingSideEffects.get(key)\n if (existing) {\n existing.entity = entity\n existing.identifiers = {\n id: identifiers.id,\n organizationId: identifiers.organizationId ?? null,\n tenantId: identifiers.tenantId ?? null,\n }\n existing.syncOrigin = opts.syncOrigin ?? null\n existing.actorUserId = opts.actorUserId ?? null\n if (opts.events) existing.events = opts.events as CrudEventsConfig<unknown>\n // Explicit always wins, on the merge branch too: a second `events:`-only mark on the same\n // key must not let the route default overwrite the `indexer:` an earlier mark installed,\n // which would silently drop that handler's own `buildUpsertPayload`.\n if (opts.indexer) existing.indexer = opts.indexer as CrudIndexerConfig<unknown>\n else if (!existing.indexer && indexer) existing.indexer = indexer\n this.pendingSideEffects.set(key, existing)\n return\n }\n const entry: QueuedCrudSideEffect = {\n action: opts.action,\n entity,\n identifiers: {\n id: identifiers.id,\n organizationId: identifiers.organizationId ?? null,\n tenantId: identifiers.tenantId ?? null,\n },\n syncOrigin: opts.syncOrigin ?? null,\n actorUserId: opts.actorUserId ?? null,\n }\n if (opts.events) entry.events = opts.events as CrudEventsConfig<unknown>\n if (indexer) entry.indexer = indexer\n this.pendingSideEffects.set(key, entry)\n }\n\n async flushOrmEntityChanges(suppress?: BulkImportSuppression): Promise<void> {\n if (!this.pendingSideEffects.size) return\n const entries = Array.from(this.pendingSideEffects.values())\n this.pendingSideEffects.clear()\n for (const entry of entries) {\n if (entry.indexer && !suppress?.skipReindex && this.matchesDefaultEntityClass(entry.entity)) {\n this.indexedDefaultEntityClass = true\n }\n try {\n await this.emitOrmEntityEvent({\n action: entry.action,\n entity: entry.entity,\n identifiers: entry.identifiers,\n syncOrigin: entry.syncOrigin ?? null,\n actorUserId: entry.actorUserId ?? null,\n events: entry.events as CrudEventsConfig<unknown>,\n indexer: entry.indexer as CrudIndexerConfig<unknown>,\n suppress,\n })\n } catch (error) {\n if (isReadProjectionAlwaysConsistent()) {\n throw error\n }\n // best-effort; continue with remaining side effects\n }\n }\n }\n\n private buildSideEffectKey(action: CrudEventAction, identifiers: CrudEntityIdentifiers): string {\n const id = identifiers.id ?? ''\n const org = identifiers.organizationId ?? ''\n const tenant = identifiers.tenantId ?? ''\n return [action, id, org, tenant].join('|')\n }\n}\n"],
5
+ "mappings": "AAGA,SAAsB,WAAW;AACjC,SAAS,6BAA6B;AACtC,SAAS,uCAAuC;AAChD,SAAS,mDAAmD;AAE5D;AAAA,EACE;AAAA,OAKK;AAEP,SAAS,qBAAqB;AAC9B,SAAS,wCAAwC;AACjD,SAAS,oBAAoB;AAC7B,SAAS,kCAAkC;AAC3C,SAAS,yBAAyB;AAClC,SAAS,wCAAwC;AACjD,SAAS,uBAAuB;AAChC,SAAS,oBAAoB;AAC7B,SAAS,4BAA4B;AAErC,MAAM,SAAS,aAAa,QAAQ,EAAE,MAAM,EAAE,WAAW,cAAc,CAAC;AAExE,MAAM,wBAAwB,oBAAI,IAAY;AAE9C,SAAS,sBAAsB,WAAmB,SAAuB;AACvE,MAAI,gBAAgB,SAAS,EAAG;AAChC,MAAI,sBAAsB,IAAI,SAAS,EAAG;AAC1C,wBAAsB,IAAI,SAAS;AACnC,SAAO,KAAK,6IAAwI,EAAE,SAAS,UAAU,CAAC;AAC5K;AAGO,SAAS,yCAA+C;AAC7D,wBAAsB,MAAM;AAC9B;AAGA,MAAM,mBAAmB,qBAA2B;AAAA,EAClD,QAAQ;AAAA,EACR,cAAc;AAAA,EACd,mBAAmB;AACrB,CAAC;AAGM,SAAS,kCAAwC;AACtD,mBAAiB,MAAM;AACzB;AAEA,MAAM,+BAA+B,IAAI,KAAK;AAC9C,MAAM,yBAAyB,oBAAI,IAAoB;AAEvD,SAAS,6BAA6B,YAAgC,UAAkC;AACtG,MAAI,CAAC,WAAY,QAAO;AACxB,QAAM,MAAM,GAAG,UAAU,IAAI,YAAY,UAAU;AACnD,QAAM,MAAM,KAAK,IAAI;AACrB,QAAM,OAAO,uBAAuB,IAAI,GAAG,KAAK;AAChD,MAAI,MAAM,OAAO,6BAA8B,QAAO;AACtD,yBAAuB,IAAI,KAAK,GAAG;AACnC,SAAO;AACT;AAgIO,MAAM,qCAAqC;AAY3C,SAAS,0BAA0B,IAAmB,UAA2B;AACtF,QAAM,WAAW,aAAa,KAAK;AACnC,QAAM,YAAY,OAAO,OAAO,QAAQ,EAAE,QAAQ,CAAC,mBAAmB,OAAO,OAAO,kBAAkB,CAAC,CAAC,CAAC;AACzG,MAAI,UAAU,SAAS,KAAK,CAAC,UAAU,SAAS,QAAQ,EAAG,QAAO;AAClE,SAAO,iCAAiC,IAAI,QAAQ,MAAM;AAC5D;AAQO,SAAS,kCAAkC,IAAmB,UAAwB;AAC3F,MAAI,0BAA0B,IAAI,QAAQ,GAAG;AAC3C,UAAM,IAAI,cAAc,KAAK;AAAA,MAC3B,OAAO;AAAA,MACP,MAAM;AAAA,MACN;AAAA,IACF,CAAC;AAAA,EACH;AACF;AAEO,MAAM,kBAAwC;AAAA,EAInD,YAAoB,IAA2B,WAA4B;AAAvD;AAA2B;AAH/C,SAAQ,qBAAqB,oBAAI,IAAkC;AACnE,SAAQ,iBAAkD;AAC1D,SAAQ,4BAA4B;AAAA,EACwC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAW5E,wBAAwB,QAA+C;AACrE,SAAK,iBAAiB;AACtB,SAAK,4BAA4B;AAAA,EACnC;AAAA,EAEA,+BAAwC;AACtC,WAAO,KAAK;AAAA,EACd;AAAA,EAEQ,0BAA0B,QAA0B;AAC1D,UAAM,WAAW,KAAK,gBAAgB;AAMtC,QAAI,OAAO,aAAa,WAAY,QAAO;AAC3C,WAAO,kBAAkB;AAAA,EAC3B;AAAA,EAEQ,sBAAsB,QAAyD;AACrF,WAAO,KAAK,0BAA0B,MAAM,IAAI,KAAK,gBAAgB,UAAU;AAAA,EACjF;AAAA,EAEA,MAAM,gBAAgB,MAAmE;AACvF,UAAM,EAAE,UAAU,UAAU,iBAAiB,MAAM,WAAW,MAAM,OAAO,IAAI;AAC/E,UAAM,kBAAkB,MAAM,4CAA4C,KAAK,IAAI;AAAA,MACjF;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF,CAAC;AACD,UAAM,KAAK,0BAA0B,UAAU,gBAAgB,UAAU,eAA0C;AACnH,QAAI,oBAAyB;AAC7B,QAAI;AACF,0BAAoB,KAAK,UAAU,QAAQ,yBAAyB;AAAA,IACtE,QAAQ;AACN,0BAAoB;AAAA,IACtB;AACA,UAAM,sBAAsB,KAAK,IAAI;AAAA,MACnC;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA,QAAQ;AAAA,MACR;AAAA,IACF,CAAC;AACD,QAAI,KAAK,WAAW,OAAO;AACzB,UAAI,MAAuB;AAC3B,UAAI;AACF,cAAO,KAAK,UAAU,QAAQ,UAAU;AAAA,MAC1C,QAAQ;AACN,cAAM;AAAA,MACR;AACA,UAAI,KAAK;AACP,cAAM,CAAC,KAAK,GAAG,KAAK,YAAY,IAAI,MAAM,GAAG;AAC7C,YAAI,OAAO,KAAK;AACd,gBAAM,YAAY,GAAG,GAAG,IAAI,GAAG;AAC/B,gCAAsB,WAAW,iBAAiB;AAClD,cAAI;AACF,kBAAM,IAAI,UAAU,WAAW,EAAE,IAAI,UAAU,gBAAgB,SAAS,GAAG;AAAA,cACzE,YAAY;AAAA,cACZ;AAAA,cACA;AAAA,cACA,iBAAiB;AAAA,YACnB,CAAC;AAAA,UACH,QAAQ;AAAA,UAER;AAAA,QACF;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAAA,EAEQ,mBAAmB,QAAgD;AACzE,UAAM,MAA0B,CAAC;AACjC,eAAW,CAAC,GAAG,CAAC,KAAK,OAAO,QAAQ,UAAU,CAAC,CAAC,GAAG;AAEjD,UAAI,MAAM,QAAQ,MAAM,eAAe,MAAM,WAAY;AAEzD,UAAI,EAAE,WAAW,KAAK,EAAG,KAAI,MAAM,EAAE,MAAM,CAAC,CAAC,EAAE,IAAI;AAAA,UAC9C,KAAI,CAAC,IAAI;AAAA,IAChB;AACA,WAAO;AAAA,EACT;AAAA,EAEQ,uBAAgC;AACtC,QAAI;AACF,aAAO,kBAAkB,QAAQ,IAAI,sCAAsC,EAAE,MAAM;AAAA,IACrF,QAAQ;AAAE,aAAO;AAAA,IAAM;AAAA,EACzB;AAAA,EAEQ,YAAyB;AAC/B,WAAO,KAAK,GAAG,UAAe;AAAA,EAChC;AAAA,EAEA,MAAc,2BAA0C;AACtD,UAAM,WAAW;AACjB,QAAI,iBAAiB,IAAI,QAAQ,EAAG;AACpC,UAAM,KAAK,KAAK,UAAU;AAC1B,UAAM,SAAS,MAAM,GAClB,WAAW,2BAAkC,EAC7C,OAAO,OAAO,GAAG,SAAS,CAAC,EAC3B,MAAM,cAAqB,KAAK,yBAAyB,EACzD,iBAAiB;AACpB,QAAI,CAAC,QAAQ;AACX,YAAM,IAAI,MAAM,6EAA6E;AAAA,IAC/F;AACA,qBAAiB,IAAI,UAAU,IAAI;AAAA,EACrC;AAAA,EAEQ,6BAA6B,QAA6E;AAChH,QAAI,CAAC,OAAQ,QAAO,CAAC;AACrB,UAAM,MAA+B,CAAC;AACtC,eAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,MAAM,GAAG;AACjD,UAAI,UAAU,OAAW;AACzB,UAAI,IAAI,WAAW,KAAK,KAAK,IAAI,WAAW,KAAK,GAAG;AAClD,cAAM,aAAa,IAAI,MAAM,CAAC;AAC9B,YAAI,WAAY,KAAI,UAAU,IAAI;AAClC;AAAA,MACF;AACA,UAAI,GAAG,IAAI;AAAA,IACb;AACA,WAAO;AAAA,EACT;AAAA,EAEA,MAAc,0BACZ,UACA,gBACA,UACA,QACe;AACf,UAAM,WAAW,KAAK,6BAA6B,MAAM;AACzD,QAAI,CAAC,YAAY,OAAO,KAAK,QAAQ,EAAE,WAAW,EAAG;AACrD,UAAM,SAAS,MAAM,gCAAgC,KAAK,IAAI;AAAA,MAC5D;AAAA,MACA;AAAA,MACA;AAAA,MACA,QAAQ;AAAA,IACV,CAAC;AACD,QAAI,CAAC,OAAO,IAAI;AACd,YAAM,IAAI,cAAc,KAAK,EAAE,OAAO,qBAAqB,QAAQ,OAAO,YAAY,CAAC;AAAA,IACzF;AAAA,EACF;AAAA,EAEA,MAAM,yBAAyB,MAAsF;AACnH,sCAAkC,KAAK,IAAI,KAAK,QAAQ;AACxD,UAAM,KAAK,KAAK,UAAU;AAC1B,UAAM,KAAK,yBAAyB;AACpC,UAAM,kBAAkB,MAAM,4CAA4C,KAAK,IAAI;AAAA,MACjF,UAAU,KAAK;AAAA,MACf,gBAAgB,KAAK,kBAAkB;AAAA,MACvC,UAAU,KAAK,YAAY;AAAA,MAC3B,QAAQ,KAAK,UAAU,CAAC;AAAA,IAC1B,CAAC;AACD,UAAM,KAAK,0BAA0B,KAAK,UAAU,KAAK,kBAAkB,MAAM,KAAK,YAAY,MAAM,eAAe;AACvH,UAAM,QAAQ,OAAO,KAAK,YAAY,EAAE,EAAE,KAAK;AAC/C,UAAM,SAAS,6EAA6E,KAAK,KAAK;AACtG,UAAM,WAAW,MAAM,YAAY;AACnC,UAAM,iBAAiB,CAAC,SAAS,CAAC,UAAU,aAAa,YAAY,aAAa,SAAS,aAAa,UAAU,aAAa;AAC/H,UAAM,KAAK,kBAAkB,MAAc;AACzC,YAAM,IAAI;AACV,UAAI,EAAE,QAAQ,WAAY,QAAO,EAAE,OAAO,WAAW;AAErD,aAAO,uCAAuC,QAAQ,SAAS,CAAC,MAAM;AACpE,cAAM,IAAK,KAAK,OAAO,IAAI,KAAM;AACjC,cAAM,IAAI,MAAM,MAAM,IAAK,IAAI,IAAO;AACtC,eAAO,EAAE,SAAS,EAAE;AAAA,MACtB,CAAC;AAAA,IACH,GAAG,IAAI;AACP,UAAM,QAAQ,KAAK,kBAAkB;AACrC,UAAM,WAAW,KAAK,YAAY;AAClC,UAAM,MAA+B,EAAE,IAAI,GAAG,KAAK,mBAAmB,mBAAmB,CAAC,CAAC,EAAE;AAE7F,UAAM,MAAM;AACZ,UAAM,UAAU;AAAA,MACd,aAAa,KAAK;AAAA,MAClB,WAAW;AAAA,MACX,iBAAiB;AAAA,MACjB,WAAW;AAAA,MACX,KAAK,MAAM,KAAK,UAAU,GAAG,CAAC;AAAA,MAC9B,YAAY;AAAA,MACZ,YAAY;AAAA,MACZ,YAAY;AAAA,IACd;AAGA,QAAI;AACF,YAAM,GACH,WAAW,yBAAgC,EAC3C,OAAO,OAAc,EACrB,WAAW,CAAC,OAAO,GACjB,QAAQ,CAAC,eAAe,aAAa,iBAAiB,CAAC,EACvD,YAAY;AAAA,QACX,KAAK,MAAM,KAAK,UAAU,GAAG,CAAC;AAAA,QAC9B,YAAY;AAAA,QACZ,YAAY;AAAA,MACd,CAAQ,CAAC,EACV,QAAQ;AAAA,IACb,QAAQ;AAEN,UAAI;AACF,cAAM,UAAU,MAAM,GACnB,YAAY,yBAAgC,EAC5C,IAAI;AAAA,UACH,KAAK,MAAM,KAAK,UAAU,GAAG,CAAC;AAAA,UAC9B,YAAY;AAAA,UACZ,YAAY;AAAA,QACd,CAAQ,EACP,MAAM,eAAsB,KAAK,KAAK,QAAQ,EAC9C,MAAM,aAAoB,KAAK,EAAE,EACjC,MAAM,mBAA0B,UAAU,OAAO,OAAO,KAAK,KAAY,EACzE,MAAM,aAAoB,aAAa,OAAO,OAAO,KAAK,QAAe,EACzE,iBAAiB;AACpB,YAAI,CAAC,WAAW,OAAO,QAAQ,kBAAkB,CAAC,MAAM,GAAG;AACzD,gBAAM,GAAG,WAAW,yBAAgC,EAAE,OAAO,OAAc,EAAE,QAAQ;AAAA,QACvF;AAAA,MACF,SAAS,KAAK;AAEZ,cAAM;AAAA,MACR;AAAA,IACF;AAGA,QAAI,KAAK,qBAAqB,KAAK,mBAAmB,OAAO,KAAK,eAAe,EAAE,SAAS,GAAG;AAC7F,YAAM,KAAK,gBAAgB;AAAA,QACzB,UAAU,KAAK;AAAA,QACf,UAAU;AAAA,QACV,gBAAgB;AAAA,QAChB;AAAA,QACA,QAAQ,2BAA2B,eAAe;AAAA,QAClD,QAAQ,KAAK;AAAA;AAAA,MACf,CAAC;AAAA,IACH;AAEA,WAAO,EAAE,GAAG;AAAA,EACd;AAAA,EAEA,MAAM,yBAAyB,MAA4E;AACzG,sCAAkC,KAAK,IAAI,KAAK,QAAQ;AACxD,UAAM,KAAK,KAAK,UAAU;AAC1B,UAAM,kBAAkB,MAAM,4CAA4C,KAAK,IAAI;AAAA,MACjF,UAAU,KAAK;AAAA,MACf,gBAAgB,KAAK,kBAAkB;AAAA,MACvC,UAAU,KAAK,YAAY;AAAA,MAC3B,QAAQ,KAAK,UAAU,CAAC;AAAA,IAC1B,CAAC;AACD,UAAM,KAAK,0BAA0B,KAAK,UAAU,KAAK,kBAAkB,MAAM,KAAK,YAAY,MAAM,eAAe;AACvH,UAAM,KAAK,OAAO,KAAK,QAAQ;AAC/B,UAAM,QAAQ,KAAK,kBAAkB;AACrC,UAAM,WAAW,KAAK,YAAY;AAGlC,UAAM,KAAK,yBAAyB;AACpC,UAAM,aAAa,CAA2D,MAAS;AACrF,UAAI,QAAQ,EAAE,MAAM,eAAsB,KAAK,KAAK,QAAQ;AAC5D,cAAQ,MAAM,MAAM,aAAoB,KAAK,EAAE;AAC/C,cAAQ,UAAU,OACd,MAAM,MAAM,mBAA0B,MAAM,IAAW,IACvD,MAAM,MAAM,mBAA0B,KAAK,KAAK;AACpD,cAAQ,aAAa,OACjB,MAAM,MAAM,aAAoB,MAAM,IAAW,IACjD,MAAM,MAAM,aAAoB,KAAK,QAAQ;AACjD,aAAO;AAAA,IACT;AACA,UAAM,MAAM,MAAM;AAAA,MAChB,GAAG,WAAW,yBAAgC,EAAE,OAAO,CAAC,KAAY,CAAC;AAAA,IACvE,EAAE,iBAAiB;AACnB,UAAM,UAAoC,KAAa,OAAO,EAAE,GAAG;AACnE,UAAM,UAAmC,EAAE,GAAG,SAAS,GAAG,KAAK,mBAAmB,mBAAmB,CAAC,CAAC,GAAG,GAAG;AAC7G,QAAI;AACF,YAAM,UAAU,MAAM;AAAA,QACpB,GAAG,YAAY,yBAAgC,EAAE,IAAI;AAAA,UACnD,KAAK,MAAM,KAAK,UAAU,OAAO,CAAC;AAAA,UAClC,YAAY;AAAA,UACZ,YAAY;AAAA,QACd,CAAQ;AAAA,MACV,EAAE,iBAAiB;AACnB,UAAI,CAAC,WAAW,OAAQ,QAAgB,kBAAkB,CAAC,MAAM,GAAG;AAClE,cAAM,GAAG,WAAW,yBAAgC,EAAE,OAAO;AAAA,UAC3D,aAAa,KAAK;AAAA,UAClB,WAAW;AAAA,UACX,iBAAiB;AAAA,UACjB,WAAW;AAAA,UACX,KAAK,MAAM,KAAK,UAAU,OAAO,CAAC;AAAA,UAClC,YAAY;AAAA,UACZ,YAAY;AAAA,UACZ,YAAY;AAAA,QACd,CAAQ,EAAE,QAAQ;AAAA,MACpB;AAAA,IACF,SAAS,KAAK;AACZ,YAAM;AAAA,IACR;AAGA,QAAI,KAAK,qBAAqB,KAAK,mBAAmB,OAAO,KAAK,eAAe,EAAE,SAAS,GAAG;AAC7F,YAAM,KAAK,gBAAgB;AAAA,QACzB,UAAU,KAAK;AAAA,QACf,UAAU;AAAA,QACV,gBAAgB;AAAA,QAChB;AAAA,QACA,QAAQ,2BAA2B,eAAe;AAAA,QAClD,QAAQ,KAAK;AAAA;AAAA,MACf,CAAC;AAAA,IACH;AAAA,EACF;AAAA,EAEA,MAAM,yBAAyB,MAA4E;AACzG,sCAAkC,KAAK,IAAI,KAAK,QAAQ;AACxD,UAAM,KAAK,KAAK,UAAU;AAC1B,UAAM,KAAK,OAAO,KAAK,QAAQ;AAC/B,UAAM,QAAQ,KAAK,kBAAkB;AACrC,UAAM,WAAW,KAAK,YAAY;AAClC,UAAM,OAAO,KAAK,SAAS;AAE3B,UAAM,aAAa,CAA2D,MAAS;AACrF,UAAI,QAAQ,EAAE,MAAM,eAAsB,KAAK,KAAK,QAAQ;AAC5D,cAAQ,MAAM,MAAM,aAAoB,KAAK,EAAE;AAC/C,cAAQ,UAAU,OACd,MAAM,MAAM,mBAA0B,MAAM,IAAW,IACvD,MAAM,MAAM,mBAA0B,KAAK,KAAK;AACpD,cAAQ,aAAa,OACjB,MAAM,MAAM,aAAoB,MAAM,IAAW,IACjD,MAAM,MAAM,aAAoB,KAAK,QAAQ;AACjD,aAAO;AAAA,IACT;AAEA,QAAI,MAAM;AACR,YAAM;AAAA,QACJ,GAAG,YAAY,yBAAgC,EAAE,IAAI;AAAA,UACnD,YAAY;AAAA,UACZ,YAAY;AAAA,QACd,CAAQ;AAAA,MACV,EAAE,QAAQ;AAAA,IACZ,OAAO;AACL,YAAM,WAAW,GAAG,WAAW,yBAAgC,CAAQ,EAAE,QAAQ;AAAA,IACnF;AAGA,QAAI;AACF,YAAM,EAAE,iBAAiB,IAAI,MAAM,OAAO,mDAAmD;AAC7F,YAAM,SAAS,MAAM,KAAK,GAAG,KAAK,kBAAkB;AAAA,QAClD,UAAU,KAAK;AAAA,QACf,UAAU;AAAA,QACV,gBAAgB;AAAA,QAChB,UAAU,KAAK,YAAY;AAAA,MAC7B,CAAC;AACD,YAAM,MAAM,oBAAI,KAAK;AACrB,YAAM,UAAU,OAAO,OAAO,CAAC,WAAW;AACxC,YAAI,OAAO,UAAW,QAAO;AAC7B,eAAO,YAAY;AACnB,eAAO;AAAA,MACT,CAAC;AACD,UAAI,QAAQ,QAAQ;AAClB,mBAAW,UAAU,OAAQ,MAAK,GAAG,QAAQ,MAAM;AACnD,cAAM,KAAK,GAAG,MAAM;AAAA,MACtB;AAAA,IACF,QAAQ;AAAA,IAAqB;AAAA,EAC/B;AAAA,EAEA,MAAM,gBAAkC,MAAkE;AACxG,UAAM,SAAS,KAAK,GAAG;AAAA,MACrB,KAAK;AAAA,MACL,KAAK;AAAA,IACP;AACA,UAAM,KAAK,GAAG,QAAQ,MAAM,EAAE,MAAM;AACpC,WAAO;AAAA,EACT;AAAA,EAEA,MAAM,gBAAkC,MAIlB;AACpB,UAAM,UAAU,MAAM,KAAK,GAAG,QAAQ,KAAK,QAAyB,KAAK,KAAgC;AACzG,QAAI,CAAC,QAAS,QAAO;AACrB,UAAM,KAAK,MAAM,OAAO;AACxB,UAAM,KAAK,GAAG,QAAQ,OAAO,EAAE,MAAM;AACrC,WAAO;AAAA,EACT;AAAA,EAEA,MAAM,gBAAkC,MAKlB;AACpB,UAAM,UAAU,MAAM,KAAK,GAAG,QAAQ,KAAK,QAAyB,KAAK,KAAgC;AACzG,QAAI,CAAC,QAAS,QAAO;AACrB,QAAI,KAAK,SAAS,OAAO;AACvB,YAAM,QAAQ,KAAK,mBAAoB;AACvC,UAAI,OAAO,YAAY,YAAY,YAAY,MAAM;AACnD;AAAC,QAAC,QAAoC,KAAK,IAAI,oBAAI,KAAK;AACxD,cAAM,KAAK,GAAG,QAAQ,OAAO,EAAE,MAAM;AAAA,MACvC;AAAA,IACF,OAAO;AACL,YAAM,KAAK,GAAG,OAAO,OAAO,EAAE,MAAM;AAAA,IACtC;AACA,WAAO;AAAA,EACT;AAAA,EAEA,MAAM,mBAAsB,MASV;AAChB,UAAM,EAAE,QAAQ,QAAQ,QAAQ,SAAS,aAAa,YAAY,SAAS,IAAI;AAG/E,UAAM,aAAa,CAAC,CAAC,UAAU,CAAC,UAAU;AAC1C,UAAM,aAAa,CAAC,CAAC,WAAW,CAAC,UAAU;AAC3C,QAAI,CAAC,cAAc,CAAC,WAAY;AAChC,QAAI,CAAC,aAAa,GAAI;AAEtB,QAAI,MAAuB;AAC3B,QAAI;AACF,YAAO,KAAK,UAAU,QAAQ,UAAU;AAAA,IAC1C,QAAQ;AACN,YAAM;AAAA,IACR;AACA,QAAI,CAAC,IAAK;AAEV,UAAM,MAAM;AAAA,MACV;AAAA,MACA;AAAA,MACA,aAAa;AAAA,QACX,IAAI,YAAY;AAAA,QAChB,gBAAgB,YAAY,kBAAkB;AAAA,QAC9C,UAAU,YAAY,YAAY;AAAA,MACpC;AAAA,MACA,YAAY,cAAc;AAAA,MAC1B,aAAa,KAAK,eAAe;AAAA,IACnC;AAEA,QAAI,UAAU,CAAC,UAAU,YAAY;AACnC,YAAM,YAAY,GAAG,OAAO,MAAM,IAAI,OAAO,MAAM,IAAI,MAAM;AAC7D,4BAAsB,WAAW,oBAAoB;AACrD,YAAM,eAAe,OAAO,eACxB,OAAO,aAAa,GAAG,IACvB;AAAA,QACE,IAAI,IAAI,YAAY;AAAA,QACpB,gBAAgB,IAAI,YAAY;AAAA,QAChC,UAAU,IAAI,YAAY;AAAA,QAC1B,GAAI,IAAI,aAAa,EAAE,YAAY,IAAI,WAAW,IAAI,CAAC;AAAA,MACzD;AAQJ,YAAM,UAAU,WAAW,gBAAgB,OAAO,iBAAiB,YAAY,CAAC,MAAM,QAAQ,YAAY,IACtG,OAAO;AAAA,QACL,EAAE,GAAI,aAAyC;AAAA,QAC/C;AAAA,QACA,EAAE,OAAO,MAAM,YAAY,MAAM;AAAA,MACnC,IACA;AACJ,UAAI;AACF,cAAM,IAAI,UAAU,WAAW,SAAS;AAAA,UACtC,YAAY,CAAC,CAAC,OAAO;AAAA,UACrB,UAAU,IAAI,YAAY,YAAY;AAAA,UACtC,gBAAgB,IAAI,YAAY,kBAAkB;AAAA,UAClD,iBAAiB,OAAO;AAAA,QAC1B,CAAC;AAAA,MACH,QAAQ;AAAA,MAER;AAAA,IACF;AAEA,QAAI,WAAW,CAAC,UAAU,aAAa;AACrC,YAAM,mBAAmB,iCAAiC;AAC1D,YAAM,2BAA2B,MAA0B;AACzD,YAAI,WAAW,UAAW,QAAO;AACjC,YAAI,WAAW,UAAW,QAAO;AACjC,eAAO;AAAA,MACT;AACA,YAAM,oBAAoB,yBAAyB;AAEnD,UAAI,WAAW,WAAW;AACxB,cAAM,UAAU,QAAQ,qBACpB,QAAQ,mBAAmB,GAAG,IAC9B;AAAA,UACE,YAAY,QAAQ;AAAA,UACpB,UAAU,IAAI,YAAY;AAAA,UAC1B,gBAAgB,IAAI,YAAY;AAAA,UAChC,UAAU,IAAI,YAAY;AAAA,QAC5B;AACJ,cAAM,kBAAkB;AACxB,wBAAgB,aAAa;AAC7B,YAAI,sBAAsB,OAAW,iBAAgB,oBAAoB;AACzE,YAAI,IAAI,WAAY,iBAAgB,aAAa,IAAI;AAOrD,YAAI,kBAAkB;AACpB,gBAAM,IAAI,UAAU,0BAA0B,iBAAiB,EAAE,sBAAsB,KAAK,CAAC;AAAA,QAC/F,OAAO;AACL,gBAAM,IAAI,UAAU,0BAA0B,eAAe,EAAE,MAAM,CAAC,QAAiB;AACrF,mBAAO,MAAM,sCAAsC,EAAE,IAAI,CAAC;AAAA,UAC5D,CAAC;AAAA,QACH;AAAA,MACF,OAAO;AACL,cAAM,UAAU,QAAQ,qBACpB,QAAQ,mBAAmB,GAAG,IAC9B;AAAA,UACE,YAAY,QAAQ;AAAA,UACpB,UAAU,IAAI,YAAY;AAAA,UAC1B,gBAAgB,IAAI,YAAY;AAAA,UAChC,UAAU,IAAI,YAAY;AAAA,QAC5B;AACJ,cAAM,kBAAkB;AACxB,wBAAgB,aAAa;AAC7B,YAAI,sBAAsB,OAAW,iBAAgB,oBAAoB;AACzE,YAAI,IAAI,WAAY,iBAAgB,aAAa,IAAI;AAKrD,YAAI,kBAAkB;AACpB,gBAAM,IAAI,UAAU,0BAA0B,iBAAiB,EAAE,sBAAsB,KAAK,CAAC;AAAA,QAC/F,OAAO;AACL,gBAAM,IAAI,UAAU,0BAA0B,eAAe,EAAE,MAAM,CAAC,QAAiB;AACrF,mBAAO,MAAM,sCAAsC,EAAE,IAAI,CAAC;AAAA,UAC5D,CAAC;AAAA,QACH;AAAA,MACF;AAEA,UAAI,oBAAoB,6BAA6B,QAAQ,YAAY,IAAI,YAAY,YAAY,IAAI,GAAG;AAC1G,cAAM,kBAAkB;AAAA,UACtB,YAAY,QAAQ;AAAA,UACpB,UAAU,IAAI,YAAY,YAAY;AAAA,UACtC,gBAAgB;AAAA,UAChB,SAAS;AAAA,QACX;AACA,YAAI,kBAAkB;AACpB,gBAAM,IAAI,UAAU,gCAAgC,iBAAiB,EAAE,sBAAsB,KAAK,CAAC;AAAA,QACrG,OAAO;AACL,eAAK,IAAI,UAAU,gCAAgC,eAAe,EAAE,MAAM,MAAM,MAAS;AAAA,QAC3F;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAAA,EAEA,oBAAuB,MAQd;AACP,UAAM,EAAE,QAAQ,YAAY,IAAI;AAChC,QAAI,CAAC,OAAQ;AACb,QAAI,CAAC,aAAa,GAAI;AAItB,UAAM,UAAU,KAAK,WAAW,KAAK,sBAAsB,MAAM;AACjE,UAAM,MAAM,KAAK,mBAAmB,KAAK,QAAQ,WAAW;AAC5D,UAAM,WAAW,KAAK,mBAAmB,IAAI,GAAG;AAChD,QAAI,UAAU;AACZ,eAAS,SAAS;AAClB,eAAS,cAAc;AAAA,QACrB,IAAI,YAAY;AAAA,QAChB,gBAAgB,YAAY,kBAAkB;AAAA,QAC9C,UAAU,YAAY,YAAY;AAAA,MACpC;AACA,eAAS,aAAa,KAAK,cAAc;AACzC,eAAS,cAAc,KAAK,eAAe;AAC3C,UAAI,KAAK,OAAQ,UAAS,SAAS,KAAK;AAIxC,UAAI,KAAK,QAAS,UAAS,UAAU,KAAK;AAAA,eACjC,CAAC,SAAS,WAAW,QAAS,UAAS,UAAU;AAC1D,WAAK,mBAAmB,IAAI,KAAK,QAAQ;AACzC;AAAA,IACF;AACA,UAAM,QAA8B;AAAA,MAClC,QAAQ,KAAK;AAAA,MACb;AAAA,MACA,aAAa;AAAA,QACX,IAAI,YAAY;AAAA,QAChB,gBAAgB,YAAY,kBAAkB;AAAA,QAC9C,UAAU,YAAY,YAAY;AAAA,MACpC;AAAA,MACA,YAAY,KAAK,cAAc;AAAA,MAC/B,aAAa,KAAK,eAAe;AAAA,IACnC;AACA,QAAI,KAAK,OAAQ,OAAM,SAAS,KAAK;AACrC,QAAI,QAAS,OAAM,UAAU;AAC7B,SAAK,mBAAmB,IAAI,KAAK,KAAK;AAAA,EACxC;AAAA,EAEA,MAAM,sBAAsB,UAAiD;AAC3E,QAAI,CAAC,KAAK,mBAAmB,KAAM;AACnC,UAAM,UAAU,MAAM,KAAK,KAAK,mBAAmB,OAAO,CAAC;AAC3D,SAAK,mBAAmB,MAAM;AAC9B,eAAW,SAAS,SAAS;AAC3B,UAAI,MAAM,WAAW,CAAC,UAAU,eAAe,KAAK,0BAA0B,MAAM,MAAM,GAAG;AAC3F,aAAK,4BAA4B;AAAA,MACnC;AACA,UAAI;AACF,cAAM,KAAK,mBAAmB;AAAA,UAC5B,QAAQ,MAAM;AAAA,UACd,QAAQ,MAAM;AAAA,UACd,aAAa,MAAM;AAAA,UACnB,YAAY,MAAM,cAAc;AAAA,UAChC,aAAa,MAAM,eAAe;AAAA,UAClC,QAAQ,MAAM;AAAA,UACd,SAAS,MAAM;AAAA,UACf;AAAA,QACF,CAAC;AAAA,MACH,SAAS,OAAO;AACd,YAAI,iCAAiC,GAAG;AACtC,gBAAM;AAAA,QACR;AAAA,MAEF;AAAA,IACF;AAAA,EACF;AAAA,EAEQ,mBAAmB,QAAyB,aAA4C;AAC9F,UAAM,KAAK,YAAY,MAAM;AAC7B,UAAM,MAAM,YAAY,kBAAkB;AAC1C,UAAM,SAAS,YAAY,YAAY;AACvC,WAAO,CAAC,QAAQ,IAAI,KAAK,MAAM,EAAE,KAAK,GAAG;AAAA,EAC3C;AACF;",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,37 @@
1
+ import { parseNumberWithDefault } from "../number.js";
2
+ function createBoundedTtlMemo(options) {
3
+ const store = /* @__PURE__ */ new Map();
4
+ const resolveTtlMs = () => parseNumberWithDefault(process.env[options.ttlEnv], options.defaultTtlMs, { integer: true, min: 0 });
5
+ const resolveMaxEntries = () => options.maxEntriesEnv ? parseNumberWithDefault(process.env[options.maxEntriesEnv], options.defaultMaxEntries, { integer: true, min: 1 }) : options.defaultMaxEntries;
6
+ return {
7
+ get(key) {
8
+ const entry = store.get(key);
9
+ if (!entry) return void 0;
10
+ if (entry.expiresAt <= Date.now()) return void 0;
11
+ return entry.value;
12
+ },
13
+ set(key, value) {
14
+ const ttlMs = resolveTtlMs();
15
+ if (ttlMs <= 0) return;
16
+ const maxEntries = resolveMaxEntries();
17
+ if (store.size >= maxEntries) {
18
+ const now = Date.now();
19
+ for (const [entryKey, entry] of store) {
20
+ if (entry.expiresAt <= now) store.delete(entryKey);
21
+ }
22
+ if (store.size >= maxEntries) store.clear();
23
+ }
24
+ store.set(key, { value, expiresAt: Date.now() + ttlMs });
25
+ },
26
+ clear() {
27
+ store.clear();
28
+ },
29
+ size() {
30
+ return store.size;
31
+ }
32
+ };
33
+ }
34
+ export {
35
+ createBoundedTtlMemo
36
+ };
37
+ //# sourceMappingURL=bounded-ttl-memo.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/lib/query/bounded-ttl-memo.ts"],
4
+ "sourcesContent": ["import { parseNumberWithDefault } from '../number'\n\nexport type BoundedTtlMemo<V> = {\n get(key: string): V | undefined\n set(key: string, value: V): void\n clear(): void\n size(): number\n}\n\nexport type BoundedTtlMemoOptions = {\n ttlEnv: string\n maxEntriesEnv?: string\n defaultTtlMs: number\n defaultMaxEntries: number\n}\n\n/**\n * A `{ value, expiresAt }` map with amortized eviction: on overflow, expired entries are swept\n * first, and only if the cap is still exceeded is the map cleared wholesale. `TTL=0` disables\n * storage \u2014 `set` becomes a no-op and every `get` misses.\n */\nexport function createBoundedTtlMemo<V>(options: BoundedTtlMemoOptions): BoundedTtlMemo<V> {\n const store = new Map<string, { value: V; expiresAt: number }>()\n\n const resolveTtlMs = (): number => parseNumberWithDefault(process.env[options.ttlEnv], options.defaultTtlMs, { integer: true, min: 0 })\n const resolveMaxEntries = (): number =>\n options.maxEntriesEnv\n ? parseNumberWithDefault(process.env[options.maxEntriesEnv], options.defaultMaxEntries, { integer: true, min: 1 })\n : options.defaultMaxEntries\n\n return {\n get(key) {\n const entry = store.get(key)\n if (!entry) return undefined\n if (entry.expiresAt <= Date.now()) return undefined\n return entry.value\n },\n set(key, value) {\n const ttlMs = resolveTtlMs()\n if (ttlMs <= 0) return\n const maxEntries = resolveMaxEntries()\n if (store.size >= maxEntries) {\n const now = Date.now()\n for (const [entryKey, entry] of store) {\n if (entry.expiresAt <= now) store.delete(entryKey)\n }\n if (store.size >= maxEntries) store.clear()\n }\n store.set(key, { value, expiresAt: Date.now() + ttlMs })\n },\n clear() {\n store.clear()\n },\n size() {\n return store.size\n },\n }\n}\n"],
5
+ "mappings": "AAAA,SAAS,8BAA8B;AAqBhC,SAAS,qBAAwB,SAAmD;AACzF,QAAM,QAAQ,oBAAI,IAA6C;AAE/D,QAAM,eAAe,MAAc,uBAAuB,QAAQ,IAAI,QAAQ,MAAM,GAAG,QAAQ,cAAc,EAAE,SAAS,MAAM,KAAK,EAAE,CAAC;AACtI,QAAM,oBAAoB,MACxB,QAAQ,gBACJ,uBAAuB,QAAQ,IAAI,QAAQ,aAAa,GAAG,QAAQ,mBAAmB,EAAE,SAAS,MAAM,KAAK,EAAE,CAAC,IAC/G,QAAQ;AAEd,SAAO;AAAA,IACL,IAAI,KAAK;AACP,YAAM,QAAQ,MAAM,IAAI,GAAG;AAC3B,UAAI,CAAC,MAAO,QAAO;AACnB,UAAI,MAAM,aAAa,KAAK,IAAI,EAAG,QAAO;AAC1C,aAAO,MAAM;AAAA,IACf;AAAA,IACA,IAAI,KAAK,OAAO;AACd,YAAM,QAAQ,aAAa;AAC3B,UAAI,SAAS,EAAG;AAChB,YAAM,aAAa,kBAAkB;AACrC,UAAI,MAAM,QAAQ,YAAY;AAC5B,cAAM,MAAM,KAAK,IAAI;AACrB,mBAAW,CAAC,UAAU,KAAK,KAAK,OAAO;AACrC,cAAI,MAAM,aAAa,IAAK,OAAM,OAAO,QAAQ;AAAA,QACnD;AACA,YAAI,MAAM,QAAQ,WAAY,OAAM,MAAM;AAAA,MAC5C;AACA,YAAM,IAAI,KAAK,EAAE,OAAO,WAAW,KAAK,IAAI,IAAI,MAAM,CAAC;AAAA,IACzD;AAAA,IACA,QAAQ;AACN,YAAM,MAAM;AAAA,IACd;AAAA,IACA,OAAO;AACL,aAAO,MAAM;AAAA,IACf;AAAA,EACF;AACF;",
6
+ "names": []
7
+ }
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.8.1-develop.7225.1.73190a4a95";
1
+ const APP_VERSION = "0.8.1-develop.7228.1.b7ea09d2d3";
2
2
  const appVersion = APP_VERSION;
3
3
  export {
4
4
  APP_VERSION,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/version.ts"],
4
- "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.8.1-develop.7225.1.73190a4a95';\nexport const appVersion = APP_VERSION;\n"],
4
+ "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.8.1-develop.7228.1.b7ea09d2d3';\nexport const appVersion = APP_VERSION;\n"],
5
5
  "mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/shared",
3
- "version": "0.8.1-develop.7225.1.73190a4a95",
3
+ "version": "0.8.1-develop.7228.1.b7ea09d2d3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -113,7 +113,7 @@
113
113
  "@mikro-orm/core": "^7.1.14",
114
114
  "@mikro-orm/decorators": "^7.1.14",
115
115
  "@mikro-orm/postgresql": "^7.1.14",
116
- "@open-mercato/cache": "0.8.1-develop.7225.1.73190a4a95",
116
+ "@open-mercato/cache": "0.8.1-develop.7228.1.b7ea09d2d3",
117
117
  "@types/html-to-text": "^9.0.4",
118
118
  "@types/sanitize-html": "^2.16.1",
119
119
  "dotenv": "^17.4.2",
@@ -1,4 +1,4 @@
1
- import { DefaultDataEngine } from '../engine'
1
+ import { DefaultDataEngine, clearDataEngineTableExistsCache } from '../engine'
2
2
  import { registerEntityIds } from '../../encryption/entityIds'
3
3
 
4
4
  const ENTITY_ID = 'example:todo'
@@ -88,6 +88,7 @@ function expectTenantScope(query: QueryCall, tenantId: string | null | undefined
88
88
  describe('custom-entity document storage tenant scope', () => {
89
89
  beforeEach(() => {
90
90
  registerEntityIds({ example: { todo: ENTITY_ID } })
91
+ clearDataEngineTableExistsCache()
91
92
  })
92
93
 
93
94
  afterEach(() => {
@@ -0,0 +1,122 @@
1
+ import { DefaultDataEngine, clearDataEngineTableExistsCache } from '../engine'
2
+ import { registerEntityIds } from '../../encryption/entityIds'
3
+
4
+ const ENTITY_ID = 'example:todo'
5
+ const RECORD_ID = '11111111-1111-4111-8111-111111111111'
6
+
7
+ function buildDb(options: { tableExists?: boolean } = {}) {
8
+ const tableProbeCalls: string[] = []
9
+ const tableExists = options.tableExists !== false
10
+
11
+ const chain: any = {
12
+ select: () => chain,
13
+ where: (column: string, _op: string, value: unknown) => {
14
+ if (column === 'table_name') tableProbeCalls.push(String(value))
15
+ return chain
16
+ },
17
+ values: () => chain,
18
+ set: () => chain,
19
+ onConflict: (callback: (builder: { columns: (_columns: string[]) => { doUpdateSet: (_values: unknown) => unknown } }) => unknown) => {
20
+ callback({ columns: () => ({ doUpdateSet: () => ({}) }) })
21
+ return chain
22
+ },
23
+ executeTakeFirst: async () => (tableExists ? { present: 1 } : undefined),
24
+ execute: async () => [],
25
+ }
26
+
27
+ return {
28
+ tableProbeCalls,
29
+ db: {
30
+ selectFrom: () => chain,
31
+ insertInto: () => chain,
32
+ updateTable: () => chain,
33
+ deleteFrom: () => chain,
34
+ },
35
+ }
36
+ }
37
+
38
+ function buildEngine(options: { tableExists?: boolean } = {}) {
39
+ const { db, tableProbeCalls } = buildDb(options)
40
+ const em = {
41
+ getKysely: () => db,
42
+ getMetadata: () => ({ find: () => undefined, getAll: () => [] }),
43
+ find: async () => [],
44
+ persist: () => undefined,
45
+ flush: async () => undefined,
46
+ }
47
+ return { engine: new DefaultDataEngine(em as never, {} as never), tableProbeCalls }
48
+ }
49
+
50
+ describe('module-scoped custom_entities_storage existence cache (#5619)', () => {
51
+ const originalTtl = process.env.OM_DATA_ENGINE_TABLE_EXISTS_CACHE_MS
52
+
53
+ beforeEach(() => {
54
+ registerEntityIds({ example: { todo: ENTITY_ID } })
55
+ clearDataEngineTableExistsCache()
56
+ })
57
+
58
+ afterEach(() => {
59
+ registerEntityIds({})
60
+ if (originalTtl === undefined) delete process.env.OM_DATA_ENGINE_TABLE_EXISTS_CACHE_MS
61
+ else process.env.OM_DATA_ENGINE_TABLE_EXISTS_CACHE_MS = originalTtl
62
+ jest.restoreAllMocks()
63
+ })
64
+
65
+ test('the table-existence answer is reused across two separately constructed engines', async () => {
66
+ // `createRequestContainer()` builds a fresh `DefaultDataEngine` per HTTP request. The cache
67
+ // must live on the module, not the instance, so a later "request" (a second engine here)
68
+ // reuses the first request's answer instead of re-probing `information_schema.tables`.
69
+ const { engine: engine1, tableProbeCalls: calls1 } = buildEngine()
70
+ await engine1.createCustomEntityRecord({ entityId: ENTITY_ID, recordId: RECORD_ID, organizationId: null, values: {} })
71
+ expect(calls1.filter((name) => name === 'custom_entities_storage').length).toBe(1)
72
+
73
+ const { engine: engine2, tableProbeCalls: calls2 } = buildEngine()
74
+ await engine2.createCustomEntityRecord({ entityId: ENTITY_ID, recordId: RECORD_ID, organizationId: null, values: {} })
75
+ expect(calls2.filter((name) => name === 'custom_entities_storage').length).toBe(0)
76
+ })
77
+
78
+ test('a negative answer expires at the TTL and is re-probed rather than cached forever', async () => {
79
+ const now = 1_700_000_000_000
80
+ const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(now)
81
+
82
+ const { engine: missingEngine, tableProbeCalls: missingCalls } = buildEngine({ tableExists: false })
83
+ await expect(
84
+ missingEngine.createCustomEntityRecord({ entityId: ENTITY_ID, recordId: RECORD_ID, organizationId: null, values: {} }),
85
+ ).rejects.toThrow('custom_entities_storage table is missing')
86
+ expect(missingCalls.length).toBe(1)
87
+
88
+ // A negative answer is never cached, so an immediate retry probes again rather than
89
+ // inheriting the earlier failure for the TTL window.
90
+ const { engine: stillMissingEngine, tableProbeCalls: stillMissingCalls } = buildEngine({ tableExists: false })
91
+ await expect(
92
+ stillMissingEngine.createCustomEntityRecord({ entityId: ENTITY_ID, recordId: RECORD_ID, organizationId: null, values: {} }),
93
+ ).rejects.toThrow('custom_entities_storage table is missing')
94
+ expect(stillMissingCalls.length).toBe(1)
95
+
96
+ nowSpy.mockReturnValue(now)
97
+ const { engine: migratedEngine, tableProbeCalls: migratedCalls } = buildEngine({ tableExists: true })
98
+ await migratedEngine.createCustomEntityRecord({ entityId: ENTITY_ID, recordId: RECORD_ID, organizationId: null, values: {} })
99
+ expect(migratedCalls.length).toBe(1)
100
+
101
+ const { engine: cachedEngine, tableProbeCalls: cachedCalls } = buildEngine({ tableExists: true })
102
+ await cachedEngine.createCustomEntityRecord({ entityId: ENTITY_ID, recordId: RECORD_ID, organizationId: null, values: {} })
103
+ expect(cachedCalls.length).toBe(0)
104
+
105
+ nowSpy.mockReturnValue(now + 3_600_001)
106
+ const { engine: expiredEngine, tableProbeCalls: expiredCalls } = buildEngine({ tableExists: true })
107
+ await expiredEngine.createCustomEntityRecord({ entityId: ENTITY_ID, recordId: RECORD_ID, organizationId: null, values: {} })
108
+ expect(expiredCalls.length).toBe(1)
109
+ })
110
+
111
+ test('OM_DATA_ENGINE_TABLE_EXISTS_CACHE_MS=0 disables the memo and probes per call again', async () => {
112
+ process.env.OM_DATA_ENGINE_TABLE_EXISTS_CACHE_MS = '0'
113
+
114
+ const { engine: engine1, tableProbeCalls: calls1 } = buildEngine({ tableExists: true })
115
+ await engine1.createCustomEntityRecord({ entityId: ENTITY_ID, recordId: RECORD_ID, organizationId: null, values: {} })
116
+ expect(calls1.length).toBe(1)
117
+
118
+ const { engine: engine2, tableProbeCalls: calls2 } = buildEngine({ tableExists: true })
119
+ await engine2.createCustomEntityRecord({ entityId: ENTITY_ID, recordId: RECORD_ID, organizationId: null, values: {} })
120
+ expect(calls2.length).toBe(1)
121
+ })
122
+ })
@@ -22,6 +22,7 @@ import { parseBooleanToken } from '../boolean'
22
22
  import { isReadProjectionAlwaysConsistent } from './consistency'
23
23
  import { isEventDeclared } from '../../modules/events'
24
24
  import { createLogger } from '../logger'
25
+ import { createBoundedTtlMemo } from '../query/bounded-ttl-memo'
25
26
 
26
27
  const logger = createLogger('shared').child({ component: 'data-engine' })
27
28
 
@@ -39,6 +40,18 @@ export function __resetUndeclaredEventWarningsForTests(): void {
39
40
  undeclaredEventWarned.clear()
40
41
  }
41
42
 
43
+ /** Only `true` is cached: a cached `false` would keep this failing after a migration creates the table. */
44
+ const tableExistsCache = createBoundedTtlMemo<true>({
45
+ ttlEnv: 'OM_DATA_ENGINE_TABLE_EXISTS_CACHE_MS',
46
+ defaultTtlMs: 3_600_000,
47
+ defaultMaxEntries: 1_000,
48
+ })
49
+
50
+ /** Test-only: the module-scoped memo would otherwise leak state across specs. */
51
+ export function clearDataEngineTableExistsCache(): void {
52
+ tableExistsCache.clear()
53
+ }
54
+
42
55
  const COVERAGE_REFRESH_INTERVAL_MS = 5 * 60 * 1000
43
56
  const coverageRefreshTracker = new Map<string, number>()
44
57
 
@@ -325,6 +338,8 @@ export class DefaultDataEngine implements DataEngine {
325
338
  }
326
339
 
327
340
  private async ensureStorageTableExists(): Promise<void> {
341
+ const cacheKey = 'custom_entities_storage'
342
+ if (tableExistsCache.get(cacheKey)) return
328
343
  const db = this.getKysely()
329
344
  const exists = await db
330
345
  .selectFrom('information_schema.tables' as any)
@@ -334,6 +349,7 @@ export class DefaultDataEngine implements DataEngine {
334
349
  if (!exists) {
335
350
  throw new Error('custom_entities_storage table is missing. Run migrations (yarn db:migrate).')
336
351
  }
352
+ tableExistsCache.set(cacheKey, true)
337
353
  }
338
354
 
339
355
  private normalizeValuesForValidation(values: Record<string, unknown> | undefined | null): Record<string, unknown> {
@@ -0,0 +1,86 @@
1
+ import { createBoundedTtlMemo } from '../bounded-ttl-memo'
2
+
3
+ describe('createBoundedTtlMemo', () => {
4
+ const TTL_ENV = 'TEST_BOUNDED_TTL_MEMO_MS'
5
+ const MAX_ENTRIES_ENV = 'TEST_BOUNDED_TTL_MEMO_MAX_ENTRIES'
6
+ const originalTtl = process.env[TTL_ENV]
7
+ const originalMaxEntries = process.env[MAX_ENTRIES_ENV]
8
+
9
+ afterEach(() => {
10
+ if (originalTtl === undefined) delete process.env[TTL_ENV]
11
+ else process.env[TTL_ENV] = originalTtl
12
+ if (originalMaxEntries === undefined) delete process.env[MAX_ENTRIES_ENV]
13
+ else process.env[MAX_ENTRIES_ENV] = originalMaxEntries
14
+ jest.restoreAllMocks()
15
+ })
16
+
17
+ function buildMemo<V>() {
18
+ return createBoundedTtlMemo<V>({
19
+ ttlEnv: TTL_ENV,
20
+ maxEntriesEnv: MAX_ENTRIES_ENV,
21
+ defaultTtlMs: 1_000,
22
+ defaultMaxEntries: 3,
23
+ })
24
+ }
25
+
26
+ test('a stored value is returned until it expires', () => {
27
+ const now = 1_700_000_000_000
28
+ const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(now)
29
+ const memo = buildMemo<string>()
30
+
31
+ memo.set('key', 'value')
32
+ expect(memo.get('key')).toBe('value')
33
+
34
+ nowSpy.mockReturnValue(now + 999)
35
+ expect(memo.get('key')).toBe('value')
36
+
37
+ nowSpy.mockReturnValue(now + 1_001)
38
+ expect(memo.get('key')).toBeUndefined()
39
+ })
40
+
41
+ test('overflow sweeps expired entries first, then clears wholesale only if still over the cap', () => {
42
+ const now = 1_700_000_000_000
43
+ const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(now)
44
+ process.env[MAX_ENTRIES_ENV] = '2'
45
+ const memo = buildMemo<number>()
46
+
47
+ memo.set('a', 1)
48
+ nowSpy.mockReturnValue(now + 1_001)
49
+ memo.set('b', 2)
50
+ expect(memo.get('a')).toBeUndefined()
51
+
52
+ nowSpy.mockReturnValue(now + 1_002)
53
+ memo.set('c', 3)
54
+ expect(memo.size()).toBeLessThanOrEqual(2)
55
+ expect(memo.get('b')).toBe(2)
56
+ expect(memo.get('c')).toBe(3)
57
+ })
58
+
59
+ test('the cache stays bounded under sustained distinct-key pressure', () => {
60
+ process.env[MAX_ENTRIES_ENV] = '4'
61
+ const memo = buildMemo<number>()
62
+
63
+ for (let index = 0; index < 50; index += 1) {
64
+ memo.set(`key-${index}`, index)
65
+ expect(memo.size()).toBeLessThanOrEqual(4)
66
+ }
67
+ })
68
+
69
+ test('TTL=0 disables storage entirely', () => {
70
+ process.env[TTL_ENV] = '0'
71
+ const memo = buildMemo<string>()
72
+
73
+ memo.set('key', 'value')
74
+ expect(memo.get('key')).toBeUndefined()
75
+ expect(memo.size()).toBe(0)
76
+ })
77
+
78
+ test('clear() empties the memo', () => {
79
+ const memo = buildMemo<string>()
80
+ memo.set('key', 'value')
81
+ expect(memo.size()).toBe(1)
82
+ memo.clear()
83
+ expect(memo.size()).toBe(0)
84
+ expect(memo.get('key')).toBeUndefined()
85
+ })
86
+ })
@@ -0,0 +1,58 @@
1
+ import { parseNumberWithDefault } from '../number'
2
+
3
+ export type BoundedTtlMemo<V> = {
4
+ get(key: string): V | undefined
5
+ set(key: string, value: V): void
6
+ clear(): void
7
+ size(): number
8
+ }
9
+
10
+ export type BoundedTtlMemoOptions = {
11
+ ttlEnv: string
12
+ maxEntriesEnv?: string
13
+ defaultTtlMs: number
14
+ defaultMaxEntries: number
15
+ }
16
+
17
+ /**
18
+ * A `{ value, expiresAt }` map with amortized eviction: on overflow, expired entries are swept
19
+ * first, and only if the cap is still exceeded is the map cleared wholesale. `TTL=0` disables
20
+ * storage — `set` becomes a no-op and every `get` misses.
21
+ */
22
+ export function createBoundedTtlMemo<V>(options: BoundedTtlMemoOptions): BoundedTtlMemo<V> {
23
+ const store = new Map<string, { value: V; expiresAt: number }>()
24
+
25
+ const resolveTtlMs = (): number => parseNumberWithDefault(process.env[options.ttlEnv], options.defaultTtlMs, { integer: true, min: 0 })
26
+ const resolveMaxEntries = (): number =>
27
+ options.maxEntriesEnv
28
+ ? parseNumberWithDefault(process.env[options.maxEntriesEnv], options.defaultMaxEntries, { integer: true, min: 1 })
29
+ : options.defaultMaxEntries
30
+
31
+ return {
32
+ get(key) {
33
+ const entry = store.get(key)
34
+ if (!entry) return undefined
35
+ if (entry.expiresAt <= Date.now()) return undefined
36
+ return entry.value
37
+ },
38
+ set(key, value) {
39
+ const ttlMs = resolveTtlMs()
40
+ if (ttlMs <= 0) return
41
+ const maxEntries = resolveMaxEntries()
42
+ if (store.size >= maxEntries) {
43
+ const now = Date.now()
44
+ for (const [entryKey, entry] of store) {
45
+ if (entry.expiresAt <= now) store.delete(entryKey)
46
+ }
47
+ if (store.size >= maxEntries) store.clear()
48
+ }
49
+ store.set(key, { value, expiresAt: Date.now() + ttlMs })
50
+ },
51
+ clear() {
52
+ store.clear()
53
+ },
54
+ size() {
55
+ return store.size
56
+ },
57
+ }
58
+ }