@open-mercato/cli 0.7.1-develop.7136.1.0f76137a1d → 0.7.1-develop.7148.1.3076e5ccf7

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 (81) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +2 -0
  3. package/dist/agentic/guides/module-facts.json +128 -128
  4. package/dist/agentic/guides/module-facts.v2.json +128 -128
  5. package/dist/agentic/guides/modules/ai_assistant/index.md +1 -1
  6. package/dist/agentic/guides/modules/api_docs/index.md +1 -1
  7. package/dist/agentic/guides/modules/api_keys/index.md +1 -1
  8. package/dist/agentic/guides/modules/attachments/index.md +1 -1
  9. package/dist/agentic/guides/modules/audit_logs/index.md +1 -1
  10. package/dist/agentic/guides/modules/auth/index.md +1 -1
  11. package/dist/agentic/guides/modules/business_rules/index.md +1 -1
  12. package/dist/agentic/guides/modules/catalog/index.md +1 -1
  13. package/dist/agentic/guides/modules/channel_apns/index.md +1 -1
  14. package/dist/agentic/guides/modules/channel_discord/index.md +1 -1
  15. package/dist/agentic/guides/modules/channel_expo/index.md +1 -1
  16. package/dist/agentic/guides/modules/channel_fcm/index.md +1 -1
  17. package/dist/agentic/guides/modules/channel_gmail/index.md +1 -1
  18. package/dist/agentic/guides/modules/channel_imap/index.md +1 -1
  19. package/dist/agentic/guides/modules/checkout/index.md +1 -1
  20. package/dist/agentic/guides/modules/communication_channels/index.md +1 -1
  21. package/dist/agentic/guides/modules/configs/index.md +1 -1
  22. package/dist/agentic/guides/modules/content/index.md +1 -1
  23. package/dist/agentic/guides/modules/currencies/index.md +1 -1
  24. package/dist/agentic/guides/modules/customer_accounts/index.md +1 -1
  25. package/dist/agentic/guides/modules/customers/index.md +1 -1
  26. package/dist/agentic/guides/modules/dashboards/index.md +1 -1
  27. package/dist/agentic/guides/modules/data_sync/index.md +1 -1
  28. package/dist/agentic/guides/modules/design_system/index.md +1 -1
  29. package/dist/agentic/guides/modules/devices/index.md +1 -1
  30. package/dist/agentic/guides/modules/dictionaries/index.md +1 -1
  31. package/dist/agentic/guides/modules/directory/index.md +1 -1
  32. package/dist/agentic/guides/modules/documents/index.md +1 -1
  33. package/dist/agentic/guides/modules/entities/index.md +1 -1
  34. package/dist/agentic/guides/modules/eudr/index.md +1 -1
  35. package/dist/agentic/guides/modules/events/index.md +1 -1
  36. package/dist/agentic/guides/modules/feature_toggles/index.md +1 -1
  37. package/dist/agentic/guides/modules/gateway_stripe/index.md +1 -1
  38. package/dist/agentic/guides/modules/generators/index.md +1 -1
  39. package/dist/agentic/guides/modules/inbox_ops/index.md +1 -1
  40. package/dist/agentic/guides/modules/integrations/index.md +1 -1
  41. package/dist/agentic/guides/modules/messages/index.md +1 -1
  42. package/dist/agentic/guides/modules/notifications/index.md +1 -1
  43. package/dist/agentic/guides/modules/onboarding/index.md +1 -1
  44. package/dist/agentic/guides/modules/payment_gateways/index.md +1 -1
  45. package/dist/agentic/guides/modules/perspectives/index.md +1 -1
  46. package/dist/agentic/guides/modules/planner/index.md +1 -1
  47. package/dist/agentic/guides/modules/portal/index.md +1 -1
  48. package/dist/agentic/guides/modules/progress/index.md +1 -1
  49. package/dist/agentic/guides/modules/push_notifications/index.md +1 -1
  50. package/dist/agentic/guides/modules/query_index/index.md +1 -1
  51. package/dist/agentic/guides/modules/record_locks/index.md +1 -1
  52. package/dist/agentic/guides/modules/resources/index.md +1 -1
  53. package/dist/agentic/guides/modules/sales/index.md +1 -1
  54. package/dist/agentic/guides/modules/scheduler/index.md +1 -1
  55. package/dist/agentic/guides/modules/search/index.md +1 -1
  56. package/dist/agentic/guides/modules/security/index.md +1 -1
  57. package/dist/agentic/guides/modules/shipping_carriers/index.md +1 -1
  58. package/dist/agentic/guides/modules/sso/index.md +1 -1
  59. package/dist/agentic/guides/modules/staff/index.md +1 -1
  60. package/dist/agentic/guides/modules/storage_s3/index.md +1 -1
  61. package/dist/agentic/guides/modules/sync_akeneo/index.md +1 -1
  62. package/dist/agentic/guides/modules/sync_excel/index.md +1 -1
  63. package/dist/agentic/guides/modules/system_status_overlays/index.md +1 -1
  64. package/dist/agentic/guides/modules/translations/index.md +1 -1
  65. package/dist/agentic/guides/modules/warranty_claims/index.md +1 -1
  66. package/dist/agentic/guides/modules/webhooks/index.md +1 -1
  67. package/dist/agentic/guides/modules/wms/index.md +1 -1
  68. package/dist/agentic/guides/modules/workflows/index.md +1 -1
  69. package/dist/agentic/guides/reference-module-facts.json +1 -1
  70. package/dist/agentic/guides/upstream/manifest.json +1 -1
  71. package/dist/agentic/shared/ai/review-checklist.md +1 -0
  72. package/dist/agentic/shared/ai/skills/om-data-model-design/references/migration-workflow.md +30 -2
  73. package/dist/lib/db/commands.js +106 -60
  74. package/dist/lib/db/commands.js.map +2 -2
  75. package/dist/lib/db/migration-reindex.js +104 -0
  76. package/dist/lib/db/migration-reindex.js.map +7 -0
  77. package/package.json +6 -6
  78. package/src/lib/db/__tests__/commands.migrate-reindex.test.ts +189 -0
  79. package/src/lib/db/__tests__/migration-reindex.test.ts +203 -0
  80. package/src/lib/db/commands.ts +132 -72
  81. package/src/lib/db/migration-reindex.ts +156 -0
