@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +2 -0
- package/dist/agentic/guides/module-facts.json +128 -128
- package/dist/agentic/guides/module-facts.v2.json +128 -128
- package/dist/agentic/guides/modules/ai_assistant/index.md +1 -1
- package/dist/agentic/guides/modules/api_docs/index.md +1 -1
- package/dist/agentic/guides/modules/api_keys/index.md +1 -1
- package/dist/agentic/guides/modules/attachments/index.md +1 -1
- package/dist/agentic/guides/modules/audit_logs/index.md +1 -1
- package/dist/agentic/guides/modules/auth/index.md +1 -1
- package/dist/agentic/guides/modules/business_rules/index.md +1 -1
- package/dist/agentic/guides/modules/catalog/index.md +1 -1
- package/dist/agentic/guides/modules/channel_apns/index.md +1 -1
- package/dist/agentic/guides/modules/channel_discord/index.md +1 -1
- package/dist/agentic/guides/modules/channel_expo/index.md +1 -1
- package/dist/agentic/guides/modules/channel_fcm/index.md +1 -1
- package/dist/agentic/guides/modules/channel_gmail/index.md +1 -1
- package/dist/agentic/guides/modules/channel_imap/index.md +1 -1
- package/dist/agentic/guides/modules/checkout/index.md +1 -1
- package/dist/agentic/guides/modules/communication_channels/index.md +1 -1
- package/dist/agentic/guides/modules/configs/index.md +1 -1
- package/dist/agentic/guides/modules/content/index.md +1 -1
- package/dist/agentic/guides/modules/currencies/index.md +1 -1
- package/dist/agentic/guides/modules/customer_accounts/index.md +1 -1
- package/dist/agentic/guides/modules/customers/index.md +1 -1
- package/dist/agentic/guides/modules/dashboards/index.md +1 -1
- package/dist/agentic/guides/modules/data_sync/index.md +1 -1
- package/dist/agentic/guides/modules/design_system/index.md +1 -1
- package/dist/agentic/guides/modules/devices/index.md +1 -1
- package/dist/agentic/guides/modules/dictionaries/index.md +1 -1
- package/dist/agentic/guides/modules/directory/index.md +1 -1
- package/dist/agentic/guides/modules/documents/index.md +1 -1
- package/dist/agentic/guides/modules/entities/index.md +1 -1
- package/dist/agentic/guides/modules/eudr/index.md +1 -1
- package/dist/agentic/guides/modules/events/index.md +1 -1
- package/dist/agentic/guides/modules/feature_toggles/index.md +1 -1
- package/dist/agentic/guides/modules/gateway_stripe/index.md +1 -1
- package/dist/agentic/guides/modules/generators/index.md +1 -1
- package/dist/agentic/guides/modules/inbox_ops/index.md +1 -1
- package/dist/agentic/guides/modules/integrations/index.md +1 -1
- package/dist/agentic/guides/modules/messages/index.md +1 -1
- package/dist/agentic/guides/modules/notifications/index.md +1 -1
- package/dist/agentic/guides/modules/onboarding/index.md +1 -1
- package/dist/agentic/guides/modules/payment_gateways/index.md +1 -1
- package/dist/agentic/guides/modules/perspectives/index.md +1 -1
- package/dist/agentic/guides/modules/planner/index.md +1 -1
- package/dist/agentic/guides/modules/portal/index.md +1 -1
- package/dist/agentic/guides/modules/progress/index.md +1 -1
- package/dist/agentic/guides/modules/push_notifications/index.md +1 -1
- package/dist/agentic/guides/modules/query_index/index.md +1 -1
- package/dist/agentic/guides/modules/record_locks/index.md +1 -1
- package/dist/agentic/guides/modules/resources/index.md +1 -1
- package/dist/agentic/guides/modules/sales/index.md +1 -1
- package/dist/agentic/guides/modules/scheduler/index.md +1 -1
- package/dist/agentic/guides/modules/search/index.md +1 -1
- package/dist/agentic/guides/modules/security/index.md +1 -1
- package/dist/agentic/guides/modules/shipping_carriers/index.md +1 -1
- package/dist/agentic/guides/modules/sso/index.md +1 -1
- package/dist/agentic/guides/modules/staff/index.md +1 -1
- package/dist/agentic/guides/modules/storage_s3/index.md +1 -1
- package/dist/agentic/guides/modules/sync_akeneo/index.md +1 -1
- package/dist/agentic/guides/modules/sync_excel/index.md +1 -1
- package/dist/agentic/guides/modules/system_status_overlays/index.md +1 -1
- package/dist/agentic/guides/modules/translations/index.md +1 -1
- package/dist/agentic/guides/modules/warranty_claims/index.md +1 -1
- package/dist/agentic/guides/modules/webhooks/index.md +1 -1
- package/dist/agentic/guides/modules/wms/index.md +1 -1
- package/dist/agentic/guides/modules/workflows/index.md +1 -1
- package/dist/agentic/guides/reference-module-facts.json +1 -1
- package/dist/agentic/guides/upstream/manifest.json +1 -1
- package/dist/agentic/shared/ai/review-checklist.md +1 -0
- package/dist/agentic/shared/ai/skills/om-data-model-design/references/migration-workflow.md +30 -2
- package/dist/lib/db/commands.js +106 -60
- package/dist/lib/db/commands.js.map +2 -2
- package/dist/lib/db/migration-reindex.js +104 -0
- package/dist/lib/db/migration-reindex.js.map +7 -0
- package/package.json +6 -6
- package/src/lib/db/__tests__/commands.migrate-reindex.test.ts +189 -0
- package/src/lib/db/__tests__/migration-reindex.test.ts +203 -0
- package/src/lib/db/commands.ts +132 -72
- 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
|
+
})
|
package/src/lib/db/commands.ts
CHANGED
|
@@ -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
|
-
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
355
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
381
|
-
|
|
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
|
-
|
|
387
|
-
ssl: sslConfig,
|
|
388
|
-
} : undefined,
|
|
473
|
+
onWarn: (message) => console.warn(message),
|
|
389
474
|
})
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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> {
|