@open-mercato/shared 0.6.4-develop.4382.1.6b4f656b77 → 0.6.4

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 (95) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +10 -0
  3. package/dist/lib/auth/apiKeyAuthCache.js +17 -6
  4. package/dist/lib/auth/apiKeyAuthCache.js.map +2 -2
  5. package/dist/lib/commands/command-bus.js +56 -47
  6. package/dist/lib/commands/command-bus.js.map +2 -2
  7. package/dist/lib/commands/flush.js +23 -1
  8. package/dist/lib/commands/flush.js.map +2 -2
  9. package/dist/lib/commands/index.js +6 -1
  10. package/dist/lib/commands/index.js.map +2 -2
  11. package/dist/lib/commands/redo.js +106 -0
  12. package/dist/lib/commands/redo.js.map +7 -0
  13. package/dist/lib/commands/runCrudCommandWrite.js +38 -0
  14. package/dist/lib/commands/runCrudCommandWrite.js.map +7 -0
  15. package/dist/lib/commands/scope.js +51 -37
  16. package/dist/lib/commands/scope.js.map +2 -2
  17. package/dist/lib/commands/types.js.map +2 -2
  18. package/dist/lib/crud/errors.js +22 -0
  19. package/dist/lib/crud/errors.js.map +2 -2
  20. package/dist/lib/crud/factory.js +16 -0
  21. package/dist/lib/crud/factory.js.map +2 -2
  22. package/dist/lib/crud/optimistic-lock-command.js +109 -0
  23. package/dist/lib/crud/optimistic-lock-command.js.map +7 -0
  24. package/dist/lib/crud/optimistic-lock-headers.js +15 -0
  25. package/dist/lib/crud/optimistic-lock-headers.js.map +7 -0
  26. package/dist/lib/crud/optimistic-lock-store.js +52 -0
  27. package/dist/lib/crud/optimistic-lock-store.js.map +7 -0
  28. package/dist/lib/crud/optimistic-lock.js +172 -0
  29. package/dist/lib/crud/optimistic-lock.js.map +7 -0
  30. package/dist/lib/data/engine.js +2 -2
  31. package/dist/lib/data/engine.js.map +2 -2
  32. package/dist/lib/di/container.js +18 -2
  33. package/dist/lib/di/container.js.map +2 -2
  34. package/dist/lib/encryption/aes.js +37 -3
  35. package/dist/lib/encryption/aes.js.map +2 -2
  36. package/dist/lib/encryption/kms.js +57 -23
  37. package/dist/lib/encryption/kms.js.map +2 -2
  38. package/dist/lib/encryption/subscriber.js +41 -8
  39. package/dist/lib/encryption/subscriber.js.map +2 -2
  40. package/dist/lib/encryption/tenantDataEncryptionService.js +35 -7
  41. package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
  42. package/dist/lib/i18n/context.js +5 -0
  43. package/dist/lib/i18n/context.js.map +2 -2
  44. package/dist/lib/query/engine.js +41 -31
  45. package/dist/lib/query/engine.js.map +2 -2
  46. package/dist/lib/version.js +1 -1
  47. package/dist/lib/version.js.map +1 -1
  48. package/dist/modules/integrations/types.js.map +2 -2
  49. package/dist/modules/search.js.map +2 -2
  50. package/package.json +8 -9
  51. package/src/lib/auth/__tests__/apiKeyAuthCache.test.ts +35 -0
  52. package/src/lib/auth/apiKeyAuthCache.ts +20 -6
  53. package/src/lib/commands/__tests__/command-bus.cache.test.ts +2 -0
  54. package/src/lib/commands/__tests__/command-bus.undo-audit.test.ts +2 -0
  55. package/src/lib/commands/__tests__/command-bus.undo-toctou.test.ts +122 -0
  56. package/src/lib/commands/__tests__/flush.test.ts +110 -9
  57. package/src/lib/commands/__tests__/redo.test.ts +265 -0
  58. package/src/lib/commands/__tests__/runCrudCommandWrite.test.ts +390 -0
  59. package/src/lib/commands/__tests__/scope.test.ts +48 -0
  60. package/src/lib/commands/command-bus.ts +62 -44
  61. package/src/lib/commands/flush.ts +79 -2
  62. package/src/lib/commands/index.ts +9 -0
  63. package/src/lib/commands/redo.ts +235 -0
  64. package/src/lib/commands/runCrudCommandWrite.ts +82 -0
  65. package/src/lib/commands/scope.ts +70 -55
  66. package/src/lib/commands/types.ts +54 -1
  67. package/src/lib/crud/__tests__/crud-factory.test.ts +106 -0
  68. package/src/lib/crud/__tests__/optimistic-lock-command.test.ts +425 -0
  69. package/src/lib/crud/__tests__/optimistic-lock-store.test.ts +194 -0
  70. package/src/lib/crud/__tests__/optimistic-lock.test.ts +526 -0
  71. package/src/lib/crud/errors.ts +29 -0
  72. package/src/lib/crud/factory.ts +23 -0
  73. package/src/lib/crud/optimistic-lock-command.ts +305 -0
  74. package/src/lib/crud/optimistic-lock-headers.ts +30 -0
  75. package/src/lib/crud/optimistic-lock-store.ts +87 -0
  76. package/src/lib/crud/optimistic-lock.ts +379 -0
  77. package/src/lib/data/engine.ts +11 -8
  78. package/src/lib/di/container.ts +17 -1
  79. package/src/lib/encryption/__tests__/dek-lifecycle.test.ts +194 -0
  80. package/src/lib/encryption/__tests__/kms.test.ts +44 -6
  81. package/src/lib/encryption/__tests__/lookupHash.test.ts +113 -0
  82. package/src/lib/encryption/__tests__/subscriber.change-tracking.test.ts +96 -0
  83. package/src/lib/encryption/__tests__/subscriber.deep-decrypt-collections.test.ts +123 -0
  84. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +68 -1
  85. package/src/lib/encryption/aes.ts +78 -2
  86. package/src/lib/encryption/kms.ts +76 -24
  87. package/src/lib/encryption/subscriber.ts +54 -9
  88. package/src/lib/encryption/tenantDataEncryptionService.ts +53 -8
  89. package/src/lib/i18n/context.tsx +11 -0
  90. package/src/lib/query/__tests__/resolve-registered-entity-table.test.ts +83 -0
  91. package/src/lib/query/engine.ts +59 -30
  92. package/src/modules/integrations/types.ts +14 -0
  93. package/src/modules/notifications/handler.ts +7 -0
  94. package/src/modules/search.ts +9 -0
  95. package/src/modules/vector.ts +7 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/modules/search.ts"],