@@ -0,0 +1,189 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { MikroORM } from '@mikro-orm/core'
5
+ import { dbMigrate } from '../commands'
6
+ import type { ModuleEntry, PackageResolver } from '../../resolver'
7
+
8
+ const collectQueryIndexReindexEntityTypes = jest.fn<Promise<string[]>, [any, any]>()
9
+ const requestQueryIndexReindex = jest.fn<Promise<{ requested: string[]; queued: boolean }>, [any, any]>()
10
+ const isMigrationReindexEnabled = jest.fn<boolean, []>()
11
+
12
+ jest.mock('../migration-reindex', () => ({
13
+ collectQueryIndexReindexEntityTypes: (...args: any[]) => collectQueryIndexReindexEntityTypes(...(args as [any, any])),
14
+ requestQueryIndexReindex: (...args: any[]) => requestQueryIndexReindex(...(args as [any, any])),
15
+ isMigrationReindexEnabled: () => isMigrationReindexEnabled(),
16
+ }))
17
+
18
+ // `MikroORM.init` is stubbed with a spy rather than a module mock: `@mikro-orm/sql` extends the
19
+ // real `MikroORM` class at import time, so replacing the module export breaks the driver import.
20
+ const ormInit = jest.fn()
21
+
22
+ type PendingPlan = { pending: string[]; failOn?: string }
23
+
24
+ const tempDirs: string[] = []
25
+ const ormCloses: string[] = []
26
+
27
+ function createTempModule(id: string): string {
28
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), `mercato-migrate-${id}-`))
29
+ tempDirs.push(dir)
30
+ fs.mkdirSync(path.join(dir, 'data'), { recursive: true })
31
+ fs.mkdirSync(path.join(dir, 'migrations'), { recursive: true })
32
+ fs.writeFileSync(path.join(dir, 'data', 'entities.ts'), `export class TestEntity_${id} {}\n`, 'utf8')
33
+ return dir
34
+ }
35
+
36
+ function createMockResolver(modules: { id: string; dir: string }[]): PackageResolver {
37
+ const entries: ModuleEntry[] = modules.map((m) => ({ id: m.id, from: '@app' as const }))
38
+ const byId = new Map(modules.map((m) => [m.id, m.dir]))
39
+ return {
40
+ isMonorepo: () => true,
41
+ getRootDir: () => '/tmp/test-root',
42
+ getAppDir: () => '/tmp/test-app',
43
+ getOutputDir: () => '/tmp/test-out',
44
+ getModulesConfigPath: () => '/tmp/test-root/modules.ts',
45
+ discoverPackages: () => [],
46
+ loadEnabledModules: () => entries,
47
+ getModulePaths: (entry: ModuleEntry) => {
48
+ const dir = byId.get(entry.id) ?? '/nonexistent'
49
+ return { appBase: dir, pkgBase: dir }
50
+ },
51
+ getModuleImportBase: (entry: ModuleEntry) => ({
52
+ appBase: `@/modules/${entry.id}`,
53
+ pkgBase: `@open-mercato/core/modules/${entry.id}`,
54
+ }),
55
+ getPackageOutputDir: () => '/tmp/test-out',
56
+ getPackageRoot: () => '/tmp/test-root',
57
+ }
58
+ }
59
+
60
+ /**
61
+ * `dbMigrate` calls `MikroORM.init` once per module, in `sortModules` order. Each fake ORM serves
62
+ * that module's plan and records its own `close()`, so a test can assert both the discharge wiring
63
+ * and that no connection pool leaks when a migration throws.
64
+ */
65
+ function stubOrmsFor(plans: Record<string, PendingPlan>): void {
66
+ ormInit.mockImplementation(async (config: any) => {
67
+ const moduleId = Object.keys(plans).find((id) => String(config.migrations.path).includes(`-${id}-`))
68
+ const plan = moduleId ? plans[moduleId] : { pending: [] }
69
+ return {
70
+ migrator: {
71
+ getPending: async () => plan.pending.map((name) => ({ name, path: null })),
72
+ up: async (options: { migrations?: string[] } = {}) => {
73
+ const name = options.migrations?.[0]
74
+ if (plan.failOn && name === plan.failOn) throw new Error(`migration ${name} failed`)
75
+ },
76
+ },
77
+ close: async () => {
78
+ if (moduleId) ormCloses.push(moduleId)
79
+ },
80
+ }
81
+ })
82
+ }
83
+
84
+ describe('dbMigrate discharges query-index reindex declarations', () => {
85
+ let logSpy: jest.SpyInstance
86
+ let warnSpy: jest.SpyInstance
87
+ let initSpy: jest.SpyInstance
88
+ let stdoutSpy: jest.SpyInstance
89
+
90
+ beforeEach(() => {
91
+ process.env.DATABASE_URL = process.env.DATABASE_URL || 'postgres://noop@localhost:5432/test'
92
+ ormCloses.length = 0
93
+ ormInit.mockReset()
94
+ initSpy = jest.spyOn(MikroORM, 'init').mockImplementation((...args: any[]) => ormInit(...args))
95
+ collectQueryIndexReindexEntityTypes.mockReset()
96
+ requestQueryIndexReindex.mockReset()
97
+ isMigrationReindexEnabled.mockReset()
98
+ isMigrationReindexEnabled.mockReturnValue(true)
99
+ collectQueryIndexReindexEntityTypes.mockResolvedValue(['customers:customer_dictionary_entry'])
100
+ requestQueryIndexReindex.mockResolvedValue({
101
+ requested: ['customers:customer_dictionary_entry'],
102
+ queued: true,
103
+ })
104
+ logSpy = jest.spyOn(console, 'log').mockImplementation(() => {})
105
+ warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
106
+ stdoutSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true)
107
+ })
108
+
109
+ afterEach(() => {
110
+ initSpy.mockRestore()
111
+ logSpy.mockRestore()
112
+ warnSpy.mockRestore()
113
+ stdoutSpy.mockRestore()
114
+ for (const dir of tempDirs.splice(0)) {
115
+ try { fs.rmSync(dir, { recursive: true, force: true }) } catch {}
116
+ }
117
+ })
118
+
119
+ it('queues a reindex for the migrations it applied, after every module closed its ORM', async () => {
120
+ const moduleA = { id: 'alpha', dir: createTempModule('alpha') }
121
+ const moduleB = { id: 'beta', dir: createTempModule('beta') }
122
+ stubOrmsFor({
123
+ alpha: { pending: ['Migration20260901120000_reindex_alpha'] },
124
+ beta: { pending: [] },
125
+ })
126
+
127
+ await dbMigrate(createMockResolver([moduleA, moduleB]))
128
+
129
+ expect(ormCloses).toEqual(['alpha', 'beta'])
130
+ const [applied] = collectQueryIndexReindexEntityTypes.mock.calls[0]
131
+ expect(applied).toEqual([
132
+ expect.objectContaining({ moduleId: 'alpha', name: 'Migration20260901120000_reindex_alpha' }),
133
+ ])
134
+ expect(applied[0].migrationsPath).toContain('migrations')
135
+ expect(requestQueryIndexReindex).toHaveBeenCalledTimes(1)
136
+ expect(requestQueryIndexReindex.mock.calls[0][0]).toEqual(['customers:customer_dictionary_entry'])
137
+ })
138
+
139
+ it('still discharges the declarations it already collected when a later migration throws', async () => {
140
+ // The failure this guards: `customers` applies and commits, `sales` then fails. Without an
141
+ // unconditional discharge the customers declaration is lost for good — a re-run never sees it
142
+ // again, because that migration is no longer pending.
143
+ const moduleA = { id: 'alpha', dir: createTempModule('alpha') }
144
+ const moduleB = { id: 'beta', dir: createTempModule('beta') }
145
+ stubOrmsFor({
146
+ alpha: { pending: ['Migration20260901120000_reindex_alpha'] },
147
+ beta: { pending: ['Migration20260901130000_broken'], failOn: 'Migration20260901130000_broken' },
148
+ })
149
+
150
+ await expect(dbMigrate(createMockResolver([moduleA, moduleB]))).rejects.toThrow(
151
+ 'migration Migration20260901130000_broken failed',
152
+ )
153
+
154
+ expect(collectQueryIndexReindexEntityTypes).toHaveBeenCalledTimes(1)
155
+ expect(collectQueryIndexReindexEntityTypes.mock.calls[0][0]).toEqual([
156
+ expect.objectContaining({ moduleId: 'alpha', name: 'Migration20260901120000_reindex_alpha' }),
157
+ ])
158
+ expect(requestQueryIndexReindex).toHaveBeenCalledTimes(1)
159
+ // Both ORMs must have been closed even though the second module threw.
160
+ expect(ormCloses).toEqual(['alpha', 'beta'])
161
+ })
162
+
163
+ it('never lets a discharge failure replace the migration error the operator needs to see', async () => {
164
+ const moduleA = { id: 'alpha', dir: createTempModule('alpha') }
165
+ stubOrmsFor({
166
+ alpha: {
167
+ pending: ['Migration20260901120000_reindex_alpha', 'Migration20260901130000_broken'],
168
+ failOn: 'Migration20260901130000_broken',
169
+ },
170
+ })
171
+ collectQueryIndexReindexEntityTypes.mockRejectedValue(new Error('container refused to build'))
172
+
173
+ await expect(dbMigrate(createMockResolver([moduleA]))).rejects.toThrow(
174
+ 'migration Migration20260901130000_broken failed',
175
+ )
176
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('container refused to build'))
177
+ })
178
+
179
+ it('skips the discharge entirely when the opt-out is set', async () => {
180
+ const moduleA = { id: 'alpha', dir: createTempModule('alpha') }
181
+ stubOrmsFor({ alpha: { pending: ['Migration20260901120000_reindex_alpha'] } })
182
+ isMigrationReindexEnabled.mockReturnValue(false)
183
+
184
+ await dbMigrate(createMockResolver([moduleA]))
185
+
186
+ expect(collectQueryIndexReindexEntityTypes).not.toHaveBeenCalled()
187
+ expect(requestQueryIndexReindex).not.toHaveBeenCalled()
188
+ })
189
+ })
@@ -0,0 +1,203 @@
1
+ import path from 'node:path'
2
+ import {
3
+ collectQueryIndexReindexEntityTypes,
4
+ formatManualReindexInstructions,
5
+ isMigrationReindexEnabled,
6
+ requestQueryIndexReindex,
7
+ resolveMigrationFilePath,
8
+ type AppliedMigration,
9
+ } from '../migration-reindex'
10
+
11
+ const MIGRATIONS_PATH = '/repo/packages/core/src/modules/customers/migrations'
12
+
13
+ function migration(name: string, overrides: Partial<AppliedMigration> = {}): AppliedMigration {
14
+ return { moduleId: 'customers', migrationsPath: MIGRATIONS_PATH, name, ...overrides }
15
+ }
16
+
17
+ describe('resolveMigrationFilePath', () => {
18
+ const existing = new Set([path.join(MIGRATIONS_PATH, 'Migration20260901120000_reindex.ts')])
19
+ const fileExists = (filePath: string) => existing.has(filePath)
20
+
21
+ it('resolves a bare migration name to its source file', () => {
22
+ expect(resolveMigrationFilePath(MIGRATIONS_PATH, 'Migration20260901120000_reindex', null, fileExists))
23
+ .toBe(path.join(MIGRATIONS_PATH, 'Migration20260901120000_reindex.ts'))
24
+ })
25
+
26
+ it('accepts a name that already carries its extension', () => {
27
+ expect(resolveMigrationFilePath(MIGRATIONS_PATH, 'Migration20260901120000_reindex.js', null, fileExists))
28
+ .toBe(path.join(MIGRATIONS_PATH, 'Migration20260901120000_reindex.ts'))
29
+ })
30
+
31
+ it('prefers a path the migrator already resolved', () => {
32
+ const known = path.join(MIGRATIONS_PATH, 'Migration20260901120000_reindex.ts')
33
+ expect(resolveMigrationFilePath(MIGRATIONS_PATH, 'anything', known, fileExists)).toBe(known)
34
+ })
35
+
36
+ it('keeps a directory-qualified migration name inside the migrations directory', () => {
37
+ expect(resolveMigrationFilePath(MIGRATIONS_PATH, '../../../etc/passwd', null, () => true))
38
+ .toBe(path.join(MIGRATIONS_PATH, 'passwd.ts'))
39
+ })
40
+
41
+ it('refuses names that survive basename but are not a plausible file name', () => {
42
+ expect(resolveMigrationFilePath(MIGRATIONS_PATH, 'migration name; rm -rf /', null, () => true)).toBeNull()
43
+ })
44
+
45
+ it('returns null when no file backs the migration name', () => {
46
+ expect(resolveMigrationFilePath(MIGRATIONS_PATH, 'Migration20990101000000', null, fileExists)).toBeNull()
47
+ })
48
+ })
49
+
50
+ describe('collectQueryIndexReindexEntityTypes', () => {
51
+ const fileExists = () => true
52
+
53
+ it('collects declarations across modules and deduplicates them', async () => {
54
+ const modules: Record<string, unknown> = {
55
+ [path.join(MIGRATIONS_PATH, 'a.ts')]: { queryIndexReindexEntityTypes: ['customers:customer_dictionary_entry'] },
56
+ [path.join(MIGRATIONS_PATH, 'b.ts')]: {
57
+ queryIndexReindexEntityTypes: ['customers:customer_dictionary_entry', 'workflows:workflow_definition'],
58
+ },
59
+ [path.join(MIGRATIONS_PATH, 'c.ts')]: {},
60
+ }
61
+
62
+ const collected = await collectQueryIndexReindexEntityTypes(
63
+ [migration('a'), migration('b'), migration('c')],
64
+ { importModule: async (filePath) => modules[filePath], fileExists },
65
+ )
66
+
67
+ expect(collected).toEqual(['customers:customer_dictionary_entry', 'workflows:workflow_definition'])
68
+ })
69
+
70
+ it('warns and keeps going when a migration file cannot be imported', async () => {
71
+ const onWarn = jest.fn()
72
+ const collected = await collectQueryIndexReindexEntityTypes(
73
+ [migration('broken'), migration('good')],
74
+ {
75
+ fileExists,
76
+ onWarn,
77
+ importModule: async (filePath) => {
78
+ if (filePath.endsWith('broken.ts')) throw new Error('boom')
79
+ return { queryIndexReindexEntityTypes: ['dictionaries:dictionary_entry'] }
80
+ },
81
+ },
82
+ )
83
+
84
+ expect(collected).toEqual(['dictionaries:dictionary_entry'])
85
+ expect(onWarn).toHaveBeenCalledWith(expect.stringContaining('customers/broken'))
86
+ })
87
+
88
+ it('warns per invalid declaration entry, naming the migration that carries it', async () => {
89
+ const onWarn = jest.fn()
90
+ const collected = await collectQueryIndexReindexEntityTypes(
91
+ [migration('typo')],
92
+ {
93
+ fileExists,
94
+ onWarn,
95
+ // A migration author who writes the export by hand rather than through the helper gets no
96
+ // throw — without this warning the entry vanishes and `db migrate` still exits 0.
97
+ importModule: async () => ({
98
+ queryIndexReindexEntityTypes: ['customers:customerDictionaryEntry', 'customers:deal'],
99
+ }),
100
+ },
101
+ )
102
+
103
+ expect(collected).toEqual(['customers:deal'])
104
+ expect(onWarn).toHaveBeenCalledTimes(1)
105
+ const warning = onWarn.mock.calls[0][0] as string
106
+ expect(warning).toContain('customers/typo')
107
+ expect(warning).toContain('customers:customerDictionaryEntry')
108
+ expect(warning).toContain('module:entity')
109
+ })
110
+
111
+ it('skips migrations whose file is gone', async () => {
112
+ const importModule = jest.fn()
113
+ const collected = await collectQueryIndexReindexEntityTypes(
114
+ [migration('missing')],
115
+ { importModule, fileExists: () => false },
116
+ )
117
+
118
+ expect(collected).toEqual([])
119
+ expect(importModule).not.toHaveBeenCalled()
120
+ })
121
+ })
122
+
123
+ describe('isMigrationReindexEnabled', () => {
124
+ it('is on by default and opts out on an explicit falsy token', () => {
125
+ expect(isMigrationReindexEnabled({})).toBe(true)
126
+ expect(isMigrationReindexEnabled({ OM_MIGRATION_REINDEX: 'off' })).toBe(false)
127
+ expect(isMigrationReindexEnabled({ OM_MIGRATION_REINDEX: 'false' })).toBe(false)
128
+ expect(isMigrationReindexEnabled({ OM_MIGRATION_REINDEX: 'true' })).toBe(true)
129
+ })
130
+ })
131
+
132
+ describe('requestQueryIndexReindex', () => {
133
+ it('queues one persistent in-place reindex per entity type', async () => {
134
+ const emitEvent = jest.fn().mockResolvedValue(undefined)
135
+ const dispose = jest.fn().mockResolvedValue(undefined)
136
+
137
+ const result = await requestQueryIndexReindex(
138
+ ['customers:customer_dictionary_entry', 'workflows:workflow_definition'],
139
+ { createContainer: async () => ({ resolve: () => ({ emitEvent }), dispose }) },
140
+ )
141
+
142
+ expect(result).toEqual({
143
+ requested: ['customers:customer_dictionary_entry', 'workflows:workflow_definition'],
144
+ queued: true,
145
+ })
146
+ expect(emitEvent).toHaveBeenCalledTimes(2)
147
+ expect(emitEvent).toHaveBeenNthCalledWith(
148
+ 1,
149
+ 'query_index.reindex',
150
+ { entityType: 'customers:customer_dictionary_entry', allowAllTenants: true, force: false },
151
+ { persistent: true },
152
+ )
153
+ expect(dispose).toHaveBeenCalled()
154
+ })
155
+
156
+ it('does nothing when no migration declared a reindex', async () => {
157
+ const createContainer = jest.fn()
158
+ await expect(requestQueryIndexReindex([], { createContainer })).resolves.toEqual({ requested: [], queued: false })
159
+ expect(createContainer).not.toHaveBeenCalled()
160
+ })
161
+
162
+ it('degrades to a manual-command warning instead of failing the migration run', async () => {
163
+ const onWarn = jest.fn()
164
+
165
+ const result = await requestQueryIndexReindex(['customers:deal'], {
166
+ onWarn,
167
+ createContainer: async () => {
168
+ throw new Error('no event bus here')
169
+ },
170
+ })
171
+
172
+ expect(result).toEqual({ requested: [], queued: false })
173
+ expect(onWarn).toHaveBeenCalledTimes(1)
174
+ const warning = onWarn.mock.calls[0][0] as string
175
+ expect(warning).toContain('mercato query_index rebuild --entity customers:deal --global')
176
+ expect(warning).toContain('no event bus here')
177
+ })
178
+
179
+ it('names only the entity types that did not make it into the queue', async () => {
180
+ const onWarn = jest.fn()
181
+ const emitEvent = jest.fn(async (_event: string, payload: Record<string, unknown>) => {
182
+ if (payload.entityType === 'workflows:workflow_definition') throw new Error('queue went away')
183
+ })
184
+
185
+ const result = await requestQueryIndexReindex(
186
+ ['customers:deal', 'workflows:workflow_definition'],
187
+ { onWarn, createContainer: async () => ({ resolve: () => ({ emitEvent }) }) },
188
+ )
189
+
190
+ expect(result).toEqual({ requested: ['customers:deal'], queued: false })
191
+ const warning = onWarn.mock.calls[0][0] as string
192
+ expect(warning).toContain('mercato query_index rebuild --entity workflows:workflow_definition --global')
193
+ expect(warning).not.toContain('--entity customers:deal')
194
+ })
195
+ })
196
+
197
+ describe('formatManualReindexInstructions', () => {
198
+ it('lists one rebuild command per entity type', () => {
199
+ const message = formatManualReindexInstructions(['customers:deal', 'workflows:workflow_definition'])
200
+ expect(message).toContain('mercato query_index rebuild --entity customers:deal --global')
201
+ expect(message).toContain('mercato query_index rebuild --entity workflows:workflow_definition --global')
202
+ })
203
+ })
@@ -9,6 +9,12 @@ import { PostgreSqlDriver } from '@mikro-orm/postgresql'
9
9
  import { getSslConfig } from '@open-mercato/shared/lib/db/ssl'
