@open-mercato/shared 0.7.1-develop.7193.1.910a5b0a1e → 0.7.1-develop.7194.1.ab4fc81f82

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 (50) 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/openapi/generator.js +3 -2
  14. package/dist/lib/openapi/generator.js.map +2 -2
  15. package/dist/lib/openapi/index.js +3 -2
  16. package/dist/lib/openapi/index.js.map +2 -2
  17. package/dist/lib/seed/crypto.js +73 -0
  18. package/dist/lib/seed/crypto.js.map +7 -0
  19. package/dist/lib/seed/index.js +4 -0
  20. package/dist/lib/seed/index.js.map +7 -0
  21. package/dist/lib/seed/loader.js +73 -0
  22. package/dist/lib/seed/loader.js.map +7 -0
  23. package/dist/lib/seed/types.js +33 -0
  24. package/dist/lib/seed/types.js.map +7 -0
  25. package/dist/lib/version.js +1 -1
  26. package/dist/lib/version.js.map +1 -1
  27. package/dist/modules/events/factory.js +28 -9
  28. package/dist/modules/events/factory.js.map +2 -2
  29. package/package.json +3 -2
  30. package/src/lib/ai/__tests__/opencode-tool-parts.test.ts +81 -0
  31. package/src/lib/ai/__tests__/token-count.test.ts +20 -0
  32. package/src/lib/ai/opencode-tool-parts.ts +80 -0
  33. package/src/lib/ai/token-count.ts +21 -0
  34. package/src/lib/bootstrap/dynamicLoader.ts +24 -1
  35. package/src/lib/commands/__tests__/command-bus.test.ts +64 -0
  36. package/src/lib/commands/__tests__/registry.test.ts +35 -0
  37. package/src/lib/commands/command-bus.ts +16 -1
  38. package/src/lib/commands/registry.ts +11 -0
  39. package/src/lib/commands/types.ts +31 -0
  40. package/src/lib/openapi/__tests__/generator-response-fallback.test.ts +73 -0
  41. package/src/lib/openapi/generator.ts +3 -3
  42. package/src/lib/openapi/index.ts +1 -1
  43. package/src/lib/seed/__tests__/seed-crypto.test.ts +64 -0
  44. package/src/lib/seed/crypto.ts +87 -0
  45. package/src/lib/seed/index.ts +3 -0
  46. package/src/lib/seed/loader.ts +124 -0
  47. package/src/lib/seed/types.ts +48 -0
  48. package/src/modules/events/__tests__/factory.test.ts +96 -0
  49. package/src/modules/events/factory.ts +41 -10
  50. package/src/modules/events/types.ts +44 -0