4
- "sourcesContent": ["import type { EntityId } from './entities'\n\n// =============================================================================\n// Strategy Identifiers\n// =============================================================================\n\n/**\n * Built-in strategy identifiers plus extensible string for third-party strategies.\n */\nexport type SearchStrategyId = 'tokens' | 'vector' | 'fulltext' | (string & Record<string, never>)\n\n// =============================================================================\n// Result Types\n// =============================================================================\n\n/**\n * Presenter metadata for displaying search results in UI (Cmd+K, global search).\n */\nexport type SearchResultPresenter = {\n title: string\n subtitle?: string\n icon?: string\n badge?: string\n}\n\n/**\n * Deep link rendered next to a search result.\n */\nexport type SearchResultLink = {\n href: string\n label: string\n kind?: 'primary' | 'secondary'\n}\n\n/**\n * A single search result returned by a strategy.\n */\nexport type SearchResult = {\n /** Entity type identifier, e.g., 'customers:customer_person_profile' */\n entityId: EntityId\n /** Record primary key */\n recordId: string\n /** Relevance score (normalized 0-1 range preferred, but RRF scores may exceed 1) */\n score: number\n /** Which strategy produced this result */\n source: SearchStrategyId\n /** Optional presenter for quick display */\n presenter?: SearchResultPresenter\n /** Primary URL when result is clicked */\n url?: string\n /** Additional action links */\n links?: SearchResultLink[]\n /** Extra metadata from the strategy */\n metadata?: Record<string, unknown>\n /** Organization scope of the result, when known by the strategy. */\n organizationId?: string | null\n}\n\n// =============================================================================\n// Search Options\n// =============================================================================\n\n/**\n * Options passed to SearchService.search()\n */\nexport type SearchOptions = {\n /** Tenant isolation - required */\n tenantId: string\n /**\n * Optional organization filter.\n * - `string` restricts results to that organization only.\n * - `undefined` or `null` means no organization filter (tenant-wide).\n */\n organizationId?: string | null\n /**\n * Optional organization allowlist.\n * - Non-empty array restricts results to one of those organizations.\n * - Empty array means no organizations are visible and should return no results.\n * - `undefined` or `null` means no organization filter (tenant-wide).\n *\n * `organizationId` takes precedence when both are provided.\n */\n organizationIds?: string[] | null\n /** Filter to specific entity types */\n entityTypes?: EntityId[]\n /** Use only specific strategies (defaults to all available) */\n strategies?: SearchStrategyId[]\n /** Maximum results per strategy before merging */\n limit?: number\n /** Offset for pagination */\n offset?: number\n /** How to combine results: 'or' merges all, 'and' requires match in all strategies */\n combineMode?: 'or' | 'and'\n}\n\n// =============================================================================\n// Indexable Record\n// =============================================================================\n\n/**\n * A record prepared for indexing across all strategies.\n */\nexport type IndexableRecord = {\n /** Entity type identifier */\n entityId: EntityId\n /** Record primary key */\n recordId: string\n /** Tenant for isolation */\n tenantId: string\n /** Optional organization for additional filtering */\n organizationId?: string | null\n /** All fields from the record (strategies will filter based on their needs) */\n fields: Record<string, unknown>\n /** Optional presenter for result display */\n presenter?: SearchResultPresenter\n /** Primary URL for the record */\n url?: string\n /** Additional action links */\n links?: SearchResultLink[]\n /** Text content for embedding (from buildSource, used by vector strategy) */\n text?: string | string[]\n /** Source object for checksum calculation (change detection) */\n checksumSource?: unknown\n}\n\n// =============================================================================\n// Strategy Interface\n// =============================================================================\n\n/**\n * Interface that all search strategies must implement.\n * Following the cache module's strategy pattern.\n */\nexport interface SearchStrategy {\n /** Unique strategy identifier */\n readonly id: SearchStrategyId\n\n /** Human-readable name for debugging/logging */\n readonly name: string\n\n /** Priority for result merging (higher = more prominent in results) */\n readonly priority: number\n\n /** Check if strategy is available and configured */\n isAvailable(): Promise<boolean>\n\n /** Initialize strategy resources (lazy, called on first use) */\n ensureReady(): Promise<void>\n\n /** Execute a search query */\n search(query: string, options: SearchOptions): Promise<SearchResult[]>\n\n /** Index a record */\n index(record: IndexableRecord): Promise<void>\n\n /** Delete a record from the index */\n delete(entityId: EntityId, recordId: string, tenantId: string): Promise<void>\n\n /** Bulk index multiple records (optional optimization) */\n bulkIndex?(records: IndexableRecord[]): Promise<void>\n\n /** Purge all records for an entity type (optional) */\n purge?(entityId: EntityId, tenantId: string): Promise<void>\n}\n\n// =============================================================================\n// Service Configuration\n// =============================================================================\n\n/**\n * Configuration for result merging across strategies.\n */\nexport type ResultMergeConfig = {\n /** How to handle duplicate results: 'highest_score' | 'first' | 'merge_scores' */\n duplicateHandling: 'highest_score' | 'first' | 'merge_scores'\n /** Weight multipliers per strategy (e.g., { meilisearch: 1.2, tokens: 0.8 }) */\n strategyWeights?: Record<SearchStrategyId, number>\n /** Minimum score threshold to include in results */\n minScore?: number\n}\n\n/**\n * Callback function to enrich search results with presenter data.\n * Used to load presenter from database when not available from search strategy.\n */\nexport type PresenterEnricherFn = (\n results: SearchResult[],\n tenantId: string,\n organizationId?: string | null,\n) => Promise<SearchResult[]>\n\n/**\n * Options for creating a SearchService instance.\n */\nexport type SearchServiceOptions = {\n /** Array of strategy instances */\n strategies?: SearchStrategy[]\n /** Default strategies to use when not specified in search options */\n defaultStrategies?: SearchStrategyId[]\n /** Fallback strategy when others fail */\n fallbackStrategy?: SearchStrategyId\n /** Configuration for merging results from multiple strategies */\n mergeConfig?: ResultMergeConfig\n /** Callback to enrich results with presenter data from database */\n presenterEnricher?: PresenterEnricherFn\n /** TTL (ms) for the per-strategy availability cache. Defaults to 2_000. */\n availabilityCacheTtlMs?: number\n}\n\n// =============================================================================\n// Module Configuration (for modules defining searchable entities)\n// =============================================================================\n\n/**\n * Context passed to buildSource, formatResult, resolveUrl, and resolveLinks.\n */\nexport type SearchBuildContext = {\n /** The record being indexed */\n record: Record<string, unknown>\n /** Custom fields for the record */\n customFields: Record<string, unknown>\n /** Organization ID if applicable */\n organizationId?: string | null\n /** Tenant ID */\n tenantId?: string | null\n /** DI container for resolving dependencies */\n container?: unknown\n /** Query engine for loading related records (optional, used by buildSource for entity hydration) */\n queryEngine?: unknown\n}\n\n/**\n * Source data for indexing a record.\n */\nexport type SearchIndexSource = {\n /** Text content for keyword/fuzzy search (single string or array of chunks) */\n text: string | string[]\n /** Optional structured fields for filtering */\n fields?: Record<string, unknown>\n /** Presenter for quick display in search results */\n presenter?: SearchResultPresenter\n /** Deep links for the result */\n links?: SearchResultLink[]\n /** Source object used for checksum calculation (change detection) */\n checksumSource?: unknown\n}\n\n/**\n * Policy defining how fields should be handled for search indexing.\n */\nexport type SearchFieldPolicy = {\n /** Fields safe to send to external providers (fuzzy searchable) */\n searchable?: string[]\n /** Fields for hash-based search only (encrypted/sensitive) */\n hashOnly?: string[]\n /** Fields to exclude from all search */\n excluded?: string[]\n}\n\n/**\n * Configuration for a single searchable entity within a module.\n */\nexport type SearchEntityConfig = {\n /** Entity identifier, e.g., 'customers:customer_person_profile' */\n entityId: EntityId\n /** Enable/disable search for this entity (default: true) */\n enabled?: boolean\n /** Override strategies for this specific entity */\n strategies?: SearchStrategyId[]\n /** Priority for result ordering (higher = more prominent) */\n priority?: number\n /** Build searchable content from record */\n buildSource?: (ctx: SearchBuildContext) => Promise<SearchIndexSource | null> | SearchIndexSource | null\n /** Format result for display in Cmd+K */\n formatResult?: (ctx: SearchBuildContext) => Promise<SearchResultPresenter | null> | SearchResultPresenter | null\n /** Resolve primary URL when result is clicked */\n resolveUrl?: (ctx: SearchBuildContext) => Promise<string | null> | string | null\n /** Resolve additional action links */\n resolveLinks?: (ctx: SearchBuildContext) => Promise<SearchResultLink[] | null> | SearchResultLink[] | null\n /** Define which fields are searchable vs hash-only */\n fieldPolicy?: SearchFieldPolicy\n}\n\n/**\n * Module-level search configuration (defined in search.ts files).\n */\nexport type SearchModuleConfig = {\n /** Default strategies for all entities in this module */\n defaultStrategies?: SearchStrategyId[]\n /** Entity configurations */\n entities: SearchEntityConfig[]\n}\n\n// =============================================================================\n// Event Payloads (for indexer events)\n// =============================================================================\n\n/**\n * Payload for search.index_record events.\n */\nexport type SearchIndexPayload = {\n entityId: EntityId\n recordId: string\n tenantId: string\n organizationId?: string | null\n record: Record<string, unknown>\n customFields?: Record<string, unknown>\n}\n\n/**\n * Payload for search.delete_record events.\n */\nexport type SearchDeletePayload = {\n entityId: EntityId\n recordId: string\n tenantId: string\n}\n\n// =============================================================================\n// Global Registry for Search Module Configs\n// =============================================================================\n\nlet _searchModuleConfigs: SearchModuleConfig[] | null = null\n\n/**\n * Register search module configurations globally.\n * Called during app bootstrap with configs from search.generated.ts.\n */\nexport function registerSearchModuleConfigs(configs: SearchModuleConfig[]): void {\n if (_searchModuleConfigs !== null && process.env.NODE_ENV === 'development') {\n console.debug('[Bootstrap] Search module configs re-registered (this may occur during HMR)')\n }\n _searchModuleConfigs = configs\n}\n\n/**\n * Get registered search module configurations.\n * Returns empty array if not registered (search module may not be enabled).\n */\nexport function getSearchModuleConfigs(): SearchModuleConfig[] {\n return _searchModuleConfigs ?? []\n}\n"],
5
- "mappings": "AAkUA,IAAI,uBAAoD;AAMjD,SAAS,4BAA4B,SAAqC;AAC/E,MAAI,yBAAyB,QAAQ,QAAQ,IAAI,aAAa,eAAe;AAC3E,YAAQ,MAAM,6EAA6E;AAAA,EAC7F;AACA,yBAAuB;AACzB;AAMO,SAAS,yBAA+C;AAC7D,SAAO,wBAAwB,CAAC;AAClC;",
4
+ "sourcesContent": ["import type { EntityId } from './entities'\n\n// =============================================================================\n// Strategy Identifiers\n// =============================================================================\n\n/**\n * Built-in strategy identifiers plus extensible string for third-party strategies.\n */\nexport type SearchStrategyId = 'tokens' | 'vector' | 'fulltext' | (string & Record<string, never>)\n\n// =============================================================================\n// Result Types\n// =============================================================================\n\n/**\n * Presenter metadata for displaying search results in UI (Cmd+K, global search).\n */\nexport type SearchResultPresenter = {\n title: string\n subtitle?: string\n icon?: string\n badge?: string\n}\n\n/**\n * Deep link rendered next to a search result.\n */\nexport type SearchResultLink = {\n href: string\n label: string\n kind?: 'primary' | 'secondary'\n}\n\n/**\n * A single search result returned by a strategy.\n */\nexport type SearchResult = {\n /** Entity type identifier, e.g., 'customers:customer_person_profile' */\n entityId: EntityId\n /** Record primary key */\n recordId: string\n /** Relevance score (normalized 0-1 range preferred, but RRF scores may exceed 1) */\n score: number\n /** Which strategy produced this result */\n source: SearchStrategyId\n /** Optional presenter for quick display */\n presenter?: SearchResultPresenter\n /** Primary URL when result is clicked */\n url?: string\n /** Additional action links */\n links?: SearchResultLink[]\n /** Extra metadata from the strategy */\n metadata?: Record<string, unknown>\n /** Organization scope of the result, when known by the strategy. */\n organizationId?: string | null\n}\n\n// =============================================================================\n// Search Options\n// =============================================================================\n\n/**\n * Options passed to SearchService.search()\n */\nexport type SearchOptions = {\n /** Tenant isolation - required */\n tenantId: string\n /**\n * Optional organization filter.\n * - `string` restricts results to that organization only.\n * - `undefined` or `null` means no organization filter (tenant-wide).\n */\n organizationId?: string | null\n /**\n * Optional organization allowlist.\n * - Non-empty array restricts results to one of those organizations.\n * - Empty array means no organizations are visible and should return no results.\n * - `undefined` or `null` means no organization filter (tenant-wide).\n *\n * `organizationId` takes precedence when both are provided.\n */\n organizationIds?: string[] | null\n /** Filter to specific entity types */\n entityTypes?: EntityId[]\n /** Use only specific strategies (defaults to all available) */\n strategies?: SearchStrategyId[]\n /** Maximum results per strategy before merging */\n limit?: number\n /** Offset for pagination */\n offset?: number\n /** How to combine results: 'or' merges all, 'and' requires match in all strategies */\n combineMode?: 'or' | 'and'\n}\n\n// =============================================================================\n// Indexable Record\n// =============================================================================\n\n/**\n * A record prepared for indexing across all strategies.\n */\nexport type IndexableRecord = {\n /** Entity type identifier */\n entityId: EntityId\n /** Record primary key */\n recordId: string\n /** Tenant for isolation */\n tenantId: string\n /** Optional organization for additional filtering */\n organizationId?: string | null\n /** All fields from the record (strategies will filter based on their needs) */\n fields: Record<string, unknown>\n /** Optional presenter for result display */\n presenter?: SearchResultPresenter\n /** Primary URL for the record */\n url?: string\n /** Additional action links */\n links?: SearchResultLink[]\n /** Text content for embedding (from buildSource, used by vector strategy) */\n text?: string | string[]\n /** Source object for checksum calculation (change detection) */\n checksumSource?: unknown\n}\n\n// =============================================================================\n// Strategy Interface\n// =============================================================================\n\n/**\n * Interface that all search strategies must implement.\n * Following the cache module's strategy pattern.\n */\nexport interface SearchStrategy {\n /** Unique strategy identifier */\n readonly id: SearchStrategyId\n\n /** Human-readable name for debugging/logging */\n readonly name: string\n\n /** Priority for result merging (higher = more prominent in results) */\n readonly priority: number\n\n /** Check if strategy is available and configured */\n isAvailable(): Promise<boolean>\n\n /** Initialize strategy resources (lazy, called on first use) */\n ensureReady(): Promise<void>\n\n /** Execute a search query */\n search(query: string, options: SearchOptions): Promise<SearchResult[]>\n\n /** Index a record */\n index(record: IndexableRecord): Promise<void>\n\n /** Delete a record from the index */\n delete(entityId: EntityId, recordId: string, tenantId: string): Promise<void>\n\n /** Bulk index multiple records (optional optimization) */\n bulkIndex?(records: IndexableRecord[]): Promise<void>\n\n /** Purge all records for an entity type (optional) */\n purge?(entityId: EntityId, tenantId: string): Promise<void>\n}\n\n// =============================================================================\n// Service Configuration\n// =============================================================================\n\n/**\n * Configuration for result merging across strategies.\n */\nexport type ResultMergeConfig = {\n /** How to handle duplicate results: 'highest_score' | 'first' | 'merge_scores' */\n duplicateHandling: 'highest_score' | 'first' | 'merge_scores'\n /** Weight multipliers per strategy (e.g., { meilisearch: 1.2, tokens: 0.8 }) */\n strategyWeights?: Record<SearchStrategyId, number>\n /** Minimum score threshold to include in results */\n minScore?: number\n}\n\n/**\n * Callback function to enrich search results with presenter data.\n * Used to load presenter from database when not available from search strategy.\n */\nexport type PresenterEnricherFn = (\n results: SearchResult[],\n tenantId: string,\n organizationId?: string | null,\n) => Promise<SearchResult[]>\n\n/**\n * Options for creating a SearchService instance.\n */\nexport type SearchServiceOptions = {\n /** Array of strategy instances */\n strategies?: SearchStrategy[]\n /** Default strategies to use when not specified in search options */\n defaultStrategies?: SearchStrategyId[]\n /** Fallback strategy when others fail */\n fallbackStrategy?: SearchStrategyId\n /** Configuration for merging results from multiple strategies */\n mergeConfig?: ResultMergeConfig\n /** Callback to enrich results with presenter data from database */\n presenterEnricher?: PresenterEnricherFn\n /** TTL (ms) for the per-strategy availability cache. Defaults to 2_000. */\n availabilityCacheTtlMs?: number\n}\n\n// =============================================================================\n// Module Configuration (for modules defining searchable entities)\n// =============================================================================\n\n/**\n * Context passed to buildSource, formatResult, resolveUrl, and resolveLinks.\n */\nexport type SearchBuildContext = {\n /** The record being indexed */\n record: Record<string, unknown>\n /** Custom fields for the record */\n customFields: Record<string, unknown>\n /** Organization ID if applicable */\n organizationId?: string | null\n /** Tenant ID */\n tenantId?: string | null\n /** DI container for resolving dependencies */\n container?: unknown\n /** Query engine for loading related records (optional, used by buildSource for entity hydration) */\n queryEngine?: unknown\n}\n\n/**\n * Source data for indexing a record.\n */\nexport type SearchIndexSource = {\n /** Text content for keyword/fuzzy search (single string or array of chunks) */\n text: string | string[]\n /** Optional structured fields for filtering */\n fields?: Record<string, unknown>\n /** Presenter for quick display in search results */\n presenter?: SearchResultPresenter\n /** Deep links for the result */\n links?: SearchResultLink[]\n /** Source object used for checksum calculation (change detection) */\n checksumSource?: unknown\n}\n\n/**\n * Policy defining how fields should be handled for search indexing.\n */\nexport type SearchFieldPolicy = {\n /** Fields safe to send to external providers (fuzzy searchable) */\n searchable?: string[]\n /** Fields for hash-based search only (encrypted/sensitive) */\n hashOnly?: string[]\n /** Fields to exclude from all search */\n excluded?: string[]\n}\n\n/**\n * Configuration for a single searchable entity within a module.\n */\nexport type SearchEntityConfig = {\n /** Entity identifier, e.g., 'customers:customer_person_profile' */\n entityId: EntityId\n /** Enable/disable search for this entity (default: true) */\n enabled?: boolean\n /** Override strategies for this specific entity */\n strategies?: SearchStrategyId[]\n /** Priority for result ordering (higher = more prominent) */\n priority?: number\n /** Build searchable content from record */\n buildSource?: (ctx: SearchBuildContext) => Promise<SearchIndexSource | null> | SearchIndexSource | null\n /** Format result for display in Cmd+K */\n formatResult?: (ctx: SearchBuildContext) => Promise<SearchResultPresenter | null> | SearchResultPresenter | null\n /** Resolve primary URL when result is clicked */\n resolveUrl?: (ctx: SearchBuildContext) => Promise<string | null> | string | null\n /** Resolve additional action links */\n resolveLinks?: (ctx: SearchBuildContext) => Promise<SearchResultLink[] | null> | SearchResultLink[] | null\n /** Define which fields are searchable vs hash-only */\n fieldPolicy?: SearchFieldPolicy\n /**\n * Per-entity view feature(s) required to read this entity's records through\n * data-returning surfaces (e.g. the `search_get` / `search_aggregate` AI tools).\n * These tools must NOT rely on the search-administration `search.view` feature\n * to gate record reads \u2014 callers must additionally hold the owning module's\n * `<entity>.view` feature(s) declared here. When omitted, those tools fail\n * closed (deny) so an entity is never exposed without an explicit grant.\n */\n aclFeatures?: string[]\n}\n\n/**\n * Module-level search configuration (defined in search.ts files).\n */\nexport type SearchModuleConfig = {\n /** Default strategies for all entities in this module */\n defaultStrategies?: SearchStrategyId[]\n /** Entity configurations */\n entities: SearchEntityConfig[]\n}\n\n// =============================================================================\n// Event Payloads (for indexer events)\n// =============================================================================\n\n/**\n * Payload for search.index_record events.\n */\nexport type SearchIndexPayload = {\n entityId: EntityId\n recordId: string\n tenantId: string\n organizationId?: string | null\n record: Record<string, unknown>\n customFields?: Record<string, unknown>\n}\n\n/**\n * Payload for search.delete_record events.\n */\nexport type SearchDeletePayload = {\n entityId: EntityId\n recordId: string\n tenantId: string\n}\n\n// =============================================================================\n// Global Registry for Search Module Configs\n// =============================================================================\n\nlet _searchModuleConfigs: SearchModuleConfig[] | null = null\n\n/**\n * Register search module configurations globally.\n * Called during app bootstrap with configs from search.generated.ts.\n */\nexport function registerSearchModuleConfigs(configs: SearchModuleConfig[]): void {\n if (_searchModuleConfigs !== null && process.env.NODE_ENV === 'development') {\n console.debug('[Bootstrap] Search module configs re-registered (this may occur during HMR)')\n }\n _searchModuleConfigs = configs\n}\n\n/**\n * Get registered search module configurations.\n * Returns empty array if not registered (search module may not be enabled).\n */\nexport function getSearchModuleConfigs(): SearchModuleConfig[] {\n return _searchModuleConfigs ?? []\n}\n"],
5
+ "mappings": "AA2UA,IAAI,uBAAoD;AAMjD,SAAS,4BAA4B,SAAqC;AAC/E,MAAI,yBAAyB,QAAQ,QAAQ,IAAI,aAAa,eAAe;AAC3E,YAAQ,MAAM,6EAA6E;AAAA,EAC7F;AACA,yBAAuB;AACzB;AAMO,SAAS,yBAA+C;AAC7D,SAAO,wBAAwB,CAAC;AAClC;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/shared",
3
- "version": "0.6.4-develop.4382.1.6b4f656b77",
3
+ "version": "0.6.4",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -89,16 +89,16 @@
89
89
  }
