@open-mercato/core 0.6.7-develop.6653.1.b5bc7194a0 → 0.6.7-develop.6661.1.0043ed1d03

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 (73) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/business_rules/lib/value-resolver.js +7 -0
  3. package/dist/modules/business_rules/lib/value-resolver.js.map +2 -2
  4. package/dist/modules/communication_channels/lib/pg-errors.js +1 -7
  5. package/dist/modules/communication_channels/lib/pg-errors.js.map +2 -2
  6. package/dist/modules/currencies/commands/currencies.js +22 -8
  7. package/dist/modules/currencies/commands/currencies.js.map +2 -2
  8. package/dist/modules/currencies/commands/exchange-rates.js +22 -8
  9. package/dist/modules/currencies/commands/exchange-rates.js.map +2 -2
  10. package/dist/modules/currencies/commands/scope.js +22 -0
  11. package/dist/modules/currencies/commands/scope.js.map +7 -0
  12. package/dist/modules/customers/components/detail/ActivitiesSection.js +5 -4
  13. package/dist/modules/customers/components/detail/ActivitiesSection.js.map +2 -2
  14. package/dist/modules/customers/components/detail/ActivityHistorySection.js +5 -4
  15. package/dist/modules/customers/components/detail/ActivityHistorySection.js.map +2 -2
  16. package/dist/modules/feature_toggles/api/global/route.js +6 -0
  17. package/dist/modules/feature_toggles/api/global/route.js.map +2 -2
  18. package/dist/modules/feature_toggles/commands/global.js +9 -9
  19. package/dist/modules/feature_toggles/commands/global.js.map +2 -2
  20. package/dist/modules/query_index/api/openapi.js +2 -2
  21. package/dist/modules/query_index/api/openapi.js.map +2 -2
  22. package/dist/modules/query_index/api/status.js +6 -4
  23. package/dist/modules/query_index/api/status.js.map +2 -2
  24. package/dist/modules/query_index/cli.js +31 -28
  25. package/dist/modules/query_index/cli.js.map +2 -2
  26. package/dist/modules/query_index/components/QueryIndexesTable.js +4 -1
  27. package/dist/modules/query_index/components/QueryIndexesTable.js.map +2 -2
  28. package/dist/modules/query_index/lib/batch.js +163 -44
  29. package/dist/modules/query_index/lib/batch.js.map +3 -3
  30. package/dist/modules/query_index/lib/jobs.js +3 -2
  31. package/dist/modules/query_index/lib/jobs.js.map +2 -2
  32. package/dist/modules/query_index/lib/reindexer.js +50 -16
  33. package/dist/modules/query_index/lib/reindexer.js.map +2 -2
  34. package/dist/modules/query_index/lib/stale.js +3 -0
  35. package/dist/modules/query_index/lib/stale.js.map +2 -2
  36. package/dist/modules/query_index/lib/subscriber-scope.js +63 -10
  37. package/dist/modules/query_index/lib/subscriber-scope.js.map +2 -2
  38. package/dist/modules/query_index/subscribers/delete_one.js +20 -6
  39. package/dist/modules/query_index/subscribers/delete_one.js.map +2 -2
  40. package/dist/modules/query_index/subscribers/upsert_one.js +8 -3
  41. package/dist/modules/query_index/subscribers/upsert_one.js.map +2 -2
  42. package/dist/modules/workflows/api/definitions/[id]/customize/route.js +2 -0
  43. package/dist/modules/workflows/api/definitions/[id]/customize/route.js.map +2 -2
  44. package/dist/modules/workflows/api/definitions/[id]/reset-to-code/route.js +4 -0
  45. package/dist/modules/workflows/api/definitions/[id]/reset-to-code/route.js.map +2 -2
  46. package/package.json +7 -7
  47. package/src/modules/business_rules/lib/value-resolver.ts +16 -0
  48. package/src/modules/communication_channels/lib/pg-errors.ts +3 -10
  49. package/src/modules/currencies/commands/currencies.ts +26 -8
  50. package/src/modules/currencies/commands/exchange-rates.ts +26 -8
  51. package/src/modules/currencies/commands/scope.ts +32 -0
  52. package/src/modules/customers/components/detail/ActivitiesSection.tsx +10 -6
  53. package/src/modules/customers/components/detail/ActivityHistorySection.tsx +6 -3
  54. package/src/modules/feature_toggles/api/global/route.ts +7 -1
  55. package/src/modules/feature_toggles/commands/global.ts +9 -12
  56. package/src/modules/query_index/api/openapi.ts +2 -2
  57. package/src/modules/query_index/api/status.ts +14 -5
  58. package/src/modules/query_index/cli.ts +36 -28
  59. package/src/modules/query_index/components/QueryIndexesTable.tsx +7 -4
  60. package/src/modules/query_index/i18n/de.json +2 -0
  61. package/src/modules/query_index/i18n/en.json +2 -0
  62. package/src/modules/query_index/i18n/es.json +2 -0
  63. package/src/modules/query_index/i18n/pl.json +2 -0
  64. package/src/modules/query_index/lib/batch.ts +244 -67
  65. package/src/modules/query_index/lib/jobs.ts +11 -0
  66. package/src/modules/query_index/lib/reindexer.ts +69 -16
  67. package/src/modules/query_index/lib/stale.ts +10 -0
  68. package/src/modules/query_index/lib/subscriber-scope.ts +98 -14
  69. package/src/modules/query_index/subscribers/delete_one.ts +21 -10
  70. package/src/modules/query_index/subscribers/upsert_one.ts +8 -3
  71. package/src/modules/workflows/AGENTS.md +22 -0
  72. package/src/modules/workflows/api/definitions/[id]/customize/route.ts +9 -0
  73. package/src/modules/workflows/api/definitions/[id]/reset-to-code/route.ts +7 -0
