@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.
- package/dist/lib/crud/factory.js +43 -3
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/crud/types.js.map +1 -1
- package/dist/lib/data/engine.js +32 -1
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/crud/__tests__/crud-factory.test.ts +173 -1
- package/src/lib/crud/factory.ts +67 -12
- package/src/lib/crud/types.ts +23 -0
- package/src/lib/data/__tests__/engine.default-indexer.test.ts +188 -0
- package/src/lib/data/engine.ts +74 -1
package/src/lib/crud/factory.ts
CHANGED
|
@@ -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
|
|
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
|
|
2341
|
-
//
|
|
2342
|
-
//
|
|
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
|
|
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
|
|
2663
|
-
//
|
|
2664
|
-
//
|
|
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
|
|
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
|
|
2995
|
-
//
|
|
2996
|
-
//
|
|
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
|
|
package/src/lib/crud/types.ts
CHANGED
|
@@ -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
|
+
})
|
package/src/lib/data/engine.ts
CHANGED
|
@@ -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 (
|
|
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,
|