@@ -0,0 +1,73 @@
1
+ import { resolveEntityIdFromMetadata } from "../encryption/entityIds.js";
2
+ import { seedDocumentSchema } from "./types.js";
3
+ class SeedDryRunRollback extends Error {
4
+ }
5
+ function resolveEntityClass(meta) {
6
+ return meta.class ?? meta.className ?? meta.name;
7
+ }
8
+ function buildEntityIdIndex(em) {
9
+ const storage = em.getMetadata();
10
+ const all = (typeof storage.getAll === "function" ? storage.getAll() : storage.metadata) ?? {};
11
+ const list = all instanceof Map ? [...all.values()] : Array.isArray(all) ? all : Object.values(all);
12
+ const index = /* @__PURE__ */ new Map();
13
+ for (const meta of list) {
14
+ if (!meta || meta.abstract) continue;
15
+ const entityId = resolveEntityIdFromMetadata(meta);
16
+ if (!entityId) continue;
17
+ if (!index.has(entityId)) index.set(entityId, meta);
18
+ }
19
+ return index;
20
+ }
21
+ function hasProperty(meta, name) {
22
+ return Boolean(meta.properties && meta.properties[name]);
23
+ }
24
+ async function loadSeedDocument(em, document, scope, options = {}) {
25
+ const doc = seedDocumentSchema.parse(document);
26
+ const index = buildEntityIdIndex(em);
27
+ const total = doc.records.length;
28
+ let created = 0;
29
+ let skipped = 0;
30
+ const run = async (tem) => {
31
+ for (let i = 0; i < doc.records.length; i += 1) {
32
+ const record = doc.records[i];
33
+ const meta = index.get(record.entity);
34
+ if (!meta) {
35
+ throw new Error(
36
+ `[internal] Unknown seed entity "${record.entity}" at record ${i}: not a registered entity id.`
37
+ );
38
+ }
39
+ const entityClass = resolveEntityClass(meta);
40
+ const data = { ...record.data };
41
+ if (hasProperty(meta, "tenantId")) data.tenantId = scope.tenantId;
42
+ if (hasProperty(meta, "organizationId")) data.organizationId = scope.organizationId;
43
+ if (record.match && record.match.length) {
44
+ const where = {};
45
+ for (const field of record.match) where[field] = data[field];
46
+ if (hasProperty(meta, "tenantId")) where.tenantId = scope.tenantId;
47
+ if (hasProperty(meta, "organizationId")) where.organizationId = scope.organizationId;
48
+ const existing = await tem.findOne(entityClass, where);
49
+ if (existing) {
50
+ skipped += 1;
51
+ options.onProgress?.({ index: i, total, entity: record.entity, action: "skipped" });
52
+ continue;
53
+ }
54
+ }
55
+ const entity = tem.create(entityClass, data);
56
+ tem.persist(entity);
57
+ await tem.flush();
58
+ created += 1;
59
+ options.onProgress?.({ index: i, total, entity: record.entity, action: "created" });
60
+ }
61
+ if (options.dryRun) throw new SeedDryRunRollback();
62
+ };
63
+ try {
64
+ await em.transactional(run);
65
+ } catch (err) {
66
+ if (!(err instanceof SeedDryRunRollback)) throw err;
67
+ }
68
+ return { total, created, skipped };
69
+ }
70
+ export {
71
+ loadSeedDocument
72
+ };
73
+ //# sourceMappingURL=loader.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/lib/seed/loader.ts"],
4
+ "sourcesContent": ["import type { EntityManager, EntityMetadata } from '@mikro-orm/postgresql'\nimport { resolveEntityIdFromMetadata } from '../encryption/entityIds'\nimport { seedDocumentSchema, type SeedDocument } from './types'\n\nexport type SeedLoadScope = {\n tenantId: string\n organizationId: string\n}\n\nexport type SeedLoadProgress = {\n index: number\n total: number\n entity: string\n action: 'created' | 'skipped'\n}\n\nexport type SeedLoadOptions = {\n /** Apply inside a transaction and roll it back; reports what would happen. */\n dryRun?: boolean\n onProgress?: (progress: SeedLoadProgress) => void\n}\n\nexport type SeedLoadResult = {\n total: number\n created: number\n skipped: number\n}\n\nclass SeedDryRunRollback extends Error {}\n\nfunction resolveEntityClass(meta: EntityMetadata<any>): unknown {\n return (meta as any).class ?? meta.className ?? meta.name\n}\n\nfunction buildEntityIdIndex(em: EntityManager): Map<string, EntityMetadata<any>> {\n const storage = em.getMetadata() as unknown as {\n getAll?: () =>\n | Map<unknown, EntityMetadata<any>>\n | Record<string, EntityMetadata<any>>\n | EntityMetadata<any>[]\n metadata?: Record<string, EntityMetadata<any>>\n }\n const all = (typeof storage.getAll === 'function' ? storage.getAll() : storage.metadata) ?? {}\n // MikroORM v7's instance getAll() returns a Map; older shapes returned a plain\n // object or array. Normalize all three to a flat list.\n const list: EntityMetadata<any>[] =\n all instanceof Map ? [...all.values()] : Array.isArray(all) ? all : Object.values(all)\n const index = new Map<string, EntityMetadata<any>>()\n for (const meta of list) {\n if (!meta || (meta as any).abstract) continue\n const entityId = resolveEntityIdFromMetadata(meta)\n if (!entityId) continue\n if (!index.has(entityId)) index.set(entityId, meta)\n }\n return index\n}\n\nfunction hasProperty(meta: EntityMetadata<any>, name: string): boolean {\n return Boolean(meta.properties && (meta.properties as Record<string, unknown>)[name])\n}\n\n/**\n * Insert seed records through the ORM so the tenant-data-encryption subscriber\n * encrypts marked fields at rest automatically. Records are applied in order;\n * `tenantId`/`organizationId` are injected from `scope` for every entity that\n * declares them. Records with a `match` list are skipped when an existing row\n * matches (idempotent re-runs); match fields MUST be non-encrypted natural keys.\n */\nexport async function loadSeedDocument(\n em: EntityManager,\n document: SeedDocument,\n scope: SeedLoadScope,\n options: SeedLoadOptions = {},\n): Promise<SeedLoadResult> {\n const doc = seedDocumentSchema.parse(document)\n const index = buildEntityIdIndex(em)\n const total = doc.records.length\n let created = 0\n let skipped = 0\n\n const run = async (tem: EntityManager) => {\n for (let i = 0; i < doc.records.length; i += 1) {\n const record = doc.records[i]\n const meta = index.get(record.entity)\n if (!meta) {\n throw new Error(\n `[internal] Unknown seed entity \"${record.entity}\" at record ${i}: not a registered entity id.`,\n )\n }\n const entityClass = resolveEntityClass(meta)\n const data: Record<string, unknown> = { ...record.data }\n if (hasProperty(meta, 'tenantId')) data.tenantId = scope.tenantId\n if (hasProperty(meta, 'organizationId')) data.organizationId = scope.organizationId\n\n if (record.match && record.match.length) {\n const where: Record<string, unknown> = {}\n for (const field of record.match) where[field] = data[field]\n if (hasProperty(meta, 'tenantId')) where.tenantId = scope.tenantId\n if (hasProperty(meta, 'organizationId')) where.organizationId = scope.organizationId\n const existing = await tem.findOne(entityClass as any, where as any)\n if (existing) {\n skipped += 1\n options.onProgress?.({ index: i, total, entity: record.entity, action: 'skipped' })\n continue\n }\n }\n\n const entity = tem.create(entityClass as any, data as any)\n tem.persist(entity)\n await tem.flush()\n created += 1\n options.onProgress?.({ index: i, total, entity: record.entity, action: 'created' })\n }\n if (options.dryRun) throw new SeedDryRunRollback()\n }\n\n try {\n await em.transactional(run)\n } catch (err) {\n if (!(err instanceof SeedDryRunRollback)) throw err\n }\n\n return { total, created, skipped }\n}\n"],
5
+ "mappings": "AACA,SAAS,mCAAmC;AAC5C,SAAS,0BAA6C;AA0BtD,MAAM,2BAA2B,MAAM;AAAC;AAExC,SAAS,mBAAmB,MAAoC;AAC9D,SAAQ,KAAa,SAAS,KAAK,aAAa,KAAK;AACvD;AAEA,SAAS,mBAAmB,IAAqD;AAC/E,QAAM,UAAU,GAAG,YAAY;AAO/B,QAAM,OAAO,OAAO,QAAQ,WAAW,aAAa,QAAQ,OAAO,IAAI,QAAQ,aAAa,CAAC;AAG7F,QAAM,OACJ,eAAe,MAAM,CAAC,GAAG,IAAI,OAAO,CAAC,IAAI,MAAM,QAAQ,GAAG,IAAI,MAAM,OAAO,OAAO,GAAG;AACvF,QAAM,QAAQ,oBAAI,IAAiC;AACnD,aAAW,QAAQ,MAAM;AACvB,QAAI,CAAC,QAAS,KAAa,SAAU;AACrC,UAAM,WAAW,4BAA4B,IAAI;AACjD,QAAI,CAAC,SAAU;AACf,QAAI,CAAC,MAAM,IAAI,QAAQ,EAAG,OAAM,IAAI,UAAU,IAAI;AAAA,EACpD;AACA,SAAO;AACT;AAEA,SAAS,YAAY,MAA2B,MAAuB;AACrE,SAAO,QAAQ,KAAK,cAAe,KAAK,WAAuC,IAAI,CAAC;AACtF;AASA,eAAsB,iBACpB,IACA,UACA,OACA,UAA2B,CAAC,GACH;AACzB,QAAM,MAAM,mBAAmB,MAAM,QAAQ;AAC7C,QAAM,QAAQ,mBAAmB,EAAE;AACnC,QAAM,QAAQ,IAAI,QAAQ;AAC1B,MAAI,UAAU;AACd,MAAI,UAAU;AAEd,QAAM,MAAM,OAAO,QAAuB;AACxC,aAAS,IAAI,GAAG,IAAI,IAAI,QAAQ,QAAQ,KAAK,GAAG;AAC9C,YAAM,SAAS,IAAI,QAAQ,CAAC;AAC5B,YAAM,OAAO,MAAM,IAAI,OAAO,MAAM;AACpC,UAAI,CAAC,MAAM;AACT,cAAM,IAAI;AAAA,UACR,mCAAmC,OAAO,MAAM,eAAe,CAAC;AAAA,QAClE;AAAA,MACF;AACA,YAAM,cAAc,mBAAmB,IAAI;AAC3C,YAAM,OAAgC,EAAE,GAAG,OAAO,KAAK;AACvD,UAAI,YAAY,MAAM,UAAU,EAAG,MAAK,WAAW,MAAM;AACzD,UAAI,YAAY,MAAM,gBAAgB,EAAG,MAAK,iBAAiB,MAAM;AAErE,UAAI,OAAO,SAAS,OAAO,MAAM,QAAQ;AACvC,cAAM,QAAiC,CAAC;AACxC,mBAAW,SAAS,OAAO,MAAO,OAAM,KAAK,IAAI,KAAK,KAAK;AAC3D,YAAI,YAAY,MAAM,UAAU,EAAG,OAAM,WAAW,MAAM;AAC1D,YAAI,YAAY,MAAM,gBAAgB,EAAG,OAAM,iBAAiB,MAAM;AACtE,cAAM,WAAW,MAAM,IAAI,QAAQ,aAAoB,KAAY;AACnE,YAAI,UAAU;AACZ,qBAAW;AACX,kBAAQ,aAAa,EAAE,OAAO,GAAG,OAAO,QAAQ,OAAO,QAAQ,QAAQ,UAAU,CAAC;AAClF;AAAA,QACF;AAAA,MACF;AAEA,YAAM,SAAS,IAAI,OAAO,aAAoB,IAAW;AACzD,UAAI,QAAQ,MAAM;AAClB,YAAM,IAAI,MAAM;AAChB,iBAAW;AACX,cAAQ,aAAa,EAAE,OAAO,GAAG,OAAO,QAAQ,OAAO,QAAQ,QAAQ,UAAU,CAAC;AAAA,IACpF;AACA,QAAI,QAAQ,OAAQ,OAAM,IAAI,mBAAmB;AAAA,EACnD;AAEA,MAAI;AACF,UAAM,GAAG,cAAc,GAAG;AAAA,EAC5B,SAAS,KAAK;AACZ,QAAI,EAAE,eAAe,oBAAqB,OAAM;AAAA,EAClD;AAEA,SAAO,EAAE,OAAO,SAAS,QAAQ;AACnC;",
6
+ "names": []
7
+ }
@@ -0,0 +1,33 @@
1
+ import { z } from "zod";
2
+ const SEED_DOCUMENT_FORMAT = "om-seed";
3
+ const SEED_DOCUMENT_VERSION = 1;
4
+ const ENCRYPTED_SEED_FORMAT = "om-encrypted-seed";
5
+ const ENCRYPTED_SEED_VERSION = 1;
6
+ const ENCRYPTED_SEED_ALGORITHM = "aes-256-gcm";
7
+ const seedRecordSchema = z.object({
8
+ entity: z.string().min(1),
9
+ match: z.array(z.string().min(1)).optional(),
10
+ data: z.record(z.string(), z.unknown())
11
+ });
12
+ const seedDocumentSchema = z.object({
13
+ format: z.literal(SEED_DOCUMENT_FORMAT),
14
+ version: z.literal(SEED_DOCUMENT_VERSION),
15
+ records: z.array(seedRecordSchema)
16
+ });
17
+ const encryptedSeedEnvelopeSchema = z.object({
18
+ format: z.literal(ENCRYPTED_SEED_FORMAT),
19
+ version: z.literal(ENCRYPTED_SEED_VERSION),
20
+ algorithm: z.literal(ENCRYPTED_SEED_ALGORITHM),
21
+ payload: z.string().min(1)
22
+ });
23
+ export {
24
+ ENCRYPTED_SEED_ALGORITHM,
25
+ ENCRYPTED_SEED_FORMAT,
26
+ ENCRYPTED_SEED_VERSION,
27
+ SEED_DOCUMENT_FORMAT,
28
+ SEED_DOCUMENT_VERSION,
29
+ encryptedSeedEnvelopeSchema,
30
+ seedDocumentSchema,
31
+ seedRecordSchema
32
+ };
33
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/lib/seed/types.ts"],
4
+ "sourcesContent": ["import { z } from 'zod'\n\nexport const SEED_DOCUMENT_FORMAT = 'om-seed'\nexport const SEED_DOCUMENT_VERSION = 1\n\nexport const ENCRYPTED_SEED_FORMAT = 'om-encrypted-seed'\nexport const ENCRYPTED_SEED_VERSION = 1\nexport const ENCRYPTED_SEED_ALGORITHM = 'aes-256-gcm'\n\n/**\n * A single record to seed. `entity` is the platform entity id (`module:entity`,\n * e.g. `customers:customer_entity`). `data` keys are the entity's own property\n * names (camelCase, as declared on the MikroORM entity). `match`, when present,\n * lists property names used for an idempotent existence check before insert \u2014\n * these MUST be non-encrypted natural keys (id, slug, code, *_hash); encrypted\n * fields cannot be matched because their ciphertext is non-deterministic.\n */\nexport const seedRecordSchema = z.object({\n entity: z.string().min(1),\n match: z.array(z.string().min(1)).optional(),\n data: z.record(z.string(), z.unknown()),\n})\nexport type SeedRecord = z.infer<typeof seedRecordSchema>\n\n/**\n * The plaintext seed document. Records are applied in array order so authors can\n * satisfy foreign-key dependencies (create the parent before the child). The\n * document MUST NOT hard-code `tenantId`/`organizationId` \u2014 the loader injects\n * the target scope at load time so the same document seeds any tenant.\n */\nexport const seedDocumentSchema = z.object({\n format: z.literal(SEED_DOCUMENT_FORMAT),\n version: z.literal(SEED_DOCUMENT_VERSION),\n records: z.array(seedRecordSchema),\n})\nexport type SeedDocument = z.infer<typeof seedDocumentSchema>\n\n/**\n * The committed-to-repo, opaque envelope. `payload` is the encrypted seed\n * document in the shared AES-GCM `iv:ct:tag:v1` wire format.\n */\nexport const encryptedSeedEnvelopeSchema = z.object({\n format: z.literal(ENCRYPTED_SEED_FORMAT),\n version: z.literal(ENCRYPTED_SEED_VERSION),\n algorithm: z.literal(ENCRYPTED_SEED_ALGORITHM),\n payload: z.string().min(1),\n})\nexport type EncryptedSeedEnvelope = z.infer<typeof encryptedSeedEnvelopeSchema>\n"],
5
+ "mappings": "AAAA,SAAS,SAAS;AAEX,MAAM,uBAAuB;AAC7B,MAAM,wBAAwB;AAE9B,MAAM,wBAAwB;AAC9B,MAAM,yBAAyB;AAC/B,MAAM,2BAA2B;AAUjC,MAAM,mBAAmB,EAAE,OAAO;AAAA,EACvC,QAAQ,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,EACxB,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,CAAC,CAAC,EAAE,SAAS;AAAA,EAC3C,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,EAAE,QAAQ,CAAC;AACxC,CAAC;AASM,MAAM,qBAAqB,EAAE,OAAO;AAAA,EACzC,QAAQ,EAAE,QAAQ,oBAAoB;AAAA,EACtC,SAAS,EAAE,QAAQ,qBAAqB;AAAA,EACxC,SAAS,EAAE,MAAM,gBAAgB;AACnC,CAAC;AAOM,MAAM,8BAA8B,EAAE,OAAO;AAAA,EAClD,QAAQ,EAAE,QAAQ,qBAAqB;AAAA,EACvC,SAAS,EAAE,QAAQ,sBAAsB;AAAA,EACzC,WAAW,EAAE,QAAQ,wBAAwB;AAAA,EAC7C,SAAS,EAAE,OAAO,EAAE,IAAI,CAAC;AAC3B,CAAC;",
6
+ "names": []
7
+ }
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.7.1-develop.7193.1.910a5b0a1e";
1
+ const APP_VERSION = "0.7.1-develop.7194.1.ab4fc81f82";
2
2
  const appVersion = APP_VERSION;
