@open-mercato/shared 0.7.1-develop.7149.1.7efa6e1612 → 0.7.1-develop.7150.1.c1941e0c22

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.
@@ -1100,6 +1100,49 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1100
1100
  const indexerConfig = opts.indexer as CrudIndexerConfig | undefined
1101
1101
  const eventsConfig = opts.events as CrudEventsConfig | undefined
1102
1102
 
1103
+ // Command-backed verbs (`actions.*`) never reach the built-in `markOrmEntityChange` calls
1104
+ // below — the handler owns the mark and the command bus owns the flush. Hand the route's
1105
+ // declared `indexer:` to the data engine for the duration of the command so a handler that
1106
+ // marks `events:` only still writes the projection the route promised, using the handler's
1107
+ // own entity and identifiers. Without this the declaration reaches no code at all (#5741).
1108
+ const withRouteIndexerDeclaration = async <TResult>(
1109
+ ctx: CrudCtx,
1110
+ operation: CrudEventAction,
1111
+ commandId: string,
1112
+ run: () => Promise<TResult>,
1113
+ ): Promise<TResult> => {
1114
+ if (!indexerConfig || !ormCfg.entity) return run()
1115
+ let de: DataEngine | null = null
1116
+ try {
1117
+ de = ctx.container.resolve('dataEngine') as DataEngine
1118
+ } catch {
1119
+ de = null
1120
+ }
1121
+ if (!de || typeof de.setDefaultIndexerConfig !== 'function') return run()
1122
+ de.setDefaultIndexerConfig({ indexer: indexerConfig, entityClass: ormCfg.entity })
1123
+ try {
1124
+ const result = await run()
1125
+ if (de.hasIndexedDefaultEntityClass?.() === false) {
1126
+ // The one genuinely undiagnosable case: a handler that marks no side effect at all,
1127
+ // so neither the route nor the command maintains the projection. One line per dropped
1128
+ // write — far narrower than warning at construction time, though not literally false-
1129
+ // positive-free: the flag tracks the route's own entity class, so a handler that
1130
+ // discharges the projection through a different class (marking a parent aggregate with
1131
+ // its own explicit `indexer:`) would also be warned about. No route in this repository
1132
+ // does that today; widen the flag to "any indexer discharged" if one ever needs to.
1133
+ logger.warn('CRUD route declares an indexer that its command handler did not discharge; the query index was not updated for this write', {
1134
+ resourceKind,
1135
+ operation,
1136
+ commandId,
1137
+ entityType: indexerConfig.entityType,
1138
+ })
1139
+ }
1140
+ return result
1141
+ } finally {
1142
+ de.setDefaultIndexerConfig(null)
1143
+ }
1144
+ }
1145
+
1103
1146
  const inferFieldValue = (item: Record<string, unknown>, keys: string[]): string | null => {
1104
1147
  for (const key of keys) {
1105
1148
  const value = item[key]
@@ -2286,7 +2329,9 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2286
2329
  context: { cacheAliases: resourceTargets },
2287
2330
  }
2288
2331
  const metadataToSend = mergeCommandMetadata(baseMetadata, userMetadata)
2289
- const { result, logEntry } = await commandBus.execute(action.commandId, { input, ctx, metadata: metadataToSend })
2332
+ const { result, logEntry } = await withRouteIndexerDeclaration(ctx, 'created', action.commandId, () =>
2333
+ commandBus.execute(action.commandId, { input, ctx, metadata: metadataToSend }),
2334
+ )
2290
2335
 
2291
2336
  // Sync after-event (*.created) — command path
2292
2337
  if (createLifecycleCmd.afterEventId && ctx.auth.tenantId) {
@@ -2337,9 +2382,11 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2337
2382
  requestHeaders: request.headers,
2338
2383
  })
2339
2384
  }
