@open-mercato/shared 0.6.4-develop.4382.1.6b4f656b77 → 0.6.4
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 +10 -0
- package/dist/lib/auth/apiKeyAuthCache.js +17 -6
- package/dist/lib/auth/apiKeyAuthCache.js.map +2 -2
- package/dist/lib/commands/command-bus.js +56 -47
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/commands/flush.js +23 -1
- package/dist/lib/commands/flush.js.map +2 -2
- package/dist/lib/commands/index.js +6 -1
- package/dist/lib/commands/index.js.map +2 -2
- package/dist/lib/commands/redo.js +106 -0
- package/dist/lib/commands/redo.js.map +7 -0
- package/dist/lib/commands/runCrudCommandWrite.js +38 -0
- package/dist/lib/commands/runCrudCommandWrite.js.map +7 -0
- package/dist/lib/commands/scope.js +51 -37
- package/dist/lib/commands/scope.js.map +2 -2
- package/dist/lib/commands/types.js.map +2 -2
- package/dist/lib/crud/errors.js +22 -0
- package/dist/lib/crud/errors.js.map +2 -2
- package/dist/lib/crud/factory.js +16 -0
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/crud/optimistic-lock-command.js +109 -0
- package/dist/lib/crud/optimistic-lock-command.js.map +7 -0
- package/dist/lib/crud/optimistic-lock-headers.js +15 -0
- package/dist/lib/crud/optimistic-lock-headers.js.map +7 -0
- package/dist/lib/crud/optimistic-lock-store.js +52 -0
- package/dist/lib/crud/optimistic-lock-store.js.map +7 -0
- package/dist/lib/crud/optimistic-lock.js +172 -0
- package/dist/lib/crud/optimistic-lock.js.map +7 -0
- package/dist/lib/data/engine.js +2 -2
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/di/container.js +18 -2
- package/dist/lib/di/container.js.map +2 -2
- package/dist/lib/encryption/aes.js +37 -3
- package/dist/lib/encryption/aes.js.map +2 -2
- package/dist/lib/encryption/kms.js +57 -23
- package/dist/lib/encryption/kms.js.map +2 -2
- package/dist/lib/encryption/subscriber.js +41 -8
- package/dist/lib/encryption/subscriber.js.map +2 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js +35 -7
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
- package/dist/lib/i18n/context.js +5 -0
- package/dist/lib/i18n/context.js.map +2 -2
- package/dist/lib/query/engine.js +41 -31
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/integrations/types.js.map +2 -2
- package/dist/modules/search.js.map +2 -2
- package/package.json +8 -9
- package/src/lib/auth/__tests__/apiKeyAuthCache.test.ts +35 -0
- package/src/lib/auth/apiKeyAuthCache.ts +20 -6
- package/src/lib/commands/__tests__/command-bus.cache.test.ts +2 -0
- package/src/lib/commands/__tests__/command-bus.undo-audit.test.ts +2 -0
- package/src/lib/commands/__tests__/command-bus.undo-toctou.test.ts +122 -0
- package/src/lib/commands/__tests__/flush.test.ts +110 -9
- package/src/lib/commands/__tests__/redo.test.ts +265 -0
- package/src/lib/commands/__tests__/runCrudCommandWrite.test.ts +390 -0
- package/src/lib/commands/__tests__/scope.test.ts +48 -0
- package/src/lib/commands/command-bus.ts +62 -44
- package/src/lib/commands/flush.ts +79 -2
- package/src/lib/commands/index.ts +9 -0
- package/src/lib/commands/redo.ts +235 -0
- package/src/lib/commands/runCrudCommandWrite.ts +82 -0
- package/src/lib/commands/scope.ts +70 -55
- package/src/lib/commands/types.ts +54 -1
- package/src/lib/crud/__tests__/crud-factory.test.ts +106 -0
- package/src/lib/crud/__tests__/optimistic-lock-command.test.ts +425 -0
- package/src/lib/crud/__tests__/optimistic-lock-store.test.ts +194 -0
- package/src/lib/crud/__tests__/optimistic-lock.test.ts +526 -0
- package/src/lib/crud/errors.ts +29 -0
- package/src/lib/crud/factory.ts +23 -0
- package/src/lib/crud/optimistic-lock-command.ts +305 -0
- package/src/lib/crud/optimistic-lock-headers.ts +30 -0
- package/src/lib/crud/optimistic-lock-store.ts +87 -0
- package/src/lib/crud/optimistic-lock.ts +379 -0
- package/src/lib/data/engine.ts +11 -8
- package/src/lib/di/container.ts +17 -1
- package/src/lib/encryption/__tests__/dek-lifecycle.test.ts +194 -0
- package/src/lib/encryption/__tests__/kms.test.ts +44 -6
- package/src/lib/encryption/__tests__/lookupHash.test.ts +113 -0
- package/src/lib/encryption/__tests__/subscriber.change-tracking.test.ts +96 -0
- package/src/lib/encryption/__tests__/subscriber.deep-decrypt-collections.test.ts +123 -0
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +68 -1
- package/src/lib/encryption/aes.ts +78 -2
- package/src/lib/encryption/kms.ts +76 -24
- package/src/lib/encryption/subscriber.ts +54 -9
- package/src/lib/encryption/tenantDataEncryptionService.ts +53 -8
- package/src/lib/i18n/context.tsx +11 -0
- package/src/lib/query/__tests__/resolve-registered-entity-table.test.ts +83 -0
- package/src/lib/query/engine.ts +59 -30
- package/src/modules/integrations/types.ts +14 -0
- package/src/modules/notifications/handler.ts +7 -0
- package/src/modules/search.ts +9 -0
- package/src/modules/vector.ts +7 -0
|
@@ -1,44 +1,69 @@
|
|
|
1
1
|
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
2
2
|
import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
|
|
3
3
|
import { isOrganizationAccessAllowed } from '@open-mercato/shared/lib/auth/organizationAccess'
|
|
4
|
+
import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
|
|
4
5
|
import { env } from 'process'
|
|
5
6
|
|
|
7
|
+
function buildScopeLogContext(ctx: CommandRuntimeContext) {
|
|
8
|
+
const requestInfo =
|
|
9
|
+
ctx.request && typeof ctx.request === 'object'
|
|
10
|
+
? {
|
|
11
|
+
method: (ctx.request as Request).method ?? undefined,
|
|
12
|
+
url: (ctx.request as Request).url ?? undefined,
|
|
13
|
+
}
|
|
14
|
+
: null
|
|
15
|
+
const scope = ctx.organizationScope
|
|
16
|
+
? {
|
|
17
|
+
selectedId: ctx.organizationScope.selectedId ?? null,
|
|
18
|
+
tenantId: ctx.organizationScope.tenantId ?? null,
|
|
19
|
+
allowedIdsCount: Array.isArray(ctx.organizationScope.allowedIds)
|
|
20
|
+
? ctx.organizationScope.allowedIds.length
|
|
21
|
+
: null,
|
|
22
|
+
filterIdsCount: Array.isArray(ctx.organizationScope.filterIds)
|
|
23
|
+
? ctx.organizationScope.filterIds.length
|
|
24
|
+
: null,
|
|
25
|
+
}
|
|
26
|
+
: null
|
|
27
|
+
return {
|
|
28
|
+
userId: ctx.auth?.sub ?? null,
|
|
29
|
+
actorTenantId: ctx.auth?.tenantId ?? null,
|
|
30
|
+
actorOrganizationId: ctx.auth?.orgId ?? null,
|
|
31
|
+
selectedOrganizationId: ctx.selectedOrganizationId ?? null,
|
|
32
|
+
organizationIdsCount: Array.isArray(ctx.organizationIds) ? ctx.organizationIds.length : null,
|
|
33
|
+
scope,
|
|
34
|
+
request: requestInfo,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isStrictOrganizationScopeEnforced(): boolean {
|
|
39
|
+
return parseBooleanWithDefault(env.OM_ENFORCE_ORG_SCOPE_STRICT, false)
|
|
40
|
+
}
|
|
41
|
+
|
|
6
42
|
function logScopeViolation(
|
|
7
43
|
ctx: CommandRuntimeContext,
|
|
8
44
|
expected: string,
|
|
9
45
|
actual: string | null
|
|
10
46
|
): void {
|
|
11
47
|
try {
|
|
12
|
-
const requestInfo =
|
|
13
|
-
ctx.request && typeof ctx.request === 'object'
|
|
14
|
-
? {
|
|
15
|
-
method: (ctx.request as Request).method ?? undefined,
|
|
16
|
-
url: (ctx.request as Request).url ?? undefined,
|
|
17
|
-
}
|
|
18
|
-
: null
|
|
19
|
-
const scope = ctx.organizationScope
|
|
20
|
-
? {
|
|
21
|
-
selectedId: ctx.organizationScope.selectedId ?? null,
|
|
22
|
-
tenantId: ctx.organizationScope.tenantId ?? null,
|
|
23
|
-
allowedIdsCount: Array.isArray(ctx.organizationScope.allowedIds)
|
|
24
|
-
? ctx.organizationScope.allowedIds.length
|
|
25
|
-
: null,
|
|
26
|
-
filterIdsCount: Array.isArray(ctx.organizationScope.filterIds)
|
|
27
|
-
? ctx.organizationScope.filterIds.length
|
|
28
|
-
: null,
|
|
29
|
-
}
|
|
30
|
-
: null
|
|
31
48
|
if (env.NODE_ENV !== 'test') {
|
|
32
49
|
console.warn('[scope] Forbidden organization scope mismatch detected', {
|
|
33
50
|
expectedId: expected,
|
|
34
51
|
actualId: actual,
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
52
|
+
...buildScopeLogContext(ctx),
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// best-effort logging
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function logUnscopedOrganizationAccess(ctx: CommandRuntimeContext, organizationId: string): void {
|
|
61
|
+
try {
|
|
62
|
+
if (env.NODE_ENV !== 'test') {
|
|
63
|
+
console.warn('[scope] Unscoped organization command executed without organization context', {
|
|
64
|
+
targetOrganizationId: organizationId,
|
|
65
|
+
strictEnforcement: isStrictOrganizationScopeEnforced(),
|
|
66
|
+
...buildScopeLogContext(ctx),
|
|
42
67
|
})
|
|
43
68
|
}
|
|
44
69
|
} catch {
|
|
@@ -52,36 +77,11 @@ function logTenantScopeViolation(
|
|
|
52
77
|
actualTenantId: string | null
|
|
53
78
|
): void {
|
|
54
79
|
try {
|
|
55
|
-
const requestInfo =
|
|
56
|
-
ctx.request && typeof ctx.request === 'object'
|
|
57
|
-
? {
|
|
58
|
-
method: (ctx.request as Request).method ?? undefined,
|
|
59
|
-
url: (ctx.request as Request).url ?? undefined,
|
|
60
|
-
}
|
|
61
|
-
: null
|
|
62
|
-
const scope = ctx.organizationScope
|
|
63
|
-
? {
|
|
64
|
-
selectedId: ctx.organizationScope.selectedId ?? null,
|
|
65
|
-
tenantId: ctx.organizationScope.tenantId ?? null,
|
|
66
|
-
allowedIdsCount: Array.isArray(ctx.organizationScope.allowedIds)
|
|
67
|
-
? ctx.organizationScope.allowedIds.length
|
|
68
|
-
: null,
|
|
69
|
-
filterIdsCount: Array.isArray(ctx.organizationScope.filterIds)
|
|
70
|
-
? ctx.organizationScope.filterIds.length
|
|
71
|
-
: null,
|
|
72
|
-
}
|
|
73
|
-
: null
|
|
74
80
|
if (env.NODE_ENV !== 'test') {
|
|
75
81
|
console.warn('[scope] Forbidden tenant scope mismatch detected', {
|
|
76
82
|
expectedTenantId,
|
|
77
83
|
actualTenantId,
|
|
78
|
-
|
|
79
|
-
actorTenantId: ctx.auth?.tenantId ?? null,
|
|
80
|
-
actorOrganizationId: ctx.auth?.orgId ?? null,
|
|
81
|
-
selectedOrganizationId: ctx.selectedOrganizationId ?? null,
|
|
82
|
-
organizationIdsCount: Array.isArray(ctx.organizationIds) ? ctx.organizationIds.length : null,
|
|
83
|
-
scope,
|
|
84
|
-
request: requestInfo,
|
|
84
|
+
...buildScopeLogContext(ctx),
|
|
85
85
|
})
|
|
86
86
|
}
|
|
87
87
|
} catch {
|
|
@@ -100,9 +100,24 @@ export function ensureOrganizationScope(ctx: CommandRuntimeContext, organization
|
|
|
100
100
|
if (!scope) {
|
|
101
101
|
if (isSuperAdmin) return
|
|
102
102
|
const currentOrg = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null
|
|
103
|
-
if (currentOrg
|
|
104
|
-
|
|
105
|
-
|
|
103
|
+
if (currentOrg) {
|
|
104
|
+
if (currentOrg !== organizationId) {
|
|
105
|
+
logScopeViolation(ctx, organizationId, currentOrg)
|
|
106
|
+
throw new CrudHttpError(403, { error: 'Forbidden' })
|
|
107
|
+
}
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
// No current org could be resolved either. This branch previously returned
|
|
111
|
+
// with no validation and no signal — a fail-open-by-omission shape (#2441):
|
|
112
|
+
// a new command path reaching here with `organizationScope: null` would act
|
|
113
|
+
// on an arbitrary target org silently. Preserve the legacy allow behavior by
|
|
114
|
+
// default (the path is load-bearing) but make the unscoped access observable,
|
|
115
|
+
// and let operators harden it into a deny via OM_ENFORCE_ORG_SCOPE_STRICT.
|
|
116
|
+
if (organizationId) {
|
|
117
|
+
logUnscopedOrganizationAccess(ctx, organizationId)
|
|
118
|
+
if (isStrictOrganizationScopeEnforced()) {
|
|
119
|
+
throw new CrudHttpError(403, { error: 'Forbidden' })
|
|
120
|
+
}
|
|
106
121
|
}
|
|
107
122
|
return
|
|
108
123
|
}
|
|
@@ -54,6 +54,38 @@ export type CommandExecuteResult<TResult> = {
|
|
|
54
54
|
logEntry: any | null
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Shape of the persisted action log handed to a command's `undo()` handler.
|
|
59
|
+
*
|
|
60
|
+
* IMPORTANT: there is intentionally **no `payload` field**. `buildLog()` returns
|
|
61
|
+
* a `payload` in its metadata, but the command bus persists that under
|
|
62
|
+
* `commandPayload` (column `command_payload`, wrapped in a redo envelope) — the
|
|
63
|
+
* stored row never has a top-level `payload`. Reading `logEntry.payload` in an
|
|
64
|
+
* undo handler is therefore always `undefined` and silently no-ops the undo
|
|
65
|
+
* (issue #2504). Always read the undo snapshot through
|
|
66
|
+
* `extractUndoPayload(logEntry)` from `@open-mercato/shared/lib/commands/undo`,
|
|
67
|
+
* which unwraps `commandPayload`/snapshots correctly. Omitting `payload` here
|
|
68
|
+
* makes the footgun a compile-time error instead of a runtime silent failure.
|
|
69
|
+
*/
|
|
70
|
+
export type CommandUndoLogEntry = {
|
|
71
|
+
id?: string
|
|
72
|
+
commandId?: string
|
|
73
|
+
commandPayload?: unknown | null
|
|
74
|
+
snapshotBefore?: unknown | null
|
|
75
|
+
snapshotAfter?: unknown | null
|
|
76
|
+
resourceKind?: string | null
|
|
77
|
+
resourceId?: string | null
|
|
78
|
+
undoToken?: string | null
|
|
79
|
+
actionLabel?: string | null
|
|
80
|
+
tenantId?: string | null
|
|
81
|
+
organizationId?: string | null
|
|
82
|
+
actorUserId?: string | null
|
|
83
|
+
changesJson?: Record<string, unknown> | null
|
|
84
|
+
contextJson?: Record<string, unknown> | null
|
|
85
|
+
createdAt?: Date | string
|
|
86
|
+
updatedAt?: Date | string
|
|
87
|
+
}
|
|
88
|
+
|
|
57
89
|
export type CommandLogBuilderArgs<TInput, TResult> = {
|
|
58
90
|
input: TInput
|
|
59
91
|
result: TResult
|
|
@@ -71,7 +103,18 @@ export interface CommandHandler<TInput = unknown, TResult = unknown> {
|
|
|
71
103
|
execute(input: TInput, ctx: CommandRuntimeContext): Promise<TResult> | TResult
|
|
72
104
|
buildLog?(args: CommandLogBuilderArgs<TInput, TResult>): Promise<CommandLogMetadata | null | undefined> | CommandLogMetadata | null | undefined
|
|
73
105
|
captureAfter?(input: TInput, result: TResult, ctx: CommandRuntimeContext): Promise<unknown> | unknown
|
|
74
|
-
undo?(params: { input: TInput; ctx: CommandRuntimeContext; logEntry:
|
|
106
|
+
undo?(params: { input: TInput; ctx: CommandRuntimeContext; logEntry: CommandUndoLogEntry }): Promise<void> | void
|
|
107
|
+
/**
|
|
108
|
+
* Optional redo handler. When defined, the command bus calls this instead of
|
|
109
|
+
* `execute()` while replaying a previously undone action (the redo route passes
|
|
110
|
+
* `redoLogEntry` in the execution options). It receives the source action log so
|
|
111
|
+
* it can re-materialize the original record **reusing its id** — for a create
|
|
112
|
+
* command this restores the soft-deleted row (or re-creates it from the
|
|
113
|
+
* `snapshotAfter`) instead of minting a new id, keeping undo/redo snapshots and
|
|
114
|
+
* references stable (issue #2506, invariant I6). Handlers without `redo` keep the
|
|
115
|
+
* legacy behavior of replaying `execute(__redoInput)`.
|
|
116
|
+
*/
|
|
117
|
+
redo?(params: { input: TInput; ctx: CommandRuntimeContext; logEntry: CommandUndoLogEntry }): Promise<TResult> | TResult
|
|
75
118
|
}
|
|
76
119
|
|
|
77
120
|
export type CommandExecutionOptions<TInput> = {
|
|
@@ -79,6 +122,16 @@ export type CommandExecutionOptions<TInput> = {
|
|
|
79
122
|
ctx: CommandRuntimeContext
|
|
80
123
|
metadata?: CommandLogMetadata | null
|
|
81
124
|
skipCacheInvalidation?: boolean
|
|
125
|
+
/**
|
|
126
|
+
* When set, marks this execution as a redo of a previously undone action. If the
|
|
127
|
+
* resolved handler defines a `redo` method, the command bus calls
|
|
128
|
+
* `handler.redo({ input, ctx, logEntry })` instead of `handler.execute(...)`. The
|
|
129
|
+
* rest of the pipeline (snapshots, buildLog, undo-token minting, persistence,
|
|
130
|
+
* cache invalidation, side effects) is identical, so the fresh log entry — and
|
|
131
|
+
* the `x-om-operation` header derived from it — automatically carry the restored
|
|
132
|
+
* resourceId. Ignored when the handler has no `redo` method (legacy replay path).
|
|
133
|
+
*/
|
|
134
|
+
redoLogEntry?: CommandUndoLogEntry | null
|
|
82
135
|
}
|
|
83
136
|
|
|
84
137
|
export function defaultUndoToken(): string {
|
|
@@ -4,6 +4,11 @@ jest.mock('@open-mercato/cache', () => ({
|
|
|
4
4
|
|
|
5
5
|
import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
|
|
6
6
|
import { registerApiInterceptors } from '@open-mercato/shared/lib/crud/interceptor-registry'
|
|
7
|
+
import {
|
|
8
|
+
clearOptimisticLockReadersForTests,
|
|
9
|
+
getAllOptimisticLockReaders,
|
|
10
|
+
registerOptimisticLockReaders,
|
|
11
|
+
} from '@open-mercato/shared/lib/crud/optimistic-lock-store'
|
|
7
12
|
import { loadCustomFieldDefinitionIndex } from '@open-mercato/shared/lib/crud/custom-fields'
|
|
8
13
|
import { z } from 'zod'
|
|
9
14
|
|
|
@@ -754,3 +759,104 @@ describe('CRUD Factory', () => {
|
|
|
754
759
|
expect(body._interceptor).toEqual({ ok: true, count: 1 })
|
|
755
760
|
})
|
|
756
761
|
})
|
|
762
|
+
|
|
763
|
+
describe('CRUD Factory — optimistic-lock auto-registration', () => {
|
|
764
|
+
beforeEach(() => {
|
|
765
|
+
clearOptimisticLockReadersForTests()
|
|
766
|
+
})
|
|
767
|
+
|
|
768
|
+
afterAll(() => {
|
|
769
|
+
clearOptimisticLockReadersForTests()
|
|
770
|
+
})
|
|
771
|
+
|
|
772
|
+
function makeMinimalRoute(opts: { eventsResource: string; entity: any }) {
|
|
773
|
+
return makeCrudRoute({
|
|
774
|
+
metadata: { GET: { requireAuth: true } },
|
|
775
|
+
orm: { entity: opts.entity, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId' },
|
|
776
|
+
events: { module: opts.eventsResource.split('.')[0], entity: opts.eventsResource.split('.')[1], persistent: false } as any,
|
|
777
|
+
list: { schema: z.object({}).passthrough() as any },
|
|
778
|
+
create: {
|
|
779
|
+
commandId: `${opts.eventsResource}.create`,
|
|
780
|
+
schema: z.object({}).passthrough() as any,
|
|
781
|
+
},
|
|
782
|
+
update: {
|
|
783
|
+
commandId: `${opts.eventsResource}.update`,
|
|
784
|
+
schema: z.object({ id: z.string() }).passthrough() as any,
|
|
785
|
+
},
|
|
786
|
+
del: {
|
|
787
|
+
commandId: `${opts.eventsResource}.delete`,
|
|
788
|
+
schema: z.object({ id: z.string() }).passthrough() as any,
|
|
789
|
+
},
|
|
790
|
+
})
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
it('auto-registers a reader for the route resourceKind at factory call time', () => {
|
|
794
|
+
expect(getAllOptimisticLockReaders()).toEqual({})
|
|
795
|
+
makeMinimalRoute({ eventsResource: 'example.todo', entity: Todo })
|
|
796
|
+
const all = getAllOptimisticLockReaders()
|
|
797
|
+
expect(Object.keys(all)).toContain('example.todo')
|
|
798
|
+
expect(typeof all['example.todo']).toBe('function')
|
|
799
|
+
})
|
|
800
|
+
|
|
801
|
+
it('does NOT override an existing hand-wired reader (IfAbsent semantics)', () => {
|
|
802
|
+
const handWired = async () => 'hand-wired'
|
|
803
|
+
registerOptimisticLockReaders({ 'example.todo': handWired })
|
|
804
|
+
makeMinimalRoute({ eventsResource: 'example.todo', entity: Todo })
|
|
805
|
+
expect(getAllOptimisticLockReaders()['example.todo']).toBe(handWired)
|
|
806
|
+
})
|
|
807
|
+
|
|
808
|
+
it('skips registration when the entity has no resolvable resourceKind', () => {
|
|
809
|
+
expect(getAllOptimisticLockReaders()).toEqual({})
|
|
810
|
+
// Route with no events.module + no command IDs → resourceKind falls back to 'resource'
|
|
811
|
+
makeCrudRoute({
|
|
812
|
+
metadata: { GET: { requireAuth: true } },
|
|
813
|
+
orm: { entity: Todo },
|
|
814
|
+
list: { schema: z.object({}).passthrough() as any },
|
|
815
|
+
} as any)
|
|
816
|
+
// 'resource' is filtered out by the auto-registration guard
|
|
817
|
+
expect(getAllOptimisticLockReaders()['resource']).toBeUndefined()
|
|
818
|
+
})
|
|
819
|
+
|
|
820
|
+
it('the registered reader projects only updatedAt and fails open on schema mismatch', async () => {
|
|
821
|
+
makeMinimalRoute({ eventsResource: 'example.todo', entity: Todo })
|
|
822
|
+
const reader = getAllOptimisticLockReaders()['example.todo']
|
|
823
|
+
expect(reader).toBeDefined()
|
|
824
|
+
let captured: { entity: unknown; filter: Record<string, unknown>; options?: Record<string, unknown> } | null = null
|
|
825
|
+
const fakeEm = {
|
|
826
|
+
async findOne(entity: unknown, filter: Record<string, unknown>, options?: Record<string, unknown>) {
|
|
827
|
+
captured = { entity, filter, options }
|
|
828
|
+
return { updatedAt: new Date('2026-05-26T07:30:00.000Z') }
|
|
829
|
+
},
|
|
830
|
+
} as never
|
|
831
|
+
const out = await reader!(fakeEm, {
|
|
832
|
+
resourceKind: 'example.todo',
|
|
833
|
+
resourceId: 'todo-1',
|
|
834
|
+
tenantId: 'tenant-1',
|
|
835
|
+
organizationId: 'org-1',
|
|
836
|
+
})
|
|
837
|
+
expect(out).toBe('2026-05-26T07:30:00.000Z')
|
|
838
|
+
expect(captured).not.toBeNull()
|
|
839
|
+
expect(captured!.entity).toBe(Todo)
|
|
840
|
+
expect(captured!.filter).toEqual({
|
|
841
|
+
id: 'todo-1',
|
|
842
|
+
tenantId: 'tenant-1',
|
|
843
|
+
organizationId: 'org-1',
|
|
844
|
+
deletedAt: null,
|
|
845
|
+
})
|
|
846
|
+
expect(captured!.options).toEqual({ fields: ['updatedAt'] })
|
|
847
|
+
|
|
848
|
+
// Fail-open contract: throwing findOne yields null, not a re-thrown error.
|
|
849
|
+
const throwingEm = {
|
|
850
|
+
async findOne() {
|
|
851
|
+
throw new Error('schema mismatch')
|
|
852
|
+
},
|
|
853
|
+
} as never
|
|
854
|
+
const safe = await reader!(throwingEm, {
|
|
855
|
+
resourceKind: 'example.todo',
|
|
856
|
+
resourceId: 'todo-1',
|
|
857
|
+
tenantId: 'tenant-1',
|
|
858
|
+
organizationId: 'org-1',
|
|
859
|
+
})
|
|
860
|
+
expect(safe).toBeNull()
|
|
861
|
+
})
|
|
862
|
+
})
|