@open-mercato/shared 0.6.7-develop.6860.1.f796b22e80 → 0.6.7
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/AGENTS.md +6 -57
- package/build.mjs +4 -2
- package/dist/lib/auth/server.js +2 -15
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/bootstrap/dynamicLoader.js +43 -117
- package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
- package/dist/lib/commands/command-bus.js +1 -5
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/commands/command-interceptor-runner.js +2 -2
- package/dist/lib/commands/command-interceptor-runner.js.map +2 -2
- package/dist/lib/crud/enricher-runner.js +11 -2
- package/dist/lib/crud/enricher-runner.js.map +2 -2
- package/dist/lib/crud/factory.js +4 -63
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/crud/interceptor-runner.js +2 -2
- package/dist/lib/crud/interceptor-runner.js.map +2 -2
- package/dist/lib/crud/mutation-guard-registry.js +2 -2
- package/dist/lib/crud/mutation-guard-registry.js.map +2 -2
- package/dist/lib/crud/types.js +0 -4
- package/dist/lib/crud/types.js.map +3 -3
- package/dist/lib/data/engine.js +11 -37
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/db/mikro.js +12 -3
- package/dist/lib/db/mikro.js.map +2 -2
- package/dist/lib/db/pg-errors.js +0 -42
- package/dist/lib/db/pg-errors.js.map +2 -2
- package/dist/lib/di/container.js +6 -48
- package/dist/lib/di/container.js.map +2 -2
- package/dist/lib/email/send.js +0 -56
- package/dist/lib/email/send.js.map +2 -2
- package/dist/lib/encryption/subscriber.js +10 -2
- package/dist/lib/encryption/subscriber.js.map +2 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js +17 -52
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
- package/dist/lib/i18n/config.js +1 -1
- package/dist/lib/i18n/config.js.map +2 -2
- package/dist/lib/i18n/server.js +4 -4
- package/dist/lib/i18n/server.js.map +3 -3
- package/dist/lib/logger/index.js +3 -47
- package/dist/lib/logger/index.js.map +3 -3
- package/dist/lib/modules/registry.js +1 -5
- package/dist/lib/modules/registry.js.map +2 -2
- package/dist/lib/query/engine.js +40 -54
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/redis/connection.js +2 -7
- package/dist/lib/redis/connection.js.map +2 -2
- package/dist/lib/search/config.js +2 -61
- package/dist/lib/search/config.js.map +2 -2
- package/dist/lib/search/tokenize.js +11 -23
- package/dist/lib/search/tokenize.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/lib/webhooks/index.js +1 -13
- package/dist/lib/webhooks/index.js.map +2 -2
- package/dist/lib/webhooks/verify.js +7 -12
- package/dist/lib/webhooks/verify.js.map +2 -2
- package/dist/modules/analytics.js.map +2 -2
- package/dist/modules/overrides.js +1 -50
- package/dist/modules/overrides.js.map +2 -2
- package/dist/modules/payment_gateways/types.js +1 -8
- package/dist/modules/payment_gateways/types.js.map +2 -2
- package/dist/modules/registry.js +0 -1
- package/dist/modules/registry.js.map +2 -2
- package/dist/security/enabledModulesRegistry.js +11 -56
- package/dist/security/enabledModulesRegistry.js.map +2 -2
- package/jest.config.cjs +1 -2
- package/package.json +9 -19
- package/src/lib/ai/llm-provider.ts +0 -33
- package/src/lib/auth/__tests__/server.test.ts +0 -23
- package/src/lib/auth/server.ts +2 -23
- package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +8 -63
- package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +1 -21
- package/src/lib/bootstrap/dynamicLoader.ts +65 -213
- package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +0 -28
- package/src/lib/commands/command-bus.ts +1 -5
- package/src/lib/commands/command-interceptor-runner.ts +2 -2
- package/src/lib/crud/__tests__/crud-factory.test.ts +0 -298
- package/src/lib/crud/__tests__/mutation-guard-registry.test.ts +0 -26
- package/src/lib/crud/enricher-runner.ts +11 -2
- package/src/lib/crud/factory.ts +1 -69
- package/src/lib/crud/interceptor-runner.ts +2 -2
- package/src/lib/crud/mutation-guard-registry.ts +2 -2
- package/src/lib/crud/types.ts +0 -3
- package/src/lib/data/__tests__/engine.bulk-suppress.test.ts +3 -18
- package/src/lib/data/engine.ts +16 -50
- package/src/lib/db/__tests__/mikro.test.ts +25 -0
- package/src/lib/db/mikro.ts +38 -13
- package/src/lib/db/pg-errors.ts +0 -58
- package/src/lib/di/container.ts +8 -72
- package/src/lib/email/__tests__/send.test.ts +1 -37
- package/src/lib/email/send.ts +0 -79
- package/src/lib/encryption/__tests__/subscriber.test.ts +2 -54
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +0 -58
- package/src/lib/encryption/subscriber.ts +14 -2
- package/src/lib/encryption/tenantDataEncryptionService.ts +18 -64
- package/src/lib/i18n/config.ts +2 -2
- package/src/lib/i18n/server.ts +2 -6
- package/src/lib/logger/__tests__/logger.test.ts +0 -77
- package/src/lib/logger/index.ts +1 -59
- package/src/lib/modules/registry.ts +0 -10
- package/src/lib/query/__tests__/engine.test.ts +13 -65
- package/src/lib/query/engine.ts +58 -75
- package/src/lib/query/types.ts +5 -4
- package/src/lib/redis/__tests__/connection.test.ts +0 -16
- package/src/lib/redis/connection.ts +7 -10
- package/src/lib/search/__tests__/config.test.ts +0 -177
- package/src/lib/search/config.ts +8 -106
- package/src/lib/search/tokenize.ts +11 -33
- package/src/lib/webhooks/__tests__/verify.test.ts +1 -20
- package/src/lib/webhooks/index.ts +1 -19
- package/src/lib/webhooks/verify.ts +9 -17
- package/src/modules/__tests__/registry.test.ts +2 -10
- package/src/modules/analytics.ts +0 -7
- package/src/modules/customer-auth.ts +0 -1
- package/src/modules/encryption.ts +0 -3
- package/src/modules/navigation/backendChrome.ts +0 -15
- package/src/modules/overrides.ts +0 -103
- package/src/modules/payment_gateways/types.ts +0 -41
- package/src/modules/registry.ts +0 -3
- package/src/security/enabledModulesRegistry.ts +12 -64
- package/dist/lib/ai/safety-identifier.js +0 -32
- package/dist/lib/ai/safety-identifier.js.map +0 -7
- package/dist/lib/auth/organizationScope.js +0 -34
- package/dist/lib/auth/organizationScope.js.map +0 -7
- package/dist/lib/bootstrap/clientOnlyModules.js +0 -55
- package/dist/lib/bootstrap/clientOnlyModules.js.map +0 -7
- package/dist/lib/data/consistency.js +0 -19
- package/dist/lib/data/consistency.js.map +0 -7
- package/dist/lib/db/entityMetadata.js +0 -26
- package/dist/lib/db/entityMetadata.js.map +0 -7
- package/dist/lib/logger/extension.js +0 -29
- package/dist/lib/logger/extension.js.map +0 -7
- package/dist/lib/modules/surfaceFingerprint.js +0 -47
- package/dist/lib/modules/surfaceFingerprint.js.map +0 -7
- package/dist/lib/query/ciphertext-search-warning.js +0 -45
- package/dist/lib/query/ciphertext-search-warning.js.map +0 -7
- package/dist/lib/search/auto-indexing.js +0 -14
- package/dist/lib/search/auto-indexing.js.map +0 -7
- package/dist/lib/search/availability.js +0 -107
- package/dist/lib/search/availability.js.map +0 -7
- package/dist/lib/search/tokenLookup.js +0 -46
- package/dist/lib/search/tokenLookup.js.map +0 -7
- package/dist/lib/telemetry/runtime.js +0 -35
- package/dist/lib/telemetry/runtime.js.map +0 -7
- package/dist/lib/webhooks/body.js +0 -57
- package/dist/lib/webhooks/body.js.map +0 -7
- package/dist/lib/webhooks/inbound-types.js +0 -1
- package/dist/lib/webhooks/inbound-types.js.map +0 -7
- package/dist/modules/widgets/extension-points.js +0 -96
- package/dist/modules/widgets/extension-points.js.map +0 -7
- package/dist/security/featurePolicy.js +0 -62
- package/dist/security/featurePolicy.js.map +0 -7
- package/scripts/versionSource.cjs +0 -65
- package/src/lib/__tests__/versionSource.test.ts +0 -46
- package/src/lib/ai/__tests__/llm-provider-contract.test.ts +0 -59
- package/src/lib/ai/__tests__/safety-identifier.test.ts +0 -71
- package/src/lib/ai/safety-identifier.ts +0 -73
- package/src/lib/auth/__tests__/organizationScope.test.ts +0 -107
- package/src/lib/auth/organizationScope.ts +0 -65
- package/src/lib/bootstrap/__tests__/clientOnlyModules.test.ts +0 -189
- package/src/lib/bootstrap/__tests__/dynamicLoader.aliasResolver.test.ts +0 -98
- package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +0 -211
- package/src/lib/bootstrap/__tests__/dynamicLoader.generatedCacheRecovery.test.ts +0 -192
- package/src/lib/bootstrap/clientOnlyModules.ts +0 -85
- package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +0 -171
- package/src/lib/data/__tests__/consistency.test.ts +0 -38
- package/src/lib/data/consistency.ts +0 -17
- package/src/lib/db/__tests__/entityMetadata.test.ts +0 -38
- package/src/lib/db/__tests__/pg-errors.test.ts +0 -48
- package/src/lib/db/entityMetadata.ts +0 -43
- package/src/lib/di/__tests__/container-app-di-absent.test.ts +0 -155
- package/src/lib/di/__tests__/container-app-di.test.ts +0 -173
- package/src/lib/di/__tests__/registrar-error-log.test.ts +0 -154
- package/src/lib/i18n/__tests__/config.test.ts +0 -8
- package/src/lib/i18n/__tests__/server-dictionary-cache.test.ts +0 -30
- package/src/lib/i18n/__tests__/server-unbootstrapped.test.ts +0 -29
- package/src/lib/logger/extension.ts +0 -59
- package/src/lib/modules/__tests__/surfaceFingerprint.test.ts +0 -122
- package/src/lib/modules/surfaceFingerprint.ts +0 -87
- package/src/lib/query/__tests__/ciphertext-search-warning.test.ts +0 -178
- package/src/lib/query/ciphertext-search-warning.ts +0 -95
- package/src/lib/search/__tests__/availability.test.ts +0 -211
- package/src/lib/search/__tests__/tokenLookup.test.ts +0 -206
- package/src/lib/search/__tests__/tokenize.test.ts +0 -49
- package/src/lib/search/auto-indexing.ts +0 -22
- package/src/lib/search/availability.ts +0 -232
- package/src/lib/search/tokenLookup.ts +0 -133
- package/src/lib/telemetry/runtime.ts +0 -72
- package/src/lib/webhooks/__tests__/body.test.ts +0 -95
- package/src/lib/webhooks/body.ts +0 -70
- package/src/lib/webhooks/inbound-types.ts +0 -125
- package/src/modules/__tests__/nav-group-order-override.test.ts +0 -183
- package/src/modules/payment_gateways/__tests__/types.test.ts +0 -37
- package/src/modules/widgets/__tests__/extension-points.test.ts +0 -101
- package/src/modules/widgets/extension-points.ts +0 -524
- package/src/security/__tests__/featurePolicy.test.ts +0 -166
- package/src/security/featurePolicy.ts +0 -89
|
@@ -10,7 +10,6 @@ import {
|
|
|
10
10
|
registerOptimisticLockReaders,
|
|
11
11
|
} from '@open-mercato/shared/lib/crud/optimistic-lock-store'
|
|
12
12
|
import { loadCustomFieldDefinitionIndex } from '@open-mercato/shared/lib/crud/custom-fields'
|
|
13
|
-
import { registerMutationGuards } from '@open-mercato/shared/lib/crud/mutation-guard-store'
|
|
14
13
|
import { z } from 'zod'
|
|
15
14
|
|
|
16
15
|
// Keep the real custom-field helpers but spy on the definition loader so we can
|
|
@@ -202,7 +201,6 @@ describe('CRUD Factory', () => {
|
|
|
202
201
|
}
|
|
203
202
|
crudMutationGuardService = null
|
|
204
203
|
registerApiInterceptors([])
|
|
205
|
-
registerMutationGuards([])
|
|
206
204
|
})
|
|
207
205
|
|
|
208
206
|
const querySchema = z.object({
|
|
@@ -545,7 +543,6 @@ describe('CRUD Factory', () => {
|
|
|
545
543
|
const data = await res.json()
|
|
546
544
|
expect(data.id).toBeDefined()
|
|
547
545
|
// CF saved
|
|
548
|
-
expect(mockDataEngine.setCustomFields).toHaveBeenCalledWith(expect.objectContaining({ notify: false }))
|
|
549
546
|
expect(setRecordCustomFields).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ entityId: 'example.todo', values: { priority: 3 } }))
|
|
550
547
|
// Event + indexer delegated to data engine
|
|
551
548
|
expect(mockDataEngine.emitOrmEntityEvent).toHaveBeenCalledTimes(1)
|
|
@@ -572,7 +569,6 @@ describe('CRUD Factory', () => {
|
|
|
572
569
|
await em.persist(created).flush()
|
|
573
570
|
const res = await route.PUT(new Request('http://x/api/example/todos', { method: 'PUT', body: JSON.stringify({ id: created.id, title: 'X2', cf_priority: 5 }), headers: { 'content-type': 'application/json' } }))
|
|
574
571
|
expect(res.status).toBe(200)
|
|
575
|
-
expect(mockDataEngine.setCustomFields).toHaveBeenCalledWith(expect.objectContaining({ notify: false }))
|
|
576
572
|
expect(setRecordCustomFields).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ values: { priority: 5 } }))
|
|
577
573
|
expect(mockDataEngine.emitOrmEntityEvent).toHaveBeenCalledTimes(1)
|
|
578
574
|
const updatedCall = mockDataEngine.emitOrmEntityEvent.mock.calls.at(0)
|
|
@@ -594,34 +590,6 @@ describe('CRUD Factory', () => {
|
|
|
594
590
|
expect(mockDataEngine.emitOrmEntityEvent).not.toHaveBeenCalled()
|
|
595
591
|
})
|
|
596
592
|
|
|
597
|
-
it('returns a retryable 503 when a handler hits a transient DB connection failure', async () => {
|
|
598
|
-
setRecordCustomFields.mockImplementationOnce(async () => {
|
|
599
|
-
throw Object.assign(new Error('sorry, too many clients already'), { code: '53300' })
|
|
600
|
-
})
|
|
601
|
-
const res = await route.POST(new Request('http://x/api/example/todos', { method: 'POST', body: JSON.stringify({ title: 'Exhausted', is_done: true, cf_priority: 3 }), headers: { 'content-type': 'application/json' } }))
|
|
602
|
-
expect(res.status).toBe(503)
|
|
603
|
-
expect(res.headers.get('Retry-After')).toBe('2')
|
|
604
|
-
// The failed write is still rolled back — no created event/index leaks out.
|
|
605
|
-
expect(Object.values(db)).toHaveLength(0)
|
|
606
|
-
expect(mockDataEngine.emitOrmEntityEvent).not.toHaveBeenCalled()
|
|
607
|
-
})
|
|
608
|
-
|
|
609
|
-
it('POST surfaces CRUD side-effect failures after custom field writes', async () => {
|
|
610
|
-
mockDataEngine.emitOrmEntityEvent.mockImplementationOnce(async () => {
|
|
611
|
-
throw new Error('index write failed')
|
|
612
|
-
})
|
|
613
|
-
|
|
614
|
-
const res = await route.POST(new Request('http://x/api/example/todos', {
|
|
615
|
-
method: 'POST',
|
|
616
|
-
body: JSON.stringify({ title: 'Indexed', is_done: true, cf_priority: 3 }),
|
|
617
|
-
headers: { 'content-type': 'application/json' },
|
|
618
|
-
}))
|
|
619
|
-
|
|
620
|
-
expect(res.status).toBe(500)
|
|
621
|
-
expect(mockDataEngine.setCustomFields).toHaveBeenCalledWith(expect.objectContaining({ notify: false }))
|
|
622
|
-
expect(mockDataEngine.emitOrmEntityEvent).toHaveBeenCalledTimes(1)
|
|
623
|
-
})
|
|
624
|
-
|
|
625
593
|
it('PUT rolls back the entity update when the custom field write fails', async () => {
|
|
626
594
|
const created = em.create(Todo, { title: 'Before', organizationId: defaultOrganizationId, tenantId: defaultTenantId }) as Rec
|
|
627
595
|
created.id = '123e4567-e89b-12d3-a456-426614174003'
|
|
@@ -753,193 +721,6 @@ describe('CRUD Factory', () => {
|
|
|
753
721
|
expect(mockDataEngine.emitOrmEntityEvent).not.toHaveBeenCalled()
|
|
754
722
|
})
|
|
755
723
|
|
|
756
|
-
it('POST command route runs mutation guards before executing the command', async () => {
|
|
757
|
-
const guardValidate = jest.fn(async (_input: any) => ({ ok: false, status: 403, message: 'Blocked by test guard' }))
|
|
758
|
-
registerMutationGuards([{ moduleId: 'example', guards: [{
|
|
759
|
-
id: 'example.block-command-create',
|
|
760
|
-
targetEntity: 'example.todo',
|
|
761
|
-
operations: ['create'],
|
|
762
|
-
validate: guardValidate,
|
|
763
|
-
}] }])
|
|
764
|
-
const commandRoute = makeCrudRoute({
|
|
765
|
-
metadata: { POST: { requireAuth: true } },
|
|
766
|
-
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
767
|
-
indexer: { entityType: 'example.todo' },
|
|
768
|
-
actions: {
|
|
769
|
-
create: {
|
|
770
|
-
commandId: 'example.todo.create',
|
|
771
|
-
schema: createSchema,
|
|
772
|
-
response: () => ({ ok: true }),
|
|
773
|
-
},
|
|
774
|
-
},
|
|
775
|
-
})
|
|
776
|
-
|
|
777
|
-
const res = await commandRoute.POST(new Request('http://x/api/example/todos/command', {
|
|
778
|
-
method: 'POST',
|
|
779
|
-
body: JSON.stringify({ title: 'A' }),
|
|
780
|
-
headers: { 'content-type': 'application/json' },
|
|
781
|
-
}))
|
|
782
|
-
|
|
783
|
-
expect(res.status).toBe(403)
|
|
784
|
-
expect(guardValidate).toHaveBeenCalledWith(expect.objectContaining({
|
|
785
|
-
resourceKind: 'example.todo',
|
|
786
|
-
resourceId: null,
|
|
787
|
-
operation: 'create',
|
|
788
|
-
mutationPayload: expect.objectContaining({ title: 'A' }),
|
|
789
|
-
}))
|
|
790
|
-
expect(commandBus.execute).not.toHaveBeenCalled()
|
|
791
|
-
})
|
|
792
|
-
|
|
793
|
-
it('POST command route merges guard modifiedPayload and runs afterSuccess with the command result id', async () => {
|
|
794
|
-
commandBus.execute.mockResolvedValue({ result: { id: 'cmd-created-1' }, logEntry: { id: 'log-1' } })
|
|
795
|
-
const guardAfterSuccess = jest.fn(async () => {})
|
|
796
|
-
registerMutationGuards([{ moduleId: 'example', guards: [{
|
|
797
|
-
id: 'example.rewrite-command-create',
|
|
798
|
-
targetEntity: 'example.todo',
|
|
799
|
-
operations: ['create'],
|
|
800
|
-
validate: async (_input: any) => ({ ok: true, modifiedPayload: { title: 'FROM-GUARD' }, shouldRunAfterSuccess: true }),
|
|
801
|
-
afterSuccess: guardAfterSuccess,
|
|
802
|
-
}] }])
|
|
803
|
-
const commandRoute = makeCrudRoute({
|
|
804
|
-
metadata: { POST: { requireAuth: true } },
|
|
805
|
-
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
806
|
-
indexer: { entityType: 'example.todo' },
|
|
807
|
-
actions: {
|
|
808
|
-
create: {
|
|
809
|
-
commandId: 'example.todo.create',
|
|
810
|
-
schema: createSchema,
|
|
811
|
-
response: () => ({ ok: true }),
|
|
812
|
-
},
|
|
813
|
-
},
|
|
814
|
-
})
|
|
815
|
-
|
|
816
|
-
const res = await commandRoute.POST(new Request('http://x/api/example/todos/command', {
|
|
817
|
-
method: 'POST',
|
|
818
|
-
body: JSON.stringify({ title: 'A' }),
|
|
819
|
-
headers: { 'content-type': 'application/json' },
|
|
820
|
-
}))
|
|
821
|
-
|
|
822
|
-
expect(res.status).toBe(201)
|
|
823
|
-
expect(commandBus.execute).toHaveBeenCalledWith('example.todo.create', expect.objectContaining({
|
|
824
|
-
input: expect.objectContaining({ title: 'FROM-GUARD' }),
|
|
825
|
-
}))
|
|
826
|
-
expect(guardAfterSuccess).toHaveBeenCalledWith(expect.objectContaining({
|
|
827
|
-
resourceId: 'cmd-created-1',
|
|
828
|
-
operation: 'create',
|
|
829
|
-
}))
|
|
830
|
-
})
|
|
831
|
-
|
|
832
|
-
it('POST command route falls back to the response payload id for guard afterSuccess', async () => {
|
|
833
|
-
commandBus.execute.mockResolvedValue({ result: { lineId: 'line-42' }, logEntry: { id: 'log-1' } })
|
|
834
|
-
const guardAfterSuccess = jest.fn(async () => {})
|
|
835
|
-
registerMutationGuards([{ moduleId: 'example', guards: [{
|
|
836
|
-
id: 'example.after-command-create',
|
|
837
|
-
targetEntity: 'example.todo',
|
|
838
|
-
operations: ['create'],
|
|
839
|
-
validate: async (_input: any) => ({ ok: true, shouldRunAfterSuccess: true }),
|
|
840
|
-
afterSuccess: guardAfterSuccess,
|
|
841
|
-
}] }])
|
|
842
|
-
const commandRoute = makeCrudRoute({
|
|
843
|
-
metadata: { POST: { requireAuth: true } },
|
|
844
|
-
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
845
|
-
indexer: { entityType: 'example.todo' },
|
|
846
|
-
actions: {
|
|
847
|
-
create: {
|
|
848
|
-
commandId: 'example.todo.create',
|
|
849
|
-
schema: createSchema,
|
|
850
|
-
response: ({ result }: any) => ({ id: result.lineId }),
|
|
851
|
-
},
|
|
852
|
-
},
|
|
853
|
-
})
|
|
854
|
-
|
|
855
|
-
const res = await commandRoute.POST(new Request('http://x/api/example/todos/command', {
|
|
856
|
-
method: 'POST',
|
|
857
|
-
body: JSON.stringify({ title: 'A' }),
|
|
858
|
-
headers: { 'content-type': 'application/json' },
|
|
859
|
-
}))
|
|
860
|
-
|
|
861
|
-
expect(res.status).toBe(201)
|
|
862
|
-
expect(guardAfterSuccess).toHaveBeenCalledWith(expect.objectContaining({
|
|
863
|
-
resourceId: 'line-42',
|
|
864
|
-
operation: 'create',
|
|
865
|
-
}))
|
|
866
|
-
})
|
|
867
|
-
|
|
868
|
-
// Commands whose mapInput wraps the payload (e.g. `{ body }`) null the factory
|
|
869
|
-
// candidateId and thereby OPT OUT of row-level mutation guards, leaving the
|
|
870
|
-
// command-level optimistic-lock check as the sole guard — a documented contract
|
|
871
|
-
// (apps/docs/docs/framework/data-integrity/concurrency-locking.mdx) that sales
|
|
872
|
-
// line/adjustment routes rely on.
|
|
873
|
-
it('PUT command route without a top-level id keeps the documented row-level guard opt-out', async () => {
|
|
874
|
-
crudMutationGuardService = {
|
|
875
|
-
validateMutation: jest.fn().mockResolvedValue({ ok: true, shouldRunAfterSuccess: false }),
|
|
876
|
-
afterMutationSuccess: jest.fn().mockResolvedValue(undefined),
|
|
877
|
-
}
|
|
878
|
-
const guardValidate = jest.fn(async (_input: any) => ({ ok: false, status: 409, message: 'must not run' }))
|
|
879
|
-
registerMutationGuards([{ moduleId: 'example', guards: [{
|
|
880
|
-
id: 'example.idless-update-opt-out',
|
|
881
|
-
targetEntity: 'example.todo',
|
|
882
|
-
operations: ['update'],
|
|
883
|
-
validate: guardValidate,
|
|
884
|
-
}] }])
|
|
885
|
-
const commandRoute = makeCrudRoute({
|
|
886
|
-
metadata: { PUT: { requireAuth: true } },
|
|
887
|
-
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
888
|
-
indexer: { entityType: 'example.todo' },
|
|
889
|
-
actions: {
|
|
890
|
-
update: {
|
|
891
|
-
commandId: 'example.todo.update',
|
|
892
|
-
schema: z.object({ title: z.string() }),
|
|
893
|
-
mapInput: ({ parsed }: any) => ({ body: parsed }),
|
|
894
|
-
response: () => ({ ok: true }),
|
|
895
|
-
},
|
|
896
|
-
},
|
|
897
|
-
})
|
|
898
|
-
|
|
899
|
-
const res = await commandRoute.PUT(new Request('http://x/api/example/todos/command', {
|
|
900
|
-
method: 'PUT',
|
|
901
|
-
body: JSON.stringify({ title: 'nested id shape' }),
|
|
902
|
-
headers: { 'content-type': 'application/json' },
|
|
903
|
-
}))
|
|
904
|
-
|
|
905
|
-
expect(res.status).toBe(200)
|
|
906
|
-
expect(guardValidate).not.toHaveBeenCalled()
|
|
907
|
-
expect(crudMutationGuardService.validateMutation).not.toHaveBeenCalled()
|
|
908
|
-
expect(commandBus.execute).toHaveBeenCalledWith('example.todo.update', expect.anything())
|
|
909
|
-
})
|
|
910
|
-
|
|
911
|
-
it('DELETE command route without any id keeps the documented row-level guard opt-out', async () => {
|
|
912
|
-
const guardValidate = jest.fn(async (_input: any) => ({ ok: false, status: 403, message: 'must not run' }))
|
|
913
|
-
registerMutationGuards([{ moduleId: 'example', guards: [{
|
|
914
|
-
id: 'example.idless-delete-opt-out',
|
|
915
|
-
targetEntity: 'example.todo',
|
|
916
|
-
operations: ['delete'],
|
|
917
|
-
validate: guardValidate,
|
|
918
|
-
}] }])
|
|
919
|
-
const commandRoute = makeCrudRoute({
|
|
920
|
-
metadata: { DELETE: { requireAuth: true } },
|
|
921
|
-
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
922
|
-
indexer: { entityType: 'example.todo' },
|
|
923
|
-
actions: {
|
|
924
|
-
delete: {
|
|
925
|
-
commandId: 'example.todo.delete',
|
|
926
|
-
schema: z.any(),
|
|
927
|
-
response: () => ({ ok: true }),
|
|
928
|
-
},
|
|
929
|
-
},
|
|
930
|
-
})
|
|
931
|
-
|
|
932
|
-
const res = await commandRoute.DELETE(new Request('http://x/api/example/todos/command', {
|
|
933
|
-
method: 'DELETE',
|
|
934
|
-
body: JSON.stringify({}),
|
|
935
|
-
headers: { 'content-type': 'application/json' },
|
|
936
|
-
}))
|
|
937
|
-
|
|
938
|
-
expect(res.status).toBe(200)
|
|
939
|
-
expect(guardValidate).not.toHaveBeenCalled()
|
|
940
|
-
expect(commandBus.execute).toHaveBeenCalledWith('example.todo.delete', expect.anything())
|
|
941
|
-
})
|
|
942
|
-
|
|
943
724
|
it('POST is blocked by interceptor before hook', async () => {
|
|
944
725
|
registerApiInterceptors([
|
|
945
726
|
{
|
|
@@ -1003,85 +784,6 @@ describe('CRUD Factory', () => {
|
|
|
1003
784
|
const body = await res.json()
|
|
1004
785
|
expect(body._interceptor).toEqual({ ok: true, count: 1 })
|
|
1005
786
|
})
|
|
1006
|
-
|
|
1007
|
-
// The command DELETE path used to hand before-interceptors the body only, so a
|
|
1008
|
-
// guard reading `?id=` saw nothing to object to and the delete went through
|
|
1009
|
-
// with a 200 (issue #4842).
|
|
1010
|
-
it('DELETE command route passes the query id to interceptor before hooks', async () => {
|
|
1011
|
-
const lockedId = '123e4567-e89b-12d3-a456-426614174010'
|
|
1012
|
-
const seenQueries: Array<Record<string, unknown> | undefined> = []
|
|
1013
|
-
registerApiInterceptors([
|
|
1014
|
-
{
|
|
1015
|
-
moduleId: 'example',
|
|
1016
|
-
interceptors: [
|
|
1017
|
-
{
|
|
1018
|
-
id: 'example.block-locked-delete',
|
|
1019
|
-
targetRoute: 'example/todos/command',
|
|
1020
|
-
methods: ['DELETE'],
|
|
1021
|
-
async before(request) {
|
|
1022
|
-
seenQueries.push(request.query)
|
|
1023
|
-
const queryId = request.query?.id
|
|
1024
|
-
if (typeof queryId === 'string' && queryId === lockedId) {
|
|
1025
|
-
return { ok: false, statusCode: 409, message: 'Record is locked' }
|
|
1026
|
-
}
|
|
1027
|
-
return { ok: true }
|
|
1028
|
-
},
|
|
1029
|
-
},
|
|
1030
|
-
],
|
|
1031
|
-
},
|
|
1032
|
-
])
|
|
1033
|
-
const commandRoute = makeCrudRoute({
|
|
1034
|
-
metadata: { DELETE: { requireAuth: true } },
|
|
1035
|
-
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
1036
|
-
indexer: { entityType: 'example.todo' },
|
|
1037
|
-
actions: {
|
|
1038
|
-
delete: {
|
|
1039
|
-
commandId: 'example.todo.delete',
|
|
1040
|
-
schema: z.any(),
|
|
1041
|
-
response: () => ({ ok: true }),
|
|
1042
|
-
},
|
|
1043
|
-
},
|
|
1044
|
-
})
|
|
1045
|
-
|
|
1046
|
-
const res = await commandRoute.DELETE(new Request(`http://x/api/example/todos/command?id=${lockedId}`, {
|
|
1047
|
-
method: 'DELETE',
|
|
1048
|
-
body: JSON.stringify({}),
|
|
1049
|
-
headers: { 'content-type': 'application/json' },
|
|
1050
|
-
}))
|
|
1051
|
-
|
|
1052
|
-
expect(res.status).toBe(409)
|
|
1053
|
-
expect(await res.json()).toMatchObject({ error: 'Record is locked' })
|
|
1054
|
-
expect(seenQueries).toEqual([{ id: lockedId }])
|
|
1055
|
-
expect(commandBus.execute).not.toHaveBeenCalled()
|
|
1056
|
-
})
|
|
1057
|
-
|
|
1058
|
-
it('DELETE route passes the whole query string to interceptor before hooks', async () => {
|
|
1059
|
-
const created = em.create(Todo, { title: 'Z', organizationId: defaultOrganizationId, tenantId: defaultTenantId }) as Rec
|
|
1060
|
-
created.id = '123e4567-e89b-12d3-a456-426614174011'
|
|
1061
|
-
await em.persist(created).flush()
|
|
1062
|
-
let seenQuery: Record<string, unknown> | undefined
|
|
1063
|
-
registerApiInterceptors([
|
|
1064
|
-
{
|
|
1065
|
-
moduleId: 'example',
|
|
1066
|
-
interceptors: [
|
|
1067
|
-
{
|
|
1068
|
-
id: 'example.capture-delete-query',
|
|
1069
|
-
targetRoute: 'example/todos',
|
|
1070
|
-
methods: ['DELETE'],
|
|
1071
|
-
async before(request) {
|
|
1072
|
-
seenQuery = request.query
|
|
1073
|
-
return { ok: true }
|
|
1074
|
-
},
|
|
1075
|
-
},
|
|
1076
|
-
],
|
|
1077
|
-
},
|
|
1078
|
-
])
|
|
1079
|
-
|
|
1080
|
-
const res = await route.DELETE(new Request(`http://x/api/example/todos?id=${created.id}&reason=cleanup`, { method: 'DELETE' }))
|
|
1081
|
-
|
|
1082
|
-
expect(res.status).toBe(200)
|
|
1083
|
-
expect(seenQuery).toEqual({ id: created.id, reason: 'cleanup' })
|
|
1084
|
-
})
|
|
1085
787
|
})
|
|
1086
788
|
|
|
1087
789
|
describe('CRUD Factory — optimistic-lock auto-registration', () => {
|
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
import { matchesEntity, runMutationGuards } from '../mutation-guard-registry'
|
|
2
2
|
import type { MutationGuard, MutationGuardInput } from '../mutation-guard-registry'
|
|
3
|
-
import {
|
|
4
|
-
applyAclFeatureOverrides,
|
|
5
|
-
resetModuleContractOverridesForTests,
|
|
6
|
-
} from '../../../modules/overrides'
|
|
7
3
|
|
|
8
4
|
describe('matchesEntity', () => {
|
|
9
5
|
it('matches wildcard "*" against any entity', () => {
|
|
@@ -28,10 +24,6 @@ describe('matchesEntity', () => {
|
|
|
28
24
|
})
|
|
29
25
|
|
|
30
26
|
describe('runMutationGuards', () => {
|
|
31
|
-
afterEach(() => {
|
|
32
|
-
resetModuleContractOverridesForTests()
|
|
33
|
-
})
|
|
34
|
-
|
|
35
27
|
const baseInput: MutationGuardInput = {
|
|
36
28
|
tenantId: 'tenant-1',
|
|
37
29
|
organizationId: 'org-1',
|
|
@@ -167,24 +159,6 @@ describe('runMutationGuards', () => {
|
|
|
167
159
|
expect(resultWithWildcard.errorBody).toEqual({ error: 'Blocked by wildcard', guardId: 'g1' })
|
|
168
160
|
})
|
|
169
161
|
|
|
170
|
-
it('does not run a guard gated by a nulled ACL feature', async () => {
|
|
171
|
-
applyAclFeatureOverrides({ 'premium.locks': null })
|
|
172
|
-
const guard = makeGuard({
|
|
173
|
-
id: 'g1',
|
|
174
|
-
features: ['premium.locks'],
|
|
175
|
-
validate: jest.fn().mockResolvedValue({ ok: false }),
|
|
176
|
-
})
|
|
177
|
-
|
|
178
|
-
const result = await runMutationGuards(
|
|
179
|
-
[guard],
|
|
180
|
-
baseInput,
|
|
181
|
-
{ userFeatures: ['*', 'premium.locks'] },
|
|
182
|
-
)
|
|
183
|
-
|
|
184
|
-
expect(result.ok).toBe(true)
|
|
185
|
-
expect(guard.validate).not.toHaveBeenCalled()
|
|
186
|
-
})
|
|
187
|
-
|
|
188
162
|
it('uses custom error body when provided', async () => {
|
|
189
163
|
const guard = makeGuard({
|
|
190
164
|
id: 'g1',
|
|
@@ -15,7 +15,6 @@ import type {
|
|
|
15
15
|
import { getEnrichersForEntity } from './enricher-registry'
|
|
16
16
|
import { logEnricherTiming } from '../umes/enricher-timing'
|
|
17
17
|
import { createLogger } from '../logger'
|
|
18
|
-
import { authorizeFeatures } from '../../security/featurePolicy'
|
|
19
18
|
|
|
20
19
|
const logger = createLogger('shared').child({ component: 'umes' })
|
|
21
20
|
|
|
@@ -36,7 +35,17 @@ function hasRequiredFeatures(
|
|
|
36
35
|
): boolean {
|
|
37
36
|
if (!enricher.features || enricher.features.length === 0) return true
|
|
38
37
|
if (!userFeatures) return false
|
|
39
|
-
|
|
38
|
+
const hasFeature = (required: string): boolean => {
|
|
39
|
+
for (const granted of userFeatures) {
|
|
40
|
+
if (granted === '*' || granted === required) return true
|
|
41
|
+
if (granted.endsWith('.*')) {
|
|
42
|
+
const prefix = granted.slice(0, -1)
|
|
43
|
+
if (required.startsWith(prefix)) return true
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return false
|
|
47
|
+
}
|
|
48
|
+
return enricher.features.every((feature) => hasFeature(feature))
|
|
40
49
|
}
|
|
41
50
|
|
|
42
51
|
function filterByACLAndTenant(
|
package/src/lib/crud/factory.ts
CHANGED
|
@@ -57,7 +57,6 @@ import {
|
|
|
57
57
|
isCrudCacheEnabled,
|
|
58
58
|
normalizeIdentifierValue,
|
|
59
59
|
normalizeTagSegment,
|
|
60
|
-
pickFirstIdentifier,
|
|
61
60
|
resolveCrudCache,
|
|
62
61
|
} from './cache'
|
|
63
62
|
import { deriveCrudSegmentTag } from './cache-stats'
|
|
@@ -73,7 +72,6 @@ import { parseExtensionHeaders } from '../umes/extension-headers'
|
|
|
73
72
|
import { createGenericOptimisticLockReader } from './optimistic-lock'
|
|
74
73
|
import { registerOptimisticLockReaderIfAbsent } from './optimistic-lock-store'
|
|
75
74
|
import { createLogger } from '../logger'
|
|
76
|
-
import { isTransientDbError } from '../db/pg-errors'
|
|
77
75
|
|
|
78
76
|
type RbacServiceLike = {
|
|
79
77
|
getGrantedFeatures: (userId: string, opts: { tenantId: string | null; organizationId: string | null }) => Promise<string[]>
|
|
@@ -556,18 +554,6 @@ function handleError(err: unknown): Response {
|
|
|
556
554
|
if (err instanceof Response) return err
|
|
557
555
|
if (isCrudHttpError(err)) return json(err.body, { status: err.status })
|
|
558
556
|
if (err instanceof z.ZodError) return json({ error: 'Invalid input', details: err.issues }, { status: 400 })
|
|
559
|
-
if (isTransientDbError(err)) {
|
|
560
|
-
// Transient DB unavailability (pool exhausted, `max_connections` reached, DB
|
|
561
|
-
// restarting) is retryable — surface a 503 with a Retry-After hint instead of
|
|
562
|
-
// a generic 500 so clients back off and retry once the DB recovers.
|
|
563
|
-
logger.warn('Transient DB failure during CRUD handler', {
|
|
564
|
-
message: err instanceof Error ? err.message : undefined,
|
|
565
|
-
})
|
|
566
|
-
return json(
|
|
567
|
-
{ error: 'Service temporarily unavailable' },
|
|
568
|
-
{ status: 503, headers: { 'Retry-After': '2' } },
|
|
569
|
-
)
|
|
570
|
-
}
|
|
571
557
|
|
|
572
558
|
const message = err instanceof Error ? err.message : undefined
|
|
573
559
|
const stack = err instanceof Error ? err.stack : undefined
|
|
@@ -929,12 +915,6 @@ function buildCrudCacheKey(
|
|
|
929
915
|
`tenant:${normalizeTagSegment(ctx.auth?.tenantId ?? null)}`,
|
|
930
916
|
`selectedOrg:${normalizeTagSegment(ctx.selectedOrganizationId ?? null)}`,
|
|
931
917
|
`scope:${scopeSegment}`,
|
|
932
|
-
// List payloads can vary per caller identity beyond tenant/org scope:
|
|
933
|
-
// buildFilters may narrow by ctx.auth (e.g. ?mine=true), before-interceptor
|
|
934
|
-
// query rewrites are feature-gated per user, and afterList/after-interceptor
|
|
935
|
-
// output is embedded in the stored payload — so entries MUST be partitioned
|
|
936
|
-
// per actor (API key or user), never shared across identities.
|
|
937
|
-
`user:${normalizeTagSegment((ctx.auth?.keyId ?? ctx.auth?.sub) ?? null)}`,
|
|
938
918
|
`query:${serializeSearchParams(url.searchParams)}`,
|
|
939
919
|
]
|
|
940
920
|
// The cached list payload already embeds enricher output (enrichment runs before
|
|
@@ -2160,31 +2140,6 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2160
2140
|
}
|
|
2161
2141
|
}
|
|
2162
2142
|
|
|
2163
|
-
// Mutation guard registry — command path (mirrors the direct create branch)
|
|
2164
|
-
const createCmdUserFeatures = await resolveUserFeatures(ctx)
|
|
2165
|
-
const { allGuards: createCmdAllGuards } = collectAndRunGuards(ctx.container)
|
|
2166
|
-
let createCmdGuardAfterCallbacks: Array<{ guard: MutationGuard; metadata: Record<string, unknown> | null }> = []
|
|
2167
|
-
if (createCmdAllGuards.length && ctx.auth.tenantId) {
|
|
2168
|
-
const guardResult = await runMutationGuards(createCmdAllGuards, {
|
|
2169
|
-
tenantId: ctx.auth.tenantId,
|
|
2170
|
-
organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null,
|
|
2171
|
-
userId: ctx.auth.sub,
|
|
2172
|
-
resourceKind,
|
|
2173
|
-
resourceId: null,
|
|
2174
|
-
operation: 'create',
|
|
2175
|
-
requestMethod: request.method,
|
|
2176
|
-
requestHeaders: request.headers,
|
|
2177
|
-
mutationPayload: input && typeof input === 'object' ? (input as Record<string, unknown>) : null,
|
|
2178
|
-
}, { userFeatures: createCmdUserFeatures ?? [] })
|
|
2179
|
-
if (!guardResult.ok) {
|
|
2180
|
-
return json(guardResult.errorBody ?? { error: 'Operation blocked by guard' }, { status: guardResult.errorStatus ?? 422 })
|
|
2181
|
-
}
|
|
2182
|
-
if (guardResult.modifiedPayload && typeof input === 'object' && input) {
|
|
2183
|
-
input = { ...input as Record<string, unknown>, ...guardResult.modifiedPayload }
|
|
2184
|
-
}
|
|
2185
|
-
createCmdGuardAfterCallbacks = guardResult.afterSuccessCallbacks
|
|
2186
|
-
}
|
|
2187
|
-
|
|
2188
2143
|
const baseMetadata: CommandLogMetadata = {
|
|
2189
2144
|
tenantId: ctx.auth?.tenantId ?? null,
|
|
2190
2145
|
organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null,
|
|
@@ -2227,22 +2182,6 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2227
2182
|
const status = action.status ?? 201
|
|
2228
2183
|
const response = json(resolvedPayload, { status })
|
|
2229
2184
|
attachOperationHeader(response, logEntry)
|
|
2230
|
-
const commandResultId = pickFirstIdentifier(
|
|
2231
|
-
(result as Record<string, unknown> | null | undefined)?.id,
|
|
2232
|
-
(resolvedPayload as Record<string, unknown> | null | undefined)?.id,
|
|
2233
|
-
)
|
|
2234
|
-
if (createCmdGuardAfterCallbacks.length && ctx.auth.tenantId && commandResultId) {
|
|
2235
|
-
await runGuardAfterSuccessCallbacks(createCmdGuardAfterCallbacks, {
|
|
2236
|
-
tenantId: ctx.auth.tenantId,
|
|
2237
|
-
organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null,
|
|
2238
|
-
userId: ctx.auth.sub,
|
|
2239
|
-
resourceKind,
|
|
2240
|
-
resourceId: commandResultId,
|
|
2241
|
-
operation: 'create',
|
|
2242
|
-
requestMethod: request.method,
|
|
2243
|
-
requestHeaders: request.headers,
|
|
2244
|
-
})
|
|
2245
|
-
}
|
|
2246
2185
|
// Note: side effects (events + indexing) are already flushed by CommandBus.execute()
|
|
2247
2186
|
// via flushCrudSideEffects(). Calling markCommandResultForIndexing here would cause
|
|
2248
2187
|
// duplicate event emissions.
|
|
@@ -2346,7 +2285,6 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2346
2285
|
organizationId: targetOrgId,
|
|
2347
2286
|
tenantId: writeTenantId,
|
|
2348
2287
|
values,
|
|
2349
|
-
notify: false,
|
|
2350
2288
|
})
|
|
2351
2289
|
}
|
|
2352
2290
|
}
|
|
@@ -2484,10 +2422,6 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2484
2422
|
const updateUserFeatures = await resolveUserFeatures(ctx)
|
|
2485
2423
|
const { allGuards: updateAllGuards } = collectAndRunGuards(ctx.container)
|
|
2486
2424
|
let cmdUpdateGuardAfterCallbacks: Array<{ guard: MutationGuard; metadata: Record<string, unknown> | null }> = []
|
|
2487
|
-
// Commands whose mapInput wraps the payload (e.g. `{ body }`) intentionally
|
|
2488
|
-
// null candidateId and OPT OUT of row-level guards, leaving the command-level
|
|
2489
|
-
// optimistic-lock check as the sole guard — a documented contract, see
|
|
2490
|
-
// apps/docs/docs/framework/data-integrity/concurrency-locking.mdx.
|
|
2491
2425
|
if (updateAllGuards.length && ctx.auth.tenantId && candidateId) {
|
|
2492
2426
|
const guardResult = await runMutationGuards(updateAllGuards, {
|
|
2493
2427
|
tenantId: ctx.auth.tenantId,
|
|
@@ -2684,7 +2618,6 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2684
2618
|
organizationId: targetOrgId,
|
|
2685
2619
|
tenantId: writeTenantId,
|
|
2686
2620
|
values,
|
|
2687
|
-
notify: false,
|
|
2688
2621
|
})
|
|
2689
2622
|
}
|
|
2690
2623
|
}
|
|
@@ -2788,7 +2721,6 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2788
2721
|
request,
|
|
2789
2722
|
method: 'DELETE',
|
|
2790
2723
|
body: interceptorInput,
|
|
2791
|
-
query: raw.query,
|
|
2792
2724
|
})
|
|
2793
2725
|
if (beforeInterceptors.errorResponse) return beforeInterceptors.errorResponse
|
|
2794
2726
|
interceptorRequestPayload = beforeInterceptors.requestPayload
|
|
@@ -2913,7 +2845,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2913
2845
|
request,
|
|
2914
2846
|
method: 'DELETE',
|
|
2915
2847
|
body: idFrom === 'query' ? undefined : ({ id } as Record<string, unknown>),
|
|
2916
|
-
query: idFrom === 'query' ?
|
|
2848
|
+
query: idFrom === 'query' ? ({ id } as Record<string, unknown>) : undefined,
|
|
2917
2849
|
})
|
|
2918
2850
|
if (beforeInterceptors.errorResponse) return beforeInterceptors.errorResponse
|
|
2919
2851
|
interceptorRequestPayload = beforeInterceptors.requestPayload
|
|
@@ -6,7 +6,7 @@ import type {
|
|
|
6
6
|
InterceptorBeforeResult,
|
|
7
7
|
} from './api-interceptor'
|
|
8
8
|
import { getApiInterceptorsForRoute } from './interceptor-registry'
|
|
9
|
-
import {
|
|
9
|
+
import { hasAllFeatures } from '../../security/features'
|
|
10
10
|
import { logInterceptorActivity } from '../umes/interceptor-activity'
|
|
11
11
|
|
|
12
12
|
const DEFAULT_TIMEOUT_MS = 5000
|
|
@@ -39,7 +39,7 @@ function sanitizeObject(input?: Record<string, unknown>): Record<string, unknown
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
function hasRequiredFeatures(features: string[] | undefined, userFeatures: string[] | undefined): boolean {
|
|
42
|
-
return
|
|
42
|
+
return hasAllFeatures(userFeatures, features)
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
function timeoutPromise(ms: number, interceptorId: string): Promise<never> {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AwilixContainer } from 'awilix'
|
|
2
|
-
import {
|
|
2
|
+
import { hasAllFeatures } from '../../security/features'
|
|
3
3
|
import { resolveCrudMutationGuardService } from './mutation-guard-service'
|
|
4
4
|
|
|
5
5
|
// ---------------------------------------------------------------------------
|
|
@@ -101,7 +101,7 @@ export async function runMutationGuards(
|
|
|
101
101
|
const matching = guards
|
|
102
102
|
.filter((g) => matchesEntity(g.targetEntity, input.resourceKind))
|
|
103
103
|
.filter((g) => g.operations.includes(input.operation))
|
|
104
|
-
.filter((g) =>
|
|
104
|
+
.filter((g) => hasAllFeatures(context.userFeatures, g.features))
|
|
105
105
|
.sort((a, b) => (a.priority ?? 50) - (b.priority ?? 50))
|
|
106
106
|
|
|
107
107
|
let payload = input.mutationPayload
|
package/src/lib/crud/types.ts
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
export type CrudEventAction = 'created' | 'updated' | 'deleted'
|
|
2
2
|
|
|
3
|
-
/** Internal payload marker: the data engine owns this CRUD event's query-index decision. */
|
|
4
|
-
export const CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY = '__omQueryIndexManaged' as const
|
|
5
|
-
|
|
6
3
|
export type CrudEntityIdentifiers = {
|
|
7
4
|
id: string
|
|
8
5
|
organizationId: string | null
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import type { AwilixContainer } from 'awilix'
|
|
2
2
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
3
3
|
import { DefaultDataEngine } from '../engine'
|
|
4
|
-
import {
|
|
5
|
-
CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY,
|
|
6
|
-
type CrudEventsConfig,
|
|
7
|
-
type CrudIndexerConfig,
|
|
8
|
-
} from '../../crud/types'
|
|
4
|
+
import type { CrudEventsConfig, CrudIndexerConfig } from '../../crud/types'
|
|
9
5
|
|
|
10
6
|
// The bulk-import deferral (`suppress`) must gate the two per-record side effects `emitOrmEntityEvent`
|
|
11
7
|
// fans out — the `<module>.<entity>.<action>` domain event and the inline `query_index.upsert_one`
|
|
@@ -29,15 +25,6 @@ function buildEngine() {
|
|
|
29
25
|
return { engine, emitEvent, emittedNames }
|
|
30
26
|
}
|
|
31
27
|
|
|
32
|
-
function expectManagedDomainPayload(emitEvent: jest.Mock, eventName: string): void {
|
|
33
|
-
const call = emitEvent.mock.calls.find(([name]) => name === eventName)
|
|
34
|
-
expect(call).toBeDefined()
|
|
35
|
-
const payload = call?.[1] as Record<string, unknown>
|
|
36
|
-
expect(payload[CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY]).toBe(true)
|
|
37
|
-
expect(Object.keys(payload)).not.toContain(CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY)
|
|
38
|
-
expect(JSON.stringify(payload)).not.toContain(CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY)
|
|
39
|
-
}
|
|
40
|
-
|
|
41
28
|
describe('DefaultDataEngine bulk-import suppression', () => {
|
|
42
29
|
// The test events are intentionally not registered in the event registry; silence the
|
|
43
30
|
// one-time "undeclared event" warning so it doesn't clutter the suite output.
|
|
@@ -46,10 +33,9 @@ describe('DefaultDataEngine bulk-import suppression', () => {
|
|
|
46
33
|
afterAll(() => { warnSpy.mockRestore() })
|
|
47
34
|
|
|
48
35
|
it('emits both the domain event and the reindex when unsuppressed', async () => {
|
|
49
|
-
const { engine,
|
|
36
|
+
const { engine, emittedNames } = buildEngine()
|
|
50
37
|
await engine.emitOrmEntityEvent({ action: 'created', entity: {}, events: EVENTS, indexer: INDEXER, identifiers: IDENTIFIERS })
|
|
51
38
|
expect(emittedNames()).toEqual(expect.arrayContaining(['sales.order.created', 'query_index.upsert_one']))
|
|
52
|
-
expectManagedDomainPayload(emitEvent, 'sales.order.created')
|
|
53
39
|
})
|
|
54
40
|
|
|
55
41
|
it('skips the domain event but keeps the reindex with skipEvents', async () => {
|
|
@@ -61,12 +47,11 @@ describe('DefaultDataEngine bulk-import suppression', () => {
|
|
|
61
47
|
})
|
|
62
48
|
|
|
63
49
|
it('skips the reindex but keeps the domain event with skipReindex', async () => {
|
|
64
|
-
const { engine,
|
|
50
|
+
const { engine, emittedNames } = buildEngine()
|
|
65
51
|
await engine.emitOrmEntityEvent({ action: 'created', entity: {}, events: EVENTS, indexer: INDEXER, identifiers: IDENTIFIERS, suppress: { skipReindex: true } })
|
|
66
52
|
const names = emittedNames()
|
|
67
53
|
expect(names).toContain('sales.order.created')
|
|
68
54
|
expect(names).not.toContain('query_index.upsert_one')
|
|
69
|
-
expectManagedDomainPayload(emitEvent, 'sales.order.created')
|
|
70
55
|
})
|
|
71
56
|
|
|
72
57
|
it('emits nothing when both are suppressed', async () => {
|