2340
- // Note: side effects (events + indexing) are already flushed by CommandBus.execute()
2341
- // via flushCrudSideEffects(). Calling markCommandResultForIndexing here would cause
2342
- // duplicate event emissions.
2385
+ // Note: side effects are already flushed by CommandBus.execute() via
2386
+ // flushCrudSideEffects(). Re-marking the result here would emit a duplicate domain
2387
+ // event, so the route does not. The route's `indexer:` declaration still reaches
2388
+ // that flush: withRouteIndexerDeclaration() hands it to the data engine as the
2389
+ // default for marks the handler makes without one (#5741).
2343
2390
  return response
2344
2391
  }
2345
2392
 
@@ -2612,7 +2659,9 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2612
2659
  }
2613
2660
  if (candidateId) baseMetadata.resourceId = candidateId
2614
2661
  const metadataToSend = mergeCommandMetadata(baseMetadata, userMetadata)
2615
- const { result, logEntry } = await commandBus.execute(action.commandId, { input, ctx, metadata: metadataToSend })
2662
+ const { result, logEntry } = await withRouteIndexerDeclaration(ctx, 'updated', action.commandId, () =>
2663
+ commandBus.execute(action.commandId, { input, ctx, metadata: metadataToSend }),
2664
+ )
2616
2665
  const payload = action.response ? action.response({ result, logEntry, ctx }) : result
2617
2666
  let resolvedPayload = await Promise.resolve(payload)
2618
2667
  if (interceptorRequestPayload && resolvedPayload && typeof resolvedPayload === 'object' && !Array.isArray(resolvedPayload)) {
@@ -2659,9 +2708,11 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2659
2708
  }
2660
2709
  }
2661
2710
 
2662
- // Note: side effects (events + indexing) are already flushed by CommandBus.execute()
2663
- // via flushCrudSideEffects(). Calling markCommandResultForIndexing here would cause
2664
- // duplicate event emissions.
2711
+ // Note: side effects are already flushed by CommandBus.execute() via
2712
+ // flushCrudSideEffects(). Re-marking the result here would emit a duplicate domain
2713
+ // event, so the route does not. The route's `indexer:` declaration still reaches
2714
+ // that flush: withRouteIndexerDeclaration() hands it to the data engine as the
2715
+ // default for marks the handler makes without one (#5741).
2665
2716
  return response
2666
2717
  }
2667
2718
 
@@ -2945,7 +2996,9 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2945
2996
  }
2946
2997
  if (candidateId) baseMetadata.resourceId = candidateId
2947
2998
  const metadataToSend = mergeCommandMetadata(baseMetadata, userMetadata)
2948
- const { result, logEntry } = await commandBus.execute(action.commandId, { input, ctx, metadata: metadataToSend })
2999
+ const { result, logEntry } = await withRouteIndexerDeclaration(ctx, 'deleted', action.commandId, () =>
3000
+ commandBus.execute(action.commandId, { input, ctx, metadata: metadataToSend }),
3001
+ )
2949
3002
  const payload = action.response ? action.response({ result, logEntry, ctx }) : result
2950
3003
  let resolvedPayload = await Promise.resolve(payload)
2951
3004
  if (interceptorRequestPayload && resolvedPayload && typeof resolvedPayload === 'object' && !Array.isArray(resolvedPayload)) {
@@ -2991,9 +3044,11 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2991
3044
  }
2992
3045
  }
2993
3046
 
2994
- // Note: side effects (events + indexing) are already flushed by CommandBus.execute()
2995
- // via flushCrudSideEffects(). Calling markCommandResultForIndexing here would cause
2996
- // duplicate event emissions.
3047
+ // Note: side effects are already flushed by CommandBus.execute() via
3048
+ // flushCrudSideEffects(). Re-marking the result here would emit a duplicate domain
3049
+ // event, so the route does not. The route's `indexer:` declaration still reaches
3050
+ // that flush: withRouteIndexerDeclaration() hands it to the data engine as the
3051
+ // default for marks the handler makes without one (#5741).
2997
3052
  return response
2998
3053
  }
2999
3054
 
@@ -24,6 +24,29 @@ export type CrudEventsConfig<TEntity = unknown> = {
24
24
  buildPayload?(ctx: CrudEmitContext<TEntity>): unknown
25
25
  }
26
26
 
