@open-mercato/shared 0.8.1-develop.7296.1.2111d779db → 0.9.0
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 +2 -0
- package/dist/lib/auth/jwt.js +4 -0
- package/dist/lib/auth/jwt.js.map +2 -2
- package/dist/lib/auth/organizationScope.js +15 -1
- package/dist/lib/auth/organizationScope.js.map +2 -2
- package/dist/lib/availability/catalogOnlyProvider.js +77 -0
- package/dist/lib/availability/catalogOnlyProvider.js.map +7 -0
- package/dist/lib/availability/index.js +8 -0
- package/dist/lib/availability/index.js.map +7 -0
- package/dist/lib/availability/registry.js +47 -0
- package/dist/lib/availability/registry.js.map +7 -0
- package/dist/lib/availability/types.js +7 -0
- package/dist/lib/availability/types.js.map +7 -0
- package/dist/lib/catalog-visibility/index.js +11 -0
- package/dist/lib/catalog-visibility/index.js.map +7 -0
- package/dist/lib/catalog-visibility/intersectScopes.js +49 -0
- package/dist/lib/catalog-visibility/intersectScopes.js.map +7 -0
- package/dist/lib/catalog-visibility/matchesOne.js +27 -0
- package/dist/lib/catalog-visibility/matchesOne.js.map +7 -0
- package/dist/lib/catalog-visibility/matchesScope.js +9 -0
- package/dist/lib/catalog-visibility/matchesScope.js.map +7 -0
- package/dist/lib/catalog-visibility/types.js +1 -0
- package/dist/lib/catalog-visibility/types.js.map +7 -0
- package/dist/lib/catalog-visibility/unionScopes.js +9 -0
- package/dist/lib/catalog-visibility/unionScopes.js.map +7 -0
- package/dist/lib/commands/command-bus.js +383 -125
- package/dist/lib/commands/command-bus.js.map +3 -3
- package/dist/lib/commands/flush.js +16 -0
- package/dist/lib/commands/flush.js.map +2 -2
- package/dist/lib/commands/index.js +8 -0
- package/dist/lib/commands/index.js.map +2 -2
- package/dist/lib/commands/redo.js +2 -7
- package/dist/lib/commands/redo.js.map +2 -2
- package/dist/lib/commands/transaction-lifetime.js +67 -0
- package/dist/lib/commands/transaction-lifetime.js.map +7 -0
- package/dist/lib/commands/types.js.map +2 -2
- package/dist/lib/commands/undo.js +54 -2
- package/dist/lib/commands/undo.js.map +2 -2
- package/dist/lib/crud/enricher-runner.js +64 -9
- package/dist/lib/crud/enricher-runner.js.map +2 -2
- package/dist/lib/crud/factory.js +42 -18
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/data/engine.js +12 -3
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/db/accentInsensitiveSearch.js +21 -0
- package/dist/lib/db/accentInsensitiveSearch.js.map +7 -0
- package/dist/lib/encryption/kms.js +1 -5
- package/dist/lib/encryption/kms.js.map +2 -2
- package/dist/lib/encryption/likeFilterWarning.js +3 -1
- package/dist/lib/encryption/likeFilterWarning.js.map +2 -2
- package/dist/lib/encryption/migration-backfill.js +69 -0
- package/dist/lib/encryption/migration-backfill.js.map +7 -0
- package/dist/lib/encryption/policyMemo.js +28 -0
- package/dist/lib/encryption/policyMemo.js.map +7 -0
- package/dist/lib/encryption/subscriber.js +30 -3
- package/dist/lib/encryption/subscriber.js.map +2 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js +113 -200
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +3 -3
- package/dist/lib/modules/api-route-identity.js +102 -0
- package/dist/lib/modules/api-route-identity.js.map +7 -0
- package/dist/lib/telemetry/runtime.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/events/factory.js +20 -0
- package/dist/modules/events/factory.js.map +2 -2
- package/dist/modules/registry.js +3 -1
- package/dist/modules/registry.js.map +2 -2
- package/package.json +12 -5
- package/src/lib/auth/__tests__/jwt.test.ts +12 -0
- package/src/lib/auth/__tests__/organizationScope.test.ts +27 -0
- package/src/lib/auth/jwt.ts +9 -0
- package/src/lib/auth/organizationScope.ts +39 -0
- package/src/lib/availability/__tests__/catalogOnlyProvider.test.ts +166 -0
- package/src/lib/availability/__tests__/registry.test.ts +130 -0
- package/src/lib/availability/catalogOnlyProvider.ts +152 -0
- package/src/lib/availability/index.ts +18 -0
- package/src/lib/availability/registry.ts +131 -0
- package/src/lib/availability/types.ts +86 -0
- package/src/lib/catalog-visibility/__tests__/intersectScopes.test.ts +168 -0
- package/src/lib/catalog-visibility/__tests__/matchesOne.test.ts +116 -0
- package/src/lib/catalog-visibility/__tests__/matchesScope.test.ts +35 -0
- package/src/lib/catalog-visibility/__tests__/testFixtures.ts +66 -0
- package/src/lib/catalog-visibility/__tests__/unionScopes.test.ts +62 -0
- package/src/lib/catalog-visibility/index.ts +5 -0
- package/src/lib/catalog-visibility/intersectScopes.ts +93 -0
- package/src/lib/catalog-visibility/matchesOne.ts +28 -0
- package/src/lib/catalog-visibility/matchesScope.ts +8 -0
- package/src/lib/catalog-visibility/types.ts +31 -0
- package/src/lib/catalog-visibility/unionScopes.ts +16 -0
- package/src/lib/commands/__tests__/command-bus.atomic-replay.test.ts +650 -0
- package/src/lib/commands/__tests__/command-bus.test.ts +175 -0
- package/src/lib/commands/__tests__/command-bus.undo-toctou.test.ts +1 -1
- package/src/lib/commands/__tests__/redo.test.ts +4 -0
- package/src/lib/commands/__tests__/transaction-lifetime.test.ts +118 -0
- package/src/lib/commands/__tests__/undo.test.ts +90 -1
- package/src/lib/commands/command-bus.ts +463 -148
- package/src/lib/commands/flush.ts +16 -0
- package/src/lib/commands/index.ts +7 -0
- package/src/lib/commands/redo.ts +4 -8
- package/src/lib/commands/transaction-lifetime.ts +88 -0
- package/src/lib/commands/types.ts +91 -0
- package/src/lib/commands/undo.ts +79 -1
- package/src/lib/crud/__tests__/crud-factory.cache-tenant-level.test.ts +149 -0
- package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +48 -6
- package/src/lib/crud/__tests__/crud-factory.test.ts +224 -27
- package/src/lib/crud/__tests__/enricher-runner.test.ts +270 -5
- package/src/lib/crud/enricher-runner.ts +83 -9
- package/src/lib/crud/factory.ts +46 -19
- package/src/lib/data/__tests__/engine.pin-organization-id.test.ts +102 -0
- package/src/lib/data/engine.ts +12 -0
- package/src/lib/db/__tests__/accentInsensitiveSearch.test.ts +51 -0
- package/src/lib/db/accentInsensitiveSearch.ts +70 -0
- package/src/lib/encryption/__tests__/dek-lifecycle.test.ts +11 -9
- package/src/lib/encryption/__tests__/fixtures/policy-process-worker.ts +96 -0
- package/src/lib/encryption/__tests__/kms.test.ts +45 -3
- package/src/lib/encryption/__tests__/likeFilterWarning.test.ts +43 -0
- package/src/lib/encryption/__tests__/migration-backfill.test.ts +57 -0
- package/src/lib/encryption/__tests__/process-policy-coherence.test.ts +128 -0
- package/src/lib/encryption/__tests__/subscriber.deep-decrypt-collections.test.ts +66 -1
- package/src/lib/encryption/__tests__/subscriber.test.ts +3 -0
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +412 -51
- package/src/lib/encryption/kms.ts +1 -6
- package/src/lib/encryption/likeFilterWarning.ts +10 -2
- package/src/lib/encryption/migration-backfill.ts +90 -0
- package/src/lib/encryption/policyMemo.ts +51 -0
- package/src/lib/encryption/subscriber.ts +41 -4
- package/src/lib/encryption/tenantDataEncryptionService.ts +153 -219
- package/src/lib/i18n/__tests__/detect-locale-narrowed.test.ts +20 -10
- package/src/lib/modules/api-route-identity.ts +116 -0
- package/src/lib/telemetry/runtime.ts +8 -0
- package/src/modules/__tests__/registry.test.ts +92 -0
- package/src/modules/events/__tests__/broadcast-coalescing-declaration.test.ts +53 -0
- package/src/modules/events/factory.ts +31 -0
- package/src/modules/events/types.ts +13 -0
- package/src/modules/notifications/types.ts +1 -0
- package/src/modules/registry.ts +4 -1
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
2
|
import type { IsolationLevel } from '@mikro-orm/core'
|
|
3
3
|
import { createLogger } from '../logger'
|
|
4
|
+
import { getTelemetryRuntime } from '../telemetry/runtime'
|
|
5
|
+
import {
|
|
6
|
+
beginTransactionLifetime,
|
|
7
|
+
completeTransactionLifetime,
|
|
8
|
+
} from './transaction-lifetime'
|
|
4
9
|
|
|
5
10
|
const logger = createLogger('shared').child({ component: 'commands' })
|
|
6
11
|
|
|
@@ -171,6 +176,7 @@ export async function withAtomicFlush(
|
|
|
171
176
|
}
|
|
172
177
|
|
|
173
178
|
await em.begin(options.isolationLevel ? { isolationLevel: options.isolationLevel } : undefined)
|
|
179
|
+
const transactionLifetime = beginTransactionLifetime(em)
|
|
174
180
|
try {
|
|
175
181
|
await runPhasesAndFlush()
|
|
176
182
|
await em.commit()
|
|
@@ -180,6 +186,16 @@ export async function withAtomicFlush(
|
|
|
180
186
|
} catch {
|
|
181
187
|
// rollback failure should not mask the original error; intentionally swallowed
|
|
182
188
|
}
|
|
189
|
+
try {
|
|
190
|
+
await completeTransactionLifetime(em, transactionLifetime, 'rolled_back')
|
|
191
|
+
} catch (completionErr) {
|
|
192
|
+
logger.error('withAtomicFlush: rollback completion failed', { label: options.label ?? null, err: completionErr })
|
|
193
|
+
getTelemetryRuntime()?.reportError(completionErr, {
|
|
194
|
+
module: 'shared',
|
|
195
|
+
code: 'shared.transaction_rollback_completion_failed',
|
|
196
|
+
})
|
|
197
|
+
}
|
|
183
198
|
throw err
|
|
184
199
|
}
|
|
200
|
+
await completeTransactionLifetime(em, transactionLifetime, 'committed')
|
|
185
201
|
}
|
|
@@ -4,6 +4,13 @@ export { CommandBus } from './command-bus'
|
|
|
4
4
|
export * from './customFieldSnapshots'
|
|
5
5
|
export * from './undo'
|
|
6
6
|
export * from './redo'
|
|
7
|
+
export {
|
|
8
|
+
getTransactionLifetime,
|
|
9
|
+
onTransactionLifetimeComplete,
|
|
10
|
+
ownsTransactionLifetime,
|
|
11
|
+
type TransactionLifetime,
|
|
12
|
+
type TransactionOutcome,
|
|
13
|
+
} from './transaction-lifetime'
|
|
7
14
|
export {
|
|
8
15
|
CommandInterceptorError,
|
|
9
16
|
isCommandInterceptorError,
|
package/src/lib/commands/redo.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { CommandRuntimeContext, CommandUndoLogEntry } from './types'
|
|
|
3
3
|
import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
|
|
4
4
|
import type { CrudEventsConfig, CrudIndexerConfig } from '@open-mercato/shared/lib/crud/types'
|
|
5
5
|
import { CrudHttpError, conflict, isUniqueViolation } from '@open-mercato/shared/lib/crud/errors'
|
|
6
|
-
import { extractUndoPayload, type UndoPayload } from './undo'
|
|
6
|
+
import { extractUndoPayload, reviveSnapshotDates, type UndoPayload } from './undo'
|
|
7
7
|
import { emitCrudSideEffects } from './helpers'
|
|
8
8
|
import { withAtomicFlush } from './flush'
|
|
9
9
|
|
|
@@ -22,7 +22,8 @@ const DEFAULT_SNAPSHOT_DATE_FIELDS = ['createdAt', 'updatedAt', 'deletedAt'] as
|
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* Turn an after-snapshot into a create seed by shallow-cloning it and reviving the
|
|
25
|
-
* declared date fields from ISO strings back to `Date
|
|
25
|
+
* declared date fields from ISO strings back to `Date` (via `reviveSnapshotDates`,
|
|
26
|
+
* which throws on an unparsable value). Single-row snapshots are a
|
|
26
27
|
* faithful serialized row whose keys already equal entity property names, so the
|
|
27
28
|
* snapshot doubles as the seed once dates are revived — no per-command mapping needed.
|
|
28
29
|
*/
|
|
@@ -30,12 +31,7 @@ export function reviveSnapshotSeed(
|
|
|
30
31
|
snapshot: Record<string, unknown>,
|
|
31
32
|
dateFields: readonly string[] = DEFAULT_SNAPSHOT_DATE_FIELDS,
|
|
32
33
|
): Record<string, unknown> {
|
|
33
|
-
|
|
34
|
-
for (const field of dateFields) {
|
|
35
|
-
const value = seed[field]
|
|
36
|
-
if (typeof value === 'string') seed[field] = new Date(value)
|
|
37
|
-
}
|
|
38
|
-
return seed
|
|
34
|
+
return reviveSnapshotDates(snapshot, dateFields)
|
|
39
35
|
}
|
|
40
36
|
|
|
41
37
|
/**
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
3
|
+
import { createLogger } from '../logger'
|
|
4
|
+
import { getTelemetryRuntime } from '../telemetry/runtime'
|
|
5
|
+
|
|
6
|
+
const logger = createLogger('shared').child({ component: 'transaction-lifetime' })
|
|
7
|
+
|
|
8
|
+
export type TransactionOutcome = 'committed' | 'rolled_back'
|
|
9
|
+
|
|
10
|
+
const transactionLifetimeBrand: unique symbol = Symbol('open-mercato.transaction-lifetime')
|
|
11
|
+
|
|
12
|
+
export type TransactionLifetime = {
|
|
13
|
+
readonly [transactionLifetimeBrand]: true
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type ActiveTransactionLifetime = TransactionLifetime & {
|
|
17
|
+
completionCallbacks: Set<(outcome: TransactionOutcome) => void | Promise<void>>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const activeTransactionLifetimes = new AsyncLocalStorage<Map<object, ActiveTransactionLifetime>>()
|
|
21
|
+
|
|
22
|
+
export function beginTransactionLifetime(em: EntityManager): TransactionLifetime {
|
|
23
|
+
const key = em as object
|
|
24
|
+
const currentLifetimes = activeTransactionLifetimes.getStore()
|
|
25
|
+
if (currentLifetimes?.has(key)) {
|
|
26
|
+
throw new Error('[internal] Transaction lifetime already active for EntityManager')
|
|
27
|
+
}
|
|
28
|
+
const lifetime: ActiveTransactionLifetime = {
|
|
29
|
+
[transactionLifetimeBrand]: true,
|
|
30
|
+
completionCallbacks: new Set(),
|
|
31
|
+
}
|
|
32
|
+
const scopedLifetimes = new Map(currentLifetimes)
|
|
33
|
+
scopedLifetimes.set(key, lifetime)
|
|
34
|
+
activeTransactionLifetimes.enterWith(scopedLifetimes)
|
|
35
|
+
return lifetime
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getTransactionLifetime(em: EntityManager): TransactionLifetime | null {
|
|
39
|
+
return activeTransactionLifetimes.getStore()?.get(em as object) ?? null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function ownsTransactionLifetime(
|
|
43
|
+
em: EntityManager,
|
|
44
|
+
lifetime: TransactionLifetime | null | undefined,
|
|
45
|
+
): lifetime is TransactionLifetime {
|
|
46
|
+
return lifetime != null && activeTransactionLifetimes.getStore()?.get(em as object) === lifetime
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function onTransactionLifetimeComplete(
|
|
50
|
+
em: EntityManager,
|
|
51
|
+
callback: (outcome: TransactionOutcome) => void | Promise<void>,
|
|
52
|
+
): void {
|
|
53
|
+
const lifetime = activeTransactionLifetimes.getStore()?.get(em as object)
|
|
54
|
+
if (!lifetime) {
|
|
55
|
+
throw new Error('[internal] Transaction-owned state requires an active transaction lifetime')
|
|
56
|
+
}
|
|
57
|
+
lifetime.completionCallbacks.add(callback)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function completeTransactionLifetime(
|
|
61
|
+
em: EntityManager,
|
|
62
|
+
lifetimeToken: TransactionLifetime,
|
|
63
|
+
outcome: TransactionOutcome,
|
|
64
|
+
): Promise<void> {
|
|
65
|
+
const key = em as object
|
|
66
|
+
const currentLifetimes = activeTransactionLifetimes.getStore()
|
|
67
|
+
const lifetime = currentLifetimes?.get(key)
|
|
68
|
+
if (!currentLifetimes || !lifetime || lifetime !== lifetimeToken) {
|
|
69
|
+
throw new Error('[internal] Transaction lifetime mismatch for EntityManager')
|
|
70
|
+
}
|
|
71
|
+
currentLifetimes.delete(key)
|
|
72
|
+
const failures: unknown[] = []
|
|
73
|
+
for (const callback of lifetime.completionCallbacks) {
|
|
74
|
+
try {
|
|
75
|
+
await callback(outcome)
|
|
76
|
+
} catch (err) {
|
|
77
|
+
failures.push(err)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
for (const err of failures) {
|
|
81
|
+
logger.error('Transaction completion callback failed', { outcome, failures: failures.length, err })
|
|
82
|
+
getTelemetryRuntime()?.reportError(err, {
|
|
83
|
+
module: 'shared',
|
|
84
|
+
code: 'shared.transaction_completion_callback_failed',
|
|
85
|
+
attributes: { outcome },
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -4,6 +4,7 @@ import type { ZodTypeAny } from 'zod'
|
|
|
4
4
|
import { randomUUID } from 'crypto'
|
|
5
5
|
import type { AuthContext } from '../auth/server'
|
|
6
6
|
import type { OrganizationScope } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
7
|
+
import type { TransactionLifetime } from './transaction-lifetime'
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Bulk-import / backfill deferral flags. When a command runs under a context that
|
|
@@ -26,6 +27,14 @@ export type BulkImportSuppression = {
|
|
|
26
27
|
skipEvents?: boolean
|
|
27
28
|
/** Advisory: handlers that fan out per-record notifications SHOULD honor this and skip them. */
|
|
28
29
|
skipNotifications?: boolean
|
|
30
|
+
/**
|
|
31
|
+
* Skip a command's own full-collection derived-data rebuild (e.g. category tree
|
|
32
|
+
* `parentId`/`treePath`/`depth`/ancestor-descendant rewrite). The caller MUST run that rebuild
|
|
33
|
+
* itself exactly once after the batch, in a `finally` that covers the completed, cancelled and
|
|
34
|
+
* failed paths — otherwise every record written under this context is left with a stale
|
|
35
|
+
* derived shape.
|
|
36
|
+
*/
|
|
37
|
+
skipDerivedRebuild?: boolean
|
|
29
38
|
}
|
|
30
39
|
|
|
31
40
|
export type CommandRuntimeContext = {
|
|
@@ -58,6 +67,27 @@ export type CommandRuntimeContext = {
|
|
|
58
67
|
* surrounding work as a single atomic, single-locked operation.
|
|
59
68
|
*/
|
|
60
69
|
transactionalEm?: EntityManager
|
|
70
|
+
/**
|
|
71
|
+
* Identifies the owner of an already-active {@link transactionalEm} lifetime.
|
|
72
|
+
* Atomic replay accepts an ambient EntityManager only when this is the exact
|
|
73
|
+
* token returned by `getTransactionLifetime(transactionalEm)` from the outer
|
|
74
|
+
* `withAtomicFlush` phase. The owner completes the token after its real commit
|
|
75
|
+
* or rollback, allowing replay leases and post-commit work to follow that
|
|
76
|
+
* boundary. Omit when the command bus opens and owns the transaction itself.
|
|
77
|
+
*/
|
|
78
|
+
transactionLifetime?: TransactionLifetime
|
|
79
|
+
/**
|
|
80
|
+
* Optional request-level replay authorization that the command bus binds to
|
|
81
|
+
* its replay EntityManager. Atomic handlers run this guard in the same
|
|
82
|
+
* transaction as the source-log transition and domain mutation; legacy
|
|
83
|
+
* handlers run it in a dedicated read transaction before preserving their
|
|
84
|
+
* existing replay lifecycle.
|
|
85
|
+
*/
|
|
86
|
+
replayTransactionGuard?: (args: {
|
|
87
|
+
operation: CommandReplayOperation
|
|
88
|
+
logEntry: CommandUndoLogEntry
|
|
89
|
+
transactionalEm: EntityManager
|
|
90
|
+
}) => Promise<void> | void
|
|
61
91
|
/**
|
|
62
92
|
* On-behalf-of attribution for non-human principals (Agent Identity &
|
|
63
93
|
* On-Behalf-Of, Wave 4 P2). When an agent runs on behalf of a human, the
|
|
@@ -84,6 +114,22 @@ export type CommandRunAsContext = {
|
|
|
84
114
|
|
|
85
115
|
export type CommandLogMetadata = {
|
|
86
116
|
skipLog?: boolean
|
|
117
|
+
/**
|
|
118
|
+
* Per-execution replay policy. `false` records the audit entry without an undo
|
|
119
|
+
* token or command payload, so neither undo nor redo can replay sensitive input.
|
|
120
|
+
* Omitted preserves the command handler's existing replay behavior.
|
|
121
|
+
*/
|
|
122
|
+
replayable?: boolean
|
|
123
|
+
/**
|
|
124
|
+
* Overrides the command input persisted in the audit entry's redo envelope.
|
|
125
|
+
* The default is the raw input, which for credential-bearing commands would put
|
|
126
|
+
* a plaintext secret at rest in `action_logs`. A handler that still wants its
|
|
127
|
+
* operation to be undoable supplies a redacted projection here instead of
|
|
128
|
+
* switching the whole entry to `replayable: false` — suppressing the undo token
|
|
129
|
+
* removes the operator's ability to revert a write that carries no secret of its
|
|
130
|
+
* own (undoing a user create only deletes a row).
|
|
131
|
+
*/
|
|
132
|
+
redoInput?: unknown
|
|
87
133
|
tenantId?: string | null
|
|
88
134
|
organizationId?: string | null
|
|
89
135
|
actorUserId?: string | null
|
|
@@ -106,6 +152,16 @@ export type CommandLogMetadata = {
|
|
|
106
152
|
export type CommandExecuteResult<TResult> = {
|
|
107
153
|
result: TResult
|
|
108
154
|
logEntry: any | null
|
|
155
|
+
/**
|
|
156
|
+
* True when an atomic redo finalized its source action log in the same
|
|
157
|
+
* transaction as the domain mutation and the newly persisted log entry, and
|
|
158
|
+
* that transaction has committed. An ambient replay returns this as false
|
|
159
|
+
* while its caller-owned transaction is still active; the returned result
|
|
160
|
+
* object is updated to true by the owner's commit callback. It remains false
|
|
161
|
+
* on rollback. Callers that historically finalized redo themselves can use
|
|
162
|
+
* this additive signal to avoid a redundant post-commit write.
|
|
163
|
+
*/
|
|
164
|
+
replaySourceFinalized?: boolean
|
|
109
165
|
}
|
|
110
166
|
|
|
111
167
|
/**
|
|
@@ -150,9 +206,35 @@ export type CommandLogBuilderArgs<TInput, TResult> = {
|
|
|
150
206
|
}
|
|
151
207
|
}
|
|
152
208
|
|
|
209
|
+
export type CommandReplayOperation = 'undo' | 'redo'
|
|
210
|
+
|
|
211
|
+
export type CommandReplayAuthorizationArgs<TInput> = {
|
|
212
|
+
operation: CommandReplayOperation
|
|
213
|
+
input: TInput
|
|
214
|
+
ctx: CommandRuntimeContext
|
|
215
|
+
logEntry: CommandUndoLogEntry
|
|
216
|
+
}
|
|
217
|
+
|
|
153
218
|
export interface CommandHandler<TInput = unknown, TResult = unknown> {
|
|
154
219
|
readonly id: string
|
|
155
220
|
readonly isUndoable?: boolean
|
|
221
|
+
/**
|
|
222
|
+
* Opts replay into the command bus's transaction-bound lifecycle. The source
|
|
223
|
+
* action-log transition, handler mutation, and any new replay log share one
|
|
224
|
+
* EntityManager and commit or roll back together. Handlers that enable this
|
|
225
|
+
* MUST reuse `ctx.transactionalEm` for every replay-time database operation.
|
|
226
|
+
* Omitted preserves the legacy non-atomic replay path.
|
|
227
|
+
*/
|
|
228
|
+
readonly atomicReplay?: boolean
|
|
229
|
+
/**
|
|
230
|
+
* Acquires every authorization-state lock needed by an atomic replay before
|
|
231
|
+
* request-level replay authorization and feature-gated interceptors run.
|
|
232
|
+
* Implementations MUST discover their complete actor/target/destination set
|
|
233
|
+
* before taking the first lock and MUST reuse `ctx.transactionalEm`.
|
|
234
|
+
* Omitted preserves the existing lifecycle for handlers that do not need
|
|
235
|
+
* domain-specific authorization stabilization.
|
|
236
|
+
*/
|
|
237
|
+
stabilizeReplay?(params: CommandReplayAuthorizationArgs<TInput>): Promise<void> | void
|
|
156
238
|
/**
|
|
157
239
|
* Optional Zod schema describing the command's return value. Feeds the
|
|
158
240
|
* workflows context ledger so downstream activities can reason about the
|
|
@@ -164,6 +246,15 @@ export interface CommandHandler<TInput = unknown, TResult = unknown> {
|
|
|
164
246
|
execute(input: TInput, ctx: CommandRuntimeContext): Promise<TResult> | TResult
|
|
165
247
|
buildLog?(args: CommandLogBuilderArgs<TInput, TResult>): Promise<CommandLogMetadata | null | undefined> | CommandLogMetadata | null | undefined
|
|
166
248
|
captureAfter?(input: TInput, result: TResult, ctx: CommandRuntimeContext): Promise<unknown> | unknown
|
|
249
|
+
/**
|
|
250
|
+
* Re-authorizes a stored command against the actor and resource state that
|
|
251
|
+
* exist at replay time. Legacy handlers are checked before claiming an undo
|
|
252
|
+
* log or beginning redo processing. Atomic handlers are checked inside their
|
|
253
|
+
* replay transaction before the guarded source transition; their mutation
|
|
254
|
+
* path can repeat the check after taking domain locks. Throwing aborts replay
|
|
255
|
+
* without committed domain side effects.
|
|
256
|
+
*/
|
|
257
|
+
authorizeReplay?(params: CommandReplayAuthorizationArgs<TInput>): Promise<void> | void
|
|
167
258
|
undo?(params: { input: TInput; ctx: CommandRuntimeContext; logEntry: CommandUndoLogEntry }): Promise<void> | void
|
|
168
259
|
/**
|
|
169
260
|
* Optional redo handler. When defined, the command bus calls this instead of
|
package/src/lib/commands/undo.ts
CHANGED
|
@@ -30,7 +30,85 @@ function snapshotFallback<T>(logEntry: UndoLogEntry): T | null {
|
|
|
30
30
|
return { before: before ?? null, after: after ?? null } as T
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
export
|
|
33
|
+
export type UndoDateRevivalOptions = {
|
|
34
|
+
/** Keys revived from ISO strings to `Date` wherever they appear in the payload (any depth, arrays included). */
|
|
35
|
+
dateFields?: readonly string[]
|
|
36
|
+
/** Dot-separated paths from the payload root revived from ISO strings to `Date`; arrays on the way are walked element-wise. */
|
|
37
|
+
datePaths?: readonly string[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
41
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function reviveDateValue(value: unknown, field: string): unknown {
|
|
45
|
+
if (typeof value !== 'string') return value
|
|
46
|
+
const date = new Date(value)
|
|
47
|
+
if (Number.isNaN(date.getTime())) {
|
|
48
|
+
throw new Error(`[internal] Invalid ${field} snapshot date: ${value}`)
|
|
49
|
+
}
|
|
50
|
+
return date
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Shallow-clone a snapshot record and revive the named date fields from ISO strings
|
|
55
|
+
* back to `Date`. `null`, `undefined` and existing `Date` values are kept as-is; an
|
|
56
|
+
* unparsable string throws so a corrupt snapshot fails loudly instead of writing a bad row.
|
|
57
|
+
*/
|
|
58
|
+
export function reviveSnapshotDates<T extends Record<string, unknown>>(value: T, fields: readonly string[]): T {
|
|
59
|
+
const revived: Record<string, unknown> = { ...value }
|
|
60
|
+
for (const field of fields) {
|
|
61
|
+
if (field in revived) revived[field] = reviveDateValue(revived[field], field)
|
|
62
|
+
}
|
|
63
|
+
return revived as T
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function reviveDateFieldsDeep(value: unknown, fields: ReadonlySet<string>): unknown {
|
|
67
|
+
if (Array.isArray(value)) return value.map((item) => reviveDateFieldsDeep(item, fields))
|
|
68
|
+
if (!isPlainRecord(value)) return value
|
|
69
|
+
return Object.fromEntries(
|
|
70
|
+
Object.entries(value).map(([key, entry]) => [
|
|
71
|
+
key,
|
|
72
|
+
fields.has(key) ? reviveDateValue(entry, key) : reviveDateFieldsDeep(entry, fields),
|
|
73
|
+
]),
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function reviveDatePath(value: unknown, segments: readonly string[]): unknown {
|
|
78
|
+
if (Array.isArray(value)) return value.map((item) => reviveDatePath(item, segments))
|
|
79
|
+
if (!isPlainRecord(value) || segments.length === 0) return value
|
|
80
|
+
const [head, ...rest] = segments
|
|
81
|
+
if (!(head in value)) return value
|
|
82
|
+
const entry = value[head]
|
|
83
|
+
return { ...value, [head]: rest.length === 0 ? reviveDateValue(entry, head) : reviveDatePath(entry, rest) }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function reviveUndoPayloadDates<T>(payload: T | null, options: UndoDateRevivalOptions | undefined): T | null {
|
|
87
|
+
if (payload === null || payload === undefined || !options) return payload
|
|
88
|
+
let revived: unknown = payload
|
|
89
|
+
if (options.dateFields && options.dateFields.length > 0) {
|
|
90
|
+
revived = reviveDateFieldsDeep(revived, new Set(options.dateFields))
|
|
91
|
+
}
|
|
92
|
+
for (const path of options.datePaths ?? []) {
|
|
93
|
+
const segments = path.split('.').filter((segment) => segment.length > 0)
|
|
94
|
+
if (segments.length > 0) revived = reviveDatePath(revived, segments)
|
|
95
|
+
}
|
|
96
|
+
return revived as T
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Read the undo payload from a command log entry. Snapshots round-trip through `jsonb`,
|
|
101
|
+
* so `Date` values come back as ISO strings; pass `dateFields` / `datePaths` to revive
|
|
102
|
+
* them to `Date` (unparsable strings throw). Without options the raw payload is returned.
|
|
103
|
+
*/
|
|
104
|
+
export function extractUndoPayload<T>(
|
|
105
|
+
logEntry: UndoLogEntry | null | undefined,
|
|
106
|
+
options?: UndoDateRevivalOptions,
|
|
107
|
+
): T | null {
|
|
108
|
+
return reviveUndoPayloadDates(extractRawUndoPayload<T>(logEntry), options)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function extractRawUndoPayload<T>(logEntry: UndoLogEntry | null | undefined): T | null {
|
|
34
112
|
if (!logEntry) return null
|
|
35
113
|
const rawPayload = logEntry.commandPayload ?? logEntry.payload
|
|
36
114
|
if (!rawPayload || typeof rawPayload !== 'object' || Array.isArray(rawPayload)) {
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Regression coverage for tenant-level (orgField: null) makeCrudRoute lists:
|
|
2
|
+
// a create invalidates the tenant-level `org:null` collection tag (the record
|
|
3
|
+
// carries no organization), so the cached list MUST be tagged with that same
|
|
4
|
+
// tag — tagging it with the caller's organization scope left the entry
|
|
5
|
+
// unflushable and served a stale list until TTL.
|
|
6
|
+
|
|
7
|
+
jest.mock('@open-mercato/cache', () => ({
|
|
8
|
+
runWithCacheTenant: async (_tenantId: string | null, fn: () => Promise<unknown>) => fn(),
|
|
9
|
+
}), { virtual: true })
|
|
10
|
+
|
|
11
|
+
import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
|
|
12
|
+
import { invalidateCrudCache } from '@open-mercato/shared/lib/crud/cache'
|
|
13
|
+
import { registerApiInterceptors } from '@open-mercato/shared/lib/crud/interceptor-registry'
|
|
14
|
+
import { z } from 'zod'
|
|
15
|
+
|
|
16
|
+
const organizationId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
|
17
|
+
const tenantId = '123e4567-e89b-12d3-a456-426614174000'
|
|
18
|
+
|
|
19
|
+
let rows: Array<{ id: string; name: string }> = []
|
|
20
|
+
|
|
21
|
+
const queryEngine = {
|
|
22
|
+
query: jest.fn(async () => ({ items: rows.map((row) => ({ ...row, tenant_id: tenantId })), total: rows.length })),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const store = new Map<string, { value: unknown; tags: string[] }>()
|
|
26
|
+
const cache = {
|
|
27
|
+
get: jest.fn(async (key: string) => store.get(key)?.value ?? null),
|
|
28
|
+
set: jest.fn(async (key: string, value: unknown, options?: { tags?: string[] }) => {
|
|
29
|
+
store.set(key, { value, tags: options?.tags ?? [] })
|
|
30
|
+
}),
|
|
31
|
+
delete: jest.fn(async (key: string) => { store.delete(key) }),
|
|
32
|
+
deleteByTags: jest.fn(async (tags: string[]) => {
|
|
33
|
+
let removed = 0
|
|
34
|
+
for (const [key, entry] of Array.from(store.entries())) {
|
|
35
|
+
if (entry.tags.some((tag) => tags.includes(tag))) {
|
|
36
|
+
store.delete(key)
|
|
37
|
+
removed += 1
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return removed
|
|
41
|
+
}),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const container = {
|
|
45
|
+
resolve: (name: string) => ({
|
|
46
|
+
em: {},
|
|
47
|
+
queryEngine,
|
|
48
|
+
cache,
|
|
49
|
+
accessLogService: { log: jest.fn(async () => {}) },
|
|
50
|
+
} as Record<string, unknown>)[name],
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
jest.mock('@open-mercato/shared/lib/di/container', () => ({
|
|
54
|
+
createRequestContainer: async () => container,
|
|
55
|
+
}))
|
|
56
|
+
|
|
57
|
+
jest.mock('@open-mercato/shared/lib/auth/server', () => {
|
|
58
|
+
const buildAuth = () => ({
|
|
59
|
+
sub: 'user-a',
|
|
60
|
+
orgId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
|
61
|
+
tenantId: '123e4567-e89b-12d3-a456-426614174000',
|
|
62
|
+
roles: ['admin'],
|
|
63
|
+
})
|
|
64
|
+
return {
|
|
65
|
+
getAuthFromCookies: async () => buildAuth(),
|
|
66
|
+
getAuthFromRequest: async () => buildAuth(),
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
jest.mock('@open-mercato/core/modules/directory/utils/organizationScope', () => ({
|
|
71
|
+
resolveOrganizationScopeForRequest: jest.fn(async () => ({
|
|
72
|
+
selectedId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
|
73
|
+
filterIds: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'],
|
|
74
|
+
allowedIds: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'],
|
|
75
|
+
tenantId: '123e4567-e89b-12d3-a456-426614174000',
|
|
76
|
+
})),
|
|
77
|
+
}))
|
|
78
|
+
|
|
79
|
+
jest.mock('@open-mercato/core/modules/entities/lib/helpers', () => ({
|
|
80
|
+
setRecordCustomFields: jest.fn(async () => {}),
|
|
81
|
+
}))
|
|
82
|
+
|
|
83
|
+
class TenantGroup {}
|
|
84
|
+
|
|
85
|
+
const querySchema = z.object({
|
|
86
|
+
page: z.coerce.number().default(1),
|
|
87
|
+
pageSize: z.coerce.number().default(50),
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const route = makeCrudRoute({
|
|
91
|
+
metadata: { GET: { requireAuth: true } },
|
|
92
|
+
orm: { entity: TenantGroup, idField: 'id', orgField: null, tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
93
|
+
events: { module: 'example', entity: 'tenant_group' },
|
|
94
|
+
indexer: { entityType: 'example:tenant_group' },
|
|
95
|
+
list: {
|
|
96
|
+
schema: querySchema,
|
|
97
|
+
entityId: 'example:tenant_group',
|
|
98
|
+
fields: ['id', 'name'],
|
|
99
|
+
transformItem: (item: { id: string; name: string }) => ({ id: item.id, name: item.name }),
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const url = 'http://x/api/example/tenant-groups?page=1&pageSize=10'
|
|
104
|
+
|
|
105
|
+
describe('CRUD Factory — tenant-level (orgField: null) list cache invalidation', () => {
|
|
106
|
+
const previousCacheFlag = process.env.ENABLE_CRUD_API_CACHE
|
|
107
|
+
|
|
108
|
+
beforeAll(() => {
|
|
109
|
+
process.env.ENABLE_CRUD_API_CACHE = 'true'
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
afterAll(() => {
|
|
113
|
+
if (previousCacheFlag === undefined) delete process.env.ENABLE_CRUD_API_CACHE
|
|
114
|
+
else process.env.ENABLE_CRUD_API_CACHE = previousCacheFlag
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
beforeEach(() => {
|
|
118
|
+
jest.clearAllMocks()
|
|
119
|
+
store.clear()
|
|
120
|
+
rows = []
|
|
121
|
+
registerApiInterceptors([])
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('tags the cached list with the tenant-level collection tag, not the caller org', async () => {
|
|
125
|
+
await route.GET(new Request(url))
|
|
126
|
+
const [entry] = Array.from(store.values())
|
|
127
|
+
expect(entry.tags).toContain(`crud:example.tenant.group:tenant:${tenantId}:org:null:collection`)
|
|
128
|
+
expect(entry.tags).not.toContain(`crud:example.tenant.group:tenant:${tenantId}:org:${organizationId}:collection`)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('serves the new row after a create invalidates the tenant-level collection', async () => {
|
|
132
|
+
const before = await route.GET(new Request(url))
|
|
133
|
+
expect(before.headers.get('x-om-cache')).toBe('miss')
|
|
134
|
+
expect((await before.json()).items).toEqual([])
|
|
135
|
+
|
|
136
|
+
rows = [{ id: 'group-1', name: 'Wholesale' }]
|
|
137
|
+
await invalidateCrudCache(
|
|
138
|
+
container as never,
|
|
139
|
+
'example.tenant.group',
|
|
140
|
+
{ id: 'group-1', organizationId: null, tenantId },
|
|
141
|
+
tenantId,
|
|
142
|
+
'created',
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
const after = await route.GET(new Request(url))
|
|
146
|
+
expect(after.headers.get('x-om-cache')).toBe('miss')
|
|
147
|
+
expect((await after.json()).items).toEqual([{ id: 'group-1', name: 'Wholesale' }])
|
|
148
|
+
})
|
|
149
|
+
})
|
|
@@ -19,6 +19,12 @@ const defaultTenantId = '123e4567-e89b-12d3-a456-426614174000'
|
|
|
19
19
|
|
|
20
20
|
let mockAuthSub = 'user-a'
|
|
21
21
|
let mockAuthKeyId: string | null = null
|
|
22
|
+
let mockOrganizationScope = {
|
|
23
|
+
selectedId: defaultOrganizationId,
|
|
24
|
+
filterIds: [defaultOrganizationId] as string[] | null,
|
|
25
|
+
allowedIds: [defaultOrganizationId] as string[] | null,
|
|
26
|
+
tenantId: defaultTenantId,
|
|
27
|
+
}
|
|
22
28
|
|
|
23
29
|
const em = {}
|
|
24
30
|
|
|
@@ -70,12 +76,7 @@ jest.mock('@open-mercato/shared/lib/auth/server', () => {
|
|
|
70
76
|
})
|
|
71
77
|
|
|
72
78
|
jest.mock('@open-mercato/core/modules/directory/utils/organizationScope', () => ({
|
|
73
|
-
resolveOrganizationScopeForRequest: jest.fn(async () =>
|
|
74
|
-
selectedId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
|
75
|
-
filterIds: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'],
|
|
76
|
-
allowedIds: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'],
|
|
77
|
-
tenantId: '123e4567-e89b-12d3-a456-426614174000',
|
|
78
|
-
})),
|
|
79
|
+
resolveOrganizationScopeForRequest: jest.fn(async () => mockOrganizationScope),
|
|
79
80
|
}))
|
|
80
81
|
|
|
81
82
|
jest.mock('@open-mercato/core/modules/entities/lib/helpers', () => ({
|
|
@@ -105,6 +106,20 @@ const route = makeCrudRoute({
|
|
|
105
106
|
},
|
|
106
107
|
})
|
|
107
108
|
|
|
109
|
+
const scopeIdentityRoute = makeCrudRoute({
|
|
110
|
+
metadata: { GET: { requireAuth: true } },
|
|
111
|
+
orm: { entity: Todo, idField: 'id', orgField: null, tenantField: null, softDeleteField: null },
|
|
112
|
+
indexer: { entityType: 'example.cache_identity' },
|
|
113
|
+
list: {
|
|
114
|
+
schema: querySchema,
|
|
115
|
+
entityId: 'example.cache_identity',
|
|
116
|
+
fields: ['id', 'title'],
|
|
117
|
+
sortFieldMap: { id: 'id', title: 'title' },
|
|
118
|
+
buildFilters: () => ({} as any),
|
|
119
|
+
transformItem: (item: any) => ({ id: item.id, title: item.title }),
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
|
|
108
123
|
const url = 'http://x/api/example/todos?page=1&pageSize=10&sortField=id&sortDir=asc'
|
|
109
124
|
|
|
110
125
|
describe('CRUD Factory — list cache is partitioned per caller identity', () => {
|
|
@@ -124,6 +139,12 @@ describe('CRUD Factory — list cache is partitioned per caller identity', () =>
|
|
|
124
139
|
store.clear()
|
|
125
140
|
mockAuthSub = 'user-a'
|
|
126
141
|
mockAuthKeyId = null
|
|
142
|
+
mockOrganizationScope = {
|
|
143
|
+
selectedId: defaultOrganizationId,
|
|
144
|
+
filterIds: [defaultOrganizationId],
|
|
145
|
+
allowedIds: [defaultOrganizationId],
|
|
146
|
+
tenantId: defaultTenantId,
|
|
147
|
+
}
|
|
127
148
|
registerApiInterceptors([])
|
|
128
149
|
})
|
|
129
150
|
|
|
@@ -168,4 +189,25 @@ describe('CRUD Factory — list cache is partitioned per caller identity', () =>
|
|
|
168
189
|
const apiKeyKey = Array.from(store.keys())[0] as string
|
|
169
190
|
expect(apiKeyKey).toContain('user:api-key-9')
|
|
170
191
|
})
|
|
192
|
+
|
|
193
|
+
it('encodes explicit empty and finite scopes as different cache keys', async () => {
|
|
194
|
+
await scopeIdentityRoute.GET(new Request(url))
|
|
195
|
+
const finiteKey = Array.from(store.keys())[0] as string
|
|
196
|
+
|
|
197
|
+
mockOrganizationScope = {
|
|
198
|
+
selectedId: defaultOrganizationId,
|
|
199
|
+
filterIds: [],
|
|
200
|
+
allowedIds: [defaultOrganizationId],
|
|
201
|
+
tenantId: defaultTenantId,
|
|
202
|
+
}
|
|
203
|
+
const emptyResponse = await scopeIdentityRoute.GET(new Request(url))
|
|
204
|
+
const keys = Array.from(store.keys())
|
|
205
|
+
|
|
206
|
+
expect(emptyResponse.headers.get('x-om-cache')).toBe('miss')
|
|
207
|
+
expect(keys).toHaveLength(2)
|
|
208
|
+
expect(finiteKey).toContain(`scope:${defaultOrganizationId}`)
|
|
209
|
+
expect(keys[1]).toContain('scope:empty')
|
|
210
|
+
expect(keys[1]).not.toBe(finiteKey)
|
|
211
|
+
expect(queryEngine.query).toHaveBeenCalledTimes(2)
|
|
212
|
+
})
|
|
171
213
|
})
|