3
3
  export {
4
4
  APP_VERSION,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/version.ts"],
4
- "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.7.1-develop.7193.1.910a5b0a1e';\nexport const appVersion = APP_VERSION;\n"],
4
+ "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.7.1-develop.7194.1.ab4fc81f82';\nexport const appVersion = APP_VERSION;\n"],
5
5
  "mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
6
6
  "names": []
7
7
  }
@@ -42,16 +42,32 @@ function getEventRegistryState() {
42
42
  return fallbackEventRegistryState;
43
43
  }
44
44
  }
45
+ const CRUD_AFTER_EVENT_SUFFIXES = [".created", ".updated", ".deleted"];
46
+ const DEFAULT_CRUD_PAYLOAD_SCHEMA = {
47
+ fields: [
48
+ { path: "id", type: "text" },
49
+ { path: "organizationId", type: "text", optional: true },
50
+ { path: "tenantId", type: "text", optional: true },
51
+ { path: "syncOrigin", type: "text", optional: true }
52
+ ]
53
+ };
54
+ function applyDefaultCrudPayloadSchema(event) {
55
+ if (event.payloadSchema) return event;
56
+ if (event.category !== "crud") return event;
57
+ if (!CRUD_AFTER_EVENT_SUFFIXES.some((suffix) => event.id.endsWith(suffix))) return event;
58
+ return { ...event, payloadSchema: DEFAULT_CRUD_PAYLOAD_SCHEMA };
59
+ }
45
60
  function addDeclaredEvent(event) {
61
+ const declared = applyDefaultCrudPayloadSchema(event);
46
62
  const state = getEventRegistryState();
47
- state.declaredEventIds.add(event.id);
48
- const existingIndex = state.declaredEvents.findIndex((candidate) => candidate.id === event.id);
63
+ state.declaredEventIds.add(declared.id);
64
+ const existingIndex = state.declaredEvents.findIndex((candidate) => candidate.id === declared.id);
49
65
  if (existingIndex < 0) {
50
- state.declaredEvents.push(event);
66
+ state.declaredEvents.push(declared);
51
67
  return;
52
68
  }
53
- if (state.declaredEvents[existingIndex]?.module === event.module) {
54
- state.declaredEvents[existingIndex] = event;
69
+ if (state.declaredEvents[existingIndex]?.module === declared.module) {
70
+ state.declaredEvents[existingIndex] = declared;
55
71
  }
56
72
  }
57
73
  function isEventDeclared(eventId) {
@@ -102,10 +118,12 @@ function getEventModuleConfigs() {
102
118
  function createModuleEvents(options) {
103
119
  const { moduleId, events, strict = false } = options;
104
120
  const validEventIds = new Set(events.map((e) => e.id));
105
- const fullEvents = events.map((e) => ({
106
- ...e,
107
- module: moduleId
108
- }));
121
+ const fullEvents = events.map(
122
+ (e) => applyDefaultCrudPayloadSchema({
123
+ ...e,
124
+ module: moduleId
125
+ })
126
+ );
109
127
  for (const event of fullEvents) {
110
128
  addDeclaredEvent(event);
111
129
  }
@@ -144,6 +162,7 @@ function createModuleEvents(options) {
144
162
  };
145
163
  }
146
164
  export {
165
+ DEFAULT_CRUD_PAYLOAD_SCHEMA,
147
166
  createModuleEvents,
148
167
  getAllDeclaredEventIds,
149
168
  getDeclaredEvents,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/modules/events/factory.ts"],
4
- "sourcesContent": ["/**\n * Event Module Factory\n *\n * Provides factory functions for creating type-safe event configurations.\n */\n\nimport { createLogger } from '../../lib/logger'\nimport type {\n EventDefinition,\n EventModuleConfig,\n EventPayload,\n EmitOptions,\n CreateModuleEventsOptions,\n ModuleEventEmitter,\n} from './types'\n\nconst logger = createLogger('events').child({ component: 'factory' })\n\n// =============================================================================\n// Global Event Bus Reference\n// =============================================================================\n\n/**\n * Type for the global event bus interface\n */\ninterface GlobalEventBus {\n emit(event: string, payload: unknown, options?: EmitOptions): Promise<void>\n}\n\nconst GLOBAL_EVENT_BUS_KEY = '__openMercatoGlobalEventBus__'\n\n// Global event bus reference (set during bootstrap)\nlet globalEventBus: GlobalEventBus | null = null\n\n/**\n * Set the global event bus instance.\n * Called during app bootstrap to wire up event emission.\n */\nexport function setGlobalEventBus(bus: GlobalEventBus): void {\n globalEventBus = bus\n try {\n ;(globalThis as Record<string, unknown>)[GLOBAL_EVENT_BUS_KEY] = bus\n } catch {\n // ignore global assignment failures\n }\n}\n\n/**\n * Get the global event bus instance.\n * Returns null if not yet bootstrapped.\n */\nexport function getGlobalEventBus(): GlobalEventBus | null {\n try {\n const sharedBus = (globalThis as Record<string, unknown>)[GLOBAL_EVENT_BUS_KEY]\n if (sharedBus && typeof sharedBus === 'object' && typeof (sharedBus as GlobalEventBus).emit === 'function') {\n return sharedBus as GlobalEventBus\n }\n } catch {\n // ignore global read failures\n }\n return globalEventBus\n}\n\n// =============================================================================\n// Event Registry for Validation\n// =============================================================================\n\ntype EventRegistryState = {\n declaredEventIds: Set<string>\n declaredEvents: EventDefinition[]\n registeredEventConfigs: EventModuleConfig[] | null\n}\n\nconst GLOBAL_EVENT_REGISTRY_KEY = '__openMercatoEventDefinitionRegistry__'\n\nconst fallbackEventRegistryState: EventRegistryState = {\n declaredEventIds: new Set<string>(),\n declaredEvents: [],\n registeredEventConfigs: null,\n}\n\nfunction isEventRegistryState(value: unknown): value is EventRegistryState {\n if (!value || typeof value !== 'object') return false\n const candidate = value as Partial<EventRegistryState>\n return candidate.declaredEventIds instanceof Set\n && Array.isArray(candidate.declaredEvents)\n && (candidate.registeredEventConfigs === null || Array.isArray(candidate.registeredEventConfigs))\n}\n\nfunction getEventRegistryState(): EventRegistryState {\n try {\n const globalScope = globalThis as Record<string, unknown>\n const existing = globalScope[GLOBAL_EVENT_REGISTRY_KEY]\n if (isEventRegistryState(existing)) return existing\n globalScope[GLOBAL_EVENT_REGISTRY_KEY] = fallbackEventRegistryState\n return fallbackEventRegistryState\n } catch {\n // Restricted runtimes may deny global access. Keep the previous\n // module-local behavior as a safe fallback.\n return fallbackEventRegistryState\n }\n}\n\nfunction addDeclaredEvent(event: EventDefinition): void {\n const state = getEventRegistryState()\n state.declaredEventIds.add(event.id)\n const existingIndex = state.declaredEvents.findIndex((candidate) => candidate.id === event.id)\n if (existingIndex < 0) {\n state.declaredEvents.push(event)\n return\n }\n // Refresh a module's own definition in place during HMR without allowing a\n // duplicate declaration from another module to take over the event id.\n if (state.declaredEvents[existingIndex]?.module === event.module) {\n state.declaredEvents[existingIndex] = event\n }\n}\n\n/**\n * Check if an event ID has been declared by any module.\n * Used for runtime validation to ensure only declared events are emitted.\n */\nexport function isEventDeclared(eventId: string): boolean {\n return getEventRegistryState().declaredEventIds.has(eventId)\n}\n\n/**\n * Get all declared event IDs.\n * Useful for debugging and introspection.\n */\nexport function getAllDeclaredEventIds(): string[] {\n return Array.from(getEventRegistryState().declaredEventIds)\n}\n\n/**\n * Get all declared events with their full definitions.\n * Used by the API to return available events for workflow triggers.\n */\nexport function getDeclaredEvents(): EventDefinition[] {\n return [...getEventRegistryState().declaredEvents]\n}\n\n/**\n * Check if an event has clientBroadcast enabled.\n * Used by the SSE endpoint to filter events for the DOM Event Bridge.\n */\nexport function isBroadcastEvent(eventId: string): boolean {\n const event = getEventRegistryState().declaredEvents.find(e => e.id === eventId)\n return event?.clientBroadcast === true\n}\n\n/**\n * Check if an event should be published over the server-to-server event bridge.\n * Browser-broadcast events remain eligible for backward compatibility, while\n * crossProcessBroadcast supports private process coordination without SSE.\n */\nexport function isCrossProcessBroadcastEvent(eventId: string): boolean {\n const event = getEventRegistryState().declaredEvents.find(e => e.id === eventId)\n return event?.clientBroadcast === true || event?.crossProcessBroadcast === true\n}\n\n/**\n * Check whether an event is reserved for private server-to-server\n * coordination. Workflow-authored EMIT_EVENT activities must not emit these\n * events because their payload and event id are tenant-managed input.\n */\nexport function isPrivateCrossProcessBroadcastEvent(eventId: string): boolean {\n const event = getEventRegistryState().declaredEvents.find(e => e.id === eventId)\n return event?.crossProcessBroadcast === true && event?.clientBroadcast !== true\n}\n\n/**\n * Verify provenance for a private cross-process event. The module id is\n * stamped by a declared module emitter or another trusted server-side seam;\n * tenant-managed event payloads never participate in this decision.\n */\nexport function isPrivateCrossProcessEventEmitter(\n eventId: string,\n emitterModuleId: string | undefined,\n): boolean {\n const event = getEventRegistryState().declaredEvents.find(e => e.id === eventId)\n if (event?.crossProcessBroadcast !== true) return true\n return typeof event.module === 'string'\n && event.module.length > 0\n && event.module === emitterModuleId\n}\n\n/**\n * Check if an event has portalBroadcast enabled.\n * Used by the portal SSE endpoint to filter events for the Portal Event Bridge.\n */\nexport function isPortalBroadcastEvent(eventId: string): boolean {\n const event = getEventRegistryState().declaredEvents.find(e => e.id === eventId)\n return event?.portalBroadcast === true\n}\n\n// =============================================================================\n// Bootstrap Registration (similar to searchModuleConfigs pattern)\n// =============================================================================\n\n/**\n * Register event module configurations globally.\n * Called during app bootstrap with configs from events.generated.ts.\n */\nexport function registerEventModuleConfigs(configs: EventModuleConfig[]): void {\n const state = getEventRegistryState()\n if (state.registeredEventConfigs !== null && process.env.NODE_ENV === 'development') {\n logger.debug('Event module configs re-registered (this may occur during HMR)')\n }\n state.registeredEventConfigs = configs\n for (const config of configs) {\n for (const event of config.events) {\n addDeclaredEvent(event)\n }\n }\n}\n\n/**\n * Get registered event module configurations.\n * Returns empty array if not registered.\n */\nexport function getEventModuleConfigs(): EventModuleConfig[] {\n return getEventRegistryState().registeredEventConfigs ?? []\n}\n\n// =============================================================================\n// Factory Function\n// =============================================================================\n\n/**\n * Creates a type-safe event configuration for a module.\n *\n * Usage in module events.ts:\n * ```typescript\n * import { createModuleEvents } from '@open-mercato/shared/modules/events'\n *\n * const events = [\n * { id: 'customers.people.created', label: 'Person Created', category: 'crud' },\n * { id: 'customers.people.updated', label: 'Person Updated', category: 'crud' },\n * ] as const\n *\n * export const eventsConfig = createModuleEvents({\n * moduleId: 'customers',\n * events,\n * })\n *\n * // Export the typed emit function for use in commands\n * export const emitCustomersEvent = eventsConfig.emit\n *\n * // Export event IDs as a type for external use\n * export type CustomersEventId = typeof events[number]['id']\n *\n * export default eventsConfig\n * ```\n *\n * TypeScript will enforce that only declared event IDs can be emitted:\n * ```typescript\n * // \u2705 This compiles - event is declared\n * emitCustomersEvent('customers.people.created', { id: '123', tenantId: 'abc' })\n *\n * // \u274C TypeScript error - event not declared\n * emitCustomersEvent('customers.people.exploded', { id: '123' })\n * ```\n */\nexport function createModuleEvents<\n const TEvents extends readonly { id: string }[],\n TEventIds extends TEvents[number]['id'] = TEvents[number]['id']\n>(options: CreateModuleEventsOptions<TEventIds>): EventModuleConfig<TEventIds> {\n const { moduleId, events, strict = false } = options\n\n // Build set of valid event IDs for runtime validation\n const validEventIds = new Set(events.map(e => e.id))\n\n // Build full event definitions with module added\n const fullEvents: EventDefinition[] = events.map(e => ({\n ...e,\n module: moduleId,\n }))\n\n // Register all event IDs and definitions in the global registry.\n for (const event of fullEvents) {\n addDeclaredEvent(event)\n }\n\n /**\n * The emit function - validates events and delegates to the global event bus\n */\n const emit = async (\n eventId: TEventIds,\n payload: EventPayload,\n emitOptions?: EmitOptions\n ): Promise<void> => {\n // Runtime validation - event must be declared\n if (!validEventIds.has(eventId)) {\n const message =\n `[events] Module \"${moduleId}\" tried to emit undeclared event \"${eventId}\". ` +\n `Add it to the module's events.ts file first.`\n\n if (strict) {\n throw new Error(message)\n } else {\n logger.error('Module tried to emit undeclared event \u2014 add it to the module events.ts first', { moduleId, eventId })\n // In non-strict mode, still emit but with warning\n }\n }\n\n // Get event bus from global reference\n const eventBus = getGlobalEventBus()\n if (!eventBus) {\n logger.warn('Event bus not available, cannot emit event', { eventId })\n return\n }\n\n const eventDefinition = fullEvents.find((event) => event.id === eventId)\n const isClientBroadcast = eventDefinition?.clientBroadcast === true\n const trustedOptions = eventDefinition?.crossProcessBroadcast === true || isClientBroadcast\n ? {\n ...emitOptions,\n // Browser-broadcast module emitters historically accepted scope in\n // their typed payload. Preserve that contract at the trusted module\n // boundary while the event bus itself relies only on options.\n ...(isClientBroadcast && emitOptions?.tenantId === undefined\n ? { tenantId: payload.tenantId ?? null }\n : {}),\n ...(isClientBroadcast && emitOptions?.organizationId === undefined\n ? { organizationId: payload.organizationId ?? null }\n : {}),\n ...(isClientBroadcast && emitOptions?.organizationIds === undefined && Array.isArray(payload.organizationIds)\n ? { organizationIds: payload.organizationIds.filter((value): value is string => typeof value === 'string') }\n : {}),\n emitterModuleId: moduleId,\n }\n : emitOptions\n await eventBus.emit(eventId, payload, trustedOptions)\n }\n\n return {\n moduleId,\n events: fullEvents,\n emit: emit as unknown as ModuleEventEmitter<TEventIds>,\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/**\n * Event Module Factory\n *\n * Provides factory functions for creating type-safe event configurations.\n */\n\nimport { createLogger } from '../../lib/logger'\nimport type {\n EventDefinition,\n EventModuleConfig,\n EventPayload,\n EventPayloadSchema,\n EmitOptions,\n CreateModuleEventsOptions,\n ModuleEventEmitter,\n} from './types'\n\nconst logger = createLogger('events').child({ component: 'factory' })\n\n// =============================================================================\n// Global Event Bus Reference\n// =============================================================================\n\n/**\n * Type for the global event bus interface\n */\ninterface GlobalEventBus {\n emit(event: string, payload: unknown, options?: EmitOptions): Promise<void>\n}\n\nconst GLOBAL_EVENT_BUS_KEY = '__openMercatoGlobalEventBus__'\n\n// Global event bus reference (set during bootstrap)\nlet globalEventBus: GlobalEventBus | null = null\n\n/**\n * Set the global event bus instance.\n * Called during app bootstrap to wire up event emission.\n */\nexport function setGlobalEventBus(bus: GlobalEventBus): void {\n globalEventBus = bus\n try {\n ;(globalThis as Record<string, unknown>)[GLOBAL_EVENT_BUS_KEY] = bus\n } catch {\n // ignore global assignment failures\n }\n}\n\n/**\n * Get the global event bus instance.\n * Returns null if not yet bootstrapped.\n */\nexport function getGlobalEventBus(): GlobalEventBus | null {\n try {\n const sharedBus = (globalThis as Record<string, unknown>)[GLOBAL_EVENT_BUS_KEY]\n if (sharedBus && typeof sharedBus === 'object' && typeof (sharedBus as GlobalEventBus).emit === 'function') {\n return sharedBus as GlobalEventBus\n }\n } catch {\n // ignore global read failures\n }\n return globalEventBus\n}\n\n// =============================================================================\n// Event Registry for Validation\n// =============================================================================\n\ntype EventRegistryState = {\n declaredEventIds: Set<string>\n declaredEvents: EventDefinition[]\n registeredEventConfigs: EventModuleConfig[] | null\n}\n\nconst GLOBAL_EVENT_REGISTRY_KEY = '__openMercatoEventDefinitionRegistry__'\n\nconst fallbackEventRegistryState: EventRegistryState = {\n declaredEventIds: new Set<string>(),\n declaredEvents: [],\n registeredEventConfigs: null,\n}\n\nfunction isEventRegistryState(value: unknown): value is EventRegistryState {\n if (!value || typeof value !== 'object') return false\n const candidate = value as Partial<EventRegistryState>\n return candidate.declaredEventIds instanceof Set\n && Array.isArray(candidate.declaredEvents)\n && (candidate.registeredEventConfigs === null || Array.isArray(candidate.registeredEventConfigs))\n}\n\nfunction getEventRegistryState(): EventRegistryState {\n try {\n const globalScope = globalThis as Record<string, unknown>\n const existing = globalScope[GLOBAL_EVENT_REGISTRY_KEY]\n if (isEventRegistryState(existing)) return existing\n globalScope[GLOBAL_EVENT_REGISTRY_KEY] = fallbackEventRegistryState\n return fallbackEventRegistryState\n } catch {\n // Restricted runtimes may deny global access. Keep the previous\n // module-local behavior as a safe fallback.\n return fallbackEventRegistryState\n }\n}\n\nconst CRUD_AFTER_EVENT_SUFFIXES = ['.created', '.updated', '.deleted'] as const\n\n/**\n * Generated payload schema for platform-emitted CRUD after-events. Mirrors the\n * default payload built by the data engine's `emitOrmEntityEvent` when no\n * `buildPayload` override is configured: `{ id, organizationId, tenantId }`\n * plus `syncOrigin` when the write originated from a sync. organizationId and\n * tenantId keys are always present but may be null, so they are `optional`.\n */\nexport const DEFAULT_CRUD_PAYLOAD_SCHEMA: EventPayloadSchema = {\n fields: [\n { path: 'id', type: 'text' },\n { path: 'organizationId', type: 'text', optional: true },\n { path: 'tenantId', type: 'text', optional: true },\n { path: 'syncOrigin', type: 'text', optional: true },\n ],\n}\n\nfunction applyDefaultCrudPayloadSchema(event: EventDefinition): EventDefinition {\n if (event.payloadSchema) return event\n if (event.category !== 'crud') return event\n if (!CRUD_AFTER_EVENT_SUFFIXES.some(suffix => event.id.endsWith(suffix))) return event\n return { ...event, payloadSchema: DEFAULT_CRUD_PAYLOAD_SCHEMA }\n}\n\nfunction addDeclaredEvent(event: EventDefinition): void {\n const declared = applyDefaultCrudPayloadSchema(event)\n const state = getEventRegistryState()\n state.declaredEventIds.add(declared.id)\n const existingIndex = state.declaredEvents.findIndex((candidate) => candidate.id === declared.id)\n if (existingIndex < 0) {\n state.declaredEvents.push(declared)\n return\n }\n // Refresh a module's own definition in place during HMR without allowing a\n // duplicate declaration from another module to take over the event id.\n if (state.declaredEvents[existingIndex]?.module === declared.module) {\n state.declaredEvents[existingIndex] = declared\n }\n}\n\n/**\n * Check if an event ID has been declared by any module.\n * Used for runtime validation to ensure only declared events are emitted.\n */\nexport function isEventDeclared(eventId: string): boolean {\n return getEventRegistryState().declaredEventIds.has(eventId)\n}\n\n/**\n * Get all declared event IDs.\n * Useful for debugging and introspection.\n */\nexport function getAllDeclaredEventIds(): string[] {\n return Array.from(getEventRegistryState().declaredEventIds)\n}\n\n/**\n * Get all declared events with their full definitions.\n * Used by the API to return available events for workflow triggers.\n */\nexport function getDeclaredEvents(): EventDefinition[] {\n return [...getEventRegistryState().declaredEvents]\n}\n\n/**\n * Check if an event has clientBroadcast enabled.\n * Used by the SSE endpoint to filter events for the DOM Event Bridge.\n */\nexport function isBroadcastEvent(eventId: string): boolean {\n const event = getEventRegistryState().declaredEvents.find(e => e.id === eventId)\n return event?.clientBroadcast === true\n}\n\n/**\n * Check if an event should be published over the server-to-server event bridge.\n * Browser-broadcast events remain eligible for backward compatibility, while\n * crossProcessBroadcast supports private process coordination without SSE.\n */\nexport function isCrossProcessBroadcastEvent(eventId: string): boolean {\n const event = getEventRegistryState().declaredEvents.find(e => e.id === eventId)\n return event?.clientBroadcast === true || event?.crossProcessBroadcast === true\n}\n\n/**\n * Check whether an event is reserved for private server-to-server\n * coordination. Workflow-authored EMIT_EVENT activities must not emit these\n * events because their payload and event id are tenant-managed input.\n */\nexport function isPrivateCrossProcessBroadcastEvent(eventId: string): boolean {\n const event = getEventRegistryState().declaredEvents.find(e => e.id === eventId)\n return event?.crossProcessBroadcast === true && event?.clientBroadcast !== true\n}\n\n/**\n * Verify provenance for a private cross-process event. The module id is\n * stamped by a declared module emitter or another trusted server-side seam;\n * tenant-managed event payloads never participate in this decision.\n */\nexport function isPrivateCrossProcessEventEmitter(\n eventId: string,\n emitterModuleId: string | undefined,\n): boolean {\n const event = getEventRegistryState().declaredEvents.find(e => e.id === eventId)\n if (event?.crossProcessBroadcast !== true) return true\n return typeof event.module === 'string'\n && event.module.length > 0\n && event.module === emitterModuleId\n}\n\n/**\n * Check if an event has portalBroadcast enabled.\n * Used by the portal SSE endpoint to filter events for the Portal Event Bridge.\n */\nexport function isPortalBroadcastEvent(eventId: string): boolean {\n const event = getEventRegistryState().declaredEvents.find(e => e.id === eventId)\n return event?.portalBroadcast === true\n}\n\n// =============================================================================\n// Bootstrap Registration (similar to searchModuleConfigs pattern)\n// =============================================================================\n\n/**\n * Register event module configurations globally.\n * Called during app bootstrap with configs from events.generated.ts.\n */\nexport function registerEventModuleConfigs(configs: EventModuleConfig[]): void {\n const state = getEventRegistryState()\n if (state.registeredEventConfigs !== null && process.env.NODE_ENV === 'development') {\n logger.debug('Event module configs re-registered (this may occur during HMR)')\n }\n state.registeredEventConfigs = configs\n for (const config of configs) {\n for (const event of config.events) {\n addDeclaredEvent(event)\n }\n }\n}\n\n/**\n * Get registered event module configurations.\n * Returns empty array if not registered.\n */\nexport function getEventModuleConfigs(): EventModuleConfig[] {\n return getEventRegistryState().registeredEventConfigs ?? []\n}\n\n// =============================================================================\n// Factory Function\n// =============================================================================\n\n/**\n * Creates a type-safe event configuration for a module.\n *\n * Usage in module events.ts:\n * ```typescript\n * import { createModuleEvents } from '@open-mercato/shared/modules/events'\n *\n * const events = [\n * { id: 'customers.people.created', label: 'Person Created', category: 'crud' },\n * { id: 'customers.people.updated', label: 'Person Updated', category: 'crud' },\n * ] as const\n *\n * export const eventsConfig = createModuleEvents({\n * moduleId: 'customers',\n * events,\n * })\n *\n * // Export the typed emit function for use in commands\n * export const emitCustomersEvent = eventsConfig.emit\n *\n * // Export event IDs as a type for external use\n * export type CustomersEventId = typeof events[number]['id']\n *\n * export default eventsConfig\n * ```\n *\n * TypeScript will enforce that only declared event IDs can be emitted:\n * ```typescript\n * // \u2705 This compiles - event is declared\n * emitCustomersEvent('customers.people.created', { id: '123', tenantId: 'abc' })\n *\n * // \u274C TypeScript error - event not declared\n * emitCustomersEvent('customers.people.exploded', { id: '123' })\n * ```\n */\nexport function createModuleEvents<\n const TEvents extends readonly { id: string }[],\n TEventIds extends TEvents[number]['id'] = TEvents[number]['id']\n>(options: CreateModuleEventsOptions<TEventIds>): EventModuleConfig<TEventIds> {\n const { moduleId, events, strict = false } = options\n\n // Build set of valid event IDs for runtime validation\n const validEventIds = new Set(events.map(e => e.id))\n\n // Build full event definitions with module added and the generated CRUD\n // payload-schema default applied, so config consumers and the global\n // registry see the same definitions.\n const fullEvents: EventDefinition[] = events.map(e =>\n applyDefaultCrudPayloadSchema({\n ...e,\n module: moduleId,\n }),\n )\n\n // Register all event IDs and definitions in the global registry.\n for (const event of fullEvents) {\n addDeclaredEvent(event)\n }\n\n /**\n * The emit function - validates events and delegates to the global event bus\n */\n const emit = async (\n eventId: TEventIds,\n payload: EventPayload,\n emitOptions?: EmitOptions\n ): Promise<void> => {\n // Runtime validation - event must be declared\n if (!validEventIds.has(eventId)) {\n const message =\n `[events] Module \"${moduleId}\" tried to emit undeclared event \"${eventId}\". ` +\n `Add it to the module's events.ts file first.`\n\n if (strict) {\n throw new Error(message)\n } else {\n logger.error('Module tried to emit undeclared event \u2014 add it to the module events.ts first', { moduleId, eventId })\n // In non-strict mode, still emit but with warning\n }\n }\n\n // Get event bus from global reference\n const eventBus = getGlobalEventBus()\n if (!eventBus) {\n logger.warn('Event bus not available, cannot emit event', { eventId })\n return\n }\n\n const eventDefinition = fullEvents.find((event) => event.id === eventId)\n const isClientBroadcast = eventDefinition?.clientBroadcast === true\n const trustedOptions = eventDefinition?.crossProcessBroadcast === true || isClientBroadcast\n ? {\n ...emitOptions,\n // Browser-broadcast module emitters historically accepted scope in\n // their typed payload. Preserve that contract at the trusted module\n // boundary while the event bus itself relies only on options.\n ...(isClientBroadcast && emitOptions?.tenantId === undefined\n ? { tenantId: payload.tenantId ?? null }\n : {}),\n ...(isClientBroadcast && emitOptions?.organizationId === undefined\n ? { organizationId: payload.organizationId ?? null }\n : {}),\n ...(isClientBroadcast && emitOptions?.organizationIds === undefined && Array.isArray(payload.organizationIds)\n ? { organizationIds: payload.organizationIds.filter((value): value is string => typeof value === 'string') }\n : {}),\n emitterModuleId: moduleId,\n }\n : emitOptions\n await eventBus.emit(eventId, payload, trustedOptions)\n }\n\n return {\n moduleId,\n events: fullEvents,\n emit: emit as unknown as ModuleEventEmitter<TEventIds>,\n }\n}\n"],
5
+ "mappings": "AAMA,SAAS,oBAAoB;AAW7B,MAAM,SAAS,aAAa,QAAQ,EAAE,MAAM,EAAE,WAAW,UAAU,CAAC;AAapE,MAAM,uBAAuB;AAG7B,IAAI,iBAAwC;AAMrC,SAAS,kBAAkB,KAA2B;AAC3D,mBAAiB;AACjB,MAAI;AACF;AAAC,IAAC,WAAuC,oBAAoB,IAAI;AAAA,EACnE,QAAQ;AAAA,EAER;AACF;AAMO,SAAS,oBAA2C;AACzD,MAAI;AACF,UAAM,YAAa,WAAuC,oBAAoB;AAC9E,QAAI,aAAa,OAAO,cAAc,YAAY,OAAQ,UAA6B,SAAS,YAAY;AAC1G,aAAO;AAAA,IACT;AAAA,EACF,QAAQ;AAAA,EAER;AACA,SAAO;AACT;AAYA,MAAM,4BAA4B;AAElC,MAAM,6BAAiD;AAAA,EACrD,kBAAkB,oBAAI,IAAY;AAAA,EAClC,gBAAgB,CAAC;AAAA,EACjB,wBAAwB;AAC1B;AAEA,SAAS,qBAAqB,OAA6C;AACzE,MAAI,CAAC,SAAS,OAAO,UAAU,SAAU,QAAO;AAChD,QAAM,YAAY;AAClB,SAAO,UAAU,4BAA4B,OACxC,MAAM,QAAQ,UAAU,cAAc,MACrC,UAAU,2BAA2B,QAAQ,MAAM,QAAQ,UAAU,sBAAsB;AACnG;AAEA,SAAS,wBAA4C;AACnD,MAAI;AACF,UAAM,cAAc;AACpB,UAAM,WAAW,YAAY,yBAAyB;AACtD,QAAI,qBAAqB,QAAQ,EAAG,QAAO;AAC3C,gBAAY,yBAAyB,IAAI;AACzC,WAAO;AAAA,EACT,QAAQ;AAGN,WAAO;AAAA,EACT;AACF;AAEA,MAAM,4BAA4B,CAAC,YAAY,YAAY,UAAU;AAS9D,MAAM,8BAAkD;AAAA,EAC7D,QAAQ;AAAA,IACN,EAAE,MAAM,MAAM,MAAM,OAAO;AAAA,IAC3B,EAAE,MAAM,kBAAkB,MAAM,QAAQ,UAAU,KAAK;AAAA,IACvD,EAAE,MAAM,YAAY,MAAM,QAAQ,UAAU,KAAK;AAAA,IACjD,EAAE,MAAM,cAAc,MAAM,QAAQ,UAAU,KAAK;AAAA,EACrD;AACF;AAEA,SAAS,8BAA8B,OAAyC;AAC9E,MAAI,MAAM,cAAe,QAAO;AAChC,MAAI,MAAM,aAAa,OAAQ,QAAO;AACtC,MAAI,CAAC,0BAA0B,KAAK,YAAU,MAAM,GAAG,SAAS,MAAM,CAAC,EAAG,QAAO;AACjF,SAAO,EAAE,GAAG,OAAO,eAAe,4BAA4B;AAChE;AAEA,SAAS,iBAAiB,OAA8B;AACtD,QAAM,WAAW,8BAA8B,KAAK;AACpD,QAAM,QAAQ,sBAAsB;AACpC,QAAM,iBAAiB,IAAI,SAAS,EAAE;AACtC,QAAM,gBAAgB,MAAM,eAAe,UAAU,CAAC,cAAc,UAAU,OAAO,SAAS,EAAE;AAChG,MAAI,gBAAgB,GAAG;AACrB,UAAM,eAAe,KAAK,QAAQ;AAClC;AAAA,EACF;AAGA,MAAI,MAAM,eAAe,aAAa,GAAG,WAAW,SAAS,QAAQ;AACnE,UAAM,eAAe,aAAa,IAAI;AAAA,EACxC;AACF;AAMO,SAAS,gBAAgB,SAA0B;AACxD,SAAO,sBAAsB,EAAE,iBAAiB,IAAI,OAAO;AAC7D;AAMO,SAAS,yBAAmC;AACjD,SAAO,MAAM,KAAK,sBAAsB,EAAE,gBAAgB;AAC5D;AAMO,SAAS,oBAAuC;AACrD,SAAO,CAAC,GAAG,sBAAsB,EAAE,cAAc;AACnD;AAMO,SAAS,iBAAiB,SAA0B;AACzD,QAAM,QAAQ,sBAAsB,EAAE,eAAe,KAAK,OAAK,EAAE,OAAO,OAAO;AAC/E,SAAO,OAAO,oBAAoB;AACpC;AAOO,SAAS,6BAA6B,SAA0B;AACrE,QAAM,QAAQ,sBAAsB,EAAE,eAAe,KAAK,OAAK,EAAE,OAAO,OAAO;AAC/E,SAAO,OAAO,oBAAoB,QAAQ,OAAO,0BAA0B;AAC7E;AAOO,SAAS,oCAAoC,SAA0B;AAC5E,QAAM,QAAQ,sBAAsB,EAAE,eAAe,KAAK,OAAK,EAAE,OAAO,OAAO;AAC/E,SAAO,OAAO,0BAA0B,QAAQ,OAAO,oBAAoB;AAC7E;AAOO,SAAS,kCACd,SACA,iBACS;AACT,QAAM,QAAQ,sBAAsB,EAAE,eAAe,KAAK,OAAK,EAAE,OAAO,OAAO;AAC/E,MAAI,OAAO,0BAA0B,KAAM,QAAO;AAClD,SAAO,OAAO,MAAM,WAAW,YAC1B,MAAM,OAAO,SAAS,KACtB,MAAM,WAAW;AACxB;AAMO,SAAS,uBAAuB,SAA0B;AAC/D,QAAM,QAAQ,sBAAsB,EAAE,eAAe,KAAK,OAAK,EAAE,OAAO,OAAO;AAC/E,SAAO,OAAO,oBAAoB;AACpC;AAUO,SAAS,2BAA2B,SAAoC;AAC7E,QAAM,QAAQ,sBAAsB;AACpC,MAAI,MAAM,2BAA2B,QAAQ,QAAQ,IAAI,aAAa,eAAe;AACnF,WAAO,MAAM,gEAAgE;AAAA,EAC/E;AACA,QAAM,yBAAyB;AAC/B,aAAW,UAAU,SAAS;AAC5B,eAAW,SAAS,OAAO,QAAQ;AACjC,uBAAiB,KAAK;AAAA,IACxB;AAAA,EACF;AACF;AAMO,SAAS,wBAA6C;AAC3D,SAAO,sBAAsB,EAAE,0BAA0B,CAAC;AAC5D;AAyCO,SAAS,mBAGd,SAA6E;AAC7E,QAAM,EAAE,UAAU,QAAQ,SAAS,MAAM,IAAI;AAG7C,QAAM,gBAAgB,IAAI,IAAI,OAAO,IAAI,OAAK,EAAE,EAAE,CAAC;AAKnD,QAAM,aAAgC,OAAO;AAAA,IAAI,OAC/C,8BAA8B;AAAA,MAC5B,GAAG;AAAA,MACH,QAAQ;AAAA,IACV,CAAC;AAAA,EACH;AAGA,aAAW,SAAS,YAAY;AAC9B,qBAAiB,KAAK;AAAA,EACxB;AAKA,QAAM,OAAO,OACX,SACA,SACA,gBACkB;AAElB,QAAI,CAAC,cAAc,IAAI,OAAO,GAAG;AAC/B,YAAM,UACJ,oBAAoB,QAAQ,qCAAqC,OAAO;AAG1E,UAAI,QAAQ;AACV,cAAM,IAAI,MAAM,OAAO;AAAA,MACzB,OAAO;AACL,eAAO,MAAM,qFAAgF,EAAE,UAAU,QAAQ,CAAC;AAAA,MAEpH;AAAA,IACF;AAGA,UAAM,WAAW,kBAAkB;AACnC,QAAI,CAAC,UAAU;AACb,aAAO,KAAK,8CAA8C,EAAE,QAAQ,CAAC;AACrE;AAAA,IACF;AAEA,UAAM,kBAAkB,WAAW,KAAK,CAAC,UAAU,MAAM,OAAO,OAAO;AACvE,UAAM,oBAAoB,iBAAiB,oBAAoB;AAC/D,UAAM,iBAAiB,iBAAiB,0BAA0B,QAAQ,oBACtE;AAAA,MACE,GAAG;AAAA;AAAA;AAAA;AAAA,MAIH,GAAI,qBAAqB,aAAa,aAAa,SAC/C,EAAE,UAAU,QAAQ,YAAY,KAAK,IACrC,CAAC;AAAA,MACL,GAAI,qBAAqB,aAAa,mBAAmB,SACrD,EAAE,gBAAgB,QAAQ,kBAAkB,KAAK,IACjD,CAAC;AAAA,MACL,GAAI,qBAAqB,aAAa,oBAAoB,UAAa,MAAM,QAAQ,QAAQ,eAAe,IACxG,EAAE,iBAAiB,QAAQ,gBAAgB,OAAO,CAAC,UAA2B,OAAO,UAAU,QAAQ,EAAE,IACzG,CAAC;AAAA,MACL,iBAAiB;AAAA,IACnB,IACA;AACJ,UAAM,SAAS,KAAK,SAAS,SAAS,cAAc;AAAA,EACtD;AAEA,SAAO;AAAA,IACL;AAAA,IACA,QAAQ;AAAA,IACR;AAAA,EACF;AACF;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/shared",
3
- "version": "0.7.1-develop.7193.1.910a5b0a1e",
3
+ "version": "0.7.1-develop.7194.1.ab4fc81f82",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -113,10 +113,11 @@
113
113
  "@mikro-orm/core": "^7.1.14",
114
114
  "@mikro-orm/decorators": "^7.1.14",
115
115
  "@mikro-orm/postgresql": "^7.1.14",
116
- "@open-mercato/cache": "0.7.1-develop.7193.1.910a5b0a1e",
116
+ "@open-mercato/cache": "0.7.1-develop.7194.1.ab4fc81f82",
117
117
  "@types/html-to-text": "^9.0.4",
118
118
  "@types/sanitize-html": "^2.16.1",
119
119
  "dotenv": "^17.4.2",
120
+ "gpt-tokenizer": "^3.4.0",
120
121
  "html-to-text": "^10.0.0",
121
122
  "pino": "^10.3.1",
122
123
  "rate-limiter-flexible": "^11.2.0",
@@ -0,0 +1,81 @@
1
+ import { normalizeOpenCodeToolPart } from '../opencode-tool-parts'
2
+
3
+ describe('normalizeOpenCodeToolPart', () => {
4
+ it('opens a native tool part on a non-terminal state', () => {
5
+ const update = normalizeOpenCodeToolPart({
6
+ type: 'tool',
7
+ id: 'prt-1',
8
+ callID: 'call-1',
9
+ tool: 'load_skill',
10
+ state: { status: 'running', input: { skillId: 'x' } },
11
+ })
12
+ expect(update).toEqual({ phase: 'progress', callId: 'call-1', toolName: 'load_skill', input: { skillId: 'x' } })
13
+ })
14
+
15
+ it('finishes a native tool part on completed, carrying the output', () => {
16
+ const update = normalizeOpenCodeToolPart({
17
+ type: 'tool',
18
+ callID: 'call-1',
19
+ tool: 'load_skill',
20
+ state: { status: 'completed', input: { skillId: 'x' }, output: { ok: true } },
21
+ })
22
+ expect(update).toEqual({
23
+ phase: 'finish',
24
+ callId: 'call-1',
25
+ toolName: 'load_skill',
26
+ input: { skillId: 'x' },
27
+ output: { ok: true },
28
+ status: 'ok',
29
+ })
30
+ })
31
+
32
+ it('maps an errored native tool part to status error, preferring state.error', () => {
33
+ const update = normalizeOpenCodeToolPart({
34
+ type: 'tool',
35
+ callID: 'call-2',
36
+ tool: 'run_skill_script',
37
+ state: { status: 'error', error: 'boom' },
38
+ })
39
+ expect(update).toEqual({
40
+ phase: 'finish',
41
+ callId: 'call-2',
42
+ toolName: 'run_skill_script',
43
+ input: undefined,
44
+ output: 'boom',
45
+ status: 'error',
46
+ })
47
+ })
48
+
49
+ it('falls back to part.id when callID is absent', () => {
50
+ const update = normalizeOpenCodeToolPart({
51
+ type: 'tool',
52
+ id: 'prt-9',
53
+ tool: 'search',
54
+ state: { status: 'running' },
55
+ })
56
+ expect(update).toMatchObject({ phase: 'progress', callId: 'prt-9', toolName: 'search' })
57
+ })
58
+
59
+ it('handles the legacy tool_use / tool_result shape', () => {
60
+ expect(normalizeOpenCodeToolPart({ type: 'tool_use', id: 'tc-1', name: 'load_skill', input: { a: 1 } })).toEqual({
61
+ phase: 'progress',
62
+ callId: 'tc-1',
63
+ toolName: 'load_skill',
64
+ input: { a: 1 },
65
+ })
66
+ expect(normalizeOpenCodeToolPart({ type: 'tool_result', tool_use_id: 'tc-1', content: { ok: true } })).toEqual({
67
+ phase: 'finish',
68
+ callId: 'tc-1',
69
+ output: { ok: true },
70
+ status: 'ok',
71
+ })
72
+ })
73
+
74
+ it('ignores non-tool and malformed parts', () => {
75
+ expect(normalizeOpenCodeToolPart({ type: 'text', text: 'hi' })).toBeNull()
76
+ expect(normalizeOpenCodeToolPart({ type: 'thinking' })).toBeNull()
77
+ expect(normalizeOpenCodeToolPart({ type: 'tool' })).toBeNull() // no callID/tool
78
+ expect(normalizeOpenCodeToolPart(null)).toBeNull()
79
+ expect(normalizeOpenCodeToolPart('nope')).toBeNull()
80
+ })
81
+ })
@@ -0,0 +1,20 @@
1
+ import { countTokens, TOKEN_ENCODING } from '../token-count'
2
+
3
+ describe('countTokens', () => {
4
+ it('returns 0 for empty / nullish input', () => {
5
+ expect(countTokens('')).toBe(0)
6
+ expect(countTokens(null)).toBe(0)
7
+ expect(countTokens(undefined)).toBe(0)
8
+ })
9
+
10
+ it('counts more tokens for longer text', () => {
11
+ const short = countTokens('hello')
12
+ const long = countTokens('hello world, this is a noticeably longer sentence.')
13
+ expect(short).toBeGreaterThan(0)
14
+ expect(long).toBeGreaterThan(short)
15
+ })
16
+
17
+ it('exposes the encoding label', () => {
18
+ expect(TOKEN_ENCODING).toBe('o200k_base')
19
+ })
20
+ })
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Normalizes an OpenCode `message.part.updated` part into a tool-call lifecycle
3
+ * update, shielding callers from OpenCode's wire schema.
4
+ *
5
+ * OpenCode (Go server) streams MCP tool invocations as parts of `type: 'tool'`
6
+ * carrying a `callID`, the `tool` name, and a `state` machine
7
+ * (`state.status: pending|running|completed|error`, `state.input`,
8
+ * `state.output`/`state.error`). The same part id is re-emitted on each state
9
+ * transition, so a tool call surfaces as one or more `progress` updates followed
10
+ * by a single `finish` once the state reaches a terminal status.
11
+ *
12
+ * Older OpenCode builds emitted Anthropic-style `tool_use` / `tool_result`
13
+ * blocks instead; those are still recognized as a fallback so a downgrade does
14
+ * not silently drop traces again.
15
+ *
16
+ * Returns `null` for any part that is not a tool invocation (text, thinking,
17
+ * step markers, …) so callers can ignore it.
18
+ */
19
+ export type OpenCodeToolPartUpdate =
20
+ | { phase: 'progress'; callId: string; toolName: string; input?: unknown }
21
+ | {
22
+ phase: 'finish'
23
+ callId: string
24
+ toolName?: string
25
+ input?: unknown
26
+ output?: unknown
27
+ status: 'ok' | 'error'
28
+ }
29
+
30
+ function asString(value: unknown): string | undefined {
31
+ return typeof value === 'string' && value.length > 0 ? value : undefined
32
+ }
33
+
34
+ function asRecord(value: unknown): Record<string, unknown> {
35
+ return value && typeof value === 'object' ? (value as Record<string, unknown>) : {}
36
+ }
37
+
38
+ export function normalizeOpenCodeToolPart(rawPart: unknown): OpenCodeToolPartUpdate | null {
39
+ if (!rawPart || typeof rawPart !== 'object') return null
40
+ const part = rawPart as Record<string, unknown>
41
+ const type = asString(part.type)
42
+ if (!type) return null
43
+
44
+ // Native OpenCode tool part with a state machine.
45
+ if (type === 'tool') {
46
+ const callId = asString(part.callID) ?? asString(part.id)
47
+ const toolName = asString(part.tool)
48
+ if (!callId || !toolName) return null
49
+ const state = asRecord(part.state)
50
+ const status = asString(state.status)
51
+ const input = 'input' in state ? state.input : undefined
52
+ if (status === 'completed' || status === 'error') {
53
+ const output = status === 'error' ? state.error ?? state.output : state.output
54
+ return {
55
+ phase: 'finish',
56
+ callId,
57
+ toolName,
58
+ input,
59
+ output,
60
+ status: status === 'error' ? 'error' : 'ok',
61
+ }
62
+ }
63
+ return { phase: 'progress', callId, toolName, input }
64
+ }
65
+
66
+ // Legacy Anthropic-style parts (older OpenCode builds).
67
+ if (type === 'tool_use') {
68
+ const callId = asString(part.id)
69
+ const toolName = asString(part.name)
70
+ if (!callId || !toolName) return null
71
+ return { phase: 'progress', callId, toolName, input: part.input }
72
+ }
73
+ if (type === 'tool_result') {
74
+ const callId = asString(part.tool_use_id) ?? asString(part.id)
75
+ if (!callId) return null
76
+ return { phase: 'finish', callId, output: part.content, status: 'ok' }
77
+ }
78
+
79
+ return null
80
+ }
@@ -0,0 +1,21 @@
1
+ import { encode } from 'gpt-tokenizer/encoding/o200k_base'
2
+
3
+ /**
4
+ * Model-agnostic offline token estimate.
5
+ *
6
+ * Uses the `o200k_base` BPE encoding (GPT-4o / GPT-5 family) as a proxy. It is
7
+ * NOT exact for non-OpenAI models — notably Claude, whose tokenizer is not
8
+ * available offline — but it is deterministic, dependency-light, and a far
9
+ * closer estimate than a chars/4 heuristic. Treat the result as an estimate.
10
+ *
11
+ * Infrastructure only: this file knows nothing about any domain shape. Callers
12
+ * that need to break a structure down into elements assemble their own totals
13
+ * on top of this primitive.
14
+ */
15
+ export function countTokens(text: string | null | undefined): number {
16
+ if (!text) return 0
17
+ return encode(text).length
18
+ }
19
+
20
+ /** The BPE encoding backing {@link countTokens}, surfaced so callers can label estimates. */
21
+ export const TOKEN_ENCODING = 'o200k_base' as const
@@ -1,3 +1,4 @@
1
+ import { asValue } from 'awilix'
1
2
  import type { BootstrapData } from './types'
2
3
  import type { AppDiRegistrar } from '../di/container'
3
4
  import { findAppRoot, type AppRoot } from './appResolver'
@@ -497,6 +498,21 @@ async function compileAndImport(
497
498
  }
498
499
 
499
500
 
501
+ /**
502
+ * Registers an app-owned generated value on the request container.
503
+ *
504
+ * The app registers these statically from `src/di.ts`, which `createRequestContainer`
505
+ * reaches through the `@/` alias — and that alias only exists under the bundler.
506
+ * A CLI or MCP process runs plain Node, so the import fails, the failure is
507
+ * swallowed, and the value is simply absent with no diagnostic. Routing it through
508
+ * a registrar built from the same generated file keeps both processes in step.
509
+ */
510
+ function appValueRegistrar(key: string, value: unknown): BootstrapData['diRegistrars'][number] {
511
+ return (container) => {
512
+ container.register({ [key]: asValue(value) })
513
+ }
514
+ }
515
+
500
516
  /**
501
517
  * Load a generated registry that older apps may not have generated yet.
502
518
  *
@@ -716,6 +732,7 @@ async function loadBootstrapDataWithActiveEsbuild(appRoot?: string): Promise<Boo
716
732
  diModule,
717
733
  searchModule,
718
734
  commandLoadersModule,
735
+ webResearchModule,
719
736
  commandInterceptorsModule,
720
737
  workflowsModule,
721
738
  ] = await Promise.all([
@@ -724,6 +741,9 @@ async function loadBootstrapDataWithActiveEsbuild(appRoot?: string): Promise<Boo
724
741
  compileAndImport(path.join(generatedDir, 'di.generated.ts')),
725
742
  loadOptionalGeneratedModule(path.join(generatedDir, 'search.generated.ts'), { searchModuleConfigs: [] }),
726
743
  loadOptionalGeneratedModule(path.join(generatedDir, 'command-loaders.generated.ts'), { commandLoaderEntries: [] }),
744
+ loadOptionalGeneratedModule(path.join(generatedDir, 'web-research-adapters.generated.ts'), {
745
+ webResearchAdapterEntries: [],
746
+ }),
727
747
  loadOptionalGeneratedModule(path.join(generatedDir, 'command-interceptors.generated.ts'), {
728
748
  commandInterceptorEntries: [],
729
749
  }),
@@ -733,7 +753,10 @@ async function loadBootstrapDataWithActiveEsbuild(appRoot?: string): Promise<Boo
733
753
  return {
734
754
  modules: modulesModule.modules as BootstrapData['modules'],
735
755
  entities: entitiesModule.entities as BootstrapData['entities'],
736
- diRegistrars: diModule.diRegistrars as BootstrapData['diRegistrars'],
756
+ diRegistrars: [
757
+ ...(diModule.diRegistrars as BootstrapData['diRegistrars']),
758
+ appValueRegistrar('webResearchAdapterEntries', webResearchModule.webResearchAdapterEntries ?? []),
759
+ ],
737
760
  entityIds: entityIdsModule.E as BootstrapData['entityIds'],
738
761
  // Search configs are needed by workers for indexing
739
762
  searchModuleConfigs: (searchModule.searchModuleConfigs ?? []) as BootstrapData['searchModuleConfigs'],