27
+ /**
28
+ * Declares that a CRUD write maintains the `query_index` projection for `entityType`.
29
+ *
30
+ * On `makeCrudRoute`'s built-in write path (`create` / `update` / `del`) the route emits the
31
+ * projection event itself. On the command path (`actions.*`) the command handler owns the
32
+ * side-effect mark and the command bus owns the flush, so the route's declaration is applied
33
+ * to the handler's mark: a handler that calls `emitCrudSideEffects({ events })` without an
34
+ * `indexer` still indexes the record under this `entityType`, and one that passes its own
35
+ * `indexer` keeps it. A handler that marks no side effect at all indexes nothing — the route
36
+ * logs a warning naming the command when that happens.
37
+ *
38
+ * Two limits of that hand-down are worth knowing before you rely on it:
39
+ *
40
+ * - It is scoped to one `CommandBus.execute()`, and the declaration lives on the request's
41
+ * `DataEngine` instance. That is sound because `createRequestContainer()` registers
42
+ * `dataEngine` per request; re-registering it as a transient would leave the command marking
43
+ * on a different instance than the route declared on, so nothing is indexed and every write
44
+ * logs the warning.
45
+ * - `CommandBus.undo()` runs outside any route, so no declaration is active there. An undo
46
+ * handler that must maintain the projection MUST pass its own `indexer` to
47
+ * `emitCrudUndoSideEffects` — otherwise undoing a delete restores the row in the database and
48
+ * leaves it missing from `query_index` until the next full rebuild.
49
+ */
27
50
  export type CrudIndexerConfig<TEntity = unknown> = {
28
51
  entityType: string
29
52
  buildUpsertPayload?(ctx: CrudEmitContext<TEntity>): unknown
@@ -0,0 +1,188 @@
1
+ import type { AwilixContainer } from 'awilix'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import { DefaultDataEngine } from '../engine'
4
+ import type { CrudEventsConfig, CrudIndexerConfig } from '../../crud/types'
5
+
6
+ // A command-backed CRUD route (`makeCrudRoute` + `actions.*`) cannot mark its own side effect:
7
+ // the handler owns the mark and the command bus owns the flush. The route therefore hands its
8
+ // declared `indexer:` to the engine for the duration of the command, and the engine applies it
9
+ // to marks the handler makes without one — otherwise the declaration reaches no code at all and
10
+ // the projection is never written (#5741). The entity-class gate is what keeps a handler's
11
+ // sibling-entity marks from being indexed under the route's entityType.
12
+
13
+ class RouteEntity {
14
+ constructor(public id: string) {}
15
+ }
16
+
17
+ class SiblingEntity {
18
+ constructor(public id: string) {}
19
+ }
20
+
21
+ const EVENTS: CrudEventsConfig<unknown> = { module: 'customers', entity: 'tag', persistent: false }
22
+ const ROUTE_INDEXER: CrudIndexerConfig<unknown> = { entityType: 'customers:customer_tag' }
23
+ const HANDLER_INDEXER: CrudIndexerConfig<unknown> = { entityType: 'customers:handler_owned' }
24
+ const IDENTIFIERS = { id: 'rec-1', organizationId: 'org-1', tenantId: 'tenant-1' }
25
+
26
+ function buildEngine() {
27
+ const emitEvent = jest.fn().mockResolvedValue(undefined)
28
+ const container = {
29
+ resolve: (token: string) => {
30
+ if (token === 'eventBus') return { emitEvent }
31
+ throw new Error(`unexpected resolve(${token})`)
32
+ },
33
+ } as unknown as AwilixContainer
34
+ const engine = new DefaultDataEngine({} as EntityManager, container)
35
+ const indexPayloads = (eventName: string) =>
36
+ emitEvent.mock.calls.filter(([name]) => name === eventName).map(([, payload]) => payload as Record<string, unknown>)
37
+ return { engine, emitEvent, indexPayloads }
38
+ }
39
+
40
+ describe('DefaultDataEngine route-declared indexer default', () => {
41
+ let warnSpy: jest.SpyInstance
42
+ beforeAll(() => { warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined) })
43
+ afterAll(() => { warnSpy.mockRestore() })
44
+
45
+ it('indexes an events-only mark under the route-declared entityType', async () => {
46
+ const { engine, indexPayloads } = buildEngine()
47
+ engine.setDefaultIndexerConfig({ indexer: ROUTE_INDEXER, entityClass: RouteEntity })
48
+
49
+ // What every command handler on the two affected core routes does: mark `events:` only.
50
+ engine.markOrmEntityChange({ action: 'created', entity: new RouteEntity('rec-1'), events: EVENTS, identifiers: IDENTIFIERS })
51
+ await engine.flushOrmEntityChanges()
52
+
53
+ const upserts = indexPayloads('query_index.upsert_one')
54
+ expect(upserts).toHaveLength(1)
55
+ expect(upserts[0]).toMatchObject({ entityType: 'customers:customer_tag', recordId: 'rec-1', crudAction: 'created' })
56
+ expect(engine.hasIndexedDefaultEntityClass()).toBe(true)
57
+ })
58
+
59
+ it('emits the delete projection for an events-only delete mark', async () => {
60
+ const { engine, indexPayloads } = buildEngine()
61
+ engine.setDefaultIndexerConfig({ indexer: ROUTE_INDEXER, entityClass: RouteEntity })
62
+
63
+ engine.markOrmEntityChange({ action: 'deleted', entity: new RouteEntity('rec-1'), events: EVENTS, identifiers: IDENTIFIERS })
64
+ await engine.flushOrmEntityChanges()
65
+
66
+ expect(indexPayloads('query_index.delete_one')).toHaveLength(1)
67
+ expect(indexPayloads('query_index.upsert_one')).toHaveLength(0)
68
+ })
69
+
70
+ it('leaves a handler-supplied indexer untouched — explicit wins over the default', async () => {
71
+ const { engine, indexPayloads } = buildEngine()
72
+ engine.setDefaultIndexerConfig({ indexer: ROUTE_INDEXER, entityClass: RouteEntity })
73
+
74
+ engine.markOrmEntityChange({
75
+ action: 'updated',
76
+ entity: new RouteEntity('rec-1'),
77
+ events: EVENTS,
78
+ indexer: HANDLER_INDEXER,
79
+ identifiers: IDENTIFIERS,
80
+ })
81
+ await engine.flushOrmEntityChanges()
82
+
83
+ const upserts = indexPayloads('query_index.upsert_one')
84
+ expect(upserts).toHaveLength(1)
85
+ expect(upserts[0]).toMatchObject({ entityType: 'customers:handler_owned' })
86
+ })
87
+
88
+ it('does not apply the default to a mark for a different entity class', async () => {
89
+ const { engine, emitEvent } = buildEngine()
90
+ engine.setDefaultIndexerConfig({ indexer: ROUTE_INDEXER, entityClass: RouteEntity })
91
+
92
+ // A tag command also marks its tag *assignments*; indexing those as `customer_tag` would
93
+ // write a projection row for the wrong record.
94
+ engine.markOrmEntityChange({ action: 'updated', entity: new SiblingEntity('assignment-1'), identifiers: { ...IDENTIFIERS, id: 'assignment-1' } })
95
+ await engine.flushOrmEntityChanges()
96
+
97
+ expect(emitEvent.mock.calls.map(([name]) => name)).not.toContain('query_index.upsert_one')
98
+ expect(engine.hasIndexedDefaultEntityClass()).toBe(false)
99
+ })
100
+
101
+ it('reports an undischarged declaration when the handler marks nothing at all', async () => {
102
+ const { engine, emitEvent } = buildEngine()
103
+ engine.setDefaultIndexerConfig({ indexer: ROUTE_INDEXER, entityClass: RouteEntity })
104
+
105
+ await engine.flushOrmEntityChanges()
106
+
107
+ expect(emitEvent).not.toHaveBeenCalled()
108
+ expect(engine.hasIndexedDefaultEntityClass()).toBe(false)
109
+ })
110
+
111
+ it('stops applying the declaration once it is cleared', async () => {
112
+ const { engine, emitEvent } = buildEngine()
113
+ engine.setDefaultIndexerConfig({ indexer: ROUTE_INDEXER, entityClass: RouteEntity })
114
+ engine.setDefaultIndexerConfig(null)
115
+
116
+ engine.markOrmEntityChange({ action: 'created', entity: new RouteEntity('rec-1'), events: EVENTS, identifiers: IDENTIFIERS })
117
+ await engine.flushOrmEntityChanges()
118
+
119
+ expect(emitEvent.mock.calls.map(([name]) => name)).not.toContain('query_index.upsert_one')
120
+ expect(engine.hasIndexedDefaultEntityClass()).toBe(false)
121
+ })
122
+
123
+ it('keeps a handler indexer when a later events-only mark hits the same key', async () => {
124
+ const { engine, indexPayloads } = buildEngine()
125
+ engine.setDefaultIndexerConfig({ indexer: ROUTE_INDEXER, entityClass: RouteEntity })
126
+
127
+ // Same (action, id, organizationId, tenantId) key twice. The merge branch must not let the
128
+ // route default overwrite the config the first mark installed — that would silently drop the
129
+ // handler's own `buildUpsertPayload` and invert the "explicit always wins" rule.
130
+ engine.markOrmEntityChange({
131
+ action: 'updated',
132
+ entity: new RouteEntity('rec-1'),
133
+ events: EVENTS,
134
+ indexer: HANDLER_INDEXER,
135
+ identifiers: IDENTIFIERS,
136
+ })
137
+ engine.markOrmEntityChange({ action: 'updated', entity: new RouteEntity('rec-1'), events: EVENTS, identifiers: IDENTIFIERS })
138
+ await engine.flushOrmEntityChanges()
139
+
140
+ const upserts = indexPayloads('query_index.upsert_one')
141
+ expect(upserts).toHaveLength(1)
142
+ expect(upserts[0]).toMatchObject({ entityType: 'customers:handler_owned' })
143
+ })
144
+
145
+ it('still applies the default when the first mark on a key carried no indexer', async () => {
146
+ const { engine, indexPayloads } = buildEngine()
147
+ engine.setDefaultIndexerConfig({ indexer: ROUTE_INDEXER, entityClass: RouteEntity })
148
+
149
+ engine.markOrmEntityChange({ action: 'updated', entity: new RouteEntity('rec-1'), events: EVENTS, identifiers: IDENTIFIERS })
150
+ engine.markOrmEntityChange({ action: 'updated', entity: new RouteEntity('rec-1'), events: EVENTS, identifiers: IDENTIFIERS })
151
+ await engine.flushOrmEntityChanges()
152
+
153
+ const upserts = indexPayloads('query_index.upsert_one')
154
+ expect(upserts).toHaveLength(1)
155
+ expect(upserts[0]).toMatchObject({ entityType: 'customers:customer_tag' })
156
+ })
157
+
158
+ it('ignores a non-constructor entityClass instead of throwing on the write path', async () => {
159
+ const { engine, emitEvent } = buildEngine()
160
+ // `OrmEntityConfig.entity` is `any` and this repo treats `EntitySchema` instances — plain
161
+ // objects, not constructors — as a first-class entity shape. `instanceof` against one throws,
162
+ // and it would throw inside `markOrmEntityChange`, outside the flush's best-effort catch.
163
+ const entitySchemaLike = { name: 'RouteEntity', meta: {} } as unknown as new (...args: never[]) => unknown
164
+ engine.setDefaultIndexerConfig({ indexer: ROUTE_INDEXER, entityClass: entitySchemaLike })
165
+
166
+ expect(() => engine.markOrmEntityChange({
167
+ action: 'created',
168
+ entity: new RouteEntity('rec-1'),
169
+ events: EVENTS,
170
+ identifiers: IDENTIFIERS,
171
+ })).not.toThrow()
172
+ await engine.flushOrmEntityChanges()
173
+
174
+ expect(emitEvent.mock.calls.map(([name]) => name)).not.toContain('query_index.upsert_one')
175
+ expect(engine.hasIndexedDefaultEntityClass()).toBe(false)
176
+ })
177
+
178
+ it('honours a bulk-import skipReindex suppression over the declaration', async () => {
179
+ const { engine, emitEvent } = buildEngine()
180
+ engine.setDefaultIndexerConfig({ indexer: ROUTE_INDEXER, entityClass: RouteEntity })
181
+
182
+ engine.markOrmEntityChange({ action: 'created', entity: new RouteEntity('rec-1'), events: EVENTS, identifiers: IDENTIFIERS })
183
+ await engine.flushOrmEntityChanges({ skipReindex: true })
184
+
185
+ expect(emitEvent.mock.calls.map(([name]) => name)).not.toContain('query_index.upsert_one')
186
+ expect(engine.hasIndexedDefaultEntityClass()).toBe(false)
187
+ })
188
+ })
@@ -64,6 +64,19 @@ type QueuedCrudSideEffect = {
64
64
  indexer?: CrudIndexerConfig<unknown>
65
65
  }