90
90
  },
91
91
  "dependencies": {
92
- "@mikro-orm/core": "^7.1.3",
93
- "@mikro-orm/decorators": "^7.1.3",
94
- "@mikro-orm/postgresql": "^7.1.3",
95
- "@open-mercato/cache": "0.6.4-develop.4382.1.6b4f656b77",
92
+ "@mikro-orm/core": "^7.1.4",
93
+ "@mikro-orm/decorators": "^7.1.4",
94
+ "@mikro-orm/postgresql": "^7.1.4",
95
+ "@open-mercato/cache": "0.6.4",
96
96
  "dotenv": "^17.4.2",
97
- "rate-limiter-flexible": "^11.1.0",
97
+ "rate-limiter-flexible": "^11.1.1",
98
98
  "re2js": "2.8.3",
99
99
  "reflect-metadata": "^0.2.2",
100
100
  "sanitize-html": "^2.17.4",
101
- "undici": "^8.3.0"
101
+ "undici": "^8.4.1"
102
102
  },
103
103
  "devDependencies": {
104
104
  "@types/jest": "^30.0.0",
@@ -113,6 +113,5 @@
113
113
  "type": "git",
114
114
  "url": "https://github.com/open-mercato/open-mercato",
115
115
  "directory": "packages/shared"
116
- },
117
- "stableVersion": "0.6.3"
116
+ }
118
117
  }
