@open-mercato/shared 0.7.1-develop.7149.1.7efa6e1612 → 0.7.1-develop.7151.1.00d0391847
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/.turbo/turbo-build.log +1 -1
- 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/email/config.js +20 -0
- package/dist/lib/email/config.js.map +2 -2
- package/dist/lib/email/send.js +25 -22
- package/dist/lib/email/send.js.map +2 -2
- package/dist/lib/email/transport.js +19 -0
- package/dist/lib/email/transport.js.map +7 -0
- 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/email/__tests__/send.test.ts +140 -69
- package/src/lib/email/config.ts +26 -1
- package/src/lib/email/send.ts +59 -37
- package/src/lib/email/transport.ts +29 -0
|
@@ -116,6 +116,8 @@ const queryEngine = {
|
|
|
116
116
|
|
|
117
117
|
const mockDataEngine = {
|
|
118
118
|
__pendingSideEffects: [] as any[],
|
|
119
|
+
__defaultIndexer: null as any,
|
|
120
|
+
__indexedDefaultEntityClass: false,
|
|
119
121
|
createOrmEntity: jest.fn(async ({ entity, data }: any) => {
|
|
120
122
|
const created = em.create(entity, data)
|
|
121
123
|
await em.persist(created as any).flush()
|
|
@@ -142,14 +144,29 @@ const mockDataEngine = {
|
|
|
142
144
|
emitOrmEntityEvent: jest.fn(async (_entry: any) => {}),
|
|
143
145
|
markOrmEntityChange: jest.fn(function (this: any, entry: any) {
|
|
144
146
|
if (!entry || !entry.entity) return
|
|
145
|
-
this.
|
|
147
|
+
const defaultIndexer = this.__defaultIndexer
|
|
148
|
+
const indexer = entry.indexer
|
|
149
|
+
?? (defaultIndexer && entry.entity instanceof defaultIndexer.entityClass ? defaultIndexer.indexer : undefined)
|
|
150
|
+
this.__pendingSideEffects.push(indexer ? { ...entry, indexer } : entry)
|
|
146
151
|
}),
|
|
147
152
|
flushOrmEntityChanges: jest.fn(async function (this: any) {
|
|
148
153
|
while (this.__pendingSideEffects.length > 0) {
|
|
149
154
|
const next = this.__pendingSideEffects.shift()
|
|
155
|
+
if (next.indexer && this.__defaultIndexer && next.entity instanceof this.__defaultIndexer.entityClass) {
|
|
156
|
+
this.__indexedDefaultEntityClass = true
|
|
157
|
+
}
|
|
150
158
|
await this.emitOrmEntityEvent(next)
|
|
151
159
|
}
|
|
152
160
|
}),
|
|
161
|
+
// Mirrors DefaultDataEngine's route-declared indexer default (#5741) so the factory's
|
|
162
|
+
// command path exercises the same contract it does against the real engine.
|
|
163
|
+
setDefaultIndexerConfig: jest.fn(function (this: any, config: any) {
|
|
164
|
+
this.__defaultIndexer = config
|
|
165
|
+
this.__indexedDefaultEntityClass = false
|
|
166
|
+
}),
|
|
167
|
+
hasIndexedDefaultEntityClass: jest.fn(function (this: any) {
|
|
168
|
+
return this.__indexedDefaultEntityClass === true
|
|
169
|
+
}),
|
|
153
170
|
}
|
|
154
171
|
|
|
155
172
|
const accessLogService = {
|
|
@@ -207,6 +224,8 @@ describe('CRUD Factory', () => {
|
|
|
207
224
|
jest.clearAllMocks()
|
|
208
225
|
accessLogService.log.mockClear()
|
|
209
226
|
mockDataEngine.__pendingSideEffects = []
|
|
227
|
+
mockDataEngine.__defaultIndexer = null
|
|
228
|
+
mockDataEngine.__indexedDefaultEntityClass = false
|
|
210
229
|
mockOrganizationScopeOverride = null
|
|
211
230
|
commandBus = {
|
|
212
231
|
execute: jest.fn(async () => ({ result: {}, logEntry: { id: 'log-1' } })),
|
|
@@ -1050,6 +1069,159 @@ describe('CRUD Factory', () => {
|
|
|
1050
1069
|
expect(mockDataEngine.emitOrmEntityEvent).not.toHaveBeenCalled()
|
|
1051
1070
|
})
|
|
1052
1071
|
|
|
1072
|
+
// #5741 — a route whose verbs are all command-backed used to declare `indexer:` that no
|
|
1073
|
+
// code ever read, so `entity_indexes` was never maintained for it and nothing said so.
|
|
1074
|
+
describe('command routes honour the route-declared indexer', () => {
|
|
1075
|
+
const routeIndexer = { entityType: 'example.todo' }
|
|
1076
|
+
|
|
1077
|
+
const buildCommandRoute = () => makeCrudRoute({
|
|
1078
|
+
metadata: { POST: { requireAuth: true }, PUT: { requireAuth: true }, DELETE: { requireAuth: true } },
|
|
1079
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
1080
|
+
indexer: routeIndexer,
|
|
1081
|
+
actions: {
|
|
1082
|
+
create: { commandId: 'example.todo.create', schema: z.any(), response: () => ({ ok: true }) },
|
|
1083
|
+
update: { commandId: 'example.todo.update', schema: z.any(), response: () => ({ ok: true }) },
|
|
1084
|
+
delete: { commandId: 'example.todo.delete', schema: z.any(), response: () => ({ ok: true }) },
|
|
1085
|
+
},
|
|
1086
|
+
})
|
|
1087
|
+
|
|
1088
|
+
// Stands in for every core command handler that ends in `emitCrudSideEffects({ events })`
|
|
1089
|
+
// with no `indexer` of its own — customers/commands/tags.ts, catalog/commands/prices.ts.
|
|
1090
|
+
const markEventsOnly = (action: 'created' | 'updated' | 'deleted', entity: object) => async () => {
|
|
1091
|
+
mockDataEngine.markOrmEntityChange({
|
|
1092
|
+
action,
|
|
1093
|
+
entity,
|
|
1094
|
+
events: { module: 'example', entity: 'todo' },
|
|
1095
|
+
identifiers: { id: 'todo-1', organizationId: defaultOrganizationId, tenantId: defaultTenantId },
|
|
1096
|
+
} as any)
|
|
1097
|
+
await mockDataEngine.flushOrmEntityChanges()
|
|
1098
|
+
return { result: { id: 'todo-1' }, logEntry: { id: 'log-1' } }
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
const flushedEntries = () => mockDataEngine.emitOrmEntityEvent.mock.calls.map(([entry]) => entry as any)
|
|
1102
|
+
|
|
1103
|
+
it.each([
|
|
1104
|
+
['POST', 'created' as const],
|
|
1105
|
+
['PUT', 'updated' as const],
|
|
1106
|
+
['DELETE', 'deleted' as const],
|
|
1107
|
+
])('%s applies the declaration to the handler\'s events-only mark', async (method, action) => {
|
|
1108
|
+
commandBus.execute.mockImplementation(markEventsOnly(action, new Todo()))
|
|
1109
|
+
const route = buildCommandRoute()
|
|
1110
|
+
const res = await (route as any)[method](new Request('http://x/api/example/todos/command?id=todo-1', {
|
|
1111
|
+
method,
|
|
1112
|
+
body: JSON.stringify({ id: 'todo-1' }),
|
|
1113
|
+
headers: { 'content-type': 'application/json' },
|
|
1114
|
+
}))
|
|
1115
|
+
|
|
1116
|
+
expect(res.status).toBeLessThan(400)
|
|
1117
|
+
expect(flushedEntries()).toEqual([expect.objectContaining({ action, indexer: routeIndexer })])
|
|
1118
|
+
// The declaration is scoped to the command; it must not linger for later writes.
|
|
1119
|
+
expect(mockDataEngine.setDefaultIndexerConfig).toHaveBeenLastCalledWith(null)
|
|
1120
|
+
})
|
|
1121
|
+
|
|
1122
|
+
it('leaves a handler-supplied indexer in place', async () => {
|
|
1123
|
+
const handlerIndexer = { entityType: 'example.todo_handler_owned' }
|
|
1124
|
+
commandBus.execute.mockImplementation(async () => {
|
|
1125
|
+
mockDataEngine.markOrmEntityChange({
|
|
1126
|
+
action: 'created',
|
|
1127
|
+
entity: new Todo(),
|
|
1128
|
+
events: { module: 'example', entity: 'todo' },
|
|
1129
|
+
indexer: handlerIndexer,
|
|
1130
|
+
identifiers: { id: 'todo-1', organizationId: defaultOrganizationId, tenantId: defaultTenantId },
|
|
1131
|
+
} as any)
|
|
1132
|
+
await mockDataEngine.flushOrmEntityChanges()
|
|
1133
|
+
return { result: { id: 'todo-1' }, logEntry: { id: 'log-1' } }
|
|
1134
|
+
})
|
|
1135
|
+
const route = buildCommandRoute()
|
|
1136
|
+
const res = await route.POST(new Request('http://x/api/example/todos/command', {
|
|
1137
|
+
method: 'POST',
|
|
1138
|
+
body: JSON.stringify({}),
|
|
1139
|
+
headers: { 'content-type': 'application/json' },
|
|
1140
|
+
}))
|
|
1141
|
+
|
|
1142
|
+
expect(res.status).toBeLessThan(400)
|
|
1143
|
+
expect(flushedEntries()).toEqual([expect.objectContaining({ indexer: handlerIndexer })])
|
|
1144
|
+
})
|
|
1145
|
+
|
|
1146
|
+
it('never declares an indexer the route did not configure', async () => {
|
|
1147
|
+
commandBus.execute.mockImplementation(markEventsOnly('created', new Todo()))
|
|
1148
|
+
const route = makeCrudRoute({
|
|
1149
|
+
metadata: { POST: { requireAuth: true } },
|
|
1150
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
1151
|
+
actions: {
|
|
1152
|
+
create: { commandId: 'example.todo.create', schema: z.any(), response: () => ({ ok: true }) },
|
|
1153
|
+
},
|
|
1154
|
+
})
|
|
1155
|
+
const res = await route.POST(new Request('http://x/api/example/todos/command', {
|
|
1156
|
+
method: 'POST',
|
|
1157
|
+
body: JSON.stringify({}),
|
|
1158
|
+
headers: { 'content-type': 'application/json' },
|
|
1159
|
+
}))
|
|
1160
|
+
|
|
1161
|
+
expect(res.status).toBeLessThan(400)
|
|
1162
|
+
expect(mockDataEngine.setDefaultIndexerConfig).not.toHaveBeenCalled()
|
|
1163
|
+
expect(flushedEntries()).toEqual([expect.not.objectContaining({ indexer: expect.anything() })])
|
|
1164
|
+
})
|
|
1165
|
+
|
|
1166
|
+
it('clears the declaration when the command throws', async () => {
|
|
1167
|
+
commandBus.execute.mockRejectedValue(new Error('boom'))
|
|
1168
|
+
const route = buildCommandRoute()
|
|
1169
|
+
await route.POST(new Request('http://x/api/example/todos/command', {
|
|
1170
|
+
method: 'POST',
|
|
1171
|
+
body: JSON.stringify({}),
|
|
1172
|
+
headers: { 'content-type': 'application/json' },
|
|
1173
|
+
}))
|
|
1174
|
+
|
|
1175
|
+
expect(mockDataEngine.setDefaultIndexerConfig).toHaveBeenLastCalledWith(null)
|
|
1176
|
+
})
|
|
1177
|
+
|
|
1178
|
+
describe('the undischarged-declaration warning', () => {
|
|
1179
|
+
// The warning is the only part of this change a module author ever sees, so it is pinned
|
|
1180
|
+
// in both directions: present when a handler drops the write, absent on the happy path.
|
|
1181
|
+
const logRecords: LoggerExtensionRecord[] = []
|
|
1182
|
+
const undischargedWarnings = () => logRecords.filter((record) =>
|
|
1183
|
+
record.level === 'warn' && String(record.message).includes('did not discharge'))
|
|
1184
|
+
|
|
1185
|
+
beforeEach(() => {
|
|
1186
|
+
logRecords.length = 0
|
|
1187
|
+
registerLoggerExtension({ emit: (record) => logRecords.push(record) })
|
|
1188
|
+
})
|
|
1189
|
+
afterEach(() => { resetLoggerExtension() })
|
|
1190
|
+
|
|
1191
|
+
it('warns once, naming the command, when the handler marks nothing at all', async () => {
|
|
1192
|
+
commandBus.execute.mockImplementation(async () => ({ result: { id: 'todo-1' }, logEntry: { id: 'log-1' } }))
|
|
1193
|
+
const route = buildCommandRoute()
|
|
1194
|
+
const res = await route.POST(new Request('http://x/api/example/todos/command', {
|
|
1195
|
+
method: 'POST',
|
|
1196
|
+
body: JSON.stringify({}),
|
|
1197
|
+
headers: { 'content-type': 'application/json' },
|
|
1198
|
+
}))
|
|
1199
|
+
|
|
1200
|
+
expect(res.status).toBeLessThan(400)
|
|
1201
|
+
const warnings = undischargedWarnings()
|
|
1202
|
+
expect(warnings).toHaveLength(1)
|
|
1203
|
+
expect(warnings[0].fields).toMatchObject({
|
|
1204
|
+
operation: 'created',
|
|
1205
|
+
commandId: 'example.todo.create',
|
|
1206
|
+
entityType: routeIndexer.entityType,
|
|
1207
|
+
})
|
|
1208
|
+
})
|
|
1209
|
+
|
|
1210
|
+
it('stays silent when the handler discharges the declaration', async () => {
|
|
1211
|
+
commandBus.execute.mockImplementation(markEventsOnly('created', new Todo()))
|
|
1212
|
+
const route = buildCommandRoute()
|
|
1213
|
+
const res = await route.POST(new Request('http://x/api/example/todos/command', {
|
|
1214
|
+
method: 'POST',
|
|
1215
|
+
body: JSON.stringify({}),
|
|
1216
|
+
headers: { 'content-type': 'application/json' },
|
|
1217
|
+
}))
|
|
1218
|
+
|
|
1219
|
+
expect(res.status).toBeLessThan(400)
|
|
1220
|
+
expect(undischargedWarnings()).toHaveLength(0)
|
|
1221
|
+
})
|
|
1222
|
+
})
|
|
1223
|
+
})
|
|
1224
|
+
|
|
1053
1225
|
it('POST command route runs mutation guards before executing the command', async () => {
|
|
1054
1226
|
const guardValidate = jest.fn(async (_input: any) => ({ ok: false, status: 403, message: 'Blocked by test guard' }))
|
|
1055
1227
|
registerMutationGuards([{ moduleId: 'example', guards: [{
|
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
|
+
})
|