66
66
 
67
+ /**
68
+ * A `makeCrudRoute` route-level `indexer:` declaration, handed to the data engine for the
69
+ * duration of one command-bus write. Command handlers own the side-effect mark on the
70
+ * `actions.*` path, and most of them mark `events:` only — without this the route's
71
+ * declaration would reach no code at all. `entityClass` scopes the default to the route's
72
+ * own ORM entity so a handler that also marks a sibling entity in the same request (a tag
73
+ * assignment alongside a tag, say) is never indexed under the route's `entityType`.
74
+ */
75
+ export type DefaultCrudIndexerConfig = {
76
+ indexer: CrudIndexerConfig<unknown>
77
+ entityClass: abstract new (...args: never[]) => object
78
+ }
79
+
67
80
  export interface DataEngine {
68
81
  setCustomFields(opts: {
69
82
  entityId: string
@@ -149,6 +162,20 @@ export interface DataEngine {
149
162
  * is responsible for rebuilding the `query_index` afterwards.
150
163
  */
151
164
  flushOrmEntityChanges(suppress?: BulkImportSuppression): Promise<void>
165
+
166
+ /**
167
+ * Declare the indexer a CRUD route configured, for marks made during one command-bus
168
+ * write that do not carry an indexer of their own. Pass `null` to clear it. Optional so
169
+ * third-party `DataEngine` implementations stay valid; callers invoke it with `?.`.
170
+ */
171
+ setDefaultIndexerConfig?(config: DefaultCrudIndexerConfig | null): void
172
+
173
+ /**
174
+ * Whether any side effect drained since the current default was declared carried an
175
+ * indexer for that default's entity class — false means the declared query-index
176
+ * obligation was discharged by nobody. Optional for the same reason as the setter.
177
+ */
178
+ hasIndexedDefaultEntityClass?(): boolean
152
179
  }
153
180
 
154
181
  export const SYSTEM_ENTITY_RECORDS_BLOCKED_CODE = 'system_entity_records_blocked'
@@ -188,8 +215,43 @@ export function assertCustomEntityStorageEntityId(em: EntityManager, entityId: s
188
215
 
189
216
  export class DefaultDataEngine implements DataEngine {
190
217
  private pendingSideEffects = new Map<string, QueuedCrudSideEffect>()
218
+ private defaultIndexer: DefaultCrudIndexerConfig | null = null
219
+ private indexedDefaultEntityClass = false
191
220
  constructor(private em: EntityManager, private container: AwilixContainer) {}
192
221
 
222
+ /**
223
+ * Per-command state, deliberately held on the engine instance rather than threaded through
224
+ * `CommandRuntimeContext` the way the bulk-import flags are. That is sound only because
225
+ * `createRequestContainer()` registers `dataEngine` per request (`lib/di/container.ts`), so
226
+ * one engine instance never spans two requests, and no `makeCrudRoute` verb runs two commands
227
+ * concurrently against it. An application that re-registers `dataEngine` as a transient would
228
+ * break both assumptions: the command would mark on a different instance than the route
229
+ * declared on, so nothing is indexed and every write logs the undischarged-declaration warning.
230
+ */
231
+ setDefaultIndexerConfig(config: DefaultCrudIndexerConfig | null): void {
232
+ this.defaultIndexer = config
233
+ this.indexedDefaultEntityClass = false
234
+ }
235
+
236
+ hasIndexedDefaultEntityClass(): boolean {
237
+ return this.indexedDefaultEntityClass
238
+ }
239
+
240
+ private matchesDefaultEntityClass(entity: unknown): boolean {
241
+ const declared = this.defaultIndexer?.entityClass
242
+ // `OrmEntityConfig.entity` is `any` and this repository treats `EntitySchema` instances as a
243
+ // first-class entity shape (`lib/bootstrap/types.ts`). An `EntitySchema` is an object rather
244
+ // than a constructor, so `instanceof` against it throws — and it would throw inside
245
+ // `markOrmEntityChange`, outside the best-effort try/catch that guards the flush, turning
246
+ // every write on such a route into a 500.
247
+ if (typeof declared !== 'function') return false
248
+ return entity instanceof declared
249
+ }
250
+
251
+ private resolveDefaultIndexer(entity: unknown): CrudIndexerConfig<unknown> | undefined {
252
+ return this.matchesDefaultEntityClass(entity) ? this.defaultIndexer?.indexer : undefined
253
+ }
254
+
193
255
  async setCustomFields(opts: Parameters<DataEngine['setCustomFields']>[0]): Promise<void> {
194
256
  const { entityId, recordId, organizationId = null, tenantId = null, values } = opts
195
257
  const sanitizedValues = await sanitizeCustomFieldHtmlRichTextValuesServer(this.em, {
@@ -728,6 +790,10 @@ export class DefaultDataEngine implements DataEngine {
728
790
  const { entity, identifiers } = opts
729
791
  if (!entity) return
730
792
  if (!identifiers?.id) return
793
+ // A command handler that marks `events:` only still discharges the route's declared
794
+ // query-index obligation — the route hands its `indexer:` down as the default so the
795
+ // handler's own entity and identifiers (the accurate ones) drive the projection write.
796
+ const indexer = opts.indexer ?? this.resolveDefaultIndexer(entity)
731
797
  const key = this.buildSideEffectKey(opts.action, identifiers)
732
798
  const existing = this.pendingSideEffects.get(key)
733
799
  if (existing) {
@@ -740,7 +806,11 @@ export class DefaultDataEngine implements DataEngine {
740
806
  existing.syncOrigin = opts.syncOrigin ?? null
741
807
  existing.actorUserId = opts.actorUserId ?? null
742
808
  if (opts.events) existing.events = opts.events as CrudEventsConfig<unknown>
809
+ // Explicit always wins, on the merge branch too: a second `events:`-only mark on the same
810
+ // key must not let the route default overwrite the `indexer:` an earlier mark installed,
811
+ // which would silently drop that handler's own `buildUpsertPayload`.
743
812
  if (opts.indexer) existing.indexer = opts.indexer as CrudIndexerConfig<unknown>
813
+ else if (!existing.indexer && indexer) existing.indexer = indexer
744
814
  this.pendingSideEffects.set(key, existing)
745
815
  return
746
816
  }
@@ -756,7 +826,7 @@ export class DefaultDataEngine implements DataEngine {
756
826
  actorUserId: opts.actorUserId ?? null,
757
827
  }
758
828
  if (opts.events) entry.events = opts.events as CrudEventsConfig<unknown>
759
- if (opts.indexer) entry.indexer = opts.indexer as CrudIndexerConfig<unknown>
829
+ if (indexer) entry.indexer = indexer
760
830
  this.pendingSideEffects.set(key, entry)
761
831
  }
762
832
 
@@ -765,6 +835,9 @@ export class DefaultDataEngine implements DataEngine {
765
835
  const entries = Array.from(this.pendingSideEffects.values())
766
836
  this.pendingSideEffects.clear()
767
837
  for (const entry of entries) {
838
+ if (entry.indexer && !suppress?.skipReindex && this.matchesDefaultEntityClass(entry.entity)) {
839
+ this.indexedDefaultEntityClass = true
840
+ }
768
841
  try {
769
842
  await this.emitOrmEntityEvent({
770
843
  action: entry.action,