@@ -114,4 +114,39 @@ describe('createApiKeyAuthCache', () => {
114
114
  cache.setMiss('')
115
115
  expect(cache.get('')).toBeUndefined()
116
116
  })
117
+
118
+ it('never retains the plaintext secret as a cache key (success and miss paths)', () => {
119
+ const clock = fakeClock()
120
+ const cache = createApiKeyAuthCache({ successTtlMs: 60_000, negativeTtlMs: 5_000, now: clock.now })
121
+ const liveSecret = 'sk_live_0123456789abcdef'
122
+ const badGuess = 'sk_live_brute_force_guess'
123
+
124
+ cache.setSuccess(liveSecret, sampleAuth, null)
125
+ cache.setMiss(badGuess)
126
+
127
+ expect(cache.get(liveSecret)).toEqual(sampleAuth)
128
+ expect(cache.get(badGuess)).toBeNull()
129
+
130
+ const storedKeys = cache.inspectEntryKeysForTests()
131
+ expect(storedKeys.length).toBe(2)
132
+ for (const key of storedKeys) {
133
+ expect(key).not.toContain(liveSecret)
134
+ expect(key).not.toContain(badGuess)
135
+ expect(key).toMatch(/^[0-9a-f]{32}$/)
136
+ }
137
+ })
138
+
139
+ it('uses an independent per-cache key so the same secret fingerprints differently across caches', () => {
140
+ const clock = fakeClock()
141
+ const secret = 'sk_live_shared_secret'
142
+ const cacheA = createApiKeyAuthCache({ successTtlMs: 60_000, now: clock.now })
143
+ const cacheB = createApiKeyAuthCache({ successTtlMs: 60_000, now: clock.now })
144
+
145
+ cacheA.setSuccess(secret, sampleAuth, null)
146
+ cacheB.setSuccess(secret, sampleAuth, null)
147
+
148
+ expect(cacheA.get(secret)).toEqual(sampleAuth)
149
+ expect(cacheB.get(secret)).toEqual(sampleAuth)
150
+ expect(cacheA.inspectEntryKeysForTests()[0]).not.toBe(cacheB.inspectEntryKeysForTests()[0])
151
+ })
117
152
  })
