@open-mercato/cli 0.8.1-develop.7296.1.2111d779db → 0.9.0
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 +8 -2
- package/AGENTS.md +2 -0
- package/dist/agentic/guides/module-facts.json +7612 -848
- package/dist/agentic/guides/module-facts.v2.json +7612 -848
- package/dist/agentic/guides/modules/agent_orchestrator/active-extension-bindings.md +1 -1
- package/dist/agentic/guides/modules/agent_orchestrator/index.md +1 -1
- package/dist/agentic/guides/modules/ai_assistant/encryption.md +13 -0
- package/dist/agentic/guides/modules/ai_assistant/exact-override-targets.md +3 -0
- package/dist/agentic/guides/modules/ai_assistant/index.md +4 -3
- package/dist/agentic/guides/modules/api_docs/index.md +1 -1
- package/dist/agentic/guides/modules/api_keys/domain-commands.md +12 -0
- package/dist/agentic/guides/modules/api_keys/index.md +3 -2
- 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/availability/acl-features.md +13 -0
- package/dist/agentic/guides/modules/availability/backend-pages.md +14 -0
- package/dist/agentic/guides/modules/availability/di-registrations-rich.md +11 -0
- package/dist/agentic/guides/modules/availability/domain-commands.md +13 -0
- package/dist/agentic/guides/modules/availability/entities.md +11 -0
- package/dist/agentic/guides/modules/availability/events.md +13 -0
- package/dist/agentic/guides/modules/availability/exact-override-targets.md +19 -0
- package/dist/agentic/guides/modules/availability/host-extension-points.md +10 -0
- package/dist/agentic/guides/modules/availability/index.md +22 -0
- package/dist/agentic/guides/modules/availability/owned-contract-module-metadata.md +11 -0
- package/dist/agentic/guides/modules/availability/setup.md +11 -0
- package/dist/agentic/guides/modules/availability/umes-hosts.md +14 -0
- package/dist/agentic/guides/modules/business_rules/index.md +1 -1
- package/dist/agentic/guides/modules/catalog/active-extension-bindings.md +1 -1
- package/dist/agentic/guides/modules/catalog/di-registrations-rich.md +1 -0
- package/dist/agentic/guides/modules/catalog/exact-override-targets.md +3 -0
- package/dist/agentic/guides/modules/catalog/index.md +1 -1
- package/dist/agentic/guides/modules/catalog/workers.md +2 -0
- 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/channel_resend/index.md +1 -1
- package/dist/agentic/guides/modules/channel_ses/index.md +1 -1
- package/dist/agentic/guides/modules/checkout/index.md +1 -1
- package/dist/agentic/guides/modules/communication_channels/acl-features.md +2 -1
- package/dist/agentic/guides/modules/communication_channels/contribution-resolutions.md +1 -0
- package/dist/agentic/guides/modules/communication_channels/di-registrations-rich.md +11 -10
- package/dist/agentic/guides/modules/communication_channels/domain-commands.md +1 -0
- package/dist/agentic/guides/modules/communication_channels/events.md +2 -1
- package/dist/agentic/guides/modules/communication_channels/exact-override-targets.md +12 -10
- package/dist/agentic/guides/modules/communication_channels/incoming-installed-contributions.md +1 -0
- package/dist/agentic/guides/modules/communication_channels/index.md +2 -2
- package/dist/agentic/guides/modules/communication_channels/umes-contributions.md +1 -0
- package/dist/agentic/guides/modules/communication_channels/umes-hosts.md +1 -0
- 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/customer_groups/acl-features.md +16 -0
- package/dist/agentic/guides/modules/customer_groups/active-extension-bindings.md +13 -0
- package/dist/agentic/guides/modules/customer_groups/backend-pages.md +14 -0
- package/dist/agentic/guides/modules/customer_groups/cli-commands.md +11 -0
- package/dist/agentic/guides/modules/customer_groups/contribution-resolutions.md +13 -0
- package/dist/agentic/guides/modules/customer_groups/di-registrations-rich.md +13 -0
- package/dist/agentic/guides/modules/customer_groups/di-service-tokens.md +11 -0
- package/dist/agentic/guides/modules/customer_groups/domain-commands.md +11 -0
- package/dist/agentic/guides/modules/customer_groups/entities.md +13 -0
- package/dist/agentic/guides/modules/customer_groups/events.md +17 -0
- package/dist/agentic/guides/modules/customer_groups/exact-override-targets.md +27 -0
- package/dist/agentic/guides/modules/customer_groups/index.md +26 -0
- package/dist/agentic/guides/modules/customer_groups/owned-contract-module-metadata.md +11 -0
- package/dist/agentic/guides/modules/customer_groups/setup.md +11 -0
- package/dist/agentic/guides/modules/customer_groups/umes-contributions.md +13 -0
- package/dist/agentic/guides/modules/customer_groups/umes-hosts.md +21 -0
- package/dist/agentic/guides/modules/customers/acl-features.md +2 -1
- package/dist/agentic/guides/modules/customers/active-extension-bindings.md +8 -7
- package/dist/agentic/guides/modules/customers/contribution-resolutions.md +1 -0
- package/dist/agentic/guides/modules/customers/domain-commands.md +1 -0
- package/dist/agentic/guides/modules/customers/entities.md +1 -0
- package/dist/agentic/guides/modules/customers/events.md +2 -1
- package/dist/agentic/guides/modules/customers/exact-override-targets.md +2 -0
- package/dist/agentic/guides/modules/customers/index.md +3 -3
- package/dist/agentic/guides/modules/customers/umes-contributions.md +1 -0
- package/dist/agentic/guides/modules/customers/umes-hosts.md +3 -0
- 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/active-extension-bindings.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/active-extension-bindings.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/forms/acl-features.md +16 -0
- package/dist/agentic/guides/modules/forms/active-extension-bindings.md +14 -0
- package/dist/agentic/guides/modules/forms/backend-pages.md +17 -0
- package/dist/agentic/guides/modules/forms/contribution-resolutions.md +21 -0
- package/dist/agentic/guides/modules/forms/di-registrations-rich.md +27 -0
- package/dist/agentic/guides/modules/forms/di-service-tokens.md +19 -0
- package/dist/agentic/guides/modules/forms/domain-commands.md +30 -0
- package/dist/agentic/guides/modules/forms/encryption.md +11 -0
- package/dist/agentic/guides/modules/forms/entities.md +21 -0
- package/dist/agentic/guides/modules/forms/events.md +28 -0
- package/dist/agentic/guides/modules/forms/exact-override-targets.md +59 -0
- package/dist/agentic/guides/modules/forms/frontend-pages.md +16 -0
- package/dist/agentic/guides/modules/forms/index.md +28 -0
- package/dist/agentic/guides/modules/forms/owned-contract-module-metadata.md +11 -0
- package/dist/agentic/guides/modules/forms/setup.md +11 -0
- package/dist/agentic/guides/modules/forms/umes-contributions.md +21 -0
- package/dist/agentic/guides/modules/forms/umes-hosts.md +43 -0
- package/dist/agentic/guides/modules/forms/workers.md +12 -0
- 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/active-extension-bindings.md +1 -1
- package/dist/agentic/guides/modules/inbox_ops/index.md +1 -1
- package/dist/agentic/guides/modules/integrations/di-registrations-rich.md +8 -8
- package/dist/agentic/guides/modules/integrations/di-service-tokens.md +4 -4
- package/dist/agentic/guides/modules/integrations/exact-override-targets.md +8 -8
- 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/phone_calls/index.md +1 -1
- package/dist/agentic/guides/modules/planner/active-extension-bindings.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/active-extension-bindings.md +1 -1
- package/dist/agentic/guides/modules/resources/index.md +1 -1
- package/dist/agentic/guides/modules/sales/active-extension-bindings.md +1 -1
- package/dist/agentic/guides/modules/sales/exact-override-targets.md +4 -4
- package/dist/agentic/guides/modules/sales/index.md +1 -1
- package/dist/agentic/guides/modules/sales/setup.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/seeds/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/active-extension-bindings.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/active-extension-bindings.md +11 -0
- package/dist/agentic/guides/modules/sync_excel/index.md +3 -2
- package/dist/agentic/guides/modules/system_status_overlays/index.md +1 -1
- package/dist/agentic/guides/modules/telemetry/index.md +1 -1
- package/dist/agentic/guides/modules/tillio/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/active-extension-bindings.md +1 -1
- package/dist/agentic/guides/modules/wms/contribution-resolutions.md +3 -0
- package/dist/agentic/guides/modules/wms/di-registrations-rich.md +8 -8
- package/dist/agentic/guides/modules/wms/exact-override-targets.md +11 -8
- package/dist/agentic/guides/modules/wms/index.md +3 -3
- package/dist/agentic/guides/modules/wms/umes-contributions.md +3 -0
- package/dist/agentic/guides/modules/workflows/active-extension-bindings.md +3 -3
- 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/BACKWARD_COMPATIBILITY.md +73 -1
- package/dist/agentic/guides/upstream/manifest.json +2 -2
- package/dist/agentic/shared/ai/skills/om-auto-upgrade-0.8.0-to-0.9.0/SKILL.md +112 -0
- package/dist/agentic/shared/ai/skills/om-data-model-design/references/sensitive-data.md +1 -1
- package/dist/agentic/shared/ai/skills/tiers.json +2 -1
- package/dist/bin.js +2 -2
- package/dist/bin.js.map +2 -2
- package/dist/lib/db/commands.js +84 -5
- package/dist/lib/db/commands.js.map +2 -2
- package/dist/lib/db/encryption-backfill-check.js +112 -0
- package/dist/lib/db/encryption-backfill-check.js.map +7 -0
- package/dist/lib/db/migration-reindex.js +2 -1
- package/dist/lib/db/migration-reindex.js.map +2 -2
- package/dist/lib/events-single-delivery.js.map +2 -2
- package/dist/lib/flush-before-exit.js +19 -0
- package/dist/lib/flush-before-exit.js.map +7 -0
- package/dist/lib/generate-watch-structure.js +113 -19
- package/dist/lib/generate-watch-structure.js.map +3 -3
- package/dist/lib/generators/module-registry.js +4 -2
- package/dist/lib/generators/module-registry.js.map +2 -2
- package/dist/lib/generators/web-research-adapters.js +32 -11
- package/dist/lib/generators/web-research-adapters.js.map +2 -2
- package/dist/lib/telemetry-init.js +69 -11
- package/dist/lib/telemetry-init.js.map +3 -3
- package/dist/lib/testing/integration.js +29 -2
- package/dist/lib/testing/integration.js.map +2 -2
- package/package.json +8 -8
- package/src/bin.ts +4 -4
- package/src/lib/__tests__/flush-before-exit.test.ts +32 -0
- package/src/lib/__tests__/generate-watch-structure.test.ts +261 -3
- package/src/lib/__tests__/telemetry-init.test.ts +89 -0
- package/src/lib/db/__tests__/commands.test.ts +43 -0
- package/src/lib/db/__tests__/dynamic-import-specifier.test.ts +36 -0
- package/src/lib/db/__tests__/encryption-backfill-check.test.ts +146 -0
- package/src/lib/db/__tests__/migration-reindex.test.ts +48 -3
- package/src/lib/db/commands.ts +113 -10
- package/src/lib/db/encryption-backfill-check.ts +163 -0
- package/src/lib/db/migration-reindex.ts +2 -1
- package/src/lib/events-single-delivery.ts +5 -2
- package/src/lib/flush-before-exit.ts +39 -0
- package/src/lib/generate-watch-structure.ts +134 -19
- package/src/lib/generators/__tests__/module-facts.customers.fixture.test.ts +14 -3
- package/src/lib/generators/__tests__/module-package-sources.test.ts +2 -2
- package/src/lib/generators/__tests__/registry-variant-parity.test.ts +16 -0
- package/src/lib/generators/__tests__/web-research-adapters.test.ts +141 -0
- package/src/lib/generators/module-registry.ts +2 -0
- package/src/lib/generators/web-research-adapters.ts +41 -20
- package/src/lib/telemetry-init.ts +68 -11
- package/src/lib/testing/__tests__/integration.test.ts +26 -0
- package/src/lib/testing/integration.ts +32 -2
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import {
|
|
2
|
+
checkEncryptionMapBackfills,
|
|
3
|
+
findEncryptionBackfillGaps,
|
|
4
|
+
formatEncryptionBackfillWarning,
|
|
5
|
+
isBackfilledByMigration,
|
|
6
|
+
isEncryptionBackfillCheckEnabled,
|
|
7
|
+
readDeclaredEncryptionMaps,
|
|
8
|
+
type EncryptionMapRow,
|
|
9
|
+
} from '../encryption-backfill-check'
|
|
10
|
+
|
|
11
|
+
const TENANT_A = '11111111-1111-1111-1111-111111111111'
|
|
12
|
+
const TENANT_B = '22222222-2222-2222-2222-222222222222'
|
|
13
|
+
const ORG = '33333333-3333-3333-3333-333333333333'
|
|
14
|
+
|
|
15
|
+
function row(tenantId: string, entityId: string, fields: string[], isActive = true): EncryptionMapRow {
|
|
16
|
+
return { tenantId, organizationId: ORG, entityId, fieldsJson: fields.map((field) => ({ field })), isActive }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('readDeclaredEncryptionMaps', () => {
|
|
20
|
+
it('reads defaultEncryptionMaps and skips system-scoped or malformed entries', () => {
|
|
21
|
+
const declared = readDeclaredEncryptionMaps('demo', {
|
|
22
|
+
defaultEncryptionMaps: [
|
|
23
|
+
{ entityId: 'demo:thing', fields: [{ field: 'secret' }, { field: 'email', hashField: 'email_hash' }] },
|
|
24
|
+
{ entityId: 'demo:system', keyScope: 'system', fields: [{ field: 'token' }] },
|
|
25
|
+
{ entityId: 'demo:empty', fields: [] },
|
|
26
|
+
{ fields: [{ field: 'orphan' }] },
|
|
27
|
+
],
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
expect(declared).toEqual([{ moduleId: 'demo', entityId: 'demo:thing', fields: ['secret', 'email'] }])
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('falls back to the default export', () => {
|
|
34
|
+
expect(readDeclaredEncryptionMaps('demo', { default: [{ entityId: 'demo:thing', fields: [{ field: 'secret' }] }] }))
|
|
35
|
+
.toEqual([{ moduleId: 'demo', entityId: 'demo:thing', fields: ['secret'] }])
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('isBackfilledByMigration', () => {
|
|
40
|
+
it('accepts a helper call or raw encryption_maps SQL naming the entity and field', () => {
|
|
41
|
+
const helperSource = `buildEncryptionMapBackfillSql({ entityId: 'demo:thing', fields: [{ field: 'secret' }] })`
|
|
42
|
+
const rawSource = `insert into "encryption_maps" select 'demo:thing', '[{"field":"secret"}]'::jsonb`
|
|
43
|
+
|
|
44
|
+
expect(isBackfilledByMigration([helperSource], 'demo:thing', 'secret')).toBe(true)
|
|
45
|
+
expect(isBackfilledByMigration([rawSource], 'demo:thing', 'secret')).toBe(true)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('rejects migrations that do not write encryption maps or name another field', () => {
|
|
49
|
+
expect(isBackfilledByMigration([`alter table demo add column "secret" text; -- demo:thing`], 'demo:thing', 'secret')).toBe(false)
|
|
50
|
+
expect(isBackfilledByMigration([`buildEncryptionMapBackfillSql({ entityId: 'demo:thing', fields: [{ field: 'secret_hash' }] })`], 'demo:thing', 'secret')).toBe(false)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('findEncryptionBackfillGaps', () => {
|
|
55
|
+
const declared = [{ moduleId: 'demo', entityId: 'demo:thing', fields: ['secret', 'email'] }]
|
|
56
|
+
|
|
57
|
+
it('reports declared fields missing for existing encryption-enabled scopes', () => {
|
|
58
|
+
const gaps = findEncryptionBackfillGaps({
|
|
59
|
+
declared,
|
|
60
|
+
rows: [row(TENANT_A, 'other:x', ['a']), row(TENANT_B, 'demo:thing', ['secret', 'email'])],
|
|
61
|
+
migrationSources: [],
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
expect(gaps).toEqual([
|
|
65
|
+
{ moduleId: 'demo', entityId: 'demo:thing', field: 'secret', missingScopes: 1, totalScopes: 2 },
|
|
66
|
+
{ moduleId: 'demo', entityId: 'demo:thing', field: 'email', missingScopes: 1, totalScopes: 2 },
|
|
67
|
+
])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('reports a new field on an existing map', () => {
|
|
71
|
+
const gaps = findEncryptionBackfillGaps({ declared, rows: [row(TENANT_A, 'demo:thing', ['secret'])], migrationSources: [] })
|
|
72
|
+
|
|
73
|
+
expect(gaps.map((gap) => gap.field)).toEqual(['email'])
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('ignores scopes with encryption disabled and fields a migration backfills', () => {
|
|
77
|
+
expect(findEncryptionBackfillGaps({ declared, rows: [row(TENANT_A, 'other:x', ['a'], false)], migrationSources: [] })).toEqual([])
|
|
78
|
+
expect(
|
|
79
|
+
findEncryptionBackfillGaps({
|
|
80
|
+
declared,
|
|
81
|
+
rows: [row(TENANT_A, 'other:x', ['a'])],
|
|
82
|
+
migrationSources: [`buildEncryptionMapBackfillSql({ entityId: 'demo:thing', fields: [{ field: 'secret' }, { field: 'email' }] })`],
|
|
83
|
+
}),
|
|
84
|
+
).toEqual([])
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('checkEncryptionMapBackfills', () => {
|
|
89
|
+
it('warns once with every gap', async () => {
|
|
90
|
+
const warn = jest.fn()
|
|
91
|
+
const gaps = await checkEncryptionMapBackfills({
|
|
92
|
+
loadDeclaredMaps: async () => [{ moduleId: 'demo', entityId: 'demo:thing', fields: ['secret'] }],
|
|
93
|
+
loadMigrationSources: () => [],
|
|
94
|
+
queryEncryptionMaps: async () => [row(TENANT_A, 'other:x', ['a'])],
|
|
95
|
+
warn,
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
expect(gaps).toHaveLength(1)
|
|
99
|
+
expect(warn).toHaveBeenCalledTimes(1)
|
|
100
|
+
expect(warn.mock.calls[0][0]).toContain('demo: demo:thing [secret] (missing in 1/1 tenant scopes)')
|
|
101
|
+
expect(warn.mock.calls[0][0]).toContain('buildEncryptionMapBackfillSql')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('does not query the database when no module declares maps', async () => {
|
|
105
|
+
const queryEncryptionMaps = jest.fn(async () => [])
|
|
106
|
+
await checkEncryptionMapBackfills({ loadDeclaredMaps: async () => [], loadMigrationSources: () => [], queryEncryptionMaps, warn: jest.fn() })
|
|
107
|
+
|
|
108
|
+
expect(queryEncryptionMaps).not.toHaveBeenCalled()
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('turns failures into a warning instead of throwing', async () => {
|
|
112
|
+
const warn = jest.fn()
|
|
113
|
+
const gaps = await checkEncryptionMapBackfills({
|
|
114
|
+
loadDeclaredMaps: async () => [{ moduleId: 'demo', entityId: 'demo:thing', fields: ['secret'] }],
|
|
115
|
+
loadMigrationSources: () => [],
|
|
116
|
+
queryEncryptionMaps: async () => {
|
|
117
|
+
throw new Error('connection refused')
|
|
118
|
+
},
|
|
119
|
+
warn,
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
expect(gaps).toEqual([])
|
|
123
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('connection refused'))
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe('formatEncryptionBackfillWarning', () => {
|
|
128
|
+
it('groups fields per entity and caps the list', () => {
|
|
129
|
+
const gaps = Array.from({ length: 22 }, (_unused, index) => [
|
|
130
|
+
{ moduleId: 'demo', entityId: `demo:thing_${index}`, field: 'secret', missingScopes: 1, totalScopes: 2 },
|
|
131
|
+
{ moduleId: 'demo', entityId: `demo:thing_${index}`, field: 'email', missingScopes: 2, totalScopes: 2 },
|
|
132
|
+
]).flat()
|
|
133
|
+
const message = formatEncryptionBackfillWarning(gaps)
|
|
134
|
+
|
|
135
|
+
expect(message).toContain(' - demo: demo:thing_0 [secret, email] (missing in 2/2 tenant scopes)')
|
|
136
|
+
expect(message).not.toContain('demo:thing_20 ')
|
|
137
|
+
expect(message).toContain('… and 2 more entities')
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe('isEncryptionBackfillCheckEnabled', () => {
|
|
142
|
+
it('is on by default and can be switched off', () => {
|
|
143
|
+
expect(isEncryptionBackfillCheckEnabled({})).toBe(true)
|
|
144
|
+
expect(isEncryptionBackfillCheckEnabled({ OM_ENCRYPTION_BACKFILL_CHECK: 'off' })).toBe(false)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
1
2
|
import path from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
2
4
|
import {
|
|
3
5
|
collectQueryIndexReindexEntityTypes,
|
|
4
6
|
formatManualReindexInstructions,
|
|
@@ -14,6 +16,10 @@ function migration(name: string, overrides: Partial<AppliedMigration> = {}): App
|
|
|
14
16
|
return { moduleId: 'customers', migrationsPath: MIGRATIONS_PATH, name, ...overrides }
|
|
15
17
|
}
|
|
16
18
|
|
|
19
|
+
function resolveImportSpecifier(specifier: string): string {
|
|
20
|
+
return specifier.startsWith('file:') ? fileURLToPath(specifier) : specifier
|
|
21
|
+
}
|
|
22
|
+
|
|
17
23
|
describe('resolveMigrationFilePath', () => {
|
|
18
24
|
const existing = new Set([path.join(MIGRATIONS_PATH, 'Migration20260901120000_reindex.ts')])
|
|
19
25
|
const fileExists = (filePath: string) => existing.has(filePath)
|
|
@@ -61,7 +67,7 @@ describe('collectQueryIndexReindexEntityTypes', () => {
|
|
|
61
67
|
|
|
62
68
|
const collected = await collectQueryIndexReindexEntityTypes(
|
|
63
69
|
[migration('a'), migration('b'), migration('c')],
|
|
64
|
-
{ importModule: async (
|
|
70
|
+
{ importModule: async (specifier) => modules[resolveImportSpecifier(specifier)], fileExists },
|
|
65
71
|
)
|
|
66
72
|
|
|
67
73
|
expect(collected).toEqual(['customers:customer_dictionary_entry', 'workflows:workflow_definition'])
|
|
@@ -74,8 +80,8 @@ describe('collectQueryIndexReindexEntityTypes', () => {
|
|
|
74
80
|
{
|
|
75
81
|
fileExists,
|
|
76
82
|
onWarn,
|
|
77
|
-
importModule: async (
|
|
78
|
-
if (
|
|
83
|
+
importModule: async (specifier) => {
|
|
84
|
+
if (resolveImportSpecifier(specifier).endsWith('broken.ts')) throw new Error('boom')
|
|
79
85
|
return { queryIndexReindexEntityTypes: ['dictionaries:dictionary_entry'] }
|
|
80
86
|
},
|
|
81
87
|
},
|
|
@@ -118,6 +124,45 @@ describe('collectQueryIndexReindexEntityTypes', () => {
|
|
|
118
124
|
expect(collected).toEqual([])
|
|
119
125
|
expect(importModule).not.toHaveBeenCalled()
|
|
120
126
|
})
|
|
127
|
+
|
|
128
|
+
it('imports cwd-relative node_modules migration paths via file: URLs (#6238)', async () => {
|
|
129
|
+
const relativePath = path.join(
|
|
130
|
+
'node_modules',
|
|
131
|
+
'@open-mercato',
|
|
132
|
+
'test-migration-reindex',
|
|
133
|
+
'Migration20260901120000_reindex.js',
|
|
134
|
+
)
|
|
135
|
+
const absolutePath = path.resolve(relativePath)
|
|
136
|
+
fs.mkdirSync(path.dirname(absolutePath), { recursive: true })
|
|
137
|
+
fs.writeFileSync(
|
|
138
|
+
absolutePath,
|
|
139
|
+
'module.exports = { queryIndexReindexEntityTypes: ["customers:deal"] };\n',
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const onWarn = jest.fn()
|
|
144
|
+
const collected = await collectQueryIndexReindexEntityTypes(
|
|
145
|
+
[{
|
|
146
|
+
moduleId: 'customers',
|
|
147
|
+
migrationsPath: path.dirname(absolutePath),
|
|
148
|
+
name: 'Migration20260901120000_reindex',
|
|
149
|
+
filePath: relativePath,
|
|
150
|
+
}],
|
|
151
|
+
{
|
|
152
|
+
onWarn,
|
|
153
|
+
importModule: async (specifier) => {
|
|
154
|
+
expect(specifier.startsWith('file:')).toBe(true)
|
|
155
|
+
return import(specifier)
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
expect(onWarn).not.toHaveBeenCalled()
|
|
161
|
+
expect(collected).toEqual(['customers:deal'])
|
|
162
|
+
} finally {
|
|
163
|
+
fs.rmSync(path.dirname(absolutePath), { recursive: true, force: true })
|
|
164
|
+
}
|
|
165
|
+
})
|
|
121
166
|
})
|
|
122
167
|
|
|
123
168
|
describe('isMigrationReindexEnabled', () => {
|
package/src/lib/db/commands.ts
CHANGED
|
@@ -9,6 +9,13 @@ 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
|
+
checkEncryptionMapBackfills,
|
|
14
|
+
isEncryptionBackfillCheckEnabled,
|
|
15
|
+
readDeclaredEncryptionMaps,
|
|
16
|
+
type DeclaredEncryptionMap,
|
|
17
|
+
type EncryptionMapRow,
|
|
18
|
+
} from './encryption-backfill-check'
|
|
12
19
|
import {
|
|
13
20
|
collectQueryIndexReindexEntityTypes,
|
|
14
21
|
isMigrationReindexEnabled,
|
|
@@ -59,18 +66,47 @@ function sortModules(mods: ModuleEntry[]): ModuleEntry[] {
|
|
|
59
66
|
}
|
|
60
67
|
|
|
61
68
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
69
|
+
* Convert a filesystem path into a specifier `import()` can resolve under plain
|
|
70
|
+
* Node ESM on every platform (#6238).
|
|
71
|
+
*
|
|
72
|
+
* Windows already needed `file:` URLs for drive-letter absolute paths. On POSIX,
|
|
73
|
+
* cwd-relative paths such as `node_modules/.../Migration.js` are treated as
|
|
74
|
+
* bare package names (`node_modules`), so migration reindex declarations were
|
|
75
|
+
* logged and skipped. Absolute paths become `file:` URLs on every platform.
|
|
76
|
+
* Already-URL / `node:` / bare package (and package-subpath) specifiers stay as-is.
|
|
66
77
|
*/
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
id
|
|
78
|
+
export function toImportableModuleSpecifier(id: string): string {
|
|
79
|
+
if (
|
|
80
|
+
id.startsWith('file:') ||
|
|
81
|
+
id.startsWith('data:') ||
|
|
82
|
+
id.startsWith('node:') ||
|
|
83
|
+
id.startsWith('http:') ||
|
|
84
|
+
id.startsWith('https:')
|
|
85
|
+
) {
|
|
86
|
+
return id
|
|
72
87
|
}
|
|
73
|
-
|
|
88
|
+
|
|
89
|
+
const isWindowsAbsolute = /^[a-zA-Z]:[\\/]/.test(id)
|
|
90
|
+
const isFilesystemRelative =
|
|
91
|
+
id.startsWith('./') ||
|
|
92
|
+
id.startsWith('../') ||
|
|
93
|
+
id.startsWith('.\\') ||
|
|
94
|
+
id.startsWith('..\\') ||
|
|
95
|
+
/(^|[\\/])node_modules([\\/]|$)/.test(id)
|
|
96
|
+
|
|
97
|
+
if (path.isAbsolute(id) || isWindowsAbsolute || isFilesystemRelative) {
|
|
98
|
+
return pathToFileURL(path.resolve(id)).href
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return id
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Custom dynamic import provider for MikroORM and migration reindex loading.
|
|
106
|
+
* Always converts filesystem paths to `file:` URLs before `import()` (#6238).
|
|
107
|
+
*/
|
|
108
|
+
async function dynamicImportProvider(id: string): Promise<any> {
|
|
109
|
+
return import(toImportableModuleSpecifier(id))
|
|
74
110
|
}
|
|
75
111
|
|
|
76
112
|
/**
|
|
@@ -228,6 +264,72 @@ function getMigrationsPath(entry: ModuleEntry, resolver: PackageResolver): strin
|
|
|
228
264
|
return path.join(roots.pkgBase, 'migrations').replace(/\\/g, '/')
|
|
229
265
|
}
|
|
230
266
|
|
|
267
|
+
async function loadModuleEncryptionMaps(entry: ModuleEntry, resolver: PackageResolver): Promise<DeclaredEncryptionMap[]> {
|
|
268
|
+
const roots = resolver.getModulePaths(entry)
|
|
269
|
+
const appFile = path.join(roots.appBase, 'encryption.ts')
|
|
270
|
+
const pkgFile = path.join(roots.pkgBase, 'encryption.ts')
|
|
271
|
+
try {
|
|
272
|
+
if (fs.existsSync(appFile)) return readDeclaredEncryptionMaps(entry.id, await importWithTypeScriptFile(appFile))
|
|
273
|
+
if (entry.from === '@app' || resolver.isMonorepo()) {
|
|
274
|
+
return fs.existsSync(pkgFile) ? readDeclaredEncryptionMaps(entry.id, await importWithTypeScriptFile(pkgFile)) : []
|
|
275
|
+
}
|
|
276
|
+
return readDeclaredEncryptionMaps(entry.id, await import(`${resolver.getModuleImportBase(entry).pkgBase}/encryption`))
|
|
277
|
+
} catch {
|
|
278
|
+
return []
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function readModuleMigrationSources(modules: ModuleEntry[], resolver: PackageResolver): string[] {
|
|
283
|
+
const sources: string[] = []
|
|
284
|
+
for (const entry of modules) {
|
|
285
|
+
const migrationsPath = getMigrationsPath(entry, resolver)
|
|
286
|
+
if (!fs.existsSync(migrationsPath)) continue
|
|
287
|
+
for (const file of fs.readdirSync(migrationsPath)) {
|
|
288
|
+
if (!/^Migration.*\.(ts|js)$/.test(file) || file.endsWith('.d.ts')) continue
|
|
289
|
+
sources.push(fs.readFileSync(path.join(migrationsPath, file), 'utf8'))
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return sources
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function queryEncryptionMapRows(): Promise<EncryptionMapRow[] | null> {
|
|
296
|
+
const { Client } = await import('pg')
|
|
297
|
+
const client = new Client({ connectionString: getClientUrl(), ssl: getSslConfig(), connectionTimeoutMillis: 5000 })
|
|
298
|
+
await client.connect()
|
|
299
|
+
try {
|
|
300
|
+
const table = await client.query(`select to_regclass('encryption_maps') is not null as "exists"`)
|
|
301
|
+
if (!table.rows[0]?.exists) return null
|
|
302
|
+
const result = await client.query(
|
|
303
|
+
`select "tenant_id", "organization_id", "entity_id", "fields_json", "is_active" from "encryption_maps" where "deleted_at" is null`,
|
|
304
|
+
)
|
|
305
|
+
return result.rows.map((row) => ({
|
|
306
|
+
tenantId: row.tenant_id ?? null,
|
|
307
|
+
organizationId: row.organization_id ?? null,
|
|
308
|
+
entityId: String(row.entity_id),
|
|
309
|
+
fieldsJson: row.fields_json,
|
|
310
|
+
isActive: row.is_active === true,
|
|
311
|
+
}))
|
|
312
|
+
} finally {
|
|
313
|
+
try {
|
|
314
|
+
await client.end()
|
|
315
|
+
} catch { }
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export async function warnAboutMissingEncryptionMapBackfills(modules: ModuleEntry[], resolver: PackageResolver): Promise<void> {
|
|
320
|
+
if (!isEncryptionBackfillCheckEnabled()) return
|
|
321
|
+
await checkEncryptionMapBackfills({
|
|
322
|
+
loadDeclaredMaps: async () => {
|
|
323
|
+
const declared: DeclaredEncryptionMap[] = []
|
|
324
|
+
for (const entry of modules) declared.push(...(await loadModuleEncryptionMaps(entry, resolver)))
|
|
325
|
+
return declared
|
|
326
|
+
},
|
|
327
|
+
loadMigrationSources: () => readModuleMigrationSources(modules, resolver),
|
|
328
|
+
queryEncryptionMaps: queryEncryptionMapRows,
|
|
329
|
+
warn: (message) => console.warn(message),
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
|
|
231
333
|
export interface DbOptions {
|
|
232
334
|
quiet?: boolean
|
|
233
335
|
}
|
|
@@ -338,6 +440,7 @@ export async function dbGenerate(resolver: PackageResolver, options: DbOptions =
|
|
|
338
440
|
}
|
|
339
441
|
|
|
340
442
|
console.log(results.join('\n'))
|
|
443
|
+
await warnAboutMissingEncryptionMapBackfills(ordered, resolver)
|
|
341
444
|
}
|
|
342
445
|
|
|
343
446
|
export async function dbMigrate(resolver: PackageResolver, options: DbOptions = {}): Promise<void> {
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Advisory check run after `db:generate`. Tenant encryption maps are seeded only when a tenant is
|
|
5
|
+
* created, so an `encryption.ts` entry added after tenants exist never reaches them unless a module
|
|
6
|
+
* migration backfills it (`buildEncryptionMapBackfillSql`). The connected database is the evidence:
|
|
7
|
+
* a declared `(entityId, field)` that some existing encryption-enabled scope lacks, and that no
|
|
8
|
+
* migration mentions, is reported. The check only warns; it never fails generation.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type DeclaredEncryptionMap = {
|
|
12
|
+
moduleId: string
|
|
13
|
+
entityId: string
|
|
14
|
+
fields: string[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type EncryptionMapRow = {
|
|
18
|
+
tenantId: string | null
|
|
19
|
+
organizationId: string | null
|
|
20
|
+
entityId: string
|
|
21
|
+
fieldsJson: unknown
|
|
22
|
+
isActive: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type EncryptionBackfillGap = {
|
|
26
|
+
moduleId: string
|
|
27
|
+
entityId: string
|
|
28
|
+
field: string
|
|
29
|
+
missingScopes: number
|
|
30
|
+
totalScopes: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type EncryptionBackfillCheckDeps = {
|
|
34
|
+
loadDeclaredMaps: () => Promise<DeclaredEncryptionMap[]>
|
|
35
|
+
loadMigrationSources: () => string[]
|
|
36
|
+
queryEncryptionMaps: () => Promise<EncryptionMapRow[] | null>
|
|
37
|
+
warn: (message: string) => void
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isEncryptionBackfillCheckEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
41
|
+
return parseBooleanWithDefault(env.OM_ENCRYPTION_BACKFILL_CHECK, true)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function readDeclaredEncryptionMaps(moduleId: string, moduleExports: unknown): DeclaredEncryptionMap[] {
|
|
45
|
+
if (!moduleExports || typeof moduleExports !== 'object') return []
|
|
46
|
+
const exportsRecord = moduleExports as Record<string, unknown>
|
|
47
|
+
const maps = exportsRecord.defaultEncryptionMaps ?? exportsRecord.encryptionMaps ?? exportsRecord.default
|
|
48
|
+
if (!Array.isArray(maps)) return []
|
|
49
|
+
const declared: DeclaredEncryptionMap[] = []
|
|
50
|
+
for (const map of maps) {
|
|
51
|
+
if (!map || typeof map !== 'object') continue
|
|
52
|
+
const { entityId, keyScope, fields } = map as Record<string, unknown>
|
|
53
|
+
if (typeof entityId !== 'string' || keyScope === 'system' || !Array.isArray(fields)) continue
|
|
54
|
+
const fieldNames = fields
|
|
55
|
+
.map((rule) => (rule && typeof rule === 'object' ? (rule as Record<string, unknown>).field : null))
|
|
56
|
+
.filter((field): field is string => typeof field === 'string' && field.length > 0)
|
|
57
|
+
if (fieldNames.length) declared.push({ moduleId, entityId, fields: fieldNames })
|
|
58
|
+
}
|
|
59
|
+
return declared
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readRuleFields(fieldsJson: unknown): Set<string> {
|
|
63
|
+
const fields = new Set<string>()
|
|
64
|
+
if (!Array.isArray(fieldsJson)) return fields
|
|
65
|
+
for (const rule of fieldsJson) {
|
|
66
|
+
if (rule && typeof rule === 'object' && typeof (rule as Record<string, unknown>).field === 'string') {
|
|
67
|
+
fields.add((rule as Record<string, string>).field)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return fields
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function scopeKey(tenantId: string | null, organizationId: string | null): string {
|
|
74
|
+
return `${tenantId ?? ''}|${organizationId ?? ''}`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function mentionsLiteral(source: string, value: string): boolean {
|
|
78
|
+
return source.includes(`'${value}'`) || source.includes(`"${value}"`) || source.includes(`\`${value}\``)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function isBackfilledByMigration(migrationSources: readonly string[], entityId: string, field: string): boolean {
|
|
82
|
+
return migrationSources.some(
|
|
83
|
+
(source) =>
|
|
84
|
+
(source.includes('encryption_maps') || source.includes('buildEncryptionMapBackfillSql')) &&
|
|
85
|
+
mentionsLiteral(source, entityId) &&
|
|
86
|
+
mentionsLiteral(source, field),
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function findEncryptionBackfillGaps(input: {
|
|
91
|
+
declared: readonly DeclaredEncryptionMap[]
|
|
92
|
+
rows: readonly EncryptionMapRow[]
|
|
93
|
+
migrationSources: readonly string[]
|
|
94
|
+
}): EncryptionBackfillGap[] {
|
|
95
|
+
const activeScopes = new Set<string>()
|
|
96
|
+
const fieldsByScopeAndEntity = new Map<string, Set<string>>()
|
|
97
|
+
for (const row of input.rows) {
|
|
98
|
+
const scope = scopeKey(row.tenantId, row.organizationId)
|
|
99
|
+
if (row.isActive) activeScopes.add(scope)
|
|
100
|
+
const key = `${scope}|${row.entityId}`
|
|
101
|
+
const fields = fieldsByScopeAndEntity.get(key) ?? new Set<string>()
|
|
102
|
+
for (const field of readRuleFields(row.fieldsJson)) fields.add(field)
|
|
103
|
+
fieldsByScopeAndEntity.set(key, fields)
|
|
104
|
+
}
|
|
105
|
+
if (activeScopes.size === 0) return []
|
|
106
|
+
|
|
107
|
+
const gaps: EncryptionBackfillGap[] = []
|
|
108
|
+
for (const map of input.declared) {
|
|
109
|
+
for (const field of map.fields) {
|
|
110
|
+
let missingScopes = 0
|
|
111
|
+
for (const scope of activeScopes) {
|
|
112
|
+
if (!fieldsByScopeAndEntity.get(`${scope}|${map.entityId}`)?.has(field)) missingScopes += 1
|
|
113
|
+
}
|
|
114
|
+
if (missingScopes === 0) continue
|
|
115
|
+
if (isBackfilledByMigration(input.migrationSources, map.entityId, field)) continue
|
|
116
|
+
gaps.push({ moduleId: map.moduleId, entityId: map.entityId, field, missingScopes, totalScopes: activeScopes.size })
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return gaps
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const MAX_WARNING_ENTITIES = 20
|
|
123
|
+
|
|
124
|
+
export function formatEncryptionBackfillWarning(gaps: readonly EncryptionBackfillGap[]): string {
|
|
125
|
+
const byEntity = new Map<string, { moduleId: string; fields: string[]; missingScopes: number; totalScopes: number }>()
|
|
126
|
+
for (const gap of gaps) {
|
|
127
|
+
const entry = byEntity.get(gap.entityId) ?? { moduleId: gap.moduleId, fields: [], missingScopes: 0, totalScopes: gap.totalScopes }
|
|
128
|
+
entry.fields.push(gap.field)
|
|
129
|
+
entry.missingScopes = Math.max(entry.missingScopes, gap.missingScopes)
|
|
130
|
+
byEntity.set(gap.entityId, entry)
|
|
131
|
+
}
|
|
132
|
+
const entities = [...byEntity.entries()]
|
|
133
|
+
const lines = entities
|
|
134
|
+
.slice(0, MAX_WARNING_ENTITIES)
|
|
135
|
+
.map(
|
|
136
|
+
([entityId, entry]) =>
|
|
137
|
+
` - ${entry.moduleId}: ${entityId} [${entry.fields.join(', ')}] (missing in ${entry.missingScopes}/${entry.totalScopes} tenant scopes)`,
|
|
138
|
+
)
|
|
139
|
+
if (entities.length > MAX_WARNING_ENTITIES) lines.push(` … and ${entities.length - MAX_WARNING_ENTITIES} more entities`)
|
|
140
|
+
return [
|
|
141
|
+
'⚠️ Encryption maps declared in encryption.ts are missing for existing tenants, and no migration backfills them:',
|
|
142
|
+
...lines,
|
|
143
|
+
' Tenants that already exist keep writing these fields as plaintext. If the module may already be deployed,',
|
|
144
|
+
' add a migration that calls buildEncryptionMapBackfillSql from @open-mercato/shared/lib/encryption/migration-backfill.',
|
|
145
|
+
' Local database only: `yarn mercato entities seed-encryption --tenant <id>`. Silence with OM_ENCRYPTION_BACKFILL_CHECK=off.',
|
|
146
|
+
].join('\n')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function checkEncryptionMapBackfills(deps: EncryptionBackfillCheckDeps): Promise<EncryptionBackfillGap[]> {
|
|
150
|
+
try {
|
|
151
|
+
const declared = await deps.loadDeclaredMaps()
|
|
152
|
+
if (!declared.length) return []
|
|
153
|
+
const rows = await deps.queryEncryptionMaps()
|
|
154
|
+
if (!rows?.length) return []
|
|
155
|
+
const gaps = findEncryptionBackfillGaps({ declared, rows, migrationSources: deps.loadMigrationSources() })
|
|
156
|
+
if (gaps.length) deps.warn(formatEncryptionBackfillWarning(gaps))
|
|
157
|
+
return gaps
|
|
158
|
+
} catch (error) {
|
|
159
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
160
|
+
deps.warn(`⚠️ Skipped the encryption map backfill check: ${message}`)
|
|
161
|
+
return []
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
+
import { pathToFileURL } from 'node:url'
|
|
3
4
|
import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
|
|
4
5
|
import {
|
|
5
6
|
formatQueryIndexRebuildCommands,
|
|
@@ -58,7 +59,7 @@ export async function collectQueryIndexReindexEntityTypes(
|
|
|
58
59
|
if (!filePath) continue
|
|
59
60
|
let moduleExports: unknown
|
|
60
61
|
try {
|
|
61
|
-
moduleExports = await deps.importModule(filePath)
|
|
62
|
+
moduleExports = await deps.importModule(pathToFileURL(path.resolve(filePath)).href)
|
|
62
63
|
} catch (error) {
|
|
63
64
|
deps.onWarn?.(
|
|
64
65
|
`[query_index] Could not read reindex declarations from ${migration.moduleId}/${migration.name}: ${
|
|
@@ -2,8 +2,11 @@ import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
|
|
|
2
2
|
import type { AutoSpawnWorkersMode } from './auto-spawn-workers'
|
|
3
3
|
|
|
4
4
|
// Keep these env names in sync with the runtime source of truth,
|
|
5
|
-
// `@open-mercato/events/single-delivery`.
|
|
6
|
-
//
|
|
5
|
+
// `@open-mercato/events/single-delivery`. This guard runs ahead of app
|
|
6
|
+
// bootstrap (before the Postgres driver and the rest of the module graph
|
|
7
|
+
// load), so it deliberately avoids importing `@open-mercato/events` here even
|
|
8
|
+
// though the package is now a declared dependency (bin.ts's post-bootstrap
|
|
9
|
+
// exit-flush uses it) — the reconcile logic is mirrored instead for this
|
|
7
10
|
// server-bootstrap guard only; the bus and worker own the runtime behavior.
|
|
8
11
|
//
|
|
9
12
|
// This guard is the ONLY place that reconciles the flag against worker
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { flushTelemetry } from './flush-telemetry'
|
|
2
|
+
|
|
3
|
+
export type ExitFlushSteps = {
|
|
4
|
+
flushBroadcasts: () => Promise<void>
|
|
5
|
+
flushTelemetry: () => Promise<void>
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
async function flushPendingBrowserBroadcasts(): Promise<void> {
|
|
9
|
+
const { BROADCAST_FLUSH_DEADLINE_MS, flushPendingBroadcasts } = await import(
|
|
10
|
+
'@open-mercato/events/broadcast-coalescer'
|
|
11
|
+
)
|
|
12
|
+
await flushPendingBroadcasts({ deadlineMs: BROADCAST_FLUSH_DEADLINE_MS })
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Runs before a returning CLI command's explicit `process.exit()`, which Node
|
|
17
|
+
* skips `beforeExit` for — the natural-exit path the broadcast-coalescer
|
|
18
|
+
* shutdown hook relies on to deliver a burst's trailing browser dispatch.
|
|
19
|
+
*
|
|
20
|
+
* The broadcast flush runs FIRST so its final `pg_notify` and the telemetry it
|
|
21
|
+
* produces are still captured by the telemetry flush that follows. It is capped
|
|
22
|
+
* at BROADCAST_FLUSH_DEADLINE_MS (3000ms), so a stalled Postgres connection at
|
|
23
|
+
* exit degrades to a dropped tail instead of hanging the command. Both steps are
|
|
24
|
+
* best-effort: neither can reject, and neither changes the command's exit code.
|
|
25
|
+
*
|
|
26
|
+
* Imported from the `broadcast-coalescer` deep path, and only after `run()` has
|
|
27
|
+
* bootstrapped the app, so it never loads the Postgres driver ahead of
|
|
28
|
+
* `initTelemetry()`.
|
|
29
|
+
*/
|
|
30
|
+
export async function flushBeforeExit(steps: Partial<ExitFlushSteps> = {}): Promise<void> {
|
|
31
|
+
const flushBroadcasts = steps.flushBroadcasts ?? flushPendingBrowserBroadcasts
|
|
32
|
+
try {
|
|
33
|
+
await flushBroadcasts()
|
|
34
|
+
} catch (error) {
|
|
35
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
36
|
+
console.warn(`[events] Broadcast flush on exit failed; the last browser refresh may be lost: ${reason}`)
|
|
37
|
+
}
|
|
38
|
+
await (steps.flushTelemetry ?? flushTelemetry)()
|
|
39
|
+
}
|