@open-mercato/shared 0.7.1-develop.7193.1.910a5b0a1e → 0.7.1-develop.7195.1.b5efd1d51a
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/dist/lib/ai/opencode-tool-parts.js +48 -0
- package/dist/lib/ai/opencode-tool-parts.js.map +7 -0
- package/dist/lib/ai/token-count.js +11 -0
- package/dist/lib/ai/token-count.js.map +7 -0
- package/dist/lib/bootstrap/dynamicLoader.js +14 -1
- package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
- package/dist/lib/commands/command-bus.js +9 -1
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/commands/registry.js +9 -0
- package/dist/lib/commands/registry.js.map +2 -2
- package/dist/lib/commands/types.js.map +2 -2
- package/dist/lib/di/container.js +7 -10
- package/dist/lib/di/container.js.map +2 -2
- package/dist/lib/encryption/subscriber.js +22 -2
- package/dist/lib/encryption/subscriber.js.map +2 -2
- package/dist/lib/openapi/generator.js +3 -2
- package/dist/lib/openapi/generator.js.map +2 -2
- package/dist/lib/openapi/index.js +3 -2
- package/dist/lib/openapi/index.js.map +2 -2
- package/dist/lib/seed/crypto.js +73 -0
- package/dist/lib/seed/crypto.js.map +7 -0
- package/dist/lib/seed/index.js +4 -0
- package/dist/lib/seed/index.js.map +7 -0
- package/dist/lib/seed/loader.js +73 -0
- package/dist/lib/seed/loader.js.map +7 -0
- package/dist/lib/seed/types.js +33 -0
- package/dist/lib/seed/types.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/events/factory.js +28 -9
- package/dist/modules/events/factory.js.map +2 -2
- package/package.json +3 -2
- package/src/lib/ai/__tests__/opencode-tool-parts.test.ts +81 -0
- package/src/lib/ai/__tests__/token-count.test.ts +20 -0
- package/src/lib/ai/opencode-tool-parts.ts +80 -0
- package/src/lib/ai/token-count.ts +21 -0
- package/src/lib/bootstrap/dynamicLoader.ts +24 -1
- package/src/lib/commands/__tests__/command-bus.test.ts +64 -0
- package/src/lib/commands/__tests__/registry.test.ts +35 -0
- package/src/lib/commands/command-bus.ts +16 -1
- package/src/lib/commands/registry.ts +11 -0
- package/src/lib/commands/types.ts +31 -0
- package/src/lib/di/__tests__/bootstrap-cache.test.ts +59 -3
- package/src/lib/di/container.ts +19 -15
- package/src/lib/encryption/__tests__/subscriber.kms-recovery.test.ts +112 -0
- package/src/lib/encryption/subscriber.ts +28 -1
- package/src/lib/openapi/__tests__/generator-response-fallback.test.ts +73 -0
- package/src/lib/openapi/generator.ts +3 -3
- package/src/lib/openapi/index.ts +1 -1
- package/src/lib/seed/__tests__/seed-crypto.test.ts +64 -0
- package/src/lib/seed/crypto.ts +87 -0
- package/src/lib/seed/index.ts +3 -0
- package/src/lib/seed/loader.ts +124 -0
- package/src/lib/seed/types.ts +48 -0
- package/src/modules/events/__tests__/factory.test.ts +96 -0
- package/src/modules/events/factory.ts +41 -10
- package/src/modules/events/types.ts +44 -0
|
@@ -50,6 +50,31 @@ function isJsonColumnProperty(prop: unknown): boolean {
|
|
|
50
50
|
return typeof customTypeName === 'string' && customTypeName.toLowerCase().includes('json')
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
// Encryption is configured on but the service reports disabled — the KMS is
|
|
54
|
+
// unreachable, so this write lands as plaintext. Throttle the warning so a long
|
|
55
|
+
// outage does not flood the log while still leaving an ongoing signal that the
|
|
56
|
+
// fail-open window is open (#5948).
|
|
57
|
+
const ENCRYPTION_PAUSED_WARN_INTERVAL_MS = 60_000
|
|
58
|
+
let lastEncryptionPausedWarnAt = 0
|
|
59
|
+
|
|
60
|
+
function warnEncryptionPaused(entity: string | undefined): void {
|
|
61
|
+
const now = Date.now()
|
|
62
|
+
if (lastEncryptionPausedWarnAt && now - lastEncryptionPausedWarnAt < ENCRYPTION_PAUSED_WARN_INTERVAL_MS) return
|
|
63
|
+
lastEncryptionPausedWarnAt = now
|
|
64
|
+
try {
|
|
65
|
+
logger.warn(
|
|
66
|
+
'Tenant data encryption is enabled but the KMS is unavailable - entity writes persist as plaintext until it recovers',
|
|
67
|
+
{ entity },
|
|
68
|
+
)
|
|
69
|
+
} catch {
|
|
70
|
+
// ignore
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function resetEncryptionPausedWarnThrottle(): void {
|
|
75
|
+
lastEncryptionPausedWarnAt = 0
|
|
76
|
+
}
|
|
77
|
+
|
|
53
78
|
const registeredEventManagers = new WeakSet<object>()
|
|
54
79
|
|
|
55
80
|
const subscribersByService = new WeakMap<TenantDataEncryptionService, TenantEncryptionSubscriber>()
|
|
@@ -206,8 +231,10 @@ export class TenantEncryptionSubscriber implements EventSubscriber<any> {
|
|
|
206
231
|
em?: { getMetadata?: () => any; getComparator?: () => any },
|
|
207
232
|
changeSet?: { payload?: Record<string, unknown> },
|
|
208
233
|
) {
|
|
209
|
-
|
|
234
|
+
const encryptionConfigured = isTenantDataEncryptionEnabled()
|
|
235
|
+
if (!encryptionConfigured || !this.service.isEnabled()) {
|
|
210
236
|
debug('⚪️ subscriber.skip', { reason: 'disabled', entity: meta?.className || meta?.name })
|
|
237
|
+
if (encryptionConfigured) warnEncryptionPaused(meta?.className || meta?.name)
|
|
211
238
|
return
|
|
212
239
|
}
|
|
213
240
|
const resolvedMeta = this.resolveMeta(meta, target, em)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { buildOpenApiDocument } from '../generator'
|
|
3
|
+
import type { Module } from '../../../modules/registry'
|
|
4
|
+
|
|
5
|
+
type SchemaNode = {
|
|
6
|
+
type?: string
|
|
7
|
+
description?: string
|
|
8
|
+
properties?: Record<string, SchemaNode>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type OperationNode = {
|
|
12
|
+
responses?: Record<string, { content?: Record<string, { schema?: SchemaNode }> }>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const noopHandler = async () => new Response(null)
|
|
16
|
+
|
|
17
|
+
function makeModules(): Module[] {
|
|
18
|
+
return [
|
|
19
|
+
{
|
|
20
|
+
id: 'example',
|
|
21
|
+
apis: [
|
|
22
|
+
{
|
|
23
|
+
path: '/example/undeclared',
|
|
24
|
+
handlers: { GET: noopHandler },
|
|
25
|
+
docs: {
|
|
26
|
+
methods: {
|
|
27
|
+
GET: {
|
|
28
|
+
summary: 'Undeclared schema',
|
|
29
|
+
responses: [{ status: 200, description: 'Success' }],
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
path: '/example/declared',
|
|
36
|
+
handlers: { GET: noopHandler },
|
|
37
|
+
docs: {
|
|
38
|
+
methods: {
|
|
39
|
+
GET: {
|
|
40
|
+
summary: 'Declared schema',
|
|
41
|
+
responses: [
|
|
42
|
+
{ status: 200, description: 'Success', schema: z.object({ ok: z.boolean() }) },
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
} as unknown as Module,
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function responseSchema(doc: ReturnType<typeof buildOpenApiDocument>, path: string): SchemaNode | undefined {
|
|
54
|
+
const pathItem = doc.paths[path] as Record<string, OperationNode> | undefined
|
|
55
|
+
return pathItem?.get?.responses?.['200']?.content?.['application/json']?.schema
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('buildOpenApiDocument response schema fallback', () => {
|
|
59
|
+
it('marks responses without a declared schema with an advisory description', () => {
|
|
60
|
+
const doc = buildOpenApiDocument(makeModules())
|
|
61
|
+
expect(responseSchema(doc, '/example/undeclared')).toEqual({
|
|
62
|
+
type: 'object',
|
|
63
|
+
description: 'Schema not declared',
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('leaves declared schemas untouched', () => {
|
|
68
|
+
const doc = buildOpenApiDocument(makeModules())
|
|
69
|
+
const schema = responseSchema(doc, '/example/declared')
|
|
70
|
+
expect(schema?.description).toBeUndefined()
|
|
71
|
+
expect(schema?.properties?.ok?.type).toBe('boolean')
|
|
72
|
+
})
|
|
73
|
+
})
|
|
@@ -17,7 +17,7 @@ type PathParamInfo = {
|
|
|
17
17
|
|
|
18
18
|
type ParameterLocation = 'query' | 'path' | 'header'
|
|
19
19
|
|
|
20
|
-
type JsonSchema = Record<string, unknown>
|
|
20
|
+
export type JsonSchema = Record<string, unknown>
|
|
21
21
|
|
|
22
22
|
type SchemaConversionContext = {
|
|
23
23
|
memo: WeakMap<ZodTypeAny, JsonSchema>
|
|
@@ -258,7 +258,7 @@ function extractZodDescription(schema?: ZodTypeAny): string | undefined {
|
|
|
258
258
|
return undefined
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
-
function zodToJsonSchema(schema?: ZodTypeAny, ctx?: SchemaConversionContext): JsonSchema | undefined {
|
|
261
|
+
export function zodToJsonSchema(schema?: ZodTypeAny, ctx?: SchemaConversionContext): JsonSchema | undefined {
|
|
262
262
|
if (!schema) return undefined
|
|
263
263
|
const context: SchemaConversionContext = ctx ?? { memo: new WeakMap<ZodTypeAny, JsonSchema>() }
|
|
264
264
|
|
|
@@ -823,7 +823,7 @@ function buildResponses(
|
|
|
823
823
|
: {
|
|
824
824
|
content: {
|
|
825
825
|
[mediaType]: {
|
|
826
|
-
schema: schema ?? { type: 'object' },
|
|
826
|
+
schema: schema ?? { type: 'object', description: 'Schema not declared' },
|
|
827
827
|
...(example !== undefined ? { example } : {}),
|
|
828
828
|
},
|
|
829
829
|
},
|
package/src/lib/openapi/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export * from './types'
|
|
2
|
-
export { buildOpenApiDocument, generateMarkdownFromOpenApi } from './generator'
|
|
2
|
+
export { buildOpenApiDocument, generateMarkdownFromOpenApi, zodToJsonSchema, type JsonSchema } from './generator'
|
|
3
3
|
export * from './crud'
|
|
4
4
|
export { sanitizeOpenApiDocument } from './sanitize'
|
|
5
5
|
export { attachOpenApiDocsToModules } from './attach-docs'
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decryptSeedEnvelope,
|
|
3
|
+
encryptSeedDocument,
|
|
4
|
+
generateSeedKey,
|
|
5
|
+
resolveSeedKey,
|
|
6
|
+
SEED_KEY_ENV,
|
|
7
|
+
} from '../crypto'
|
|
8
|
+
import { encryptedSeedEnvelopeSchema, type SeedDocument } from '../types'
|
|
9
|
+
|
|
10
|
+
const sampleDocument: SeedDocument = {
|
|
11
|
+
format: 'om-seed',
|
|
12
|
+
version: 1,
|
|
13
|
+
records: [
|
|
14
|
+
{ entity: 'customers:customer_entity', match: ['id'], data: { id: 'abc', displayName: 'Jane Doe' } },
|
|
15
|
+
{ entity: 'customers:customer_address', data: { name: 'HQ', city: 'Warsaw' } },
|
|
16
|
+
],
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('seed crypto', () => {
|
|
20
|
+
const originalEnv = process.env[SEED_KEY_ENV]
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
if (originalEnv === undefined) delete process.env[SEED_KEY_ENV]
|
|
23
|
+
else process.env[SEED_KEY_ENV] = originalEnv
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('generates a 32-byte base64 key', () => {
|
|
27
|
+
const key = generateSeedKey()
|
|
28
|
+
expect(Buffer.from(key, 'base64')).toHaveLength(32)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('round-trips a document through encrypt/decrypt', () => {
|
|
32
|
+
const key = generateSeedKey()
|
|
33
|
+
const envelope = encryptSeedDocument(sampleDocument, key)
|
|
34
|
+
expect(() => encryptedSeedEnvelopeSchema.parse(envelope)).not.toThrow()
|
|
35
|
+
// The envelope must not leak plaintext.
|
|
36
|
+
expect(envelope.payload).not.toContain('Jane Doe')
|
|
37
|
+
const decrypted = decryptSeedEnvelope(envelope, key)
|
|
38
|
+
expect(decrypted).toEqual(sampleDocument)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('produces a different ciphertext each time (randomized IV)', () => {
|
|
42
|
+
const key = generateSeedKey()
|
|
43
|
+
const a = encryptSeedDocument(sampleDocument, key)
|
|
44
|
+
const b = encryptSeedDocument(sampleDocument, key)
|
|
45
|
+
expect(a.payload).not.toEqual(b.payload)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('fails to decrypt with the wrong key', () => {
|
|
49
|
+
const envelope = encryptSeedDocument(sampleDocument, generateSeedKey())
|
|
50
|
+
expect(() => decryptSeedEnvelope(envelope, generateSeedKey())).toThrow()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('resolves the key from OM_SEED_KEY', () => {
|
|
54
|
+
const key = generateSeedKey()
|
|
55
|
+
process.env[SEED_KEY_ENV] = key
|
|
56
|
+
expect(resolveSeedKey()).toBe(key)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('rejects a missing or malformed key', () => {
|
|
60
|
+
delete process.env[SEED_KEY_ENV]
|
|
61
|
+
expect(() => resolveSeedKey()).toThrow()
|
|
62
|
+
expect(() => resolveSeedKey('not-32-bytes')).toThrow()
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import { decryptWithAesGcm, encryptWithAesGcm } from '../encryption/aes'
|
|
3
|
+
import {
|
|
4
|
+
ENCRYPTED_SEED_ALGORITHM,
|
|
5
|
+
ENCRYPTED_SEED_FORMAT,
|
|
6
|
+
ENCRYPTED_SEED_VERSION,
|
|
7
|
+
encryptedSeedEnvelopeSchema,
|
|
8
|
+
seedDocumentSchema,
|
|
9
|
+
type EncryptedSeedEnvelope,
|
|
10
|
+
type SeedDocument,
|
|
11
|
+
} from './types'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Distribution-layer key for encrypting seed blobs committed to the repo. This is
|
|
15
|
+
* deliberately separate from the platform's per-tenant field-encryption keys
|
|
16
|
+
* (Vault / TENANT_DATA_ENCRYPTION_*): rotating or sharing the seed key never
|
|
17
|
+
* touches data-at-rest encryption.
|
|
18
|
+
*/
|
|
19
|
+
export const SEED_KEY_ENV = 'OM_SEED_KEY'
|
|
20
|
+
|
|
21
|
+
const SEED_KEY_BYTES = 32
|
|
22
|
+
|
|
23
|
+
function normalizeKey(value: string): string {
|
|
24
|
+
return value.trim().replace(/(?:^['"]|['"]$)/g, '')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Generate a fresh base64-encoded 32-byte seed key. */
|
|
28
|
+
export function generateSeedKey(): string {
|
|
29
|
+
return crypto.randomBytes(SEED_KEY_BYTES).toString('base64')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve and validate the seed key from an explicit value or `OM_SEED_KEY`.
|
|
34
|
+
* Throws a clear, actionable error when missing or malformed.
|
|
35
|
+
*/
|
|
36
|
+
export function resolveSeedKey(explicit?: string | null): string {
|
|
37
|
+
const raw = normalizeKey(explicit ?? process.env[SEED_KEY_ENV] ?? '')
|
|
38
|
+
if (!raw) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`[internal] Seed key missing: set ${SEED_KEY_ENV} (base64, 32 bytes) or pass --key. Generate one with "mercato seeds keygen".`,
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
let decoded: Buffer
|
|
44
|
+
try {
|
|
45
|
+
decoded = Buffer.from(raw, 'base64')
|
|
46
|
+
} catch {
|
|
47
|
+
throw new Error(`[internal] ${SEED_KEY_ENV} must be base64-encoded.`)
|
|
48
|
+
}
|
|
49
|
+
if (decoded.length !== SEED_KEY_BYTES) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`[internal] ${SEED_KEY_ENV} must decode to ${SEED_KEY_BYTES} bytes (got ${decoded.length}). Generate one with "mercato seeds keygen".`,
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
return raw
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Encrypt a validated seed document into the opaque, committable envelope. */
|
|
58
|
+
export function encryptSeedDocument(document: SeedDocument, key: string): EncryptedSeedEnvelope {
|
|
59
|
+
const doc = seedDocumentSchema.parse(document)
|
|
60
|
+
const json = JSON.stringify(doc)
|
|
61
|
+
const { value } = encryptWithAesGcm(json, key)
|
|
62
|
+
if (!value) {
|
|
63
|
+
throw new Error('[internal] Seed encryption produced no payload.')
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
format: ENCRYPTED_SEED_FORMAT,
|
|
67
|
+
version: ENCRYPTED_SEED_VERSION,
|
|
68
|
+
algorithm: ENCRYPTED_SEED_ALGORITHM,
|
|
69
|
+
payload: value,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Decrypt and validate an envelope back into a seed document. */
|
|
74
|
+
export function decryptSeedEnvelope(envelope: unknown, key: string): SeedDocument {
|
|
75
|
+
const parsed = encryptedSeedEnvelopeSchema.parse(envelope)
|
|
76
|
+
const json = decryptWithAesGcm(parsed.payload, key)
|
|
77
|
+
if (json === null) {
|
|
78
|
+
throw new Error('[internal] Seed decryption failed — wrong key or corrupted payload.')
|
|
79
|
+
}
|
|
80
|
+
let raw: unknown
|
|
81
|
+
try {
|
|
82
|
+
raw = JSON.parse(json)
|
|
83
|
+
} catch {
|
|
84
|
+
throw new Error('[internal] Decrypted seed is not valid JSON.')
|
|
85
|
+
}
|
|
86
|
+
return seedDocumentSchema.parse(raw)
|
|
87
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { EntityManager, EntityMetadata } from '@mikro-orm/postgresql'
|
|
2
|
+
import { resolveEntityIdFromMetadata } from '../encryption/entityIds'
|
|
3
|
+
import { seedDocumentSchema, type SeedDocument } from './types'
|
|
4
|
+
|
|
5
|
+
export type SeedLoadScope = {
|
|
6
|
+
tenantId: string
|
|
7
|
+
organizationId: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type SeedLoadProgress = {
|
|
11
|
+
index: number
|
|
12
|
+
total: number
|
|
13
|
+
entity: string
|
|
14
|
+
action: 'created' | 'skipped'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type SeedLoadOptions = {
|
|
18
|
+
/** Apply inside a transaction and roll it back; reports what would happen. */
|
|
19
|
+
dryRun?: boolean
|
|
20
|
+
onProgress?: (progress: SeedLoadProgress) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type SeedLoadResult = {
|
|
24
|
+
total: number
|
|
25
|
+
created: number
|
|
26
|
+
skipped: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class SeedDryRunRollback extends Error {}
|
|
30
|
+
|
|
31
|
+
function resolveEntityClass(meta: EntityMetadata<any>): unknown {
|
|
32
|
+
return (meta as any).class ?? meta.className ?? meta.name
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function buildEntityIdIndex(em: EntityManager): Map<string, EntityMetadata<any>> {
|
|
36
|
+
const storage = em.getMetadata() as unknown as {
|
|
37
|
+
getAll?: () =>
|
|
38
|
+
| Map<unknown, EntityMetadata<any>>
|
|
39
|
+
| Record<string, EntityMetadata<any>>
|
|
40
|
+
| EntityMetadata<any>[]
|
|
41
|
+
metadata?: Record<string, EntityMetadata<any>>
|
|
42
|
+
}
|
|
43
|
+
const all = (typeof storage.getAll === 'function' ? storage.getAll() : storage.metadata) ?? {}
|
|
44
|
+
// MikroORM v7's instance getAll() returns a Map; older shapes returned a plain
|
|
45
|
+
// object or array. Normalize all three to a flat list.
|
|
46
|
+
const list: EntityMetadata<any>[] =
|
|
47
|
+
all instanceof Map ? [...all.values()] : Array.isArray(all) ? all : Object.values(all)
|
|
48
|
+
const index = new Map<string, EntityMetadata<any>>()
|
|
49
|
+
for (const meta of list) {
|
|
50
|
+
if (!meta || (meta as any).abstract) continue
|
|
51
|
+
const entityId = resolveEntityIdFromMetadata(meta)
|
|
52
|
+
if (!entityId) continue
|
|
53
|
+
if (!index.has(entityId)) index.set(entityId, meta)
|
|
54
|
+
}
|
|
55
|
+
return index
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function hasProperty(meta: EntityMetadata<any>, name: string): boolean {
|
|
59
|
+
return Boolean(meta.properties && (meta.properties as Record<string, unknown>)[name])
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Insert seed records through the ORM so the tenant-data-encryption subscriber
|
|
64
|
+
* encrypts marked fields at rest automatically. Records are applied in order;
|
|
65
|
+
* `tenantId`/`organizationId` are injected from `scope` for every entity that
|
|
66
|
+
* declares them. Records with a `match` list are skipped when an existing row
|
|
67
|
+
* matches (idempotent re-runs); match fields MUST be non-encrypted natural keys.
|
|
68
|
+
*/
|
|
69
|
+
export async function loadSeedDocument(
|
|
70
|
+
em: EntityManager,
|
|
71
|
+
document: SeedDocument,
|
|
72
|
+
scope: SeedLoadScope,
|
|
73
|
+
options: SeedLoadOptions = {},
|
|
74
|
+
): Promise<SeedLoadResult> {
|
|
75
|
+
const doc = seedDocumentSchema.parse(document)
|
|
76
|
+
const index = buildEntityIdIndex(em)
|
|
77
|
+
const total = doc.records.length
|
|
78
|
+
let created = 0
|
|
79
|
+
let skipped = 0
|
|
80
|
+
|
|
81
|
+
const run = async (tem: EntityManager) => {
|
|
82
|
+
for (let i = 0; i < doc.records.length; i += 1) {
|
|
83
|
+
const record = doc.records[i]
|
|
84
|
+
const meta = index.get(record.entity)
|
|
85
|
+
if (!meta) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`[internal] Unknown seed entity "${record.entity}" at record ${i}: not a registered entity id.`,
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
const entityClass = resolveEntityClass(meta)
|
|
91
|
+
const data: Record<string, unknown> = { ...record.data }
|
|
92
|
+
if (hasProperty(meta, 'tenantId')) data.tenantId = scope.tenantId
|
|
93
|
+
if (hasProperty(meta, 'organizationId')) data.organizationId = scope.organizationId
|
|
94
|
+
|
|
95
|
+
if (record.match && record.match.length) {
|
|
96
|
+
const where: Record<string, unknown> = {}
|
|
97
|
+
for (const field of record.match) where[field] = data[field]
|
|
98
|
+
if (hasProperty(meta, 'tenantId')) where.tenantId = scope.tenantId
|
|
99
|
+
if (hasProperty(meta, 'organizationId')) where.organizationId = scope.organizationId
|
|
100
|
+
const existing = await tem.findOne(entityClass as any, where as any)
|
|
101
|
+
if (existing) {
|
|
102
|
+
skipped += 1
|
|
103
|
+
options.onProgress?.({ index: i, total, entity: record.entity, action: 'skipped' })
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const entity = tem.create(entityClass as any, data as any)
|
|
109
|
+
tem.persist(entity)
|
|
110
|
+
await tem.flush()
|
|
111
|
+
created += 1
|
|
112
|
+
options.onProgress?.({ index: i, total, entity: record.entity, action: 'created' })
|
|
113
|
+
}
|
|
114
|
+
if (options.dryRun) throw new SeedDryRunRollback()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
await em.transactional(run)
|
|
119
|
+
} catch (err) {
|
|
120
|
+
if (!(err instanceof SeedDryRunRollback)) throw err
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { total, created, skipped }
|
|
124
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
export const SEED_DOCUMENT_FORMAT = 'om-seed'
|
|
4
|
+
export const SEED_DOCUMENT_VERSION = 1
|
|
5
|
+
|
|
6
|
+
export const ENCRYPTED_SEED_FORMAT = 'om-encrypted-seed'
|
|
7
|
+
export const ENCRYPTED_SEED_VERSION = 1
|
|
8
|
+
export const ENCRYPTED_SEED_ALGORITHM = 'aes-256-gcm'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A single record to seed. `entity` is the platform entity id (`module:entity`,
|
|
12
|
+
* e.g. `customers:customer_entity`). `data` keys are the entity's own property
|
|
13
|
+
* names (camelCase, as declared on the MikroORM entity). `match`, when present,
|
|
14
|
+
* lists property names used for an idempotent existence check before insert —
|
|
15
|
+
* these MUST be non-encrypted natural keys (id, slug, code, *_hash); encrypted
|
|
16
|
+
* fields cannot be matched because their ciphertext is non-deterministic.
|
|
17
|
+
*/
|
|
18
|
+
export const seedRecordSchema = z.object({
|
|
19
|
+
entity: z.string().min(1),
|
|
20
|
+
match: z.array(z.string().min(1)).optional(),
|
|
21
|
+
data: z.record(z.string(), z.unknown()),
|
|
22
|
+
})
|
|
23
|
+
export type SeedRecord = z.infer<typeof seedRecordSchema>
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The plaintext seed document. Records are applied in array order so authors can
|
|
27
|
+
* satisfy foreign-key dependencies (create the parent before the child). The
|
|
28
|
+
* document MUST NOT hard-code `tenantId`/`organizationId` — the loader injects
|
|
29
|
+
* the target scope at load time so the same document seeds any tenant.
|
|
30
|
+
*/
|
|
31
|
+
export const seedDocumentSchema = z.object({
|
|
32
|
+
format: z.literal(SEED_DOCUMENT_FORMAT),
|
|
33
|
+
version: z.literal(SEED_DOCUMENT_VERSION),
|
|
34
|
+
records: z.array(seedRecordSchema),
|
|
35
|
+
})
|
|
36
|
+
export type SeedDocument = z.infer<typeof seedDocumentSchema>
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The committed-to-repo, opaque envelope. `payload` is the encrypted seed
|
|
40
|
+
* document in the shared AES-GCM `iv:ct:tag:v1` wire format.
|
|
41
|
+
*/
|
|
42
|
+
export const encryptedSeedEnvelopeSchema = z.object({
|
|
43
|
+
format: z.literal(ENCRYPTED_SEED_FORMAT),
|
|
44
|
+
version: z.literal(ENCRYPTED_SEED_VERSION),
|
|
45
|
+
algorithm: z.literal(ENCRYPTED_SEED_ALGORITHM),
|
|
46
|
+
payload: z.string().min(1),
|
|
47
|
+
})
|
|
48
|
+
export type EncryptedSeedEnvelope = z.infer<typeof encryptedSeedEnvelopeSchema>
|
|
@@ -1,3 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Events factory — payloadSchema declaration and the generated CRUD default.
|
|
3
|
+
*
|
|
4
|
+
* Verifies the additive `payloadSchema` contract on `EventDefinition`:
|
|
5
|
+
* platform CRUD after-events (`*.created`/`*.updated`/`*.deleted`, category
|
|
6
|
+
* `crud`) receive the generated `{ id, organizationId, tenantId, syncOrigin }`
|
|
7
|
+
* default; explicit declarations are preserved untouched; lifecycle/system
|
|
8
|
+
* events and CRUD "before" events get no default; and `getDeclaredEvents()`
|
|
9
|
+
* exposes the same definitions the module config carries.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
createModuleEvents,
|
|
14
|
+
getDeclaredEvents,
|
|
15
|
+
DEFAULT_CRUD_PAYLOAD_SCHEMA,
|
|
16
|
+
} from '../factory'
|
|
17
|
+
import type { EventPayloadSchema } from '../types'
|
|
18
|
+
|
|
19
|
+
const explicitSchema: EventPayloadSchema = {
|
|
20
|
+
fields: [
|
|
21
|
+
{ path: 'id', type: 'text' },
|
|
22
|
+
{ path: 'amount', type: 'number', optional: true },
|
|
23
|
+
{ path: 'meta', type: 'object' },
|
|
24
|
+
],
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const config = createModuleEvents({
|
|
28
|
+
moduleId: 'factory_test',
|
|
29
|
+
events: [
|
|
30
|
+
{ id: 'factory_test.widget.created', label: 'Widget Created', entity: 'widget', category: 'crud' },
|
|
31
|
+
{ id: 'factory_test.widget.updated', label: 'Widget Updated', entity: 'widget', category: 'crud' },
|
|
32
|
+
{ id: 'factory_test.widget.deleted', label: 'Widget Deleted', entity: 'widget', category: 'crud' },
|
|
33
|
+
{ id: 'factory_test.widget.creating', label: 'Widget Creating', entity: 'widget', category: 'crud', excludeFromTriggers: true },
|
|
34
|
+
{ id: 'factory_test.widget.archived', label: 'Widget Archived', entity: 'widget', category: 'lifecycle' },
|
|
35
|
+
{ id: 'factory_test.widget.scored', label: 'Widget Scored', entity: 'widget', category: 'lifecycle', payloadSchema: explicitSchema },
|
|
36
|
+
{ id: 'factory_test.widget.replaced', label: 'Widget Replaced', entity: 'widget', category: 'crud', payloadSchema: explicitSchema },
|
|
37
|
+
] as const,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
function findEvent(id: string) {
|
|
41
|
+
const event = config.events.find(e => e.id === id)
|
|
42
|
+
if (!event) throw new Error(`[internal] event ${id} missing from config`)
|
|
43
|
+
return event
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('createModuleEvents payloadSchema', () => {
|
|
47
|
+
it('attaches the generated CRUD default to platform CRUD after-events', () => {
|
|
48
|
+
for (const suffix of ['created', 'updated', 'deleted']) {
|
|
49
|
+
expect(findEvent(`factory_test.widget.${suffix}`).payloadSchema).toEqual(DEFAULT_CRUD_PAYLOAD_SCHEMA)
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('generated default mirrors the engine emit payload fields', () => {
|
|
54
|
+
expect(DEFAULT_CRUD_PAYLOAD_SCHEMA.fields.map(f => f.path)).toEqual([
|
|
55
|
+
'id',
|
|
56
|
+
'organizationId',
|
|
57
|
+
'tenantId',
|
|
58
|
+
'syncOrigin',
|
|
59
|
+
])
|
|
60
|
+
const idField = DEFAULT_CRUD_PAYLOAD_SCHEMA.fields.find(f => f.path === 'id')
|
|
61
|
+
expect(idField).toEqual({ path: 'id', type: 'text' })
|
|
62
|
+
expect(DEFAULT_CRUD_PAYLOAD_SCHEMA.fields.filter(f => f.optional).map(f => f.path)).toEqual([
|
|
63
|
+
'organizationId',
|
|
64
|
+
'tenantId',
|
|
65
|
+
'syncOrigin',
|
|
66
|
+
])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('does not default CRUD "before" lifecycle suffixes or non-crud categories', () => {
|
|
70
|
+
expect(findEvent('factory_test.widget.creating').payloadSchema).toBeUndefined()
|
|
71
|
+
expect(findEvent('factory_test.widget.archived').payloadSchema).toBeUndefined()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('preserves an explicit payloadSchema over the generated default', () => {
|
|
75
|
+
expect(findEvent('factory_test.widget.replaced').payloadSchema).toBe(explicitSchema)
|
|
76
|
+
expect(findEvent('factory_test.widget.scored').payloadSchema).toBe(explicitSchema)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('exposes the same definitions through getDeclaredEvents()', () => {
|
|
80
|
+
const declared = getDeclaredEvents().filter(e => e.module === 'factory_test')
|
|
81
|
+
expect(declared).toHaveLength(config.events.length)
|
|
82
|
+
const declaredCreated = declared.find(e => e.id === 'factory_test.widget.created')
|
|
83
|
+
expect(declaredCreated?.payloadSchema).toEqual(DEFAULT_CRUD_PAYLOAD_SCHEMA)
|
|
84
|
+
const declaredScored = declared.find(e => e.id === 'factory_test.widget.scored')
|
|
85
|
+
expect(declaredScored?.payloadSchema).toBe(explicitSchema)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('keeps unrelated declaration fields intact when defaulting', () => {
|
|
89
|
+
const creating = findEvent('factory_test.widget.creating')
|
|
90
|
+
expect(creating.excludeFromTriggers).toBe(true)
|
|
91
|
+
const created = findEvent('factory_test.widget.created')
|
|
92
|
+
expect(created.module).toBe('factory_test')
|
|
93
|
+
expect(created.entity).toBe('widget')
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
1
97
|
const GLOBAL_EVENT_REGISTRY_KEY = '__openMercatoEventDefinitionRegistry__'
|
|
2
98
|
|
|
3
99
|
type EventFactoryModule = typeof import('../factory')
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
EventDefinition,
|
|
10
10
|
EventModuleConfig,
|
|
11
11
|
EventPayload,
|
|
12
|
+
EventPayloadSchema,
|
|
12
13
|
EmitOptions,
|
|
13
14
|
CreateModuleEventsOptions,
|
|
14
15
|
ModuleEventEmitter,
|
|
@@ -101,18 +102,44 @@ function getEventRegistryState(): EventRegistryState {
|
|
|
101
102
|
}
|
|
102
103
|
}
|
|
103
104
|
|
|
105
|
+
const CRUD_AFTER_EVENT_SUFFIXES = ['.created', '.updated', '.deleted'] as const
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Generated payload schema for platform-emitted CRUD after-events. Mirrors the
|
|
109
|
+
* default payload built by the data engine's `emitOrmEntityEvent` when no
|
|
110
|
+
* `buildPayload` override is configured: `{ id, organizationId, tenantId }`
|
|
111
|
+
* plus `syncOrigin` when the write originated from a sync. organizationId and
|
|
112
|
+
* tenantId keys are always present but may be null, so they are `optional`.
|
|
113
|
+
*/
|
|
114
|
+
export const DEFAULT_CRUD_PAYLOAD_SCHEMA: EventPayloadSchema = {
|
|
115
|
+
fields: [
|
|
116
|
+
{ path: 'id', type: 'text' },
|
|
117
|
+
{ path: 'organizationId', type: 'text', optional: true },
|
|
118
|
+
{ path: 'tenantId', type: 'text', optional: true },
|
|
119
|
+
{ path: 'syncOrigin', type: 'text', optional: true },
|
|
120
|
+
],
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function applyDefaultCrudPayloadSchema(event: EventDefinition): EventDefinition {
|
|
124
|
+
if (event.payloadSchema) return event
|
|
125
|
+
if (event.category !== 'crud') return event
|
|
126
|
+
if (!CRUD_AFTER_EVENT_SUFFIXES.some(suffix => event.id.endsWith(suffix))) return event
|
|
127
|
+
return { ...event, payloadSchema: DEFAULT_CRUD_PAYLOAD_SCHEMA }
|
|
128
|
+
}
|
|
129
|
+
|
|
104
130
|
function addDeclaredEvent(event: EventDefinition): void {
|
|
131
|
+
const declared = applyDefaultCrudPayloadSchema(event)
|
|
105
132
|
const state = getEventRegistryState()
|
|
106
|
-
state.declaredEventIds.add(
|
|
107
|
-
const existingIndex = state.declaredEvents.findIndex((candidate) => candidate.id ===
|
|
133
|
+
state.declaredEventIds.add(declared.id)
|
|
134
|
+
const existingIndex = state.declaredEvents.findIndex((candidate) => candidate.id === declared.id)
|
|
108
135
|
if (existingIndex < 0) {
|
|
109
|
-
state.declaredEvents.push(
|
|
136
|
+
state.declaredEvents.push(declared)
|
|
110
137
|
return
|
|
111
138
|
}
|
|
112
139
|
// Refresh a module's own definition in place during HMR without allowing a
|
|
113
140
|
// duplicate declaration from another module to take over the event id.
|
|
114
|
-
if (state.declaredEvents[existingIndex]?.module ===
|
|
115
|
-
state.declaredEvents[existingIndex] =
|
|
141
|
+
if (state.declaredEvents[existingIndex]?.module === declared.module) {
|
|
142
|
+
state.declaredEvents[existingIndex] = declared
|
|
116
143
|
}
|
|
117
144
|
}
|
|
118
145
|
|
|
@@ -271,11 +298,15 @@ export function createModuleEvents<
|
|
|
271
298
|
// Build set of valid event IDs for runtime validation
|
|
272
299
|
const validEventIds = new Set(events.map(e => e.id))
|
|
273
300
|
|
|
274
|
-
// Build full event definitions with module added
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
301
|
+
// Build full event definitions with module added and the generated CRUD
|
|
302
|
+
// payload-schema default applied, so config consumers and the global
|
|
303
|
+
// registry see the same definitions.
|
|
304
|
+
const fullEvents: EventDefinition[] = events.map(e =>
|
|
305
|
+
applyDefaultCrudPayloadSchema({
|
|
306
|
+
...e,
|
|
307
|
+
module: moduleId,
|
|
308
|
+
}),
|
|
309
|
+
)
|
|
279
310
|
|
|
280
311
|
// Register all event IDs and definitions in the global registry.
|
|
281
312
|
for (const event of fullEvents) {
|