@@ -1,7 +1,15 @@
1
+ import { createHmac, randomBytes } from 'node:crypto'
2
+
1
3
  const DEFAULT_SUCCESS_TTL_MS = 30_000
2
4
  const DEFAULT_NEGATIVE_TTL_MS = 5_000
3
5
  const DEFAULT_LAST_USED_WRITE_INTERVAL_MS = 60_000
4
6
  const DEFAULT_MAX_ENTRIES = 1_000
7
+ const FINGERPRINT_BYTES = 16
8
+
9
+ function createSecretFingerprinter(): (secret: string) => string {
10
+ const key = randomBytes(32)
11
+ return (secret) => createHmac('sha256', key).update(secret, 'utf8').digest('hex').slice(0, FINGERPRINT_BYTES * 2)
12
+ }
5
13
 
6
14
  export type CachedApiKeyAuth = Record<string, unknown> | null
7
15
 
@@ -27,6 +35,7 @@ export type ApiKeyAuthCache = {
27
35
  shouldWriteLastUsed(keyId: string): boolean
28
36
  clear(): void
29
37
  size(): number
38
+ inspectEntryKeysForTests(): string[]
30
39
  }
31
40
 
32
41
  function resolveTtlEnv(name: string, fallback: number): number {
@@ -46,6 +55,7 @@ export function createApiKeyAuthCache(options: ApiKeyAuthCacheOptions = {}): Api
46
55
  )
