@open-mercato/shared 0.6.5-develop.5337.1.534b781eac → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +1 -1
  3. package/dist/lib/ai/llm-provider-registry.js.map +1 -1
  4. package/dist/lib/crud/custom-fields.js +23 -15
  5. package/dist/lib/crud/custom-fields.js.map +2 -2
  6. package/dist/lib/crud/factory.js.map +1 -1
  7. package/dist/lib/crud/optimistic-lock-command.js.map +1 -1
  8. package/dist/lib/crud/optimistic-lock-headers.js.map +1 -1
  9. package/dist/lib/crud/optimistic-lock-store.js.map +1 -1
  10. package/dist/lib/crud/optimistic-lock.js.map +1 -1
  11. package/dist/lib/data/engine.js +25 -1
  12. package/dist/lib/data/engine.js.map +2 -2
  13. package/dist/lib/db/buildIlikeTerm.js +17 -0
  14. package/dist/lib/db/buildIlikeTerm.js.map +7 -0
  15. package/dist/lib/db/mikro.js +38 -9
  16. package/dist/lib/db/mikro.js.map +2 -2
  17. package/dist/lib/di/container.js +1 -1
  18. package/dist/lib/di/container.js.map +1 -1
  19. package/dist/lib/encryption/kms.js +41 -6
  20. package/dist/lib/encryption/kms.js.map +2 -2
  21. package/dist/lib/query/advanced-filter-tree.js +5 -5
  22. package/dist/lib/query/advanced-filter-tree.js.map +2 -2
  23. package/dist/lib/query/advanced-filter.js +5 -5
  24. package/dist/lib/query/advanced-filter.js.map +2 -2
  25. package/dist/lib/query/engine.js +3 -1
  26. package/dist/lib/query/engine.js.map +2 -2
  27. package/dist/lib/query/types.js.map +1 -1
  28. package/dist/lib/version.js +1 -1
  29. package/dist/lib/version.js.map +1 -1
  30. package/dist/modules/overrides.js +1 -1
  31. package/dist/modules/overrides.js.map +1 -1
  32. package/dist/modules/search.js.map +1 -1
  33. package/package.json +4 -5
  34. package/src/lib/ai/llm-provider-registry.ts +1 -1
  35. package/src/lib/ai/llm-provider.ts +1 -1
  36. package/src/lib/crud/__tests__/custom-fields.test.ts +91 -0
  37. package/src/lib/crud/custom-fields.ts +30 -17
  38. package/src/lib/crud/factory.ts +1 -1
  39. package/src/lib/crud/optimistic-lock-command.ts +1 -1
  40. package/src/lib/crud/optimistic-lock-headers.ts +1 -1
  41. package/src/lib/crud/optimistic-lock-store.ts +1 -1
  42. package/src/lib/crud/optimistic-lock.ts +1 -1
  43. package/src/lib/data/__tests__/engine.custom-entity-storage-guard.test.ts +78 -0
  44. package/src/lib/data/engine.ts +40 -0
  45. package/src/lib/db/__tests__/buildIlikeTerm.test.ts +40 -0
  46. package/src/lib/db/__tests__/escapeLikePattern.test.ts +123 -0
  47. package/src/lib/db/__tests__/mikro.test.ts +82 -0
  48. package/src/lib/db/buildIlikeTerm.ts +16 -0
  49. package/src/lib/db/mikro.ts +55 -16
  50. package/src/lib/di/container.ts +1 -1
  51. package/src/lib/encryption/__tests__/kms.test.ts +80 -0
  52. package/src/lib/encryption/kms.ts +55 -7
  53. package/src/lib/query/__tests__/engine.count-distinct.test.ts +229 -0
  54. package/src/lib/query/advanced-filter-tree.ts +5 -5
  55. package/src/lib/query/advanced-filter.ts +5 -5
  56. package/src/lib/query/engine.ts +13 -2
  57. package/src/lib/query/types.ts +10 -0
  58. package/src/modules/__tests__/overrides.test.ts +1 -1
  59. package/src/modules/__tests__/route-overrides.test.ts +1 -1
  60. package/src/modules/navigation/backendChrome.ts +9 -0
  61. package/src/modules/overrides.ts +3 -3
  62. package/src/modules/search.ts +1 -1
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/db/mikro.ts"],
4
- "sourcesContent": ["import 'dotenv/config'\nimport 'reflect-metadata'\nimport { MikroORM } from '@mikro-orm/core'\nimport { ReflectMetadataProvider } from '@mikro-orm/decorators/legacy'\nimport { PostgreSqlDriver, type EntityManager as PostgreSqlEntityManager } from '@mikro-orm/postgresql'\nimport { getSslConfig } from './ssl'\n\nexport type AppMikroORM = MikroORM<PostgreSqlDriver, PostgreSqlEntityManager<PostgreSqlDriver>>\n\nlet ormInstance: AppMikroORM | null = null\n\n// Use globalThis so standalone apps survive duplicated shared package module instances.\nconst GLOBAL_ENTITIES_KEY = '__openMercatoOrmEntities__'\n\nfunction getRegisteredEntities(): any[] | null {\n return (globalThis as Record<string, unknown>)[GLOBAL_ENTITIES_KEY] as any[] | null ?? null\n}\n\nfunction setRegisteredEntities(entities: any[]): void {\n (globalThis as Record<string, unknown>)[GLOBAL_ENTITIES_KEY] = entities\n}\n\nexport function registerOrmEntities(entities: any[]) {\n if (getRegisteredEntities() !== null && process.env.NODE_ENV === 'development') {\n console.debug('[Bootstrap] ORM entities re-registered (this may occur during HMR)')\n }\n setRegisteredEntities(entities)\n}\n\nexport function getOrmEntities(): any[] {\n const entities = getRegisteredEntities()\n if (!entities) {\n throw new Error('[Bootstrap] ORM entities not registered. Call registerOrmEntities() at bootstrap.')\n }\n return entities\n}\n\nexport async function getOrm() {\n if (ormInstance) {\n return ormInstance\n }\n\n const entities = getOrmEntities()\n const clientUrl = process.env.DATABASE_URL\n if (!clientUrl) {\n throw new Error('DATABASE_URL is not set')\n }\n\n // Parse connection pool settings from environment\n const poolMin = parseInt(process.env.DB_POOL_MIN || '2')\n const poolMax = parseInt(process.env.DB_POOL_MAX || '20')\n const poolIdleTimeout = parseInt(process.env.DB_POOL_IDLE_TIMEOUT || '3000')\n const poolAcquireTimeout = parseInt(process.env.DB_POOL_ACQUIRE_TIMEOUT || '6000')\n const idleSessionTimeoutEnv = parseInt(process.env.DB_IDLE_SESSION_TIMEOUT_MS || '')\n const idleInTxTimeoutEnv = parseInt(process.env.DB_IDLE_IN_TRANSACTION_TIMEOUT_MS || '')\n const idleSessionTimeoutMs = Number.isFinite(idleSessionTimeoutEnv)\n ? idleSessionTimeoutEnv\n : process.env.NODE_ENV === 'production'\n ? undefined\n : 600_000\n const idleInTransactionTimeoutMs = Number.isFinite(idleInTxTimeoutEnv)\n ? idleInTxTimeoutEnv\n : process.env.NODE_ENV === 'production'\n ? undefined\n : 120_000\n const connectionOptions =\n idleSessionTimeoutMs && idleSessionTimeoutMs > 0\n ? `-c idle_session_timeout=${idleSessionTimeoutMs}`\n : undefined\n\n const sslConfig = getSslConfig()\n\n if (process.env.OM_DB_POOL_DEBUG === '1' || process.env.OM_INTEGRATION_TEST === 'true') {\n console.log('[orm] pool config', {\n poolMin,\n poolMax,\n poolIdleTimeout,\n poolAcquireTimeout,\n idleSessionTimeoutMs,\n idleInTransactionTimeoutMs,\n nodeEnv: process.env.NODE_ENV,\n })\n }\n\n ormInstance = await MikroORM.init<PostgreSqlDriver, PostgreSqlEntityManager<PostgreSqlDriver>>({\n driver: PostgreSqlDriver,\n clientUrl,\n entities,\n debug: false,\n // v7 no longer defaults to ReflectMetadataProvider. Entities in this repo use\n // `@mikro-orm/decorators/legacy`, which relies on TypeScript `emitDecoratorMetadata`\n // + reflect-metadata for type inference (nullability, column types). Without this,\n // inferred types are silently wrong at runtime.\n metadataProvider: ReflectMetadataProvider,\n // MikroORM v7 pool shape (min/max/idleTimeoutMillis). Knex-era `acquireTimeoutMillis` /\n // `destroyTimeoutMillis` were removed; acquire wait maps to pg `connectionTimeoutMillis`\n // below under `driverOptions`. Mirror `connectionTimeoutMillis` here too \u2014 older Mikro\n // versions read it from `pool`; v7 reads from `driverOptions` but accepting both\n // costs nothing and protects us from upstream config-merge regressions.\n pool: {\n min: poolMin,\n max: poolMax,\n idleTimeoutMillis: poolIdleTimeout,\n acquireTimeoutMillis: poolAcquireTimeout,\n } as any,\n // Driver options are merged into pg.PoolConfig (ClientConfig + pg-pool).\n driverOptions: {\n connectionTimeoutMillis: poolAcquireTimeout,\n idle_in_transaction_session_timeout: idleInTransactionTimeoutMs,\n options: connectionOptions,\n ssl: sslConfig,\n onPoolCreated: (pool: any) => {\n if (process.env.OM_DB_POOL_DEBUG === '1' || process.env.OM_INTEGRATION_TEST === 'true') {\n console.log('[orm] pg pool created with options', {\n max: pool.options?.max,\n min: pool.options?.min,\n idleTimeoutMillis: pool.options?.idleTimeoutMillis,\n connectionTimeoutMillis: pool.options?.connectionTimeoutMillis,\n })\n }\n },\n },\n })\n\n return ormInstance\n}\n\n\nasync function closeOrmIfLoaded(): Promise<void> {\n if (ormInstance) {\n await ormInstance.close(true)\n ormInstance = null\n }\n}\n\n// In dev mode, handle reloads cleanly without leaving dangling connections.\nif (process.env.NODE_ENV !== 'production') {\n void closeOrmIfLoaded()\n}\n"],
5
- "mappings": "AAAA,OAAO;AACP,OAAO;AACP,SAAS,gBAAgB;AACzB,SAAS,+BAA+B;AACxC,SAAS,wBAAuE;AAChF,SAAS,oBAAoB;AAI7B,IAAI,cAAkC;AAGtC,MAAM,sBAAsB;AAE5B,SAAS,wBAAsC;AAC7C,SAAQ,WAAuC,mBAAmB,KAAqB;AACzF;AAEA,SAAS,sBAAsB,UAAuB;AACpD,EAAC,WAAuC,mBAAmB,IAAI;AACjE;AAEO,SAAS,oBAAoB,UAAiB;AACnD,MAAI,sBAAsB,MAAM,QAAQ,QAAQ,IAAI,aAAa,eAAe;AAC9E,YAAQ,MAAM,oEAAoE;AAAA,EACpF;AACA,wBAAsB,QAAQ;AAChC;AAEO,SAAS,iBAAwB;AACtC,QAAM,WAAW,sBAAsB;AACvC,MAAI,CAAC,UAAU;AACb,UAAM,IAAI,MAAM,mFAAmF;AAAA,EACrG;AACA,SAAO;AACT;AAEA,eAAsB,SAAS;AAC7B,MAAI,aAAa;AACf,WAAO;AAAA,EACT;AAEA,QAAM,WAAW,eAAe;AAChC,QAAM,YAAY,QAAQ,IAAI;AAC9B,MAAI,CAAC,WAAW;AACd,UAAM,IAAI,MAAM,yBAAyB;AAAA,EAC3C;AAGA,QAAM,UAAU,SAAS,QAAQ,IAAI,eAAe,GAAG;AACvD,QAAM,UAAU,SAAS,QAAQ,IAAI,eAAe,IAAI;AACxD,QAAM,kBAAkB,SAAS,QAAQ,IAAI,wBAAwB,MAAM;AAC3E,QAAM,qBAAqB,SAAS,QAAQ,IAAI,2BAA2B,MAAM;AACjF,QAAM,wBAAwB,SAAS,QAAQ,IAAI,8BAA8B,EAAE;AACnF,QAAM,qBAAqB,SAAS,QAAQ,IAAI,qCAAqC,EAAE;AACvF,QAAM,uBAAuB,OAAO,SAAS,qBAAqB,IAC9D,wBACA,QAAQ,IAAI,aAAa,eACvB,SACA;AACN,QAAM,6BAA6B,OAAO,SAAS,kBAAkB,IACjE,qBACA,QAAQ,IAAI,aAAa,eACvB,SACA;AACN,QAAM,oBACJ,wBAAwB,uBAAuB,IAC3C,2BAA2B,oBAAoB,KAC/C;AAEN,QAAM,YAAY,aAAa;AAE/B,MAAI,QAAQ,IAAI,qBAAqB,OAAO,QAAQ,IAAI,wBAAwB,QAAQ;AACtF,YAAQ,IAAI,qBAAqB;AAAA,MAC/B;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA,SAAS,QAAQ,IAAI;AAAA,IACvB,CAAC;AAAA,EACH;AAEA,gBAAc,MAAM,SAAS,KAAkE;AAAA,IAC7F,QAAQ;AAAA,IACR;AAAA,IACA;AAAA,IACA,OAAO;AAAA;AAAA;AAAA;AAAA;AAAA,IAKP,kBAAkB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAMlB,MAAM;AAAA,MACJ,KAAK;AAAA,MACL,KAAK;AAAA,MACL,mBAAmB;AAAA,MACnB,sBAAsB;AAAA,IACxB;AAAA;AAAA,IAEA,eAAe;AAAA,MACb,yBAAyB;AAAA,MACzB,qCAAqC;AAAA,MACrC,SAAS;AAAA,MACT,KAAK;AAAA,MACL,eAAe,CAAC,SAAc;AAC5B,YAAI,QAAQ,IAAI,qBAAqB,OAAO,QAAQ,IAAI,wBAAwB,QAAQ;AACtF,kBAAQ,IAAI,sCAAsC;AAAA,YAChD,KAAK,KAAK,SAAS;AAAA,YACnB,KAAK,KAAK,SAAS;AAAA,YACnB,mBAAmB,KAAK,SAAS;AAAA,YACjC,yBAAyB,KAAK,SAAS;AAAA,UACzC,CAAC;AAAA,QACH;AAAA,MACF;AAAA,IACF;AAAA,EACF,CAAC;AAED,SAAO;AACT;AAGA,eAAe,mBAAkC;AAC/C,MAAI,aAAa;AACf,UAAM,YAAY,MAAM,IAAI;AAC5B,kBAAc;AAAA,EAChB;AACF;AAGA,IAAI,QAAQ,IAAI,aAAa,cAAc;AACzC,OAAK,iBAAiB;AACxB;",
4
+ "sourcesContent": ["import 'dotenv/config'\nimport 'reflect-metadata'\nimport { MikroORM } from '@mikro-orm/core'\nimport { ReflectMetadataProvider } from '@mikro-orm/decorators/legacy'\nimport { PostgreSqlDriver, type EntityManager as PostgreSqlEntityManager } from '@mikro-orm/postgresql'\nimport { getSslConfig } from './ssl'\n\nexport type AppMikroORM = MikroORM<PostgreSqlDriver, PostgreSqlEntityManager<PostgreSqlDriver>>\n\nlet ormInstance: AppMikroORM | null = null\n\n// Use globalThis so standalone apps survive duplicated shared package module instances.\nconst GLOBAL_ENTITIES_KEY = '__openMercatoOrmEntities__'\n\nfunction getRegisteredEntities(): any[] | null {\n return (globalThis as Record<string, unknown>)[GLOBAL_ENTITIES_KEY] as any[] | null ?? null\n}\n\nfunction setRegisteredEntities(entities: any[]): void {\n (globalThis as Record<string, unknown>)[GLOBAL_ENTITIES_KEY] = entities\n}\n\nexport function registerOrmEntities(entities: any[]) {\n if (getRegisteredEntities() !== null && process.env.NODE_ENV === 'development') {\n console.debug('[Bootstrap] ORM entities re-registered (this may occur during HMR)')\n }\n setRegisteredEntities(entities)\n}\n\nexport function getOrmEntities(): any[] {\n const entities = getRegisteredEntities()\n if (!entities) {\n throw new Error('[Bootstrap] ORM entities not registered. Call registerOrmEntities() at bootstrap.')\n }\n return entities\n}\n\nexport type ResolvedPoolConfig = {\n poolMin: number\n poolMax: number\n poolIdleTimeout: number\n poolAcquireTimeout: number\n idleSessionTimeoutMs: number | undefined\n idleInTransactionTimeoutMs: number | undefined\n statementTimeoutMs: number | undefined\n lockTimeoutMs: number | undefined\n}\n\n// Parse an optional positive-millisecond env var. Returns undefined when unset,\n// non-numeric, or non-positive so callers treat \"no value\" as \"no timeout\".\nfunction parsePositiveIntEnv(raw: string | undefined): number | undefined {\n const parsed = parseInt(raw || '')\n return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined\n}\n\nexport function resolvePoolConfig(env: NodeJS.ProcessEnv = process.env): ResolvedPoolConfig {\n const idleSessionTimeoutEnv = parseInt(env.DB_IDLE_SESSION_TIMEOUT_MS || '')\n const idleInTxTimeoutEnv = parseInt(env.DB_IDLE_IN_TRANSACTION_TIMEOUT_MS || '')\n return {\n poolMin: parseInt(env.DB_POOL_MIN || '2'),\n poolMax: parseInt(env.DB_POOL_MAX || '20'),\n poolIdleTimeout: parseInt(env.DB_POOL_IDLE_TIMEOUT || '3000'),\n poolAcquireTimeout: parseInt(env.DB_POOL_ACQUIRE_TIMEOUT || '6000'),\n idleSessionTimeoutMs: Number.isFinite(idleSessionTimeoutEnv)\n ? idleSessionTimeoutEnv\n : env.NODE_ENV === 'production'\n ? undefined\n : 600_000,\n // Finite default in every environment (including production) so a leaked or idle\n // open transaction cannot pin a pool connection indefinitely and exhaust the pool.\n // Mirrors the long-standing dev value; override (incl. 0 to disable) via env.\n idleInTransactionTimeoutMs: Number.isFinite(idleInTxTimeoutEnv) ? idleInTxTimeoutEnv : 120_000,\n // Opt-in guards against runaway statements and lock waits. No timeout when unset.\n statementTimeoutMs: parsePositiveIntEnv(env.DB_STATEMENT_TIMEOUT_MS),\n lockTimeoutMs: parsePositiveIntEnv(env.DB_LOCK_TIMEOUT_MS),\n }\n}\n\nexport async function getOrm() {\n if (ormInstance) {\n return ormInstance\n }\n\n const entities = getOrmEntities()\n const clientUrl = process.env.DATABASE_URL\n if (!clientUrl) {\n throw new Error('DATABASE_URL is not set')\n }\n\n // Parse connection pool settings from environment\n const {\n poolMin,\n poolMax,\n poolIdleTimeout,\n poolAcquireTimeout,\n idleSessionTimeoutMs,\n idleInTransactionTimeoutMs,\n statementTimeoutMs,\n lockTimeoutMs,\n } = resolvePoolConfig()\n const connectionOptions =\n idleSessionTimeoutMs && idleSessionTimeoutMs > 0\n ? `-c idle_session_timeout=${idleSessionTimeoutMs}`\n : undefined\n\n const sslConfig = getSslConfig()\n\n if (process.env.OM_DB_POOL_DEBUG === '1' || process.env.OM_INTEGRATION_TEST === 'true') {\n console.log('[orm] pool config', {\n poolMin,\n poolMax,\n poolIdleTimeout,\n poolAcquireTimeout,\n idleSessionTimeoutMs,\n idleInTransactionTimeoutMs,\n statementTimeoutMs,\n lockTimeoutMs,\n nodeEnv: process.env.NODE_ENV,\n })\n }\n\n ormInstance = await MikroORM.init<PostgreSqlDriver, PostgreSqlEntityManager<PostgreSqlDriver>>({\n driver: PostgreSqlDriver,\n clientUrl,\n entities,\n debug: false,\n // v7 no longer defaults to ReflectMetadataProvider. Entities in this repo use\n // `@mikro-orm/decorators/legacy`, which relies on TypeScript `emitDecoratorMetadata`\n // + reflect-metadata for type inference (nullability, column types). Without this,\n // inferred types are silently wrong at runtime.\n metadataProvider: ReflectMetadataProvider,\n // MikroORM v7 pool shape (min/max/idleTimeoutMillis). Knex-era `acquireTimeoutMillis` /\n // `destroyTimeoutMillis` were removed; acquire wait maps to pg `connectionTimeoutMillis`\n // below under `driverOptions`. Mirror `connectionTimeoutMillis` here too \u2014 older Mikro\n // versions read it from `pool`; v7 reads from `driverOptions` but accepting both\n // costs nothing and protects us from upstream config-merge regressions.\n pool: {\n min: poolMin,\n max: poolMax,\n idleTimeoutMillis: poolIdleTimeout,\n acquireTimeoutMillis: poolAcquireTimeout,\n } as any,\n // Driver options are merged into pg.PoolConfig (ClientConfig + pg-pool).\n driverOptions: {\n connectionTimeoutMillis: poolAcquireTimeout,\n idle_in_transaction_session_timeout: idleInTransactionTimeoutMs,\n statement_timeout: statementTimeoutMs,\n lock_timeout: lockTimeoutMs,\n options: connectionOptions,\n ssl: sslConfig,\n onPoolCreated: (pool: any) => {\n if (process.env.OM_DB_POOL_DEBUG === '1' || process.env.OM_INTEGRATION_TEST === 'true') {\n console.log('[orm] pg pool created with options', {\n max: pool.options?.max,\n min: pool.options?.min,\n idleTimeoutMillis: pool.options?.idleTimeoutMillis,\n connectionTimeoutMillis: pool.options?.connectionTimeoutMillis,\n })\n }\n },\n },\n })\n\n return ormInstance\n}\n\n\nasync function closeOrmIfLoaded(): Promise<void> {\n if (ormInstance) {\n await ormInstance.close(true)\n ormInstance = null\n }\n}\n\n// In dev mode, handle reloads cleanly without leaving dangling connections.\nif (process.env.NODE_ENV !== 'production') {\n void closeOrmIfLoaded()\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -121,7 +121,7 @@ async function createRequestContainer() {
121
121
  // sent) it short-circuits at validateMutation. Module-level di.ts
122
122
  // registrations override this default via Awilix replace semantics —
123
123
  // see the enterprise `record_locks` module for the canonical override.
124
- // Spec: .ai/specs/2026-05-25-oss-optimistic-locking.md
124
+ // Spec: .ai/specs/implemented/2026-05-25-oss-optimistic-locking.md
125
125
  crudMutationGuardService: asFunction(
126
126
  ({ em: scopedEm }) => createOptimisticLockGuardService({
127
127
  getEm: () => scopedEm,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/di/container.ts"],
4
- "sourcesContent": ["import { asFunction, createContainer, asValue, AwilixContainer, InjectionMode, type Resolver } from 'awilix'\nimport { RequestContext } from '@mikro-orm/core'\nimport { getOrm } from '@open-mercato/shared/lib/db/mikro'\nimport { EntityManager } from '@mikro-orm/postgresql'\nimport { BasicQueryEngine } from '@open-mercato/shared/lib/query/engine'\nimport { DefaultDataEngine } from '@open-mercato/shared/lib/data/engine'\nimport { commandRegistry, CommandBus } from '@open-mercato/shared/lib/commands'\nimport { applyDiOverridesToContainer } from '@open-mercato/shared/modules/overrides'\nimport { createOptimisticLockGuardService } from '@open-mercato/shared/lib/crud/optimistic-lock'\nimport { getAllOptimisticLockReaders } from '@open-mercato/shared/lib/crud/optimistic-lock-store'\n\ntype DynamicCradle = Record<string, any>\n\nexport type AppContainer = AwilixContainer<DynamicCradle>\nexport type DiRegistrar = (container: AppContainer) => void\n\n// Registration pattern for publishable packages\n// Use globalThis to survive tsx/esbuild module duplication issue where the same\n// file can be loaded as multiple module instances when mixing dynamic and static imports\nconst GLOBAL_KEY = '__openMercatoDiRegistrars__'\n// Phase 5 \u2014 process-scoped bootstrap cache. The cache/event-bus/encryption\n// services bootstrap() creates are inherently process-scoped (they hold\n// state across requests). Caching them on globalThis after the first\n// successful bootstrap call lets every subsequent request skip the\n// `await bootstrap(container)` body and just re-register the cached\n// instances. Same globalThis pattern as registerDiRegistrars so HMR\n// keeps working.\nconst BOOTSTRAP_CACHE_KEY = '__openMercatoBootstrapCache__'\nconst ENCRYPTION_ENABLED_KEY = '__openMercatoEncryptionEnabledCache__'\n\nconst BOOTSTRAP_CACHE_KEYS = [\n 'cache',\n 'eventBus',\n 'kmsService',\n 'tenantEncryptionService',\n 'rateLimiterService',\n 'searchModuleConfigs',\n 'searchIndexer',\n] as const\n\ntype BootstrapCacheEntry = Partial<Record<(typeof BOOTSTRAP_CACHE_KEYS)[number], unknown>>\n\n// Phase 5 is opt-in. Some bootstrap services close over per-request state\n// (e.g. tenantEncryptionService captures the first request's `em.fork`, the\n// event-bus's resolver closes over the first container) so naively replaying\n// them on later requests yields stale references \u2014 observed as a 500 from\n// CRUD list endpoints in `next start`. Default OFF preserves develop's\n// per-request bootstrap. Set `OM_BOOTSTRAP_CACHE=1` to opt in once each\n// cached service is verified safe for cross-request reuse.\nfunction isBootstrapCacheEnabled(): boolean {\n const raw = process.env.OM_BOOTSTRAP_CACHE\n if (raw === undefined) return false\n const normalized = raw.trim().toLowerCase()\n if (!normalized.length) return false\n if (normalized === '0' || normalized === 'off' || normalized === 'false' || normalized === 'no') return false\n return true\n}\n\nfunction getBootstrapCache(): BootstrapCacheEntry | null {\n if (!isBootstrapCacheEnabled()) return null\n const existing = (globalThis as any)[BOOTSTRAP_CACHE_KEY]\n return existing && typeof existing === 'object' ? (existing as BootstrapCacheEntry) : null\n}\n\nfunction setBootstrapCache(entry: BootstrapCacheEntry): void {\n if (!isBootstrapCacheEnabled()) return\n ;(globalThis as any)[BOOTSTRAP_CACHE_KEY] = entry\n}\n\nfunction harvestBootstrapCache(container: AwilixContainer): BootstrapCacheEntry {\n const entry: BootstrapCacheEntry = {}\n for (const key of BOOTSTRAP_CACHE_KEYS) {\n try {\n const value: unknown = container.resolve(key as never)\n if (value !== undefined && value !== null) entry[key] = value\n } catch {\n // not registered \u2014 skip\n }\n }\n return entry\n}\n\ntype EncryptionEnabledProbe = { isEnabled?: () => boolean } | null | undefined\n\nfunction getCachedEncryptionEnabled(service: EncryptionEnabledProbe): boolean | null {\n if (!service || typeof service.isEnabled !== 'function') return false\n const cached = (globalThis as Record<string, unknown>)[ENCRYPTION_ENABLED_KEY]\n if (typeof cached === 'boolean') return cached\n try {\n const result = !!service.isEnabled()\n ;(globalThis as Record<string, unknown>)[ENCRYPTION_ENABLED_KEY] = result\n return result\n } catch {\n return null\n }\n}\n\nfunction getGlobalRegistrars(): DiRegistrar[] | null {\n return (globalThis as any)[GLOBAL_KEY] ?? null\n}\n\nfunction setGlobalRegistrars(registrars: DiRegistrar[]): void {\n (globalThis as any)[GLOBAL_KEY] = registrars\n}\n\nexport function registerDiRegistrars(registrars: DiRegistrar[]) {\n const existing = getGlobalRegistrars()\n if (existing !== null && process.env.NODE_ENV === 'development') {\n console.debug('[Bootstrap] DI registrars re-registered (this may occur during HMR)')\n }\n setGlobalRegistrars(registrars)\n // Force re-bootstrap on HMR \u2014 module subscribers may have changed.\n ;(globalThis as any)[BOOTSTRAP_CACHE_KEY] = null\n ;(globalThis as any)[ENCRYPTION_ENABLED_KEY] = undefined\n}\n\nexport function getDiRegistrars(): DiRegistrar[] {\n const registrars = getGlobalRegistrars()\n if (!registrars) {\n throw new Error('[Bootstrap] DI registrars not registered. Call registerDiRegistrars() at bootstrap.')\n }\n return registrars\n}\n\n/** Test-only helper to drop the process-scoped bootstrap cache. */\nexport function resetBootstrapCache(): void {\n (globalThis as any)[BOOTSTRAP_CACHE_KEY] = null\n ;(globalThis as any)[ENCRYPTION_ENABLED_KEY] = undefined\n}\n\nfunction isAwilixResolver(value: unknown): value is Resolver<unknown> {\n return Boolean(value && typeof value === 'object' && typeof (value as { resolve?: unknown }).resolve === 'function')\n}\n\nfunction toAwilixRegistrations(registrations: Record<string, unknown>): Record<string, Resolver<any>> {\n return Object.fromEntries(\n Object.entries(registrations).map(([key, value]) => [\n key,\n isAwilixResolver(value) ? value : asValue(value),\n ]),\n )\n}\n\nexport async function createRequestContainer(): Promise<AppContainer> {\n const diRegistrars = getDiRegistrars()\n const orm = await getOrm()\n // Use a fresh event manager so request-level subscribers (e.g., encryption) don't pile up globally\n const baseEm = (RequestContext.getEntityManager() as any) ?? orm.em\n const em = baseEm.fork({ clear: true, freshEventManager: true, useContext: true }) as unknown as EntityManager\n const container = createContainer<DynamicCradle>({ injectionMode: InjectionMode.CLASSIC })\n // Core registrations\n container.register({\n em: asValue(em),\n queryEngine: asValue(new BasicQueryEngine(em, undefined, () => {\n try { return container.resolve('tenantEncryptionService') as any } catch { return null }\n })),\n dataEngine: asValue(new DefaultDataEngine(em, container as any)),\n commandRegistry: asValue(commandRegistry),\n commandBus: asValue(new CommandBus()),\n // Default OSS optimistic-lock guard. Reads from the global reader store\n // (populated by `makeCrudRoute` auto-registration + any module-DI\n // hand-wired calls to `registerOptimisticLockReaders`). Service is\n // strictly additive: when `OM_OPTIMISTIC_LOCK=off` (or no header is\n // sent) it short-circuits at validateMutation. Module-level di.ts\n // registrations override this default via Awilix replace semantics \u2014\n // see the enterprise `record_locks` module for the canonical override.\n // Spec: .ai/specs/2026-05-25-oss-optimistic-locking.md\n crudMutationGuardService: asFunction(({ em: scopedEm }: { em: EntityManager }) =>\n createOptimisticLockGuardService({\n getEm: () => scopedEm,\n readers: getAllOptimisticLockReaders(),\n }),\n ).scoped(),\n })\n // Allow modules to override/extend\n for (const reg of diRegistrars) {\n try { reg?.(container) } catch {}\n }\n // Core bootstrap (cache, event bus, encryption subscriber/KMS, module subscribers)\n // Phase 5 \u2014 process-scoped once-guard. The first request runs the full\n // bootstrap() body; later requests re-register the cached services\n // directly on this request's container without re-importing or\n // re-initializing anything. HMR clears the cache (see\n // registerDiRegistrars). Skippable if a caller already wired eventBus.\n const alreadyBootstrappedOnThisContainer = !!container.registrations?.eventBus\n if (!alreadyBootstrappedOnThisContainer) {\n const cached = getBootstrapCache()\n if (cached) {\n const replay: Record<string, any> = {}\n for (const [key, value] of Object.entries(cached)) {\n if (value !== undefined && value !== null) replay[key] = asValue(value)\n }\n if (Object.keys(replay).length > 0) container.register(replay)\n } else {\n try {\n const { bootstrap } = await import('@open-mercato/core/bootstrap') as any\n if (bootstrap && typeof bootstrap === 'function') {\n await bootstrap(container)\n setBootstrapCache(harvestBootstrapCache(container))\n }\n } catch { /* optional */ }\n }\n }\n // App-level DI override (last chance)\n // This import path resolves only in the app context, not in packages\n try {\n // @ts-ignore - @/di only exists in app context, not in packages\n const appDi = await import('@/di') as any\n if (appDi?.register) {\n try {\n const maybe = appDi.register(container)\n if (maybe && typeof maybe.then === 'function') await maybe\n } catch {}\n }\n } catch {}\n applyDiOverridesToContainer({\n register: (registrations) => container.register(toAwilixRegistrations(registrations)),\n unregister: (key) => container.register({ [key]: asValue(undefined) }),\n })\n // Ensure tenant encryption subscriber is always registered on the fresh request-scoped EM\n // Phase 5 \u2014 cache `tenantEncryptionService.isEnabled()` for the process\n // lifetime. The result depends only on config that does not change at\n // runtime, so reading it once skips a config lookup per request.\n try {\n const emForEnc = container.resolve('em') as any\n const tenantEncryptionService = container.hasRegistration('tenantEncryptionService')\n ? (container.resolve('tenantEncryptionService') as any)\n : null\n if (emForEnc && tenantEncryptionService && getCachedEncryptionEnabled(tenantEncryptionService) === true) {\n const { registerTenantEncryptionSubscriber } = await import('@open-mercato/shared/lib/encryption/subscriber')\n registerTenantEncryptionSubscriber(emForEnc, tenantEncryptionService)\n }\n } catch {\n // best-effort; do not block container creation\n }\n return container\n}\ntry {\n // eslint-disable-next-line @typescript-eslint/no-var-requires\n require('server-only')\n} catch {\n // allow CLI/generator usage where Next server-only is not present\n}\n"],
4
+ "sourcesContent": ["import { asFunction, createContainer, asValue, AwilixContainer, InjectionMode, type Resolver } from 'awilix'\nimport { RequestContext } from '@mikro-orm/core'\nimport { getOrm } from '@open-mercato/shared/lib/db/mikro'\nimport { EntityManager } from '@mikro-orm/postgresql'\nimport { BasicQueryEngine } from '@open-mercato/shared/lib/query/engine'\nimport { DefaultDataEngine } from '@open-mercato/shared/lib/data/engine'\nimport { commandRegistry, CommandBus } from '@open-mercato/shared/lib/commands'\nimport { applyDiOverridesToContainer } from '@open-mercato/shared/modules/overrides'\nimport { createOptimisticLockGuardService } from '@open-mercato/shared/lib/crud/optimistic-lock'\nimport { getAllOptimisticLockReaders } from '@open-mercato/shared/lib/crud/optimistic-lock-store'\n\ntype DynamicCradle = Record<string, any>\n\nexport type AppContainer = AwilixContainer<DynamicCradle>\nexport type DiRegistrar = (container: AppContainer) => void\n\n// Registration pattern for publishable packages\n// Use globalThis to survive tsx/esbuild module duplication issue where the same\n// file can be loaded as multiple module instances when mixing dynamic and static imports\nconst GLOBAL_KEY = '__openMercatoDiRegistrars__'\n// Phase 5 \u2014 process-scoped bootstrap cache. The cache/event-bus/encryption\n// services bootstrap() creates are inherently process-scoped (they hold\n// state across requests). Caching them on globalThis after the first\n// successful bootstrap call lets every subsequent request skip the\n// `await bootstrap(container)` body and just re-register the cached\n// instances. Same globalThis pattern as registerDiRegistrars so HMR\n// keeps working.\nconst BOOTSTRAP_CACHE_KEY = '__openMercatoBootstrapCache__'\nconst ENCRYPTION_ENABLED_KEY = '__openMercatoEncryptionEnabledCache__'\n\nconst BOOTSTRAP_CACHE_KEYS = [\n 'cache',\n 'eventBus',\n 'kmsService',\n 'tenantEncryptionService',\n 'rateLimiterService',\n 'searchModuleConfigs',\n 'searchIndexer',\n] as const\n\ntype BootstrapCacheEntry = Partial<Record<(typeof BOOTSTRAP_CACHE_KEYS)[number], unknown>>\n\n// Phase 5 is opt-in. Some bootstrap services close over per-request state\n// (e.g. tenantEncryptionService captures the first request's `em.fork`, the\n// event-bus's resolver closes over the first container) so naively replaying\n// them on later requests yields stale references \u2014 observed as a 500 from\n// CRUD list endpoints in `next start`. Default OFF preserves develop's\n// per-request bootstrap. Set `OM_BOOTSTRAP_CACHE=1` to opt in once each\n// cached service is verified safe for cross-request reuse.\nfunction isBootstrapCacheEnabled(): boolean {\n const raw = process.env.OM_BOOTSTRAP_CACHE\n if (raw === undefined) return false\n const normalized = raw.trim().toLowerCase()\n if (!normalized.length) return false\n if (normalized === '0' || normalized === 'off' || normalized === 'false' || normalized === 'no') return false\n return true\n}\n\nfunction getBootstrapCache(): BootstrapCacheEntry | null {\n if (!isBootstrapCacheEnabled()) return null\n const existing = (globalThis as any)[BOOTSTRAP_CACHE_KEY]\n return existing && typeof existing === 'object' ? (existing as BootstrapCacheEntry) : null\n}\n\nfunction setBootstrapCache(entry: BootstrapCacheEntry): void {\n if (!isBootstrapCacheEnabled()) return\n ;(globalThis as any)[BOOTSTRAP_CACHE_KEY] = entry\n}\n\nfunction harvestBootstrapCache(container: AwilixContainer): BootstrapCacheEntry {\n const entry: BootstrapCacheEntry = {}\n for (const key of BOOTSTRAP_CACHE_KEYS) {\n try {\n const value: unknown = container.resolve(key as never)\n if (value !== undefined && value !== null) entry[key] = value\n } catch {\n // not registered \u2014 skip\n }\n }\n return entry\n}\n\ntype EncryptionEnabledProbe = { isEnabled?: () => boolean } | null | undefined\n\nfunction getCachedEncryptionEnabled(service: EncryptionEnabledProbe): boolean | null {\n if (!service || typeof service.isEnabled !== 'function') return false\n const cached = (globalThis as Record<string, unknown>)[ENCRYPTION_ENABLED_KEY]\n if (typeof cached === 'boolean') return cached\n try {\n const result = !!service.isEnabled()\n ;(globalThis as Record<string, unknown>)[ENCRYPTION_ENABLED_KEY] = result\n return result\n } catch {\n return null\n }\n}\n\nfunction getGlobalRegistrars(): DiRegistrar[] | null {\n return (globalThis as any)[GLOBAL_KEY] ?? null\n}\n\nfunction setGlobalRegistrars(registrars: DiRegistrar[]): void {\n (globalThis as any)[GLOBAL_KEY] = registrars\n}\n\nexport function registerDiRegistrars(registrars: DiRegistrar[]) {\n const existing = getGlobalRegistrars()\n if (existing !== null && process.env.NODE_ENV === 'development') {\n console.debug('[Bootstrap] DI registrars re-registered (this may occur during HMR)')\n }\n setGlobalRegistrars(registrars)\n // Force re-bootstrap on HMR \u2014 module subscribers may have changed.\n ;(globalThis as any)[BOOTSTRAP_CACHE_KEY] = null\n ;(globalThis as any)[ENCRYPTION_ENABLED_KEY] = undefined\n}\n\nexport function getDiRegistrars(): DiRegistrar[] {\n const registrars = getGlobalRegistrars()\n if (!registrars) {\n throw new Error('[Bootstrap] DI registrars not registered. Call registerDiRegistrars() at bootstrap.')\n }\n return registrars\n}\n\n/** Test-only helper to drop the process-scoped bootstrap cache. */\nexport function resetBootstrapCache(): void {\n (globalThis as any)[BOOTSTRAP_CACHE_KEY] = null\n ;(globalThis as any)[ENCRYPTION_ENABLED_KEY] = undefined\n}\n\nfunction isAwilixResolver(value: unknown): value is Resolver<unknown> {\n return Boolean(value && typeof value === 'object' && typeof (value as { resolve?: unknown }).resolve === 'function')\n}\n\nfunction toAwilixRegistrations(registrations: Record<string, unknown>): Record<string, Resolver<any>> {\n return Object.fromEntries(\n Object.entries(registrations).map(([key, value]) => [\n key,\n isAwilixResolver(value) ? value : asValue(value),\n ]),\n )\n}\n\nexport async function createRequestContainer(): Promise<AppContainer> {\n const diRegistrars = getDiRegistrars()\n const orm = await getOrm()\n // Use a fresh event manager so request-level subscribers (e.g., encryption) don't pile up globally\n const baseEm = (RequestContext.getEntityManager() as any) ?? orm.em\n const em = baseEm.fork({ clear: true, freshEventManager: true, useContext: true }) as unknown as EntityManager\n const container = createContainer<DynamicCradle>({ injectionMode: InjectionMode.CLASSIC })\n // Core registrations\n container.register({\n em: asValue(em),\n queryEngine: asValue(new BasicQueryEngine(em, undefined, () => {\n try { return container.resolve('tenantEncryptionService') as any } catch { return null }\n })),\n dataEngine: asValue(new DefaultDataEngine(em, container as any)),\n commandRegistry: asValue(commandRegistry),\n commandBus: asValue(new CommandBus()),\n // Default OSS optimistic-lock guard. Reads from the global reader store\n // (populated by `makeCrudRoute` auto-registration + any module-DI\n // hand-wired calls to `registerOptimisticLockReaders`). Service is\n // strictly additive: when `OM_OPTIMISTIC_LOCK=off` (or no header is\n // sent) it short-circuits at validateMutation. Module-level di.ts\n // registrations override this default via Awilix replace semantics \u2014\n // see the enterprise `record_locks` module for the canonical override.\n // Spec: .ai/specs/implemented/2026-05-25-oss-optimistic-locking.md\n crudMutationGuardService: asFunction(({ em: scopedEm }: { em: EntityManager }) =>\n createOptimisticLockGuardService({\n getEm: () => scopedEm,\n readers: getAllOptimisticLockReaders(),\n }),\n ).scoped(),\n })\n // Allow modules to override/extend\n for (const reg of diRegistrars) {\n try { reg?.(container) } catch {}\n }\n // Core bootstrap (cache, event bus, encryption subscriber/KMS, module subscribers)\n // Phase 5 \u2014 process-scoped once-guard. The first request runs the full\n // bootstrap() body; later requests re-register the cached services\n // directly on this request's container without re-importing or\n // re-initializing anything. HMR clears the cache (see\n // registerDiRegistrars). Skippable if a caller already wired eventBus.\n const alreadyBootstrappedOnThisContainer = !!container.registrations?.eventBus\n if (!alreadyBootstrappedOnThisContainer) {\n const cached = getBootstrapCache()\n if (cached) {\n const replay: Record<string, any> = {}\n for (const [key, value] of Object.entries(cached)) {\n if (value !== undefined && value !== null) replay[key] = asValue(value)\n }\n if (Object.keys(replay).length > 0) container.register(replay)\n } else {\n try {\n const { bootstrap } = await import('@open-mercato/core/bootstrap') as any\n if (bootstrap && typeof bootstrap === 'function') {\n await bootstrap(container)\n setBootstrapCache(harvestBootstrapCache(container))\n }\n } catch { /* optional */ }\n }\n }\n // App-level DI override (last chance)\n // This import path resolves only in the app context, not in packages\n try {\n // @ts-ignore - @/di only exists in app context, not in packages\n const appDi = await import('@/di') as any\n if (appDi?.register) {\n try {\n const maybe = appDi.register(container)\n if (maybe && typeof maybe.then === 'function') await maybe\n } catch {}\n }\n } catch {}\n applyDiOverridesToContainer({\n register: (registrations) => container.register(toAwilixRegistrations(registrations)),\n unregister: (key) => container.register({ [key]: asValue(undefined) }),\n })\n // Ensure tenant encryption subscriber is always registered on the fresh request-scoped EM\n // Phase 5 \u2014 cache `tenantEncryptionService.isEnabled()` for the process\n // lifetime. The result depends only on config that does not change at\n // runtime, so reading it once skips a config lookup per request.\n try {\n const emForEnc = container.resolve('em') as any\n const tenantEncryptionService = container.hasRegistration('tenantEncryptionService')\n ? (container.resolve('tenantEncryptionService') as any)\n : null\n if (emForEnc && tenantEncryptionService && getCachedEncryptionEnabled(tenantEncryptionService) === true) {\n const { registerTenantEncryptionSubscriber } = await import('@open-mercato/shared/lib/encryption/subscriber')\n registerTenantEncryptionSubscriber(emForEnc, tenantEncryptionService)\n }\n } catch {\n // best-effort; do not block container creation\n }\n return container\n}\ntry {\n // eslint-disable-next-line @typescript-eslint/no-var-requires\n require('server-only')\n} catch {\n // allow CLI/generator usage where Next server-only is not present\n}\n"],
5
5
  "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -4,11 +4,17 @@ import { isEncryptionDebugEnabled, isTenantDataEncryptionEnabled } from "./toggl
4
4
  import { parseBooleanToken } from "../boolean.js";
5
5
  import { fetchWithTimeout, resolveTimeoutMs } from "../http/fetchWithTimeout.js";
6
6
  const DEFAULT_VAULT_REQUEST_TIMEOUT_MS = 1e3;
7
+ const DEFAULT_VAULT_RECOVERY_COOLDOWN_MS = 3e4;
7
8
  function resolveVaultRequestTimeoutMs() {
8
9
  const raw = process.env.VAULT_REQUEST_TIMEOUT_MS;
9
10
  const parsed = raw ? Number.parseInt(raw, 10) : void 0;
10
11
  return resolveTimeoutMs(parsed, DEFAULT_VAULT_REQUEST_TIMEOUT_MS);
11
12
  }
13
+ function resolveVaultRecoveryCooldownMs() {
14
+ const raw = process.env.VAULT_RECOVERY_COOLDOWN_MS;
15
+ const parsed = raw ? Number.parseInt(raw, 10) : void 0;
16
+ return resolveTimeoutMs(parsed, DEFAULT_VAULT_RECOVERY_COOLDOWN_MS);
17
+ }
12
18
  class FallbackKmsService {
13
19
  constructor(primary, fallback, onFallback) {
14
20
  this.primary = primary;
@@ -115,14 +121,24 @@ class HashicorpVaultKmsService {
115
121
  constructor(opts = {}) {
116
122
  this.cache = /* @__PURE__ */ new Map();
117
123
  this.healthy = true;
124
+ // Sticky terminal failure (missing VAULT_ADDR/VAULT_TOKEN): no amount of
125
+ // re-probing fixes a misconfiguration, so this never self-heals — only a
126
+ // restart with corrected config does.
127
+ this.misconfigured = false;
128
+ // Timestamp of the last transient failure (timeout / network blip / 5xx).
129
+ // Drives the half-open circuit breaker in isHealthy(): after the cooldown the
130
+ // instance reports healthy again so the next call re-probes Vault.
131
+ this.lastTransientFailureAt = null;
118
132
  this.vaultAddr = normalizeEnv(opts.vaultAddr || process.env.VAULT_ADDR || "");
119
133
  this.vaultToken = normalizeEnv(opts.vaultToken || process.env.VAULT_TOKEN || "");
120
134
  this.mountPath = (opts.mountPath || process.env.VAULT_KV_PATH || "secret/data").replace(/\/+$/, "");
121
135
  this.ttlMs = opts.ttlMs ?? 15 * 60 * 1e3;
122
136
  this.requestTimeoutMs = resolveTimeoutMs(opts.requestTimeoutMs, resolveVaultRequestTimeoutMs());
137
+ this.recoveryCooldownMs = resolveTimeoutMs(opts.recoveryCooldownMs, resolveVaultRecoveryCooldownMs());
123
138
  this.debugEnabled = isEncryptionDebugEnabled();
124
139
  if (!this.vaultAddr || !this.vaultToken) {
125
140
  this.healthy = false;
141
+ this.misconfigured = true;
126
142
  if (this.debugEnabled) {
127
143
  console.warn("\u26A0\uFE0F [encryption][kms] Vault misconfigured (missing VAULT_ADDR or VAULT_TOKEN)");
128
144
  }
@@ -138,11 +154,25 @@ class HashicorpVaultKmsService {
138
154
  this.loggedInit = false;
139
155
  }
140
156
  isHealthy() {
141
- return this.healthy;
157
+ if (this.misconfigured) return false;
158
+ if (this.healthy) return true;
159
+ if (this.lastTransientFailureAt === null) return false;
160
+ return this.now() - this.lastTransientFailureAt >= this.recoveryCooldownMs;
142
161
  }
143
162
  now() {
144
163
  return Date.now();
145
164
  }
165
+ // Vault responded successfully (or is provably reachable): close the breaker.
166
+ markHealthy() {
167
+ this.healthy = true;
168
+ this.lastTransientFailureAt = null;
169
+ }
170
+ // Transient infra failure (timeout / network blip / 5xx): open the breaker and
171
+ // start the recovery cooldown so a later call can re-probe and self-heal.
172
+ markTransientFailure() {
173
+ this.healthy = false;
174
+ this.lastTransientFailureAt = this.now();
175
+ }
146
176
  cacheHit(tenantId) {
147
177
  const entry = this.cache.get(tenantId);
148
178
  if (!entry) return null;
@@ -155,6 +185,7 @@ class HashicorpVaultKmsService {
155
185
  async readVault(path) {
156
186
  if (!this.vaultAddr || !this.vaultToken) {
157
187
  this.healthy = false;
188
+ this.misconfigured = true;
158
189
  return null;
159
190
  }
160
191
  try {
@@ -164,16 +195,18 @@ class HashicorpVaultKmsService {
164
195
  timeoutMs: this.requestTimeoutMs
165
196
  });
166
197
  if (!res.ok) {
167
- this.healthy = res.status < 500;
198
+ if (res.status >= 500) this.markTransientFailure();
199
+ else this.markHealthy();
168
200
  console.warn("\u26A0\uFE0F [encryption][kms] Vault read failed", { path, status: res.status });
169
201
  return null;
170
202
  }
203
+ this.markHealthy();
171
204
  if (this.debugEnabled) {
172
205
  console.info("\u{1F50D} [encryption][kms] Vault read ok", { path });
173
206
  }
174
207
  return await res.json();
175
208
  } catch (err) {
176
- this.healthy = false;
209
+ this.markTransientFailure();
177
210
  console.warn("\u26A0\uFE0F [encryption][kms] Vault read error", {
178
211
  path,
179
212
  error: err?.message || String(err),
@@ -185,6 +218,7 @@ class HashicorpVaultKmsService {
185
218
  async writeVault(path, key, opts) {
186
219
  if (!this.vaultAddr || !this.vaultToken) {
187
220
  this.healthy = false;
221
+ this.misconfigured = true;
188
222
  return "error";
189
223
  }
190
224
  const body = { data: { key } };
@@ -200,18 +234,19 @@ class HashicorpVaultKmsService {
200
234
  timeoutMs: this.requestTimeoutMs
201
235
  });
202
236
  if (res.ok) {
203
- this.healthy = true;
237
+ this.markHealthy();
204
238
  return "ok";
205
239
  }
206
240
  if (typeof opts?.cas === "number" && res.status === 400) {
241
+ this.markHealthy();
207
242
  console.warn("\u26A0\uFE0F [encryption][kms] Vault write CAS conflict (concurrent DEK create)", { path, status: res.status });
208
243
  return "conflict";
209
244
  }
210
- this.healthy = false;
245
+ this.markTransientFailure();
211
246
  console.warn("\u26A0\uFE0F [encryption][kms] Vault write failed", { path, status: res.status });
212
247
  return "error";
213
248
  } catch (err) {
214
- this.healthy = false;
249
+ this.markTransientFailure();
215
250
  console.warn("\u26A0\uFE0F [encryption][kms] Vault write error", {
216
251
  path,
217
252
  error: err?.message || String(err),
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/encryption/kms.ts"],
4
- "sourcesContent": ["import crypto from 'node:crypto'\nimport { generateDek, hashForLookup } from './aes'\nimport { isEncryptionDebugEnabled, isTenantDataEncryptionEnabled } from './toggles'\nimport { parseBooleanToken } from '../boolean'\nimport { fetchWithTimeout, resolveTimeoutMs } from '../http/fetchWithTimeout'\n\nconst DEFAULT_VAULT_REQUEST_TIMEOUT_MS = 1_000\n\nfunction resolveVaultRequestTimeoutMs(): number {\n const raw = process.env.VAULT_REQUEST_TIMEOUT_MS\n const parsed = raw ? Number.parseInt(raw, 10) : undefined\n return resolveTimeoutMs(parsed, DEFAULT_VAULT_REQUEST_TIMEOUT_MS)\n}\n\nexport type TenantDek = {\n tenantId: string\n key: string // base64\n fetchedAt: number\n}\n\nexport interface KmsService {\n getTenantDek(tenantId: string): Promise<TenantDek | null>\n createTenantDek(tenantId: string): Promise<TenantDek | null>\n isHealthy(): boolean\n invalidateDek?(tenantId: string): void\n}\n\nclass FallbackKmsService implements KmsService {\n private notified = false\n constructor(\n private readonly primary: KmsService,\n private readonly fallback: KmsService | null,\n private readonly onFallback?: () => void,\n ) {}\n\n isHealthy(): boolean {\n return this.primary.isHealthy() || Boolean(this.fallback?.isHealthy?.())\n }\n\n private notifyFallback() {\n if (this.notified) return\n this.notified = true\n this.onFallback?.()\n }\n\n private async fromPrimary<T>(op: () => Promise<T | null>): Promise<T | null> {\n try {\n return await op()\n } catch (err) {\n console.warn('\u26A0\uFE0F [encryption][kms] Primary KMS failed, will try fallback', {\n error: (err as Error)?.message || String(err),\n })\n return null\n }\n }\n\n async getTenantDek(tenantId: string): Promise<TenantDek | null> {\n if (this.primary.isHealthy()) {\n const dek = await this.fromPrimary(() => this.primary.getTenantDek(tenantId))\n if (dek) return dek\n }\n if (this.fallback?.isHealthy()) {\n this.notifyFallback()\n return this.fallback.getTenantDek(tenantId)\n }\n return null\n }\n\n async createTenantDek(tenantId: string): Promise<TenantDek | null> {\n if (this.primary.isHealthy()) {\n const dek = await this.fromPrimary(() => this.primary.createTenantDek(tenantId))\n if (dek) return dek\n }\n if (this.fallback?.isHealthy()) {\n this.notifyFallback()\n return this.fallback.createTenantDek(tenantId)\n }\n return null\n }\n\n invalidateDek(tenantId: string): void {\n this.primary.invalidateDek?.(tenantId)\n this.fallback?.invalidateDek?.(tenantId)\n }\n}\n\ntype VaultClientOpts = {\n vaultAddr?: string\n vaultToken?: string\n mountPath?: string\n ttlMs?: number\n requestTimeoutMs?: number\n}\n\ntype VaultReadResponse = {\n data?: { data?: { key?: string; version?: number }; metadata?: Record<string, unknown> }\n}\n\n// 'conflict' = a check-and-set write lost to a concurrent writer (normal race\n// outcome, Vault still healthy); 'error' = the write genuinely failed.\ntype VaultWriteOutcome = 'ok' | 'conflict' | 'error'\n\nfunction normalizeEnv(value: string | undefined): string {\n if (!value) return ''\n return value.trim().replace(/(?:^['\"]|['\"]$)/g, '')\n}\n\ntype DerivedSecret = { secret: string; source: 'explicit' | 'dev-default'; envName: string }\n\nfunction resolveDerivedKeySecret(): DerivedSecret | null {\n const candidates: Array<{ value: string | null; envName: string }> = [\n { value: process.env.TENANT_DATA_ENCRYPTION_FALLBACK_KEY ?? null, envName: 'TENANT_DATA_ENCRYPTION_FALLBACK_KEY' },\n { value: process.env.TENANT_DATA_ENCRYPTION_KEY ?? null, envName: 'TENANT_DATA_ENCRYPTION_KEY' },\n ]\n for (const raw of candidates) {\n const normalized = normalizeEnv(raw.value ?? undefined)\n if (normalized) return { secret: normalized, source: 'explicit', envName: raw.envName }\n }\n if (\n process.env.NODE_ENV !== 'production'\n && parseBooleanToken(process.env.ALLOW_DERIVED_KMS_FALLBACK) === true\n ) {\n return { secret: 'om-dev-tenant-encryption', source: 'dev-default', envName: 'DEV_DEFAULT' }\n }\n return null\n}\n\nexport class NoopKmsService implements KmsService {\n isHealthy(): boolean { return !isTenantDataEncryptionEnabled() }\n async getTenantDek(): Promise<TenantDek | null> { return null }\n async createTenantDek(): Promise<TenantDek | null> { return null }\n}\n\nclass DerivedKmsService implements KmsService {\n private root: Buffer\n constructor(secret: string) {\n // Derive a stable root key from the provided secret so derived tenant keys are deterministic\n this.root = crypto.createHash('sha256').update(secret).digest()\n }\n\n isHealthy(): boolean {\n return true\n }\n\n private deriveKey(tenantId: string): string {\n const iterations = 310_000\n const keyLength = 32\n const derived = crypto.pbkdf2Sync(this.root, tenantId, iterations, keyLength, 'sha512')\n return derived.toString('base64')\n }\n\n async getTenantDek(tenantId: string): Promise<TenantDek | null> {\n if (!tenantId) return null\n return { tenantId, key: this.deriveKey(tenantId), fetchedAt: Date.now() }\n }\n\n async createTenantDek(tenantId: string): Promise<TenantDek | null> {\n return this.getTenantDek(tenantId)\n }\n}\n\nexport class HashicorpVaultKmsService implements KmsService {\n private cache = new Map<string, TenantDek>()\n private readonly vaultAddr: string\n private readonly vaultToken: string\n private readonly mountPath: string\n private readonly ttlMs: number\n private readonly requestTimeoutMs: number\n private healthy = true\n private readonly debugEnabled: boolean\n private static loggedInit = false\n\n constructor(opts: VaultClientOpts = {}) {\n this.vaultAddr = normalizeEnv(opts.vaultAddr || process.env.VAULT_ADDR || '')\n this.vaultToken = normalizeEnv(opts.vaultToken || process.env.VAULT_TOKEN || '')\n this.mountPath = (opts.mountPath || process.env.VAULT_KV_PATH || 'secret/data').replace(/\\/+$/, '')\n this.ttlMs = opts.ttlMs ?? 15 * 60 * 1000\n this.requestTimeoutMs = resolveTimeoutMs(opts.requestTimeoutMs, resolveVaultRequestTimeoutMs())\n this.debugEnabled = isEncryptionDebugEnabled()\n if (!this.vaultAddr || !this.vaultToken) {\n this.healthy = false\n if (this.debugEnabled) {\n console.warn('\u26A0\uFE0F [encryption][kms] Vault misconfigured (missing VAULT_ADDR or VAULT_TOKEN)')\n }\n }\n if (this.healthy && !HashicorpVaultKmsService.loggedInit && this.debugEnabled) {\n HashicorpVaultKmsService.loggedInit = true\n if(this.debugEnabled) {\n console.info('\uD83D\uDD10 [encryption][kms] Hashicorp Vault KMS enabled')\n }\n }\n }\n\n isHealthy(): boolean {\n return this.healthy\n }\n\n private now(): number {\n return Date.now()\n }\n\n private cacheHit(tenantId: string): TenantDek | null {\n const entry = this.cache.get(tenantId)\n if (!entry) return null\n if (this.now() - entry.fetchedAt > this.ttlMs) {\n this.cache.delete(tenantId)\n return null\n }\n return entry\n }\n\n private async readVault(path: string): Promise<VaultReadResponse | null> {\n if (!this.vaultAddr || !this.vaultToken) {\n this.healthy = false\n return null\n }\n try {\n const res = await fetchWithTimeout(`${this.vaultAddr}/v1/${path}`, {\n method: 'GET',\n headers: { 'X-Vault-Token': this.vaultToken },\n timeoutMs: this.requestTimeoutMs,\n })\n if (!res.ok) {\n this.healthy = res.status < 500\n console.warn('\u26A0\uFE0F [encryption][kms] Vault read failed', { path, status: res.status })\n return null\n }\n if (this.debugEnabled) {\n console.info('\uD83D\uDD0D [encryption][kms] Vault read ok', { path })\n }\n return (await res.json()) as VaultReadResponse\n } catch (err) {\n this.healthy = false\n console.warn('\u26A0\uFE0F [encryption][kms] Vault read error', {\n path,\n error: (err as Error)?.message || String(err),\n timeoutMs: this.requestTimeoutMs,\n })\n return null\n }\n }\n\n private async writeVault(path: string, key: string, opts?: { cas?: number }): Promise<VaultWriteOutcome> {\n if (!this.vaultAddr || !this.vaultToken) {\n this.healthy = false\n return 'error'\n }\n const body: { data: { key: string }; options?: { cas: number } } = { data: { key } }\n if (typeof opts?.cas === 'number') body.options = { cas: opts.cas }\n try {\n const res = await fetchWithTimeout(`${this.vaultAddr}/v1/${path}`, {\n method: 'POST',\n headers: {\n 'X-Vault-Token': this.vaultToken,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(body),\n timeoutMs: this.requestTimeoutMs,\n })\n if (res.ok) {\n this.healthy = true\n return 'ok'\n }\n // KV v2 returns 400 when a check-and-set write loses to a concurrent\n // writer (path already at a newer version). That is a normal race outcome,\n // not an unhealthy Vault \u2014 don't flip `healthy`.\n if (typeof opts?.cas === 'number' && res.status === 400) {\n console.warn('\u26A0\uFE0F [encryption][kms] Vault write CAS conflict (concurrent DEK create)', { path, status: res.status })\n return 'conflict'\n }\n this.healthy = false\n console.warn('\u26A0\uFE0F [encryption][kms] Vault write failed', { path, status: res.status })\n return 'error'\n } catch (err) {\n this.healthy = false\n console.warn('\u26A0\uFE0F [encryption][kms] Vault write error', {\n path,\n error: (err as Error)?.message || String(err),\n timeoutMs: this.requestTimeoutMs,\n })\n return 'error'\n }\n }\n\n private buildKeyPath(tenantId: string): string {\n const suffix = `tenant_key_${tenantId}`\n const normalizedMount = this.mountPath.replace(/^\\/+/, '')\n return `${normalizedMount}/${suffix}`\n }\n\n private remember(entry: TenantDek): TenantDek {\n this.cache.set(entry.tenantId, entry)\n return entry\n }\n\n async getTenantDek(tenantId: string): Promise<TenantDek | null> {\n const cached = this.cacheHit(tenantId)\n if (cached) return cached\n const path = this.buildKeyPath(tenantId)\n const res = await this.readVault(path)\n const key = res?.data?.data?.key\n if (!key) {\n console.warn('\u26A0\uFE0F [encryption][kms] No tenant DEK found in Vault', { tenantId, path })\n return null\n }\n const dek: TenantDek = { tenantId, key, fetchedAt: this.now() }\n return this.remember(dek)\n }\n\n async createTenantDek(tenantId: string): Promise<TenantDek | null> {\n const path = this.buildKeyPath(tenantId)\n // Read-before-write: if a DEK already exists for this tenant (another request\n // or process created it first), adopt it instead of overwriting the active\n // key \u2014 overwriting orphans every row already encrypted under it (#2746).\n const existing = await this.readVault(path)\n const existingKey = existing?.data?.data?.key\n if (existingKey) {\n return this.remember({ tenantId, key: existingKey, fetchedAt: this.now() })\n }\n // A read failure (timeout / 5xx) flips `healthy` off; don't blind-write a new\n // key over a possibly-existing one we just couldn't read \u2014 let the caller fall back.\n if (!this.healthy) return null\n const key = generateDek()\n const outcome = await this.writeVault(path, key, { cas: 0 })\n if (outcome === 'ok') {\n console.info('\uD83D\uDD11 [encryption][kms] Stored tenant DEK in Vault', { tenantId, path })\n return this.remember({ tenantId, key, fetchedAt: this.now() })\n }\n if (outcome === 'conflict') {\n // A concurrent create won the CAS race \u2014 adopt the winner's key so both\n // callers encrypt under the same DEK.\n const winner = await this.readVault(path)\n const winnerKey = winner?.data?.data?.key\n if (winnerKey) {\n console.info('\uD83D\uDD11 [encryption][kms] Adopted concurrently-created tenant DEK', { tenantId, path })\n return this.remember({ tenantId, key: winnerKey, fetchedAt: this.now() })\n }\n }\n console.warn('\u26A0\uFE0F [encryption][kms] Failed to store tenant DEK in Vault', { tenantId, path })\n return null\n }\n\n invalidateDek(tenantId: string): void {\n this.cache.delete(tenantId)\n }\n}\n\nlet loggedDerivedKeyFallbackBanner = false\n\nfunction fingerprintSecret(secret: string): string {\n return crypto.createHash('sha256').update(secret, 'utf8').digest('hex').slice(0, 16)\n}\n\nexport function buildDerivedKeyFallbackBannerLines(opts: DerivedSecret): string[] {\n const sourceLine =\n opts.source === 'explicit' ? `Source: ${opts.envName}` : 'Source: dev default secret (do NOT use in production)'\n return [\n '\uD83D\uDEA8 Using derived tenant encryption keys (Vault unavailable / no DEK)',\n sourceLine,\n `Secret fingerprint (sha256, truncated): ${fingerprintSecret(opts.secret)}`,\n 'Persist this secret securely. Without it, encrypted tenant data cannot be recovered after restart.',\n ]\n}\n\nfunction logDerivedKeyFallbackBanner(opts: DerivedSecret): void {\n if (process.env.NODE_ENV === 'test' || loggedDerivedKeyFallbackBanner) return\n loggedDerivedKeyFallbackBanner = true\n const redBg = '\\x1b[41m'\n const white = '\\x1b[97m'\n const reset = '\\x1b[0m'\n const width = 110\n const border = `${redBg}${white}${'\u2501'.repeat(width)}${reset}`\n const body = buildDerivedKeyFallbackBannerLines(opts)\n console.warn(border)\n for (const line of body) {\n const padded = line.padEnd(width - 2, ' ')\n console.warn(`${redBg}${white} ${padded} ${reset}`)\n }\n console.warn(border)\n}\n\nexport function createKmsService(): KmsService {\n if (!isTenantDataEncryptionEnabled()) return new NoopKmsService()\n const primary = new HashicorpVaultKmsService()\n\n const derived = resolveDerivedKeySecret()\n const fallback = derived ? new DerivedKmsService(derived.secret) : null\n const notifyFallback = derived\n ? () => {\n logDerivedKeyFallbackBanner(derived)\n }\n : undefined\n\n if (!primary.isHealthy()) {\n if (fallback) {\n notifyFallback?.()\n return fallback\n }\n console.warn(\n '\u26A0\uFE0F [encryption][kms] Vault not healthy or misconfigured (missing VAULT_ADDR/VAULT_TOKEN) and no fallback secret provided; falling back to noop KMS',\n )\n return new NoopKmsService()\n }\n\n if (fallback) {\n return new FallbackKmsService(primary, fallback, notifyFallback)\n }\n\n return primary\n}\n\nexport { hashForLookup }\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import crypto from 'node:crypto'\nimport { generateDek, hashForLookup } from './aes'\nimport { isEncryptionDebugEnabled, isTenantDataEncryptionEnabled } from './toggles'\nimport { parseBooleanToken } from '../boolean'\nimport { fetchWithTimeout, resolveTimeoutMs } from '../http/fetchWithTimeout'\n\nconst DEFAULT_VAULT_REQUEST_TIMEOUT_MS = 1_000\nconst DEFAULT_VAULT_RECOVERY_COOLDOWN_MS = 30_000\n\nfunction resolveVaultRequestTimeoutMs(): number {\n const raw = process.env.VAULT_REQUEST_TIMEOUT_MS\n const parsed = raw ? Number.parseInt(raw, 10) : undefined\n return resolveTimeoutMs(parsed, DEFAULT_VAULT_REQUEST_TIMEOUT_MS)\n}\n\nfunction resolveVaultRecoveryCooldownMs(): number {\n const raw = process.env.VAULT_RECOVERY_COOLDOWN_MS\n const parsed = raw ? Number.parseInt(raw, 10) : undefined\n return resolveTimeoutMs(parsed, DEFAULT_VAULT_RECOVERY_COOLDOWN_MS)\n}\n\nexport type TenantDek = {\n tenantId: string\n key: string // base64\n fetchedAt: number\n}\n\nexport interface KmsService {\n getTenantDek(tenantId: string): Promise<TenantDek | null>\n createTenantDek(tenantId: string): Promise<TenantDek | null>\n isHealthy(): boolean\n invalidateDek?(tenantId: string): void\n}\n\nclass FallbackKmsService implements KmsService {\n private notified = false\n constructor(\n private readonly primary: KmsService,\n private readonly fallback: KmsService | null,\n private readonly onFallback?: () => void,\n ) {}\n\n isHealthy(): boolean {\n return this.primary.isHealthy() || Boolean(this.fallback?.isHealthy?.())\n }\n\n private notifyFallback() {\n if (this.notified) return\n this.notified = true\n this.onFallback?.()\n }\n\n private async fromPrimary<T>(op: () => Promise<T | null>): Promise<T | null> {\n try {\n return await op()\n } catch (err) {\n console.warn('\u26A0\uFE0F [encryption][kms] Primary KMS failed, will try fallback', {\n error: (err as Error)?.message || String(err),\n })\n return null\n }\n }\n\n async getTenantDek(tenantId: string): Promise<TenantDek | null> {\n if (this.primary.isHealthy()) {\n const dek = await this.fromPrimary(() => this.primary.getTenantDek(tenantId))\n if (dek) return dek\n }\n if (this.fallback?.isHealthy()) {\n this.notifyFallback()\n return this.fallback.getTenantDek(tenantId)\n }\n return null\n }\n\n async createTenantDek(tenantId: string): Promise<TenantDek | null> {\n if (this.primary.isHealthy()) {\n const dek = await this.fromPrimary(() => this.primary.createTenantDek(tenantId))\n if (dek) return dek\n }\n if (this.fallback?.isHealthy()) {\n this.notifyFallback()\n return this.fallback.createTenantDek(tenantId)\n }\n return null\n }\n\n invalidateDek(tenantId: string): void {\n this.primary.invalidateDek?.(tenantId)\n this.fallback?.invalidateDek?.(tenantId)\n }\n}\n\ntype VaultClientOpts = {\n vaultAddr?: string\n vaultToken?: string\n mountPath?: string\n ttlMs?: number\n requestTimeoutMs?: number\n recoveryCooldownMs?: number\n}\n\ntype VaultReadResponse = {\n data?: { data?: { key?: string; version?: number }; metadata?: Record<string, unknown> }\n}\n\n// 'conflict' = a check-and-set write lost to a concurrent writer (normal race\n// outcome, Vault still healthy); 'error' = the write genuinely failed.\ntype VaultWriteOutcome = 'ok' | 'conflict' | 'error'\n\nfunction normalizeEnv(value: string | undefined): string {\n if (!value) return ''\n return value.trim().replace(/(?:^['\"]|['\"]$)/g, '')\n}\n\ntype DerivedSecret = { secret: string; source: 'explicit' | 'dev-default'; envName: string }\n\nfunction resolveDerivedKeySecret(): DerivedSecret | null {\n const candidates: Array<{ value: string | null; envName: string }> = [\n { value: process.env.TENANT_DATA_ENCRYPTION_FALLBACK_KEY ?? null, envName: 'TENANT_DATA_ENCRYPTION_FALLBACK_KEY' },\n { value: process.env.TENANT_DATA_ENCRYPTION_KEY ?? null, envName: 'TENANT_DATA_ENCRYPTION_KEY' },\n ]\n for (const raw of candidates) {\n const normalized = normalizeEnv(raw.value ?? undefined)\n if (normalized) return { secret: normalized, source: 'explicit', envName: raw.envName }\n }\n if (\n process.env.NODE_ENV !== 'production'\n && parseBooleanToken(process.env.ALLOW_DERIVED_KMS_FALLBACK) === true\n ) {\n return { secret: 'om-dev-tenant-encryption', source: 'dev-default', envName: 'DEV_DEFAULT' }\n }\n return null\n}\n\nexport class NoopKmsService implements KmsService {\n isHealthy(): boolean { return !isTenantDataEncryptionEnabled() }\n async getTenantDek(): Promise<TenantDek | null> { return null }\n async createTenantDek(): Promise<TenantDek | null> { return null }\n}\n\nclass DerivedKmsService implements KmsService {\n private root: Buffer\n constructor(secret: string) {\n // Derive a stable root key from the provided secret so derived tenant keys are deterministic\n this.root = crypto.createHash('sha256').update(secret).digest()\n }\n\n isHealthy(): boolean {\n return true\n }\n\n private deriveKey(tenantId: string): string {\n const iterations = 310_000\n const keyLength = 32\n const derived = crypto.pbkdf2Sync(this.root, tenantId, iterations, keyLength, 'sha512')\n return derived.toString('base64')\n }\n\n async getTenantDek(tenantId: string): Promise<TenantDek | null> {\n if (!tenantId) return null\n return { tenantId, key: this.deriveKey(tenantId), fetchedAt: Date.now() }\n }\n\n async createTenantDek(tenantId: string): Promise<TenantDek | null> {\n return this.getTenantDek(tenantId)\n }\n}\n\nexport class HashicorpVaultKmsService implements KmsService {\n private cache = new Map<string, TenantDek>()\n private readonly vaultAddr: string\n private readonly vaultToken: string\n private readonly mountPath: string\n private readonly ttlMs: number\n private readonly requestTimeoutMs: number\n private readonly recoveryCooldownMs: number\n private healthy = true\n // Sticky terminal failure (missing VAULT_ADDR/VAULT_TOKEN): no amount of\n // re-probing fixes a misconfiguration, so this never self-heals \u2014 only a\n // restart with corrected config does.\n private misconfigured = false\n // Timestamp of the last transient failure (timeout / network blip / 5xx).\n // Drives the half-open circuit breaker in isHealthy(): after the cooldown the\n // instance reports healthy again so the next call re-probes Vault.\n private lastTransientFailureAt: number | null = null\n private readonly debugEnabled: boolean\n private static loggedInit = false\n\n constructor(opts: VaultClientOpts = {}) {\n this.vaultAddr = normalizeEnv(opts.vaultAddr || process.env.VAULT_ADDR || '')\n this.vaultToken = normalizeEnv(opts.vaultToken || process.env.VAULT_TOKEN || '')\n this.mountPath = (opts.mountPath || process.env.VAULT_KV_PATH || 'secret/data').replace(/\\/+$/, '')\n this.ttlMs = opts.ttlMs ?? 15 * 60 * 1000\n this.requestTimeoutMs = resolveTimeoutMs(opts.requestTimeoutMs, resolveVaultRequestTimeoutMs())\n this.recoveryCooldownMs = resolveTimeoutMs(opts.recoveryCooldownMs, resolveVaultRecoveryCooldownMs())\n this.debugEnabled = isEncryptionDebugEnabled()\n if (!this.vaultAddr || !this.vaultToken) {\n this.healthy = false\n this.misconfigured = true\n if (this.debugEnabled) {\n console.warn('\u26A0\uFE0F [encryption][kms] Vault misconfigured (missing VAULT_ADDR or VAULT_TOKEN)')\n }\n }\n if (this.healthy && !HashicorpVaultKmsService.loggedInit && this.debugEnabled) {\n HashicorpVaultKmsService.loggedInit = true\n if(this.debugEnabled) {\n console.info('\uD83D\uDD10 [encryption][kms] Hashicorp Vault KMS enabled')\n }\n }\n }\n\n isHealthy(): boolean {\n // A missing-config failure is terminal \u2014 never report healthy again.\n if (this.misconfigured) return false\n if (this.healthy) return true\n // Half-open circuit breaker: once the cooldown since the last transient\n // failure has elapsed, report healthy so the next read/write re-probes\n // Vault. A successful probe flips `healthy` back on; a failing one records a\n // fresh failure timestamp and re-opens the breaker for another cooldown.\n if (this.lastTransientFailureAt === null) return false\n return this.now() - this.lastTransientFailureAt >= this.recoveryCooldownMs\n }\n\n private now(): number {\n return Date.now()\n }\n\n // Vault responded successfully (or is provably reachable): close the breaker.\n private markHealthy(): void {\n this.healthy = true\n this.lastTransientFailureAt = null\n }\n\n // Transient infra failure (timeout / network blip / 5xx): open the breaker and\n // start the recovery cooldown so a later call can re-probe and self-heal.\n private markTransientFailure(): void {\n this.healthy = false\n this.lastTransientFailureAt = this.now()\n }\n\n private cacheHit(tenantId: string): TenantDek | null {\n const entry = this.cache.get(tenantId)\n if (!entry) return null\n if (this.now() - entry.fetchedAt > this.ttlMs) {\n this.cache.delete(tenantId)\n return null\n }\n return entry\n }\n\n private async readVault(path: string): Promise<VaultReadResponse | null> {\n if (!this.vaultAddr || !this.vaultToken) {\n this.healthy = false\n this.misconfigured = true\n return null\n }\n try {\n const res = await fetchWithTimeout(`${this.vaultAddr}/v1/${path}`, {\n method: 'GET',\n headers: { 'X-Vault-Token': this.vaultToken },\n timeoutMs: this.requestTimeoutMs,\n })\n if (!res.ok) {\n // 5xx = Vault down/erroring (transient). <500 (auth/not-found/etc.) means\n // Vault is reachable and answered, so keep it healthy \u2014 a 404 for a\n // not-yet-created tenant DEK is the normal read-before-write path.\n if (res.status >= 500) this.markTransientFailure()\n else this.markHealthy()\n console.warn('\u26A0\uFE0F [encryption][kms] Vault read failed', { path, status: res.status })\n return null\n }\n this.markHealthy()\n if (this.debugEnabled) {\n console.info('\uD83D\uDD0D [encryption][kms] Vault read ok', { path })\n }\n return (await res.json()) as VaultReadResponse\n } catch (err) {\n this.markTransientFailure()\n console.warn('\u26A0\uFE0F [encryption][kms] Vault read error', {\n path,\n error: (err as Error)?.message || String(err),\n timeoutMs: this.requestTimeoutMs,\n })\n return null\n }\n }\n\n private async writeVault(path: string, key: string, opts?: { cas?: number }): Promise<VaultWriteOutcome> {\n if (!this.vaultAddr || !this.vaultToken) {\n this.healthy = false\n this.misconfigured = true\n return 'error'\n }\n const body: { data: { key: string }; options?: { cas: number } } = { data: { key } }\n if (typeof opts?.cas === 'number') body.options = { cas: opts.cas }\n try {\n const res = await fetchWithTimeout(`${this.vaultAddr}/v1/${path}`, {\n method: 'POST',\n headers: {\n 'X-Vault-Token': this.vaultToken,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(body),\n timeoutMs: this.requestTimeoutMs,\n })\n if (res.ok) {\n this.markHealthy()\n return 'ok'\n }\n // KV v2 returns 400 when a check-and-set write loses to a concurrent\n // writer (path already at a newer version). That is a normal race outcome,\n // not an unhealthy Vault \u2014 Vault is reachable, so close the breaker.\n if (typeof opts?.cas === 'number' && res.status === 400) {\n this.markHealthy()\n console.warn('\u26A0\uFE0F [encryption][kms] Vault write CAS conflict (concurrent DEK create)', { path, status: res.status })\n return 'conflict'\n }\n this.markTransientFailure()\n console.warn('\u26A0\uFE0F [encryption][kms] Vault write failed', { path, status: res.status })\n return 'error'\n } catch (err) {\n this.markTransientFailure()\n console.warn('\u26A0\uFE0F [encryption][kms] Vault write error', {\n path,\n error: (err as Error)?.message || String(err),\n timeoutMs: this.requestTimeoutMs,\n })\n return 'error'\n }\n }\n\n private buildKeyPath(tenantId: string): string {\n const suffix = `tenant_key_${tenantId}`\n const normalizedMount = this.mountPath.replace(/^\\/+/, '')\n return `${normalizedMount}/${suffix}`\n }\n\n private remember(entry: TenantDek): TenantDek {\n this.cache.set(entry.tenantId, entry)\n return entry\n }\n\n async getTenantDek(tenantId: string): Promise<TenantDek | null> {\n const cached = this.cacheHit(tenantId)\n if (cached) return cached\n const path = this.buildKeyPath(tenantId)\n const res = await this.readVault(path)\n const key = res?.data?.data?.key\n if (!key) {\n console.warn('\u26A0\uFE0F [encryption][kms] No tenant DEK found in Vault', { tenantId, path })\n return null\n }\n const dek: TenantDek = { tenantId, key, fetchedAt: this.now() }\n return this.remember(dek)\n }\n\n async createTenantDek(tenantId: string): Promise<TenantDek | null> {\n const path = this.buildKeyPath(tenantId)\n // Read-before-write: if a DEK already exists for this tenant (another request\n // or process created it first), adopt it instead of overwriting the active\n // key \u2014 overwriting orphans every row already encrypted under it (#2746).\n const existing = await this.readVault(path)\n const existingKey = existing?.data?.data?.key\n if (existingKey) {\n return this.remember({ tenantId, key: existingKey, fetchedAt: this.now() })\n }\n // A read failure (timeout / 5xx) flips `healthy` off; don't blind-write a new\n // key over a possibly-existing one we just couldn't read \u2014 let the caller fall back.\n if (!this.healthy) return null\n const key = generateDek()\n const outcome = await this.writeVault(path, key, { cas: 0 })\n if (outcome === 'ok') {\n console.info('\uD83D\uDD11 [encryption][kms] Stored tenant DEK in Vault', { tenantId, path })\n return this.remember({ tenantId, key, fetchedAt: this.now() })\n }\n if (outcome === 'conflict') {\n // A concurrent create won the CAS race \u2014 adopt the winner's key so both\n // callers encrypt under the same DEK.\n const winner = await this.readVault(path)\n const winnerKey = winner?.data?.data?.key\n if (winnerKey) {\n console.info('\uD83D\uDD11 [encryption][kms] Adopted concurrently-created tenant DEK', { tenantId, path })\n return this.remember({ tenantId, key: winnerKey, fetchedAt: this.now() })\n }\n }\n console.warn('\u26A0\uFE0F [encryption][kms] Failed to store tenant DEK in Vault', { tenantId, path })\n return null\n }\n\n invalidateDek(tenantId: string): void {\n this.cache.delete(tenantId)\n }\n}\n\nlet loggedDerivedKeyFallbackBanner = false\n\nfunction fingerprintSecret(secret: string): string {\n return crypto.createHash('sha256').update(secret, 'utf8').digest('hex').slice(0, 16)\n}\n\nexport function buildDerivedKeyFallbackBannerLines(opts: DerivedSecret): string[] {\n const sourceLine =\n opts.source === 'explicit' ? `Source: ${opts.envName}` : 'Source: dev default secret (do NOT use in production)'\n return [\n '\uD83D\uDEA8 Using derived tenant encryption keys (Vault unavailable / no DEK)',\n sourceLine,\n `Secret fingerprint (sha256, truncated): ${fingerprintSecret(opts.secret)}`,\n 'Persist this secret securely. Without it, encrypted tenant data cannot be recovered after restart.',\n ]\n}\n\nfunction logDerivedKeyFallbackBanner(opts: DerivedSecret): void {\n if (process.env.NODE_ENV === 'test' || loggedDerivedKeyFallbackBanner) return\n loggedDerivedKeyFallbackBanner = true\n const redBg = '\\x1b[41m'\n const white = '\\x1b[97m'\n const reset = '\\x1b[0m'\n const width = 110\n const border = `${redBg}${white}${'\u2501'.repeat(width)}${reset}`\n const body = buildDerivedKeyFallbackBannerLines(opts)\n console.warn(border)\n for (const line of body) {\n const padded = line.padEnd(width - 2, ' ')\n console.warn(`${redBg}${white} ${padded} ${reset}`)\n }\n console.warn(border)\n}\n\nexport function createKmsService(): KmsService {\n if (!isTenantDataEncryptionEnabled()) return new NoopKmsService()\n const primary = new HashicorpVaultKmsService()\n\n const derived = resolveDerivedKeySecret()\n const fallback = derived ? new DerivedKmsService(derived.secret) : null\n const notifyFallback = derived\n ? () => {\n logDerivedKeyFallbackBanner(derived)\n }\n : undefined\n\n if (!primary.isHealthy()) {\n if (fallback) {\n notifyFallback?.()\n return fallback\n }\n console.warn(\n '\u26A0\uFE0F [encryption][kms] Vault not healthy or misconfigured (missing VAULT_ADDR/VAULT_TOKEN) and no fallback secret provided; falling back to noop KMS',\n )\n return new NoopKmsService()\n }\n\n if (fallback) {\n return new FallbackKmsService(primary, fallback, notifyFallback)\n }\n\n return primary\n}\n\nexport { hashForLookup }\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,5 +1,5 @@
1
1
  import { isValuelessOperator } from "./advanced-filter.js";
2
- import { escapeLikePattern } from "../db/escapeLikePattern.js";
2
+ import { buildIlikeTerm } from "../db/buildIlikeTerm.js";
3
3
  const TREE_LIMITS = {
4
4
  maxGroupLevel: 3,
5
5
  // root = level 1; deepest nested group = level 3
@@ -67,25 +67,25 @@ function compileRule(rule) {
67
67
  case "contains": {
68
68
  const v = normalizeSingleValue(rule.value);
69
69
  if (v === null) return null;
70
- filter[rule.field] = { $ilike: `%${escapeLikePattern(String(v))}%` };
70
+ filter[rule.field] = { $ilike: buildIlikeTerm(String(v)) };
71
71
  break;
72
72
  }
73
73
  case "does_not_contain": {
74
74
  const v = normalizeSingleValue(rule.value);
75
75
  if (v === null) return null;
76
- filter[rule.field] = { $not: { $ilike: `%${escapeLikePattern(String(v))}%` } };
76
+ filter[rule.field] = { $not: { $ilike: buildIlikeTerm(String(v)) } };
77
77
  break;
78
78
  }
79
79
  case "starts_with": {
80
80
  const v = normalizeSingleValue(rule.value);
81
81
  if (v === null) return null;
82
- filter[rule.field] = { $ilike: `${escapeLikePattern(String(v))}%` };
82
+ filter[rule.field] = { $ilike: buildIlikeTerm(String(v), "startsWith") };
83
83
  break;
84
84
  }
85
85
  case "ends_with": {
86
86
  const v = normalizeSingleValue(rule.value);
87
87
  if (v === null) return null;
88
- filter[rule.field] = { $ilike: `%${escapeLikePattern(String(v))}` };
88
+ filter[rule.field] = { $ilike: buildIlikeTerm(String(v), "endsWith") };
89
89
  break;
90
90
  }
91
91
  case "is_empty":
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/query/advanced-filter-tree.ts"],
4
- "sourcesContent": ["// packages/shared/src/lib/query/advanced-filter-tree.ts\nimport type { FilterOperator } from './advanced-filter'\nimport { isValuelessOperator } from './advanced-filter'\nimport { escapeLikePattern } from '../db/escapeLikePattern'\n\nexport type FilterCombinator = 'and' | 'or'\n\nexport type FilterRule = {\n id: string\n type: 'rule'\n field: string\n operator: FilterOperator\n value: unknown\n // Runtime-only metadata used by the editor reducer (e.g. removeLast) to find\n // the most recently inserted node. MUST NOT be serialized into URL or persisted state.\n addedAt?: number\n}\n\nexport type FilterGroup = {\n id: string\n type: 'group'\n combinator: FilterCombinator\n children: Array<FilterRule | FilterGroup>\n // Runtime-only metadata used by the editor reducer (e.g. removeLast) to find\n // the most recently inserted node. MUST NOT be serialized into URL or persisted state.\n addedAt?: number\n}\n\nexport type AdvancedFilterTree = {\n root: FilterGroup\n}\n\nexport const TREE_LIMITS = {\n maxGroupLevel: 3, // root = level 1; deepest nested group = level 3\n maxChildrenPerGroup: 15, // counts both rules and nested groups\n maxTotalRules: 50,\n} as const\n\nexport function createEmptyTree(): AdvancedFilterTree {\n return {\n root: {\n id: crypto.randomUUID(),\n type: 'group',\n combinator: 'and',\n children: [],\n },\n }\n}\n\nexport type ValidationResult =\n | { ok: true }\n | { ok: false; reason: 'depth' | 'width' | 'total' }\n\nexport function validateTreeLimits(tree: AdvancedFilterTree): ValidationResult {\n let totalRules = 0\n function walk(node: FilterRule | FilterGroup, level: number): ValidationResult {\n if (node.type === 'rule') {\n totalRules += 1\n return { ok: true }\n }\n if (level > TREE_LIMITS.maxGroupLevel) return { ok: false, reason: 'depth' }\n if (node.children.length > TREE_LIMITS.maxChildrenPerGroup) return { ok: false, reason: 'width' }\n for (const child of node.children) {\n const r = walk(child, level + 1)\n if (!r.ok) return r\n }\n return { ok: true }\n }\n const r = walk(tree.root, 1)\n if (!r.ok) return r\n if (totalRules > TREE_LIMITS.maxTotalRules) return { ok: false, reason: 'total' }\n return { ok: true }\n}\n\nfunction normalizeSingleValue(value: unknown): unknown {\n if (typeof value !== 'string') return value\n const trimmed = value.trim()\n return trimmed.length > 0 ? trimmed : null\n}\n\nfunction normalizeListValue(value: unknown): unknown[] {\n const list = Array.isArray(value) ? value : [value]\n return list.map(normalizeSingleValue).filter((v) => v !== null)\n}\n\nfunction compileRule(rule: FilterRule): Record<string, unknown> | null {\n if (!rule.field || !rule.operator) return null\n const filter: Record<string, unknown> = {}\n switch (rule.operator) {\n case 'is':\n case 'equals': {\n const v = normalizeSingleValue(rule.value)\n if (v === null) return null\n filter[rule.field] = { $eq: v }; break\n }\n case 'is_not':\n case 'not_equals': {\n const v = normalizeSingleValue(rule.value)\n if (v === null) return null\n filter[rule.field] = { $ne: v }; break\n }\n case 'contains': {\n const v = normalizeSingleValue(rule.value)\n if (v === null) return null\n filter[rule.field] = { $ilike: `%${escapeLikePattern(String(v))}%` }; break\n }\n case 'does_not_contain': {\n const v = normalizeSingleValue(rule.value)\n if (v === null) return null\n filter[rule.field] = { $not: { $ilike: `%${escapeLikePattern(String(v))}%` } }; break\n }\n case 'starts_with': {\n const v = normalizeSingleValue(rule.value)\n if (v === null) return null\n filter[rule.field] = { $ilike: `${escapeLikePattern(String(v))}%` }; break\n }\n case 'ends_with': {\n const v = normalizeSingleValue(rule.value)\n if (v === null) return null\n filter[rule.field] = { $ilike: `%${escapeLikePattern(String(v))}` }; break\n }\n case 'is_empty': filter[rule.field] = { $exists: false }; break\n case 'is_not_empty': filter[rule.field] = { $exists: true }; break\n case 'greater_than': { const v = normalizeSingleValue(rule.value); if (v === null) return null; filter[rule.field] = { $gt: v }; break }\n case 'less_than': { const v = normalizeSingleValue(rule.value); if (v === null) return null; filter[rule.field] = { $lt: v }; break }\n case 'greater_or_equal': { const v = normalizeSingleValue(rule.value); if (v === null) return null; filter[rule.field] = { $gte: v }; break }\n case 'less_or_equal': { const v = normalizeSingleValue(rule.value); if (v === null) return null; filter[rule.field] = { $lte: v }; break }\n case 'between': {\n if (Array.isArray(rule.value) && rule.value.length === 2) {\n const start = normalizeSingleValue(rule.value[0])\n const end = normalizeSingleValue(rule.value[1])\n if (start === null && end === null) return null\n if (start !== null && end !== null) filter[rule.field] = { $gte: start, $lte: end }\n else if (start !== null) filter[rule.field] = { $gte: start }\n else filter[rule.field] = { $lte: end }\n } else {\n return null\n }\n break\n }\n case 'is_before': { const v = normalizeSingleValue(rule.value); if (v === null) return null; filter[rule.field] = { $lt: v }; break }\n case 'is_after': { const v = normalizeSingleValue(rule.value); if (v === null) return null; filter[rule.field] = { $gt: v }; break }\n case 'is_any_of':\n case 'has_any_of': {\n const list = normalizeListValue(rule.value)\n if (list.length === 0) return null\n filter[rule.field] = { $in: list }; break\n }\n case 'is_none_of':\n case 'has_none_of': {\n const list = normalizeListValue(rule.value)\n if (list.length === 0) return null\n filter[rule.field] = { $nin: list }; break\n }\n case 'has_all_of': {\n const list = normalizeListValue(rule.value)\n if (list.length === 0) return null\n filter[rule.field] = { $contains: list }; break\n }\n case 'is_true': filter[rule.field] = { $eq: true }; break\n case 'is_false': filter[rule.field] = { $eq: false }; break\n }\n return Object.keys(filter).length > 0 ? filter : null\n}\n\nexport function compileTreeToWhere(tree: AdvancedFilterTree): Record<string, unknown> | null {\n return compileGroup(tree.root)\n}\n\nfunction compileGroup(node: FilterGroup): Record<string, unknown> | null {\n const compiled = node.children\n .map((child) => (child.type === 'rule' ? compileRule(child) : compileGroup(child)))\n .filter((c): c is Record<string, unknown> => c !== null)\n if (compiled.length === 0) return null\n if (compiled.length === 1) return compiled[0]\n return node.combinator === 'or' ? { $or: compiled } : { $and: compiled }\n}\n\n// Re-export isValuelessOperator so consumers of this module can access it\nexport { isValuelessOperator }\n\n/**\n * Build a single-rule tree wrapped in an AND root group.\n * Useful for quick-filter presets that apply one rule.\n */\nexport function makeRuleTree(rule: { field: string; operator: FilterOperator; value: unknown }): AdvancedFilterTree {\n const ruleNode: FilterRule = {\n id: crypto.randomUUID(),\n type: 'rule',\n field: rule.field,\n operator: rule.operator,\n value: rule.value,\n }\n return {\n root: {\n id: crypto.randomUUID(),\n type: 'group',\n combinator: 'and',\n children: [ruleNode],\n },\n }\n}\n\n/**\n * Persisted shape of an `AdvancedFilterTree` inside `PerspectiveSettings.filters`.\n * The `v: 2` marker disambiguates the tree from legacy flat `FilterValues` and\n * from the plain `{root: ...}` shape. Runtime-only `addedAt` is stripped before\n * persisting so saved perspectives are deterministic across reloads.\n */\nexport type PersistedFilterTree = {\n v: 2\n root: FilterGroup\n}\n\nfunction stripRuntimeMetadataDeep<T extends FilterRule | FilterGroup>(node: T): T {\n if (node.type === 'rule') {\n const { addedAt: _ignored, ...rest } = node as FilterRule & { addedAt?: number }\n return rest as T\n }\n const { addedAt: _ignored, children, ...rest } = node as FilterGroup & { addedAt?: number }\n const cleanChildren = children.map((c) => stripRuntimeMetadataDeep(c))\n return { ...rest, children: cleanChildren } as T\n}\n\n/** Serialize a tree for persistence (perspective settings, exports, etc.). */\nexport function serializeTreeForPersist(tree: AdvancedFilterTree): PersistedFilterTree {\n return { v: 2, root: stripRuntimeMetadataDeep(tree.root) }\n}\n\n/** Type guard: does `value` look like a persisted filter tree? */\nexport function isPersistedFilterTree(value: unknown): value is PersistedFilterTree {\n if (!value || typeof value !== 'object') return false\n const record = value as Record<string, unknown>\n if (record.v !== 2) return false\n const root = record.root\n if (!root || typeof root !== 'object') return false\n const r = root as Record<string, unknown>\n return r.type === 'group' && (r.combinator === 'and' || r.combinator === 'or') && Array.isArray(r.children)\n}\n\n/**\n * Restore a tree from its persisted shape. Returns `null` when the input is\n * not a recognizable persisted tree \u2014 callers should fall back to legacy\n * filter handling in that case.\n */\nexport function deserializeTreeFromPersist(value: unknown): AdvancedFilterTree | null {\n if (!isPersistedFilterTree(value)) return null\n return { root: value.root }\n}\n\n/**\n * Best-effort back-conversion of a tree to the legacy flat `AdvancedFilterState`\n * shape (`{logic, conditions[]}`). The flat shape only supports a single level\n * of AND/OR joining between sibling conditions; nested groups are flattened by\n * walking all leaf rules into the top level. Use this only for the legacy\n * `DataTable.advancedFilter` BC bridge \u2014 new code should keep the tree shape.\n *\n * The function picks `logic` from the root group's combinator and uses each\n * rule's parent-group combinator as its `join`. The first rule's `join` is\n * always `'and'` (it has no left neighbor).\n */\nexport function treeToFlat(tree: AdvancedFilterTree): {\n logic: 'and' | 'or'\n conditions: Array<{\n id: string\n field: string\n operator: import('./advanced-filter').FilterOperator\n value: unknown\n join: 'and' | 'or'\n }>\n} {\n const conditions: Array<{\n id: string\n field: string\n operator: import('./advanced-filter').FilterOperator\n value: unknown\n join: 'and' | 'or'\n }> = []\n\n function walk(group: FilterGroup, parentJoin: 'and' | 'or') {\n for (const child of group.children) {\n if (child.type === 'rule') {\n conditions.push({\n id: child.id,\n field: child.field,\n operator: child.operator,\n value: child.value,\n // First rule overall gets 'and' (it has no left neighbor); the rest\n // inherit the parent group's combinator.\n join: conditions.length === 0 ? 'and' : parentJoin,\n })\n } else {\n walk(child, child.combinator)\n }\n }\n }\n walk(tree.root, tree.root.combinator)\n\n return { logic: tree.root.combinator, conditions }\n}\n\n/**\n * Build a multi-rule tree wrapped in a root group with the given combinator.\n * Useful for quick-filter presets that combine multiple rules (e.g.\n * `status is win AND close_date is_after start_of_quarter`).\n */\nexport function makeMultiRuleTree(\n rules: Array<{ field: string; operator: FilterOperator; value: unknown }>,\n combinator: FilterCombinator = 'and',\n): AdvancedFilterTree {\n return {\n root: {\n id: crypto.randomUUID(),\n type: 'group',\n combinator,\n children: rules.map((rule) => {\n const node: FilterRule = {\n id: crypto.randomUUID(),\n type: 'rule',\n field: rule.field,\n operator: rule.operator,\n value: rule.value,\n }\n return node\n }),\n },\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["// packages/shared/src/lib/query/advanced-filter-tree.ts\nimport type { FilterOperator } from './advanced-filter'\nimport { isValuelessOperator } from './advanced-filter'\nimport { buildIlikeTerm } from '../db/buildIlikeTerm'\n\nexport type FilterCombinator = 'and' | 'or'\n\nexport type FilterRule = {\n id: string\n type: 'rule'\n field: string\n operator: FilterOperator\n value: unknown\n // Runtime-only metadata used by the editor reducer (e.g. removeLast) to find\n // the most recently inserted node. MUST NOT be serialized into URL or persisted state.\n addedAt?: number\n}\n\nexport type FilterGroup = {\n id: string\n type: 'group'\n combinator: FilterCombinator\n children: Array<FilterRule | FilterGroup>\n // Runtime-only metadata used by the editor reducer (e.g. removeLast) to find\n // the most recently inserted node. MUST NOT be serialized into URL or persisted state.\n addedAt?: number\n}\n\nexport type AdvancedFilterTree = {\n root: FilterGroup\n}\n\nexport const TREE_LIMITS = {\n maxGroupLevel: 3, // root = level 1; deepest nested group = level 3\n maxChildrenPerGroup: 15, // counts both rules and nested groups\n maxTotalRules: 50,\n} as const\n\nexport function createEmptyTree(): AdvancedFilterTree {\n return {\n root: {\n id: crypto.randomUUID(),\n type: 'group',\n combinator: 'and',\n children: [],\n },\n }\n}\n\nexport type ValidationResult =\n | { ok: true }\n | { ok: false; reason: 'depth' | 'width' | 'total' }\n\nexport function validateTreeLimits(tree: AdvancedFilterTree): ValidationResult {\n let totalRules = 0\n function walk(node: FilterRule | FilterGroup, level: number): ValidationResult {\n if (node.type === 'rule') {\n totalRules += 1\n return { ok: true }\n }\n if (level > TREE_LIMITS.maxGroupLevel) return { ok: false, reason: 'depth' }\n if (node.children.length > TREE_LIMITS.maxChildrenPerGroup) return { ok: false, reason: 'width' }\n for (const child of node.children) {\n const r = walk(child, level + 1)\n if (!r.ok) return r\n }\n return { ok: true }\n }\n const r = walk(tree.root, 1)\n if (!r.ok) return r\n if (totalRules > TREE_LIMITS.maxTotalRules) return { ok: false, reason: 'total' }\n return { ok: true }\n}\n\nfunction normalizeSingleValue(value: unknown): unknown {\n if (typeof value !== 'string') return value\n const trimmed = value.trim()\n return trimmed.length > 0 ? trimmed : null\n}\n\nfunction normalizeListValue(value: unknown): unknown[] {\n const list = Array.isArray(value) ? value : [value]\n return list.map(normalizeSingleValue).filter((v) => v !== null)\n}\n\nfunction compileRule(rule: FilterRule): Record<string, unknown> | null {\n if (!rule.field || !rule.operator) return null\n const filter: Record<string, unknown> = {}\n switch (rule.operator) {\n case 'is':\n case 'equals': {\n const v = normalizeSingleValue(rule.value)\n if (v === null) return null\n filter[rule.field] = { $eq: v }; break\n }\n case 'is_not':\n case 'not_equals': {\n const v = normalizeSingleValue(rule.value)\n if (v === null) return null\n filter[rule.field] = { $ne: v }; break\n }\n case 'contains': {\n const v = normalizeSingleValue(rule.value)\n if (v === null) return null\n filter[rule.field] = { $ilike: buildIlikeTerm(String(v)) }; break\n }\n case 'does_not_contain': {\n const v = normalizeSingleValue(rule.value)\n if (v === null) return null\n filter[rule.field] = { $not: { $ilike: buildIlikeTerm(String(v)) } }; break\n }\n case 'starts_with': {\n const v = normalizeSingleValue(rule.value)\n if (v === null) return null\n filter[rule.field] = { $ilike: buildIlikeTerm(String(v), 'startsWith') }; break\n }\n case 'ends_with': {\n const v = normalizeSingleValue(rule.value)\n if (v === null) return null\n filter[rule.field] = { $ilike: buildIlikeTerm(String(v), 'endsWith') }; break\n }\n case 'is_empty': filter[rule.field] = { $exists: false }; break\n case 'is_not_empty': filter[rule.field] = { $exists: true }; break\n case 'greater_than': { const v = normalizeSingleValue(rule.value); if (v === null) return null; filter[rule.field] = { $gt: v }; break }\n case 'less_than': { const v = normalizeSingleValue(rule.value); if (v === null) return null; filter[rule.field] = { $lt: v }; break }\n case 'greater_or_equal': { const v = normalizeSingleValue(rule.value); if (v === null) return null; filter[rule.field] = { $gte: v }; break }\n case 'less_or_equal': { const v = normalizeSingleValue(rule.value); if (v === null) return null; filter[rule.field] = { $lte: v }; break }\n case 'between': {\n if (Array.isArray(rule.value) && rule.value.length === 2) {\n const start = normalizeSingleValue(rule.value[0])\n const end = normalizeSingleValue(rule.value[1])\n if (start === null && end === null) return null\n if (start !== null && end !== null) filter[rule.field] = { $gte: start, $lte: end }\n else if (start !== null) filter[rule.field] = { $gte: start }\n else filter[rule.field] = { $lte: end }\n } else {\n return null\n }\n break\n }\n case 'is_before': { const v = normalizeSingleValue(rule.value); if (v === null) return null; filter[rule.field] = { $lt: v }; break }\n case 'is_after': { const v = normalizeSingleValue(rule.value); if (v === null) return null; filter[rule.field] = { $gt: v }; break }\n case 'is_any_of':\n case 'has_any_of': {\n const list = normalizeListValue(rule.value)\n if (list.length === 0) return null\n filter[rule.field] = { $in: list }; break\n }\n case 'is_none_of':\n case 'has_none_of': {\n const list = normalizeListValue(rule.value)\n if (list.length === 0) return null\n filter[rule.field] = { $nin: list }; break\n }\n case 'has_all_of': {\n const list = normalizeListValue(rule.value)\n if (list.length === 0) return null\n filter[rule.field] = { $contains: list }; break\n }\n case 'is_true': filter[rule.field] = { $eq: true }; break\n case 'is_false': filter[rule.field] = { $eq: false }; break\n }\n return Object.keys(filter).length > 0 ? filter : null\n}\n\nexport function compileTreeToWhere(tree: AdvancedFilterTree): Record<string, unknown> | null {\n return compileGroup(tree.root)\n}\n\nfunction compileGroup(node: FilterGroup): Record<string, unknown> | null {\n const compiled = node.children\n .map((child) => (child.type === 'rule' ? compileRule(child) : compileGroup(child)))\n .filter((c): c is Record<string, unknown> => c !== null)\n if (compiled.length === 0) return null\n if (compiled.length === 1) return compiled[0]\n return node.combinator === 'or' ? { $or: compiled } : { $and: compiled }\n}\n\n// Re-export isValuelessOperator so consumers of this module can access it\nexport { isValuelessOperator }\n\n/**\n * Build a single-rule tree wrapped in an AND root group.\n * Useful for quick-filter presets that apply one rule.\n */\nexport function makeRuleTree(rule: { field: string; operator: FilterOperator; value: unknown }): AdvancedFilterTree {\n const ruleNode: FilterRule = {\n id: crypto.randomUUID(),\n type: 'rule',\n field: rule.field,\n operator: rule.operator,\n value: rule.value,\n }\n return {\n root: {\n id: crypto.randomUUID(),\n type: 'group',\n combinator: 'and',\n children: [ruleNode],\n },\n }\n}\n\n/**\n * Persisted shape of an `AdvancedFilterTree` inside `PerspectiveSettings.filters`.\n * The `v: 2` marker disambiguates the tree from legacy flat `FilterValues` and\n * from the plain `{root: ...}` shape. Runtime-only `addedAt` is stripped before\n * persisting so saved perspectives are deterministic across reloads.\n */\nexport type PersistedFilterTree = {\n v: 2\n root: FilterGroup\n}\n\nfunction stripRuntimeMetadataDeep<T extends FilterRule | FilterGroup>(node: T): T {\n if (node.type === 'rule') {\n const { addedAt: _ignored, ...rest } = node as FilterRule & { addedAt?: number }\n return rest as T\n }\n const { addedAt: _ignored, children, ...rest } = node as FilterGroup & { addedAt?: number }\n const cleanChildren = children.map((c) => stripRuntimeMetadataDeep(c))\n return { ...rest, children: cleanChildren } as T\n}\n\n/** Serialize a tree for persistence (perspective settings, exports, etc.). */\nexport function serializeTreeForPersist(tree: AdvancedFilterTree): PersistedFilterTree {\n return { v: 2, root: stripRuntimeMetadataDeep(tree.root) }\n}\n\n/** Type guard: does `value` look like a persisted filter tree? */\nexport function isPersistedFilterTree(value: unknown): value is PersistedFilterTree {\n if (!value || typeof value !== 'object') return false\n const record = value as Record<string, unknown>\n if (record.v !== 2) return false\n const root = record.root\n if (!root || typeof root !== 'object') return false\n const r = root as Record<string, unknown>\n return r.type === 'group' && (r.combinator === 'and' || r.combinator === 'or') && Array.isArray(r.children)\n}\n\n/**\n * Restore a tree from its persisted shape. Returns `null` when the input is\n * not a recognizable persisted tree \u2014 callers should fall back to legacy\n * filter handling in that case.\n */\nexport function deserializeTreeFromPersist(value: unknown): AdvancedFilterTree | null {\n if (!isPersistedFilterTree(value)) return null\n return { root: value.root }\n}\n\n/**\n * Best-effort back-conversion of a tree to the legacy flat `AdvancedFilterState`\n * shape (`{logic, conditions[]}`). The flat shape only supports a single level\n * of AND/OR joining between sibling conditions; nested groups are flattened by\n * walking all leaf rules into the top level. Use this only for the legacy\n * `DataTable.advancedFilter` BC bridge \u2014 new code should keep the tree shape.\n *\n * The function picks `logic` from the root group's combinator and uses each\n * rule's parent-group combinator as its `join`. The first rule's `join` is\n * always `'and'` (it has no left neighbor).\n */\nexport function treeToFlat(tree: AdvancedFilterTree): {\n logic: 'and' | 'or'\n conditions: Array<{\n id: string\n field: string\n operator: import('./advanced-filter').FilterOperator\n value: unknown\n join: 'and' | 'or'\n }>\n} {\n const conditions: Array<{\n id: string\n field: string\n operator: import('./advanced-filter').FilterOperator\n value: unknown\n join: 'and' | 'or'\n }> = []\n\n function walk(group: FilterGroup, parentJoin: 'and' | 'or') {\n for (const child of group.children) {\n if (child.type === 'rule') {\n conditions.push({\n id: child.id,\n field: child.field,\n operator: child.operator,\n value: child.value,\n // First rule overall gets 'and' (it has no left neighbor); the rest\n // inherit the parent group's combinator.\n join: conditions.length === 0 ? 'and' : parentJoin,\n })\n } else {\n walk(child, child.combinator)\n }\n }\n }\n walk(tree.root, tree.root.combinator)\n\n return { logic: tree.root.combinator, conditions }\n}\n\n/**\n * Build a multi-rule tree wrapped in a root group with the given combinator.\n * Useful for quick-filter presets that combine multiple rules (e.g.\n * `status is win AND close_date is_after start_of_quarter`).\n */\nexport function makeMultiRuleTree(\n rules: Array<{ field: string; operator: FilterOperator; value: unknown }>,\n combinator: FilterCombinator = 'and',\n): AdvancedFilterTree {\n return {\n root: {\n id: crypto.randomUUID(),\n type: 'group',\n combinator,\n children: rules.map((rule) => {\n const node: FilterRule = {\n id: crypto.randomUUID(),\n type: 'rule',\n field: rule.field,\n operator: rule.operator,\n value: rule.value,\n }\n return node\n }),\n },\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["r", "_ignored", "rest"]
7
7
  }
@@ -1,4 +1,4 @@
1
- import { escapeLikePattern } from "../db/escapeLikePattern.js";
1
+ import { buildIlikeTerm } from "../db/buildIlikeTerm.js";
2
2
  const OPERATORS_BY_FIELD_TYPE = {
3
3
  text: ["is", "is_not", "contains", "does_not_contain", "starts_with", "ends_with", "is_empty", "is_not_empty"],
4
4
  number: ["equals", "not_equals", "greater_than", "less_than", "greater_or_equal", "less_or_equal", "between", "is_empty"],
@@ -152,19 +152,19 @@ function buildConditionFilter(condition) {
152
152
  break;
153
153
  case "contains":
154
154
  if (normalizeSingleValue(condition.value) === null) return null;
155
- filter[condition.field] = { $ilike: `%${escapeLikePattern(String(normalizeSingleValue(condition.value)))}%` };
155
+ filter[condition.field] = { $ilike: buildIlikeTerm(String(normalizeSingleValue(condition.value))) };
156
156
  break;
157
157
  case "does_not_contain":
158
158
  if (normalizeSingleValue(condition.value) === null) return null;
159
- filter[condition.field] = { $not: { $ilike: `%${escapeLikePattern(String(normalizeSingleValue(condition.value)))}%` } };
159
+ filter[condition.field] = { $not: { $ilike: buildIlikeTerm(String(normalizeSingleValue(condition.value))) } };
160
160
  break;
161
161
  case "starts_with":
162
162
  if (normalizeSingleValue(condition.value) === null) return null;
163
- filter[condition.field] = { $ilike: `${escapeLikePattern(String(normalizeSingleValue(condition.value)))}%` };
163
+ filter[condition.field] = { $ilike: buildIlikeTerm(String(normalizeSingleValue(condition.value)), "startsWith") };
164
164
  break;
165
165
  case "ends_with":
166
166
  if (normalizeSingleValue(condition.value) === null) return null;
167
- filter[condition.field] = { $ilike: `%${escapeLikePattern(String(normalizeSingleValue(condition.value)))}` };
167
+ filter[condition.field] = { $ilike: buildIlikeTerm(String(normalizeSingleValue(condition.value)), "endsWith") };
168
168
  break;
169
169
  case "is_empty":
170
170
  filter[condition.field] = { $exists: false };