@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.
Files changed (58) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/lib/ai/opencode-tool-parts.js +48 -0
  3. package/dist/lib/ai/opencode-tool-parts.js.map +7 -0
  4. package/dist/lib/ai/token-count.js +11 -0
  5. package/dist/lib/ai/token-count.js.map +7 -0
  6. package/dist/lib/bootstrap/dynamicLoader.js +14 -1
  7. package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
  8. package/dist/lib/commands/command-bus.js +9 -1
  9. package/dist/lib/commands/command-bus.js.map +2 -2
  10. package/dist/lib/commands/registry.js +9 -0
  11. package/dist/lib/commands/registry.js.map +2 -2
  12. package/dist/lib/commands/types.js.map +2 -2
  13. package/dist/lib/di/container.js +7 -10
  14. package/dist/lib/di/container.js.map +2 -2
  15. package/dist/lib/encryption/subscriber.js +22 -2
  16. package/dist/lib/encryption/subscriber.js.map +2 -2
  17. package/dist/lib/openapi/generator.js +3 -2
  18. package/dist/lib/openapi/generator.js.map +2 -2
  19. package/dist/lib/openapi/index.js +3 -2
  20. package/dist/lib/openapi/index.js.map +2 -2
  21. package/dist/lib/seed/crypto.js +73 -0
  22. package/dist/lib/seed/crypto.js.map +7 -0
  23. package/dist/lib/seed/index.js +4 -0
  24. package/dist/lib/seed/index.js.map +7 -0
  25. package/dist/lib/seed/loader.js +73 -0
  26. package/dist/lib/seed/loader.js.map +7 -0
  27. package/dist/lib/seed/types.js +33 -0
  28. package/dist/lib/seed/types.js.map +7 -0
  29. package/dist/lib/version.js +1 -1
  30. package/dist/lib/version.js.map +1 -1
  31. package/dist/modules/events/factory.js +28 -9
  32. package/dist/modules/events/factory.js.map +2 -2
  33. package/package.json +3 -2
  34. package/src/lib/ai/__tests__/opencode-tool-parts.test.ts +81 -0
  35. package/src/lib/ai/__tests__/token-count.test.ts +20 -0
  36. package/src/lib/ai/opencode-tool-parts.ts +80 -0
  37. package/src/lib/ai/token-count.ts +21 -0
  38. package/src/lib/bootstrap/dynamicLoader.ts +24 -1
  39. package/src/lib/commands/__tests__/command-bus.test.ts +64 -0
  40. package/src/lib/commands/__tests__/registry.test.ts +35 -0
  41. package/src/lib/commands/command-bus.ts +16 -1
  42. package/src/lib/commands/registry.ts +11 -0
  43. package/src/lib/commands/types.ts +31 -0
  44. package/src/lib/di/__tests__/bootstrap-cache.test.ts +59 -3
  45. package/src/lib/di/container.ts +19 -15
  46. package/src/lib/encryption/__tests__/subscriber.kms-recovery.test.ts +112 -0
  47. package/src/lib/encryption/subscriber.ts +28 -1
  48. package/src/lib/openapi/__tests__/generator-response-fallback.test.ts +73 -0
  49. package/src/lib/openapi/generator.ts +3 -3
  50. package/src/lib/openapi/index.ts +1 -1
  51. package/src/lib/seed/__tests__/seed-crypto.test.ts +64 -0
  52. package/src/lib/seed/crypto.ts +87 -0
  53. package/src/lib/seed/index.ts +3 -0
  54. package/src/lib/seed/loader.ts +124 -0
  55. package/src/lib/seed/types.ts +48 -0
  56. package/src/modules/events/__tests__/factory.test.ts +96 -0
  57. package/src/modules/events/factory.ts +41 -10
  58. 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
- if (!isTenantDataEncryptionEnabled() || !this.service.isEnabled()) {
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
  },
@@ -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,3 @@
1
+ export * from './types'
2
+ export * from './crypto'
3
+ export * from './loader'
@@ -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(event.id)
107
- const existingIndex = state.declaredEvents.findIndex((candidate) => candidate.id === event.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(event)
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 === event.module) {
115
- state.declaredEvents[existingIndex] = event
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
- const fullEvents: EventDefinition[] = events.map(e => ({
276
- ...e,
277
- module: moduleId,
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) {