47
56
  const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES
48
57
  const now = options.now ?? (() => Date.now())
58
+ const fingerprint = createSecretFingerprinter()
49
59
 
50
60
  const entries = new Map<string, CachedEntry>()
51
61
  const lastUsedWrites = new Map<string, number>()
@@ -68,12 +78,13 @@ export function createApiKeyAuthCache(options: ApiKeyAuthCacheOptions = {}): Api
68
78
  return {
69
79
  get(secret) {
70
80
  if (!secret) return undefined
71
- const entry = entries.get(secret)
81
+ const key = fingerprint(secret)
82
+ const entry = entries.get(key)
72
83
  if (!entry) return undefined
73
84
  const currentMs = now()
74
- if (purgeStale(secret, entry, currentMs)) return undefined
75
- entries.delete(secret)
76
- entries.set(secret, entry)
85
+ if (purgeStale(key, entry, currentMs)) return undefined
86
+ entries.delete(key)
87
+ entries.set(key, entry)
77
88
  return entry.auth
78
89
  },
79
90
  setSuccess(secret, auth, expiresAtMs) {
@@ -83,13 +94,13 @@ export function createApiKeyAuthCache(options: ApiKeyAuthCacheOptions = {}): Api
83
94
  const ttlEnd = currentMs + successTtlMs
84
95
  const effectiveExpiry = expiresAtMs != null ? Math.min(ttlEnd, expiresAtMs) : ttlEnd
85
96
  if (effectiveExpiry <= currentMs) return
86
- touch(secret, { auth, cachedAtMs: currentMs, expiresAtMs: effectiveExpiry })
97
+ touch(fingerprint(secret), { auth, cachedAtMs: currentMs, expiresAtMs: effectiveExpiry })
87
98
  },
88
99
  setMiss(secret) {
89
100
  if (!secret) return
90
101
  if (negativeTtlMs <= 0) return
91
102
  const currentMs = now()
92
- touch(secret, { auth: null, cachedAtMs: currentMs, expiresAtMs: currentMs + negativeTtlMs })
103
+ touch(fingerprint(secret), { auth: null, cachedAtMs: currentMs, expiresAtMs: currentMs + negativeTtlMs })
93
104
  },
94
105
  invalidateByKeyId(keyId) {
95
106
  if (!keyId) return
@@ -117,6 +128,9 @@ export function createApiKeyAuthCache(options: ApiKeyAuthCacheOptions = {}): Api
117
128
  size() {
118
129
  return entries.size
119
130
  },
131
+ inspectEntryKeysForTests() {
132
+ return Array.from(entries.keys())
133
+ },
120
134
  }
121
135
  }
122
136
 
@@ -61,6 +61,8 @@ describe('CommandBus cache invalidation for sales documents', () => {
61
61
  actionLogService: asValue({
62
62
  log: logMock,
63
63
  findByUndoToken: jest.fn(async () => logRecord),
64
+ claimForUndo: jest.fn(async () => true),
65
+ releaseUndoClaim: jest.fn(async () => true),
64
66
  markUndone: jest.fn(async () => {}),
65
67
  }),
66
68
  dataEngine: asValue({ flushOrmEntityChanges: jest.fn() }),
@@ -42,6 +42,8 @@ describe('CommandBus undo audit trace', () => {
42
42
  lastName: { from: 'Before', to: 'After' },
43
43
  },
44
44
  })),
45
+ claimForUndo: jest.fn(async () => true),
46
+ releaseUndoClaim: jest.fn(async () => true),
45
47
  markUndone: markUndoneMock,
46
48
  }),
47
49
  dataEngine: asValue({ flushOrmEntityChanges: jest.fn(async () => undefined) }),