@@ -1,11 +1,28 @@
1
1
  import type { EntityManager } from '@mikro-orm/postgresql'
2
- import { resolveEntityTableName } from '@open-mercato/shared/lib/query/engine'
2
+ import { getEntityIds } from '@open-mercato/shared/lib/encryption/entityIds'
3
+ import { resolveRegisteredEntityTableName } from '@open-mercato/shared/lib/query/engine'
3
4
 
4
5
  export type QueryIndexScope = {
5
6
  tenantId: string | null
6
7
  organizationId: string | null
7
8
  }
8
9
 
10
+ export type QueryIndexSourceMetadata = {
11
+ table: string
12
+ organizationColumn: string | null
13
+ tenantColumn: string | null
14
+ }
15
+
16
+ export type QueryIndexSourceScope =
17
+ | { kind: 'global' }
18
+ | { kind: 'missing' }
19
+ | { kind: 'row'; scope: QueryIndexScope }
20
+
21
+ type QueryIndexEntityMetadata = {
22
+ tableName?: unknown
23
+ properties?: Record<string, { fieldNames?: unknown }>
24
+ }
25
+
9
26
  export class QueryIndexScopeError extends Error {
10
27
  constructor(message: string) {
11
28
  super(message)
@@ -18,7 +35,7 @@ type ResolveRecordScopeInput = {
18
35
  payloadOrganizationId: string | null | undefined
19
36
  hasPayloadTenantId: boolean
20
37
  hasPayloadOrganizationId: boolean
21
- rowScope: QueryIndexScope | null
38
+ sourceScope: QueryIndexSourceScope
22
39
  }
23
40
 
24
41
  type ResolveReindexScopeInput = {
@@ -27,26 +44,66 @@ type ResolveReindexScopeInput = {
27
44
  allowAllTenants?: boolean
28
45
  }
29
46
 
30
- export async function loadQueryIndexRowScope(
47
+ export function resolveQueryIndexSourceMetadata(
31
48
  em: EntityManager,
32
49
  entityType: string,
33
- recordId: string
34
- ): Promise<QueryIndexScope | null> {
50
+ ): QueryIndexSourceMetadata {
51
+ const registeredEntityIds = Object.values(getEntityIds(false)).flatMap((moduleEntities) => Object.values(moduleEntities ?? {}))
52
+ if (!registeredEntityIds.includes(entityType)) {
53
+ throw new QueryIndexScopeError(`Query index entity type is not registered: ${entityType}`)
54
+ }
55
+
56
+ const table = resolveRegisteredEntityTableName(em, entityType as never)
57
+ if (!table) {
58
+ throw new QueryIndexScopeError(`Query index entity type has no registered ORM table: ${entityType}`)
59
+ }
60
+
61
+ const registry = em.getMetadata?.()
62
+ const allMetadataRaw = typeof registry?.getAll === 'function' ? registry.getAll() : null
63
+ const allMetadata: QueryIndexEntityMetadata[] = Array.isArray(allMetadataRaw)
64
+ ? allMetadataRaw as QueryIndexEntityMetadata[]
65
+ : allMetadataRaw instanceof Map
66
+ ? Array.from(allMetadataRaw.values()) as QueryIndexEntityMetadata[]
67
+ : Object.values(allMetadataRaw ?? {}) as QueryIndexEntityMetadata[]
68
+ const metadata = allMetadata.find((candidate) => String(candidate.tableName ?? '') === table)
69
+ if (!metadata) {
70
+ throw new QueryIndexScopeError(`Query index entity metadata was not found for table: ${table}`)
71
+ }
72
+
73
+ return {
74
+ table,
75
+ organizationColumn: resolveScopeColumn(metadata, 'organizationId', table),
76
+ tenantColumn: resolveScopeColumn(metadata, 'tenantId', table),
77
+ }
78
+ }
79
+
80
+ export async function loadQueryIndexRowScope(
81
+ em: EntityManager,
82
+ source: QueryIndexSourceMetadata,
83
+ recordId: string,
84
+ ): Promise<QueryIndexSourceScope> {
85
+ if (!source.organizationColumn && !source.tenantColumn) {
86
+ return { kind: 'global' }
87
+ }
88
+
35
89
  const db = em.getKysely<any>()
36
- const table = resolveEntityTableName(em, entityType)
90
+ const columns = [source.organizationColumn, source.tenantColumn].filter((column): column is string => column !== null)
37
91
  const row = await db
38
- .selectFrom(table as any)
39
- .select(['organization_id' as any, 'tenant_id' as any])
92
+ .selectFrom(source.table as any)
93
+ .select(columns as any)
40
94
  .where('id' as any, '=', recordId)
41
- .executeTakeFirst() as { organization_id: string | null; tenant_id: string | null } | undefined
95
+ .executeTakeFirst() as Record<string, string | null | undefined> | undefined
42
96
 
43
97
  if (!row) {
44
- return null
98
+ return { kind: 'missing' }
45
99
  }
46
100
 
47
101
  return {
48
- organizationId: row.organization_id ?? null,
49
- tenantId: row.tenant_id ?? null,
102
+ kind: 'row',
103
+ scope: {
104
+ organizationId: source.organizationColumn ? row[source.organizationColumn] ?? null : null,
105
+ tenantId: source.tenantColumn ? row[source.tenantColumn] ?? null : null,
106
+ },
50
107
  }
51
108
  }
52
109
 
@@ -56,10 +113,23 @@ export function resolveQueryIndexRecordScope(input: ResolveRecordScopeInput): Qu
56
113
  payloadOrganizationId,
57
114
  hasPayloadTenantId,
58
115
  hasPayloadOrganizationId,
59
- rowScope,
116
+ sourceScope,
60
117
  } = input
61
118
 
62
- if (!rowScope) {
119
+ if (sourceScope.kind === 'global') {
120
+ if (!hasPayloadTenantId || !hasPayloadOrganizationId || payloadTenantId !== null || payloadOrganizationId !== null) {
121
+ throw new QueryIndexScopeError(
122
+ 'Query index event for a global entity must explicitly provide tenantId and organizationId as null'
123
+ )
124
+ }
125
+
126
+ return {
127
+ tenantId: null,
128
+ organizationId: null,
129
+ }
130
+ }
131
+
132
+ if (sourceScope.kind === 'missing') {
63
133
  if (!hasPayloadTenantId || !hasPayloadOrganizationId) {
64
134
  throw new QueryIndexScopeError(
65
135
  'Query index event is missing tenantId/organizationId and source row scope could not be resolved'
@@ -72,6 +142,8 @@ export function resolveQueryIndexRecordScope(input: ResolveRecordScopeInput): Qu
72
142
  }
73
143
  }
74
144
 
145
+ const rowScope = sourceScope.scope
146
+
75
147
  if (hasPayloadTenantId && !isSameScopeValue(payloadTenantId, rowScope.tenantId)) {
76
148
  throw new QueryIndexScopeError(
77
149
  `Query index event tenantId does not match source row scope (payload=${String(payloadTenantId ?? null)}, row=${String(rowScope.tenantId)})`
@@ -109,3 +181,15 @@ export function resolveQueryIndexReindexScope(input: ResolveReindexScopeInput):
109
181
  function isSameScopeValue(left: string | null | undefined, right: string | null): boolean {
110
182
  return (left ?? null) === right
111
183
  }
184
+
185
+ function resolveScopeColumn(metadata: QueryIndexEntityMetadata, property: string, table: string): string | null {
186
+ const scopeProperty = metadata.properties?.[property]
187
+ if (!scopeProperty) return null
188
+ const fieldNames = scopeProperty.fieldNames
189
+ if (!Array.isArray(fieldNames) || fieldNames.length !== 1 || typeof fieldNames[0] !== 'string' || !fieldNames[0]) {
190
+ throw new QueryIndexScopeError(
191
+ `Query index ${property} metadata must map exactly one physical column for table: ${table}`
192
+ )
193
+ }
194
+ return fieldNames[0]
195
+ }
@@ -1,9 +1,12 @@
1
1
  import { recordIndexerError } from '@open-mercato/shared/lib/indexers/error-log'
2
- import { resolveEntityTableName } from '@open-mercato/shared/lib/query/engine'
3
2
  import { sql } from 'kysely'
4
3
  import { markDeleted } from '../lib/indexer'
5
4
  import { applyCoverageAdjustments, createCoverageAdjustments } from '../lib/coverage'
6
- import { loadQueryIndexRowScope, resolveQueryIndexRecordScope } from '../lib/subscriber-scope'
5
+ import {
6
+ loadQueryIndexRowScope,
7
+ resolveQueryIndexRecordScope,
8
+ resolveQueryIndexSourceMetadata,
9
+ } from '../lib/subscriber-scope'
7
10
 
8
11
  export const metadata = { event: 'query_index.delete_one', persistent: false }
9
12
 
@@ -40,13 +43,14 @@ export default async function handle(payload: any, ctx: { resolve: <T=any>(name:
40
43
  try {
41
44
  const hasPayloadOrganizationId = Object.prototype.hasOwnProperty.call(payload ?? {}, 'organizationId')
42
45
  const hasPayloadTenantId = Object.prototype.hasOwnProperty.call(payload ?? {}, 'tenantId')
43
- const rowScope = await loadQueryIndexRowScope(em, entityType, recordId).catch(() => null)
46
+ const source = resolveQueryIndexSourceMetadata(em, entityType)
47
+ const sourceScope = await loadQueryIndexRowScope(em, source, recordId)
44
48
  const resolvedScope = resolveQueryIndexRecordScope({
45
49
  payloadOrganizationId: payload?.organizationId,
46
50
  payloadTenantId: payload?.tenantId,
47
51
  hasPayloadOrganizationId,
48
52
  hasPayloadTenantId,
49
- rowScope,
53
+ sourceScope,
50
54
  })
51
55
  organizationId = resolvedScope.organizationId
52
56
  tenantId = resolvedScope.tenantId
@@ -57,14 +61,21 @@ export default async function handle(payload: any, ctx: { resolve: <T=any>(name:
57
61
  let baseCheckSucceeded = false
58
62
  try {
59
63
  const db = (em as any).getKysely()
60
- const table = resolveEntityTableName(em, entityType)
61
- const row = await db
62
- .selectFrom(table as any)
64
+ let baseQuery = db
65
+ .selectFrom(source.table as any)
63
66
  .select(['deleted_at' as any])
64
67
  .where('id' as any, '=', recordId)
65
- .where('organization_id' as any, organizationId === null ? 'is' : '=', organizationId as any)
66
- .where(sql`tenant_id is not distinct from ${tenantId}`)
67
- .executeTakeFirst() as { deleted_at: Date | null } | undefined
68
+ if (source.organizationColumn) {
69
+ baseQuery = baseQuery.where(
70
+ source.organizationColumn as any,
71
+ organizationId === null ? 'is' : '=',
72
+ organizationId as any,
73
+ )
74
+ }
75
+ if (source.tenantColumn) {
76
+ baseQuery = baseQuery.where(sql`${sql.ref(source.tenantColumn)} is not distinct from ${tenantId}`)
77
+ }
78
+ const row = await baseQuery.executeTakeFirst() as { deleted_at: Date | null } | undefined
68
79
  const baseMissing = !row
69
80
  const baseDeleted = baseMissing || (row && row.deleted_at != null)
70
81
  baseCheckSucceeded = true
@@ -1,7 +1,11 @@
1
1
  import { recordIndexerError } from '@open-mercato/shared/lib/indexers/error-log'
2
2
  import { upsertIndexRow, reindexSearchTokensForRecord } from '../lib/indexer'
3
3
  import { applyCoverageAdjustments, createCoverageAdjustments } from '../lib/coverage'
4
- import { loadQueryIndexRowScope, resolveQueryIndexRecordScope } from '../lib/subscriber-scope'
4
+ import {
5
+ loadQueryIndexRowScope,
6
+ resolveQueryIndexRecordScope,
7
+ resolveQueryIndexSourceMetadata,
8
+ } from '../lib/subscriber-scope'
5
9
 
6
10
  export const metadata = { event: 'query_index.upsert_one', persistent: false }
7
11
 
@@ -25,13 +29,14 @@ export default async function handle(payload: any, ctx: { resolve: <T=any>(name:
25
29
  try {
26
30
  const hasPayloadOrganizationId = Object.prototype.hasOwnProperty.call(payload ?? {}, 'organizationId')
27
31
  const hasPayloadTenantId = Object.prototype.hasOwnProperty.call(payload ?? {}, 'tenantId')
28
- const rowScope = await loadQueryIndexRowScope(em, entityType, recordId).catch(() => null)
32
+ const source = resolveQueryIndexSourceMetadata(em, entityType)
33
+ const sourceScope = await loadQueryIndexRowScope(em, source, recordId)
29
34
  const resolvedScope = resolveQueryIndexRecordScope({
30
35
  payloadOrganizationId: payload?.organizationId,
31
36
  payloadTenantId: payload?.tenantId,
32
37
  hasPayloadOrganizationId,
33
38
  hasPayloadTenantId,
34
- rowScope,
39
+ sourceScope,
35
40
  })
36
41
  organizationId = resolvedScope.organizationId
37
42
  tenantId = resolvedScope.tenantId
@@ -142,6 +142,28 @@ Configure automatic workflow starts from domain events:
142
142
  5. Configure `contextMapping` to extract event payload into workflow context
143
143
  6. Use `debounceMs` and `maxConcurrentInstances` to prevent trigger storms
144
144
 
145
+ ### Trigger Sources And Precedence
146
+
147
+ `loadTriggersForTenant()` (`lib/event-trigger-service.ts`) merges three sources into `UnifiedTrigger`s, each tagged with a `source` discriminator:
148
+
149
+ | `source` | Origin | Notes |
150
+ |----------|--------|-------|
151
+ | `legacy` | `workflow_event_triggers` rows | Backward compatibility with triggers created before triggers were embedded in definitions |
152
+ | `embedded` | `triggers[]` inside a `workflow_definitions` row's `definition` JSONB | What the visual editor and the definitions API write |
153
+ | `code` | `triggers[]` on a code-defined workflow in the in-memory registry (`defineWorkflow`) | Projected by `loadCodeTriggers()`; no DB row required (#4425) |
154
+
155
+ Precedence: **a DB-backed definition wins over its code counterpart.** Any non-deleted `workflow_definitions` row shadows the code projection for the same `workflowId` — including a disabled row, and including a customization whose `triggers[]` was emptied. This preserves `customize` semantics: once an operator materializes a code workflow, the DB row alone decides which triggers are live.
156
+
157
+ MUST invalidate the trigger cache after any write that changes which source owns a workflow's triggers — `loadTriggersForTenant()` caches per tenant/organization for `TRIGGER_CACHE_TTL` (5 min), so without invalidation the wildcard subscriber keeps matching a stale snapshot:
158
+
159
+ ```typescript
160
+ import { invalidateTriggerCache } from '../lib/event-trigger-service'
161
+
162
+ if (tenantId) invalidateTriggerCache(tenantId, organizationId ?? undefined)
163
+ ```
164
+
165
+ This covers definition create/update/delete **and** `POST .../[id]/customize` (code projection → embedded row) and `POST .../[id]/reset-to-code` (embedded row → code projection). Invalidate for the **written row's own** tenant/organization rather than the caller's — `customize` looks an override up by `(workflowId, tenantId)`, so it can revive a row owned by a sibling organization. Omitting `organizationId` clears every organization under the tenant.
166
+
145
167
  ## Widget Injection
146
168
 
147
169
  The module injects an order-approval widget into the sales module:
@@ -17,6 +17,7 @@ import { validateCrudMutationGuard, runCrudMutationGuardAfterSuccess } from '@op
17
17
  import { WorkflowDefinition } from '../../../../data/entities'
18
18
  import { serializeWorkflowDefinition } from '../../serialize'
19
19
  import { getCodeWorkflow } from '../../../../lib/code-registry'
20
+ import { invalidateTriggerCache } from '../../../../lib/event-trigger-service'
20
21
  import { createLogger } from '@open-mercato/shared/lib/logger'
21
22
 
22
23
  const logger = createLogger('workflows')
@@ -115,6 +116,14 @@ export async function POST(request: NextRequest, context: RouteContext) {
115
116
  saved = override
116
117
  }
117
118
 
119
+ // Materializing the override moves trigger ownership from the code registry
120
+ // to the new `workflow_definitions` row, so the cached snapshot the wildcard
121
+ // subscriber reads is stale until TRIGGER_CACHE_TTL expires (#4425). Scope
122
+ // the invalidation to the saved row's own organization: reviving an existing
123
+ // override belonging to a sibling organization changes triggers for THAT
124
+ // organization, not for the caller's.
125
+ if (saved.tenantId) invalidateTriggerCache(saved.tenantId, saved.organizationId ?? undefined)
126
+
118
127
  if (guardResult?.shouldRunAfterSuccess) {
119
128
  await runCrudMutationGuardAfterSuccess(container, {
120
129
  tenantId: tenantId ?? '',
@@ -16,6 +16,7 @@ import { validateCrudMutationGuard, runCrudMutationGuardAfterSuccess } from '@op
16
16
  import { WorkflowDefinition, WorkflowInstance } from '../../../../data/entities'
17
17
  import { serializeCodeWorkflowDefinition } from '../../serialize'
18
18
  import { getCodeWorkflow } from '../../../../lib/code-registry'
19
+ import { invalidateTriggerCache } from '../../../../lib/event-trigger-service'
19
20
  import { createLogger } from '@open-mercato/shared/lib/logger'
20
21
 
21
22
  const logger = createLogger('workflows')
@@ -140,6 +141,12 @@ export async function POST(
140
141
  em.remove(definition)
141
142
  await em.flush()
142
143
 
144
+ // Trigger ownership falls back to the code registry, so the cached snapshot
145
+ // still holds the embedded triggers of a row that no longer exists (#4425).
146
+ if (removedSnapshot.tenantId) {
147
+ invalidateTriggerCache(removedSnapshot.tenantId, removedSnapshot.organizationId ?? undefined)
148
+ }
149
+
143
150
  if (guardResult?.shouldRunAfterSuccess) {
144
151
  await runCrudMutationGuardAfterSuccess(container, {
145
152
  tenantId: tenantId ?? '',