10
10
  import type { PackageResolver, ModuleEntry } from '../resolver'
11
11
  import { quotePostgresIdentifier } from './identifiers'
12
+ import {
13
+ collectQueryIndexReindexEntityTypes,
14
+ isMigrationReindexEnabled,
15
+ requestQueryIndexReindex,
16
+ type AppliedMigration,
17
+ } from './migration-reindex'
12
18
 
13
19
  const QUIET_MODE = process.env.OM_CLI_QUIET === '1' || process.env.MERCATO_QUIET === '1'
14
20
  const PROGRESS_EMOJI = ''
@@ -338,87 +344,141 @@ export async function dbMigrate(resolver: PackageResolver, options: DbOptions =
338
344
  const modules = resolver.loadEnabledModules()
339
345
  const ordered = sortModules(modules)
340
346
  const results: string[] = []
347
+ const appliedMigrations: AppliedMigration[] = []
341
348
 
342
- for (const entry of ordered) {
343
- const modId = entry.id
344
- const sanitizedModId = sanitizeModuleId(modId)
345
- const entities = await loadModuleEntities(entry, resolver)
349
+ try {
350
+ for (const entry of ordered) {
351
+ const modId = entry.id
352
+ const sanitizedModId = sanitizeModuleId(modId)
353
+ const entities = await loadModuleEntities(entry, resolver)
354
+
355
+ const migrationsPath = getMigrationsPath(entry, resolver)
356
+
357
+ // Skip if no entities AND no migrations directory exists
358
+ // (allows @app modules to run migrations even if entities can't be dynamically imported)
359
+ if (!entities.length && !fs.existsSync(migrationsPath)) continue
360
+ fs.mkdirSync(migrationsPath, { recursive: true })
361
+
362
+ const tableName = `mikro_orm_migrations_${sanitizedModId}`
363
+ validateTableName(tableName)
364
+
365
+ // dbMigrate only runs existing migration files — entities are intentionally
366
+ // omitted so MikroORM does not compare them against the snapshot and
367
+ // auto-generate a phantom diff migration (that would duplicate tables
368
+ // already created by committed migrations).
369
+ const sslConfig = getSslConfig()
370
+ const orm = await MikroORM.init<PostgreSqlDriver>({
371
+ driver: PostgreSqlDriver,
372
+ clientUrl: getClientUrl(),
373
+ loggerFactory: () => createMinimalLogger(),
374
+ dynamicImportProvider,
375
+ entities: [],
376
+ metadataProvider: ReflectMetadataProvider,
377
+ discovery: { warnWhenNoEntities: false },
378
+ migrations: {
379
+ path: migrationsPath,
380
+ glob: '!(*.d).{ts,js}',
381
+ tableName,
382
+ snapshot: false,
383
+ dropTables: false,
384
+ },
385
+ schemaGenerator: {
386
+ disableForeignKeys: true,
387
+ },
388
+ pool: {
389
+ min: 1,
390
+ max: 3,
391
+ idleTimeoutMillis: 30000,
392
+ // acquireTimeoutMillis removed for v7 (pg.Pool doesn't support it; use connectionTimeoutMillis in driverOptions if needed)
393
+ },
394
+ driverOptions: sslConfig ? {
395
+ ssl: sslConfig,
396
+ } : undefined,
397
+ })
346
398
 
347
- const migrationsPath = getMigrationsPath(entry, resolver)
399
+ try {
400
+ const migrator = orm.migrator as Migrator
401
+ const pending = await migrator.getPending()
402
+ if (!pending.length) {
403
+ results.push(formatResult(modId, 'no pending migrations', ''))
404
+ } else {
405
+ const renderProgress = createProgressRenderer(pending.length)
406
+ let applied = 0
407
+ if (!QUIET_MODE) {
408
+ process.stdout.write(` ${PROGRESS_EMOJI} ${modId}: ${renderProgress(applied)}`)
409
+ }
410
+ for (const migration of pending) {
411
+ const migrationName =
412
+ typeof migration === 'string'
413
+ ? migration
414
+ : (migration as any).name ?? (migration as any).fileName
415
+ await migrator.up(migrationName ? { migrations: [migrationName] } : undefined)
416
+ if (migrationName) {
417
+ appliedMigrations.push({
418
+ moduleId: modId,
419
+ migrationsPath,
420
+ name: String(migrationName),
421
+ filePath: typeof migration === 'string' ? null : (migration as any).path ?? null,
422
+ })
423
+ }
424
+ applied += 1
425
+ if (!QUIET_MODE) {
426
+ process.stdout.write(`\r ${PROGRESS_EMOJI} ${modId}: ${renderProgress(applied)}`)
427
+ }
428
+ }
429
+ if (!QUIET_MODE) process.stdout.write('\n')
430
+ results.push(
431
+ formatResult(modId, `${pending.length} migration${pending.length === 1 ? '' : 's'} applied`, '')
432
+ )
433
+ }
434
+ } finally {
435
+ await orm.close(true)
436
+ }
437
+ }
438
+ } finally {
439
+ console.log(results.join('\n'))
440
+ await dischargeQueryIndexReindexRequests(appliedMigrations)
441
+ }
442
+ }
348
443
 
349
- // Skip if no entities AND no migrations directory exists
350
- // (allows @app modules to run migrations even if entities can't be dynamically imported)
351
- if (!entities.length && !fs.existsSync(migrationsPath)) continue
352
- fs.mkdirSync(migrationsPath, { recursive: true })
444
+ /**
445
+ * Runs after every module migrated and after `orm.close()`, so the rewrites the declarations
446
+ * refer to are committed before the reindex is queued.
447
+ *
448
+ * Reached from a `finally`, so it also runs when a later migration threw: the migrations that
449
+ * did apply already committed, and their declarations are the only record that their projections
450
+ * are stale — a re-run cannot recover them, because `getPending()` no longer lists them. It must
451
+ * therefore never throw, or it would replace the migration failure the operator needs to see.
452
+ */
453
+ export async function dischargeQueryIndexReindexRequests(
454
+ applied: readonly AppliedMigration[],
455
+ ): Promise<void> {
456
+ if (!applied.length || !isMigrationReindexEnabled()) return
353
457
 
354
- const tableName = `mikro_orm_migrations_${sanitizedModId}`
355
- validateTableName(tableName)
458
+ try {
459
+ const entityTypes = await collectQueryIndexReindexEntityTypes(applied, {
460
+ importModule: dynamicImportProvider,
461
+ onWarn: (message) => console.warn(message),
462
+ })
463
+ if (!entityTypes.length) return
356
464
 
357
- // dbMigrate only runs existing migration files — entities are intentionally
358
- // omitted so MikroORM does not compare them against the snapshot and
359
- // auto-generate a phantom diff migration (that would duplicate tables
360
- // already created by committed migrations).
361
- const sslConfig = getSslConfig()
362
- const orm = await MikroORM.init<PostgreSqlDriver>({
363
- driver: PostgreSqlDriver,
364
- clientUrl: getClientUrl(),
365
- loggerFactory: () => createMinimalLogger(),
366
- dynamicImportProvider,
367
- entities: [],
368
- metadataProvider: ReflectMetadataProvider,
369
- discovery: { warnWhenNoEntities: false },
370
- migrations: {
371
- path: migrationsPath,
372
- glob: '!(*.d).{ts,js}',
373
- tableName,
374
- snapshot: false,
375
- dropTables: false,
376
- },
377
- schemaGenerator: {
378
- disableForeignKeys: true,
465
+ await requestQueryIndexReindex(entityTypes, {
466
+ createContainer: async () => {
467
+ const { createRequestContainer } = await import('@open-mercato/shared/lib/di/container')
468
+ return (await createRequestContainer()) as any
379
469
  },
380
- pool: {
381
- min: 1,
382
- max: 3,
383
- idleTimeoutMillis: 30000,
384
- // acquireTimeoutMillis removed for v7 (pg.Pool doesn't support it; use connectionTimeoutMillis in driverOptions if needed)
470
+ onInfo: (message) => {
471
+ if (!QUIET_MODE) console.log(message)
385
472
  },
386
- driverOptions: sslConfig ? {
387
- ssl: sslConfig,
388
- } : undefined,
473
+ onWarn: (message) => console.warn(message),
389
474
  })
390
-
391
- const migrator = orm.migrator as Migrator
392
- const pending = await migrator.getPending()
393
- if (!pending.length) {
394
- results.push(formatResult(modId, 'no pending migrations', ''))
395
- } else {
396
- const renderProgress = createProgressRenderer(pending.length)
397
- let applied = 0
398
- if (!QUIET_MODE) {
399
- process.stdout.write(` ${PROGRESS_EMOJI} ${modId}: ${renderProgress(applied)}`)
400
- }
401
- for (const migration of pending) {
402
- const migrationName =
403
- typeof migration === 'string'
404
- ? migration
405
- : (migration as any).name ?? (migration as any).fileName
406
- await migrator.up(migrationName ? { migrations: [migrationName] } : undefined)
407
- applied += 1
408
- if (!QUIET_MODE) {
409
- process.stdout.write(`\r ${PROGRESS_EMOJI} ${modId}: ${renderProgress(applied)}`)
410
- }
411
- }
412
- if (!QUIET_MODE) process.stdout.write('\n')
413
- results.push(
414
- formatResult(modId, `${pending.length} migration${pending.length === 1 ? '' : 's'} applied`, '')
415
- )
416
- }
417
-
418
- await orm.close(true)
475
+ } catch (error) {
476
+ console.warn(
477
+ `[query_index] Could not determine which projections the migrations invalidated: ${
478
+ error instanceof Error ? error.message : String(error)
479
+ }`,
480
+ )
419
481
  }
420
-
421
- console.log(results.join('\n'))
422
482
  }
423
483
 
424
484
  export async function dbGreenfield(resolver: PackageResolver, options: GreenfieldOptions): Promise<void> {