@@ -0,0 +1,122 @@
1
+ import { asValue, createContainer, InjectionMode } from 'awilix'
2
+ import { CommandBus, registerCommand, unregisterCommand } from '@open-mercato/shared/lib/commands'
3
+
4
+ type ActionLogServiceMock = {
5
+ findByUndoToken: jest.Mock
6
+ claimForUndo: jest.Mock
7
+ releaseUndoClaim: jest.Mock
8
+ markUndone: jest.Mock
9
+ }
10
+
11
+ function buildContainer(service: ActionLogServiceMock) {
12
+ const container = createContainer({ injectionMode: InjectionMode.CLASSIC })
13
+ container.register({
14
+ actionLogService: asValue(service),
15
+ dataEngine: asValue({ flushOrmEntityChanges: jest.fn(async () => undefined) }),
16
+ })
17
+ return container
18
+ }
19
+
20
+ function buildLogStub() {
21
+ return {
22
+ id: 'log-1',
23
+ commandId: 'customers.people.update',
24
+ actionLabel: 'Update person',
25
+ actorUserId: 'user-1',
26
+ tenantId: 'tenant-1',
27
+ organizationId: 'org-1',
28
+ resourceKind: 'customers.person',
29
+ resourceId: 'person-1',
30
+ parentResourceKind: null,
31
+ parentResourceId: null,
32
+ relatedResourceKind: null,
33
+ relatedResourceId: null,
34
+ commandPayload: { id: 'person-1' },
35
+ snapshotBefore: { id: 'person-1', lastName: 'Before' },
36
+ snapshotAfter: { id: 'person-1', lastName: 'After' },
37
+ changesJson: null,
38
+ }
39
+ }
40
+
41
+ const ctx = {
42
+ container: null as unknown,
43
+ auth: { sub: 'user-2', tenantId: 'tenant-1', orgId: 'org-1' },
44
+ organizationScope: null,
45
+ selectedOrganizationId: 'org-1',
46
+ organizationIds: null,
47
+ }
48
+
49
+ describe('CommandBus.undo TOCTOU race guard', () => {
50
+ afterEach(() => {
51
+ unregisterCommand('customers.people.update')
52
+ })
53
+
54
+ it('claims the row atomically before invoking the undo handler', async () => {
55
+ const callOrder: string[] = []
56
+ const undoMock = jest.fn(async () => {
57
+ callOrder.push('undo')
58
+ })
59
+ registerCommand({ id: 'customers.people.update', execute: jest.fn(), undo: undoMock })
60
+
61
+ const service: ActionLogServiceMock = {
62
+ findByUndoToken: jest.fn(async () => buildLogStub()),
63
+ claimForUndo: jest.fn(async () => {
64
+ callOrder.push('claim')
65
+ return true
66
+ }),
67
+ releaseUndoClaim: jest.fn(async () => true),
68
+ markUndone: jest.fn(async () => null),
69
+ }
70
+
71
+ const bus = new CommandBus()
72
+ await bus.undo('undo-token', { ...ctx, container: buildContainer(service) } as never)
73
+
74
+ expect(service.claimForUndo).toHaveBeenCalledWith('log-1')
75
+ expect(undoMock).toHaveBeenCalledTimes(1)
76
+ expect(callOrder).toEqual(['claim', 'undo'])
77
+ expect(service.markUndone).toHaveBeenCalledTimes(1)
78
+ expect(service.releaseUndoClaim).not.toHaveBeenCalled()
79
+ })
80
+
81
+ it('does not run the undo handler when the claim is lost to a concurrent request', async () => {
82
+ const undoMock = jest.fn(async () => {})
83
+ registerCommand({ id: 'customers.people.update', execute: jest.fn(), undo: undoMock })
84
+
85
+ const service: ActionLogServiceMock = {
86
+ findByUndoToken: jest.fn(async () => buildLogStub()),
87
+ claimForUndo: jest.fn(async () => false),
88
+ releaseUndoClaim: jest.fn(async () => true),
89
+ markUndone: jest.fn(async () => null),
90
+ }
91
+
92
+ const bus = new CommandBus()
93
+ await expect(
94
+ bus.undo('undo-token', { ...ctx, container: buildContainer(service) } as never),
95
+ ).rejects.toThrow('Undo token already consumed')
96
+
97
+ expect(undoMock).not.toHaveBeenCalled()
98
+ expect(service.markUndone).not.toHaveBeenCalled()
99
+ })
100
+
101
+ it('releases the claim when the undo handler fails so the action stays retryable', async () => {
102
+ const undoMock = jest.fn(async () => {
103
+ throw new Error('handler boom')
104
+ })
105
+ registerCommand({ id: 'customers.people.update', execute: jest.fn(), undo: undoMock })
106
+
107
+ const service: ActionLogServiceMock = {
108
+ findByUndoToken: jest.fn(async () => buildLogStub()),
109
+ claimForUndo: jest.fn(async () => true),
110
+ releaseUndoClaim: jest.fn(async () => true),
111
+ markUndone: jest.fn(async () => null),
112
+ }
113
+
114
+ const bus = new CommandBus()
115
+ await expect(
116
+ bus.undo('undo-token', { ...ctx, container: buildContainer(service) } as never),
117
+ ).rejects.toThrow('handler boom')
118
+
119
+ expect(service.markUndone).not.toHaveBeenCalled()
120
+ expect(service.releaseUndoClaim).toHaveBeenCalledWith('log-1')
121
+ })
122
+ })
@@ -19,9 +19,12 @@ function createFakeEm(overrides?: { inTransaction?: boolean }): FakeEntityManage
19
19
  }
20
20
 
21
21
  describe('withAtomicFlush', () => {
22
- it('runs phases in order and flushes once at the end', async () => {
22
+ it('runs phases in order and flushes after each phase (SPEC-018 boundaries)', async () => {
23
23
  const em = createFakeEm()
24
24
  const calls: string[] = []
25
+ em.flush.mockImplementation(async () => {
26
+ calls.push('flush')
27
+ })
25
28
 
26
29
  await withAtomicFlush(em as any, [
27
30
  async () => {
@@ -37,14 +40,23 @@ describe('withAtomicFlush', () => {
37
40
  },
38
41
  ])
39
42
 
40
- expect(calls).toEqual(['phase1-start', 'phase1-end', 'phase2', 'phase3'])
41
- expect(em.flush).toHaveBeenCalledTimes(1)
43
+ // Each phase is flushed before the next begins — the interleaved-read guard.
44
+ expect(calls).toEqual([
45
+ 'phase1-start',
46
+ 'phase1-end',
47
+ 'flush',
48
+ 'phase2',
49
+ 'flush',
50
+ 'phase3',
51
+ 'flush',
52
+ ])
53
+ expect(em.flush).toHaveBeenCalledTimes(3)
42
54
  expect(em.begin).not.toHaveBeenCalled()
43
55
  expect(em.commit).not.toHaveBeenCalled()
44
56
  expect(em.rollback).not.toHaveBeenCalled()
45
57
  })
46
58
 
47
- it('lets a later phase observe state a prior phase mutated', async () => {
59
+ it('flushes a phase before a later phase observes its mutation', async () => {
48
60
  const em = createFakeEm()
49
61
  const state: { value: number } = { value: 0 }
50
62
  let observed: number | null = null
@@ -59,7 +71,8 @@ describe('withAtomicFlush', () => {
59
71
  ])
60
72
 
61
73
  expect(observed).toBe(42)
62
- expect(em.flush).toHaveBeenCalledTimes(1)
74
+ // Two phases → flushed at each boundary.
75
+ expect(em.flush).toHaveBeenCalledTimes(2)
63
76
  })
64
77
 
65
78
  it('wraps phases in begin/commit when transaction option is true', async () => {
@@ -93,25 +106,31 @@ describe('withAtomicFlush', () => {
93
106
  expect(em.rollback).toHaveBeenCalledTimes(1)
94
107
  })
95
108
 
96
- it('propagates a thrown error and does NOT flush when a phase throws (non-transactional)', async () => {
109
+ it('propagates a thrown error and stops at the failing phase (non-transactional, per-phase flush)', async () => {
97
110
  const em = createFakeEm()
98
111
  const failure = new Error('phase-failure')
112
+ let thirdPhaseRan = false
99
113
 
100
114
  await expect(
101
115
  withAtomicFlush(em as any, [
102
116
  () => {
103
- // ok
117
+ // ok — its changeset is flushed at the phase boundary before phase 2 runs
104
118
  },
105
119
  () => {
106
120
  throw failure
107
121
  },
108
122
  () => {
109
- throw new Error('should-not-run')
123
+ thirdPhaseRan = true
110
124
  },
111
125
  ]),
112
126
  ).rejects.toBe(failure)
113
127
 
114
- expect(em.flush).not.toHaveBeenCalled()
128
+ // Non-transactional: the first phase flushed independently before phase 2
129
+ // threw; the failing phase's own flush and every later phase are skipped.
130
+ // (This independent-commit risk is exactly why mutating commands pass
131
+ // `{ transaction: true }`, where the whole sequence rolls back instead.)
132
+ expect(em.flush).toHaveBeenCalledTimes(1)
133
+ expect(thirdPhaseRan).toBe(false)
115
134
  })
116
135
 
117
136
  it('is a true no-op when phases is empty — no flush, no transaction', async () => {
@@ -220,6 +239,88 @@ describe('withAtomicFlush', () => {
220
239
  expect(em.begin).toHaveBeenCalledWith(undefined)
221
240
  })
222
241
 
242
+ describe('commit-boundary pending-changes guard', () => {
243
+ type UowEm = FakeEntityManager & {
244
+ getUnitOfWork: jest.Mock<{ computeChangeSets: jest.Mock; getChangeSets: jest.Mock }, []>
245
+ }
246
+
247
+ function createUowEm(pendingChangeSets: unknown[], opts?: { inTransaction?: boolean }): UowEm {
248
+ const computeChangeSets = jest.fn()
249
+ const getChangeSets = jest.fn().mockReturnValue(pendingChangeSets)
250
+ return {
251
+ ...createFakeEm(opts),
252
+ getUnitOfWork: jest.fn().mockReturnValue({ computeChangeSets, getChangeSets }),
253
+ }
254
+ }
255
+
256
+ it('flushes once more when a change set lingers past the last phase flush', async () => {
257
+ // One managed entity is still dirty at the commit boundary (a phase mutated
258
+ // after its own flush). The guard must persist it instead of letting the
259
+ // transaction commit the work-in-progress silently.
260
+ const em = createUowEm([{ entity: 'lingering' }], { inTransaction: false })
261
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {})
262
+ try {
263
+ await withAtomicFlush(em as any, [() => {}], { transaction: true, label: 'demo.command' })
264
+ } finally {
265
+ warn.mockRestore()
266
+ }
267
+
268
+ // 1 per-phase flush + 1 defensive guard flush, all inside the same transaction.
269
+ expect(em.flush).toHaveBeenCalledTimes(2)
270
+ expect(em.commit).toHaveBeenCalledTimes(1)
271
+ expect(em.rollback).not.toHaveBeenCalled()
272
+ })
273
+
274
+ it('warns (dev) and names the label when the guard has to act', async () => {
275
+ const em = createUowEm([{ a: 1 }, { b: 2 }])
276
+ const previousEnv = process.env.NODE_ENV
277
+ process.env.NODE_ENV = 'development'
278
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {})
279
+ let warnCallCount = 0
280
+ let warnMessage = ''
281
+ try {
282
+ await withAtomicFlush(em as any, [() => {}], { label: 'sales.update_shipment' })
283
+ // Capture BEFORE mockRestore — restore() resets mock.calls.
284
+ warnCallCount = warn.mock.calls.length
285
+ warnMessage = String(warn.mock.calls[0]?.[0] ?? '')
286
+ } finally {
287
+ warn.mockRestore()
288
+ process.env.NODE_ENV = previousEnv
289
+ }
290
+
291
+ expect(warnCallCount).toBe(1)
292
+ expect(warnMessage).toContain('sales.update_shipment')
293
+ expect(warnMessage).toContain('2 pending change-set(s)')
294
+ })
295
+
296
+ it('does NOT flush again when the UnitOfWork is clean at the boundary', async () => {
297
+ const em = createUowEm([])
298
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {})
299
+ try {
300
+ await withAtomicFlush(em as any, [() => {}, () => {}])
301
+ } finally {
302
+ warn.mockRestore()
303
+ }
304
+
305
+ // 2 phases → 2 per-phase flushes; the clean guard adds nothing.
306
+ expect(em.flush).toHaveBeenCalledTimes(2)
307
+ expect(warn).not.toHaveBeenCalled()
308
+ })
309
+
310
+ it('never throws when the UnitOfWork probe itself fails', async () => {
311
+ const em: any = {
312
+ ...createFakeEm(),
313
+ getUnitOfWork: jest.fn(() => {
314
+ throw new Error('uow unavailable')
315
+ }),
316
+ }
317
+
318
+ await expect(withAtomicFlush(em, [() => {}])).resolves.toBeUndefined()
319
+ // Probe failure → unknown → no defensive flush beyond the per-phase one.
320
+ expect(em.flush).toHaveBeenCalledTimes(1)
321
+ })
322
+ })
323
+
223
324
  it('opens its own transaction when the EM does not implement isInTransaction (partial/mock EM)', async () => {
224
325
  // Many command unit tests mock an EntityManager with begin/commit/rollback/flush
225
326
  // but no isInTransaction. The re-entrancy probe must not throw on such EMs — it