@open-mercato/shared 0.6.7-develop.6785.1.1dd7cfac55 → 0.6.7-develop.6795.1.8a3f27921c
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 +1 -0
- package/build.mjs +2 -4
- package/dist/lib/auth/organizationScope.js +34 -0
- package/dist/lib/auth/organizationScope.js.map +7 -0
- package/dist/lib/query/engine.js +25 -42
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/search/availability.js +107 -0
- package/dist/lib/search/availability.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/jest.config.cjs +2 -1
- package/package.json +9 -8
- package/scripts/versionSource.cjs +65 -0
- package/src/lib/__tests__/versionSource.test.ts +46 -0
- package/src/lib/auth/__tests__/organizationScope.test.ts +107 -0
- package/src/lib/auth/organizationScope.ts +65 -0
- package/src/lib/i18n/__tests__/server-dictionary-cache.test.ts +30 -0
- package/src/lib/query/__tests__/engine.test.ts +65 -13
- package/src/lib/query/engine.ts +36 -60
- package/src/lib/search/__tests__/availability.test.ts +211 -0
- package/src/lib/search/availability.ts +232 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/lib/search/availability.ts"],
|
|
4
|
+
"sourcesContent": ["import { sql } from 'kysely'\nimport { parseNumberWithDefault } from '@open-mercato/shared/lib/number'\n\nexport type OrganizationScope = { ids: string[]; includeNull: boolean }\n\nexport type SearchTokenSourceRef = { entity: string; recordIdColumn?: string }\n\ntype ProbeExpression = object | string | readonly string[] | null\n\nexport type SearchTokenProbeQueryBuilder = {\n select: (selection: ProbeExpression) => SearchTokenProbeQueryBuilder\n where: (column: ProbeExpression, operator?: string, value?: ProbeExpression) => SearchTokenProbeQueryBuilder\n limit: (count: number) => SearchTokenProbeQueryBuilder\n executeTakeFirst: () => Promise<object | undefined>\n}\n\nexport type SearchTokenProbeDb = { selectFrom: (table: string) => SearchTokenProbeQueryBuilder }\n\nexport type SearchTokenAvailabilityDebugPayload = {\n entity: string\n tenantId: string | null\n organizationScope?: OrganizationScope | null\n recordIdColumn?: string\n hasTokens?: boolean\n error?: string\n}\n\nexport type SearchTokenAvailabilityDeps = {\n getDb: () => SearchTokenProbeDb\n getConfig: () => { enabled: boolean }\n applyOrganizationScope: (\n query: SearchTokenProbeQueryBuilder,\n column: string,\n scope: OrganizationScope,\n ) => SearchTokenProbeQueryBuilder\n logDebug: (event: string, payload: SearchTokenAvailabilityDebugPayload) => void\n}\n\nexport type SearchTokenAvailability = {\n /**\n * The cheap, statically-known half of the decision: search is configured on\n * AND the `search_tokens` table exists. Safe to resolve eagerly (consumers\n * like custom-field source attachment need it before any filter is\n * inspected); the table probe is memoized per instance.\n */\n staticEnabled: () => Promise<boolean>\n /**\n * The expensive half: does `search_tokens` hold any row for this\n * (entity, tenant, organization scope)? Historically the `LIMIT 1` probe\n * could degenerate into a seq scan on a large table (#4723), so callers MUST\n * only ask when the query actually carries a like/ilike filter \u2014 use\n * `hasSearchFilter` for the gate. Three layers keep it cheap for the end\n * user: index-usable predicates (no `IS NOT DISTINCT FROM`) served by the\n * dedicated `search_tokens_presence_idx (entity_type, tenant_id,\n * organization_id)` prefix \u2014 which makes the MISS as cheap as the hit \u2014 a\n * per-request instance memo, and a process-level TTL cache\n * (`OM_SEARCH_TOKEN_PRESENCE_CACHE_MS`, default 30s) that amortizes the\n * probe across requests. Probe errors log `search:has-tokens-error`,\n * resolve to `false`, and are never TTL-cached.\n */\n hasTokens: (entity: string, tenantId: string | null, orgScope?: OrganizationScope | null) => Promise<boolean>\n /** First-hit sweep over token sources; logs `search:source-has-tokens` per probed source. */\n anySourceHasTokens: (\n sources: SearchTokenSourceRef[],\n tenantId: string | null,\n orgScope?: OrganizationScope | null,\n ) => Promise<boolean>\n}\n\nexport function isSearchFilterOp(op: string | null | undefined): boolean {\n return op === 'like' || op === 'ilike'\n}\n\n/**\n * The single definition of \"this query actually searches\". Every consumer of\n * the token-availability answer sits behind a like/ilike guard, so when this\n * returns `false` the `hasTokens` probe's answer would never be read \u2014 gate\n * the probe on it.\n */\nexport function hasSearchFilter(filters: ReadonlyArray<{ op?: string | null }>): boolean {\n return filters.some((filter) => isSearchFilterOp(filter.op))\n}\n\nfunction orgScopeKey(scope: OrganizationScope | null | undefined): string {\n if (!scope) return 'none'\n return `${scope.includeNull ? '1' : '0'}:${[...scope.ids].sort((left, right) => left.localeCompare(right)).join(',')}`\n}\n\nconst PRESENCE_CACHE_DEFAULT_TTL_MS = 30_000\nconst PRESENCE_CACHE_MAX_ENTRIES = 10_000\n\n// Process-level TTL cache for the token-presence answer. Module-level (process-global) on\n// purpose: `createRequestContainer` builds fresh engines \u2014 and with them fresh resolver\n// instances \u2014 per request, so an instance-scoped memo alone re-pays the probe on every\n// request. On a large `search_tokens` one probe can be pathologically expensive (#4723),\n// so the answer is amortized across requests here and only re-checked once per TTL.\n// Staleness contract: a stale `false` keeps like/ilike on the plain-column fallback for up\n// to the TTL after an entity's first tokens are written; a stale `true` routes search\n// through an emptied token set for up to the TTL after a purge. Both converge within the\n// TTL; set OM_SEARCH_TOKEN_PRESENCE_CACHE_MS=0 to disable and probe per request again.\nconst presenceCache = new Map<string, { value: boolean; expiresAt: number }>()\n\nfunction resolvePresenceCacheTtlMs(): number {\n return parseNumberWithDefault(process.env.OM_SEARCH_TOKEN_PRESENCE_CACHE_MS, PRESENCE_CACHE_DEFAULT_TTL_MS, { integer: true, min: 0 })\n}\n\nfunction storePresence(key: string, value: boolean, ttlMs: number): void {\n if (presenceCache.size >= PRESENCE_CACHE_MAX_ENTRIES) {\n const now = Date.now()\n for (const [entryKey, entry] of presenceCache) {\n if (entry.expiresAt <= now) presenceCache.delete(entryKey)\n }\n if (presenceCache.size >= PRESENCE_CACHE_MAX_ENTRIES) presenceCache.clear()\n }\n presenceCache.set(key, { value, expiresAt: Date.now() + ttlMs })\n}\n\nexport function clearSearchTokenPresenceCache(): void {\n presenceCache.clear()\n}\n\n/**\n * One place that answers \"is token search usable here?\" for both query\n * engines, instead of each hand-assembling config + table-existence +\n * token-presence probes with private duplicate helpers and ad-hoc memo maps.\n *\n * Memoization is per instance; engines are constructed per request\n * (`createRequestContainer`), so entries never outlive a request \u2014 the same\n * staleness contract the engines' previous per-query join maps had. A\n * rejected table probe is evicted so the next call retries instead of\n * observing a poisoned cache entry.\n */\nexport function createSearchTokenAvailability(deps: SearchTokenAvailabilityDeps): SearchTokenAvailability {\n const tablePresence = new Map<string, Promise<boolean>>()\n const tokenPresence = new Map<string, Promise<boolean>>()\n\n const tableExists = (table: string): Promise<boolean> => {\n const cached = tablePresence.get(table)\n if (cached) return cached\n const probe = (async () => {\n const row = await deps.getDb()\n .selectFrom('information_schema.tables')\n .select(sql<number>`1`.as('one'))\n .where('table_name', '=', table)\n .limit(1)\n .executeTakeFirst()\n return !!row\n })()\n tablePresence.set(table, probe)\n probe.catch(() => tablePresence.delete(table))\n return probe\n }\n\n const probeTokens = async (\n entity: string,\n tenantId: string | null,\n orgScope?: OrganizationScope | null,\n ): Promise<boolean> => {\n let query = deps.getDb()\n .selectFrom('search_tokens')\n .select(sql<number>`1`.as('one'))\n .where('entity_type', '=', entity)\n // Deliberately `= / IS NULL` instead of `IS NOT DISTINCT FROM` (identical semantics\n // for a string|null tenant): the latter cannot serve as an index condition, which is\n // part of why the planner degraded this probe to a seq scan on large tables (#4723).\n // With plain predicates the probe is a pure prefix seek on\n // `search_tokens_presence_idx (entity_type, tenant_id, organization_id)`, making the\n // miss as cheap as the hit.\n query = tenantId == null\n ? query.where('tenant_id', 'is', null)\n : query.where('tenant_id', '=', tenantId)\n if (orgScope) {\n query = deps.applyOrganizationScope(query, 'search_tokens.organization_id', orgScope)\n }\n const row = await query.limit(1).executeTakeFirst()\n return !!row\n }\n\n const hasTokens = (\n entity: string,\n tenantId: string | null,\n orgScope?: OrganizationScope | null,\n ): Promise<boolean> => {\n const key = `${entity}|${tenantId ?? '__null__'}|${orgScopeKey(orgScope)}`\n const ttlMs = resolvePresenceCacheTtlMs()\n if (ttlMs > 0) {\n const entry = presenceCache.get(key)\n if (entry && entry.expiresAt > Date.now()) return Promise.resolve(entry.value)\n }\n const cached = tokenPresence.get(key)\n if (cached) return cached\n const probe = (async () => {\n try {\n const value = await probeTokens(entity, tenantId, orgScope)\n // Only genuine probe results enter the process-level cache \u2014 caching an\n // error-driven `false` would pin degraded search for a full TTL after a\n // transient DB failure.\n if (ttlMs > 0) storePresence(key, value, ttlMs)\n return value\n } catch (err) {\n deps.logDebug('search:has-tokens-error', {\n entity,\n tenantId,\n organizationScope: orgScope,\n error: err instanceof Error ? err.message : String(err),\n })\n return false\n }\n })()\n tokenPresence.set(key, probe)\n return probe\n }\n\n return {\n staticEnabled: async () => deps.getConfig().enabled && await tableExists('search_tokens'),\n hasTokens,\n anySourceHasTokens: async (sources, tenantId, orgScope) => {\n for (const source of sources) {\n const ok = await hasTokens(source.entity, tenantId, orgScope)\n deps.logDebug('search:source-has-tokens', {\n entity: source.entity,\n recordIdColumn: source.recordIdColumn,\n tenantId,\n organizationScope: orgScope,\n hasTokens: ok,\n })\n if (ok) return true\n }\n return false\n },\n }\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,WAAW;AACpB,SAAS,8BAA8B;AAoEhC,SAAS,iBAAiB,IAAwC;AACvE,SAAO,OAAO,UAAU,OAAO;AACjC;AAQO,SAAS,gBAAgB,SAAyD;AACvF,SAAO,QAAQ,KAAK,CAAC,WAAW,iBAAiB,OAAO,EAAE,CAAC;AAC7D;AAEA,SAAS,YAAY,OAAqD;AACxE,MAAI,CAAC,MAAO,QAAO;AACnB,SAAO,GAAG,MAAM,cAAc,MAAM,GAAG,IAAI,CAAC,GAAG,MAAM,GAAG,EAAE,KAAK,CAAC,MAAM,UAAU,KAAK,cAAc,KAAK,CAAC,EAAE,KAAK,GAAG,CAAC;AACtH;AAEA,MAAM,gCAAgC;AACtC,MAAM,6BAA6B;AAWnC,MAAM,gBAAgB,oBAAI,IAAmD;AAE7E,SAAS,4BAAoC;AAC3C,SAAO,uBAAuB,QAAQ,IAAI,mCAAmC,+BAA+B,EAAE,SAAS,MAAM,KAAK,EAAE,CAAC;AACvI;AAEA,SAAS,cAAc,KAAa,OAAgB,OAAqB;AACvE,MAAI,cAAc,QAAQ,4BAA4B;AACpD,UAAM,MAAM,KAAK,IAAI;AACrB,eAAW,CAAC,UAAU,KAAK,KAAK,eAAe;AAC7C,UAAI,MAAM,aAAa,IAAK,eAAc,OAAO,QAAQ;AAAA,IAC3D;AACA,QAAI,cAAc,QAAQ,2BAA4B,eAAc,MAAM;AAAA,EAC5E;AACA,gBAAc,IAAI,KAAK,EAAE,OAAO,WAAW,KAAK,IAAI,IAAI,MAAM,CAAC;AACjE;AAEO,SAAS,gCAAsC;AACpD,gBAAc,MAAM;AACtB;AAaO,SAAS,8BAA8B,MAA4D;AACxG,QAAM,gBAAgB,oBAAI,IAA8B;AACxD,QAAM,gBAAgB,oBAAI,IAA8B;AAExD,QAAM,cAAc,CAAC,UAAoC;AACvD,UAAM,SAAS,cAAc,IAAI,KAAK;AACtC,QAAI,OAAQ,QAAO;AACnB,UAAM,SAAS,YAAY;AACzB,YAAM,MAAM,MAAM,KAAK,MAAM,EAC1B,WAAW,2BAA2B,EACtC,OAAO,OAAe,GAAG,KAAK,CAAC,EAC/B,MAAM,cAAc,KAAK,KAAK,EAC9B,MAAM,CAAC,EACP,iBAAiB;AACpB,aAAO,CAAC,CAAC;AAAA,IACX,GAAG;AACH,kBAAc,IAAI,OAAO,KAAK;AAC9B,UAAM,MAAM,MAAM,cAAc,OAAO,KAAK,CAAC;AAC7C,WAAO;AAAA,EACT;AAEA,QAAM,cAAc,OAClB,QACA,UACA,aACqB;AACrB,QAAI,QAAQ,KAAK,MAAM,EACpB,WAAW,eAAe,EAC1B,OAAO,OAAe,GAAG,KAAK,CAAC,EAC/B,MAAM,eAAe,KAAK,MAAM;AAOnC,YAAQ,YAAY,OAChB,MAAM,MAAM,aAAa,MAAM,IAAI,IACnC,MAAM,MAAM,aAAa,KAAK,QAAQ;AAC1C,QAAI,UAAU;AACZ,cAAQ,KAAK,uBAAuB,OAAO,iCAAiC,QAAQ;AAAA,IACtF;AACA,UAAM,MAAM,MAAM,MAAM,MAAM,CAAC,EAAE,iBAAiB;AAClD,WAAO,CAAC,CAAC;AAAA,EACX;AAEA,QAAM,YAAY,CAChB,QACA,UACA,aACqB;AACrB,UAAM,MAAM,GAAG,MAAM,IAAI,YAAY,UAAU,IAAI,YAAY,QAAQ,CAAC;AACxE,UAAM,QAAQ,0BAA0B;AACxC,QAAI,QAAQ,GAAG;AACb,YAAM,QAAQ,cAAc,IAAI,GAAG;AACnC,UAAI,SAAS,MAAM,YAAY,KAAK,IAAI,EAAG,QAAO,QAAQ,QAAQ,MAAM,KAAK;AAAA,IAC/E;AACA,UAAM,SAAS,cAAc,IAAI,GAAG;AACpC,QAAI,OAAQ,QAAO;AACnB,UAAM,SAAS,YAAY;AACzB,UAAI;AACF,cAAM,QAAQ,MAAM,YAAY,QAAQ,UAAU,QAAQ;AAI1D,YAAI,QAAQ,EAAG,eAAc,KAAK,OAAO,KAAK;AAC9C,eAAO;AAAA,MACT,SAAS,KAAK;AACZ,aAAK,SAAS,2BAA2B;AAAA,UACvC;AAAA,UACA;AAAA,UACA,mBAAmB;AAAA,UACnB,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAAA,QACxD,CAAC;AACD,eAAO;AAAA,MACT;AAAA,IACF,GAAG;AACH,kBAAc,IAAI,KAAK,KAAK;AAC5B,WAAO;AAAA,EACT;AAEA,SAAO;AAAA,IACL,eAAe,YAAY,KAAK,UAAU,EAAE,WAAW,MAAM,YAAY,eAAe;AAAA,IACxF;AAAA,IACA,oBAAoB,OAAO,SAAS,UAAU,aAAa;AACzD,iBAAW,UAAU,SAAS;AAC5B,cAAM,KAAK,MAAM,UAAU,OAAO,QAAQ,UAAU,QAAQ;AAC5D,aAAK,SAAS,4BAA4B;AAAA,UACxC,QAAQ,OAAO;AAAA,UACf,gBAAgB,OAAO;AAAA,UACvB;AAAA,UACA,mBAAmB;AAAA,UACnB,WAAW;AAAA,QACb,CAAC;AACD,YAAI,GAAI,QAAO;AAAA,MACjB;AACA,aAAO;AAAA,IACT;AAAA,EACF;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/version.ts"],
|
|
4
|
-
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.7-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.7-develop.6795.1.8a3f27921c';\nexport const appVersion = APP_VERSION;\n"],
|
|
5
5
|
"mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/jest.config.cjs
CHANGED
|
@@ -12,7 +12,8 @@ module.exports = {
|
|
|
12
12
|
'^@open-mercato/core/(.*)$': '<rootDir>/../core/src/$1',
|
|
13
13
|
},
|
|
14
14
|
transform: {
|
|
15
|
-
|
|
15
|
+
// `.cjs` is included so the build-time source emitters under scripts/ are testable.
|
|
16
|
+
'^.+\\.(cjs|(t|j)sx?)$': [
|
|
16
17
|
'<rootDir>/../../scripts/jest-mikroorm-transformer.cjs',
|
|
17
18
|
{
|
|
18
19
|
tsconfig: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/shared",
|
|
3
|
-
"version": "0.6.7-develop.
|
|
3
|
+
"version": "0.6.7-develop.6795.1.8a3f27921c",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -102,23 +102,24 @@
|
|
|
102
102
|
}
|
|
103
103
|
},
|
|
104
104
|
"dependencies": {
|
|
105
|
-
"@mikro-orm/core": "^7.1.
|
|
106
|
-
"@mikro-orm/decorators": "^7.1.
|
|
107
|
-
"@mikro-orm/postgresql": "^7.1.
|
|
108
|
-
"@open-mercato/cache": "0.6.7-develop.
|
|
105
|
+
"@mikro-orm/core": "^7.1.8",
|
|
106
|
+
"@mikro-orm/decorators": "^7.1.8",
|
|
107
|
+
"@mikro-orm/postgresql": "^7.1.8",
|
|
108
|
+
"@open-mercato/cache": "0.6.7-develop.6795.1.8a3f27921c",
|
|
109
109
|
"@types/sanitize-html": "^2.16.1",
|
|
110
110
|
"dotenv": "^17.4.2",
|
|
111
111
|
"pino": "^10.3.1",
|
|
112
112
|
"rate-limiter-flexible": "^11.2.0",
|
|
113
113
|
"re2js": "2.8.6",
|
|
114
114
|
"reflect-metadata": "^0.2.2",
|
|
115
|
-
"sanitize-html": "
|
|
116
|
-
"undici": "^8.
|
|
115
|
+
"sanitize-html": "2.17.5",
|
|
116
|
+
"undici": "^8.9.0"
|
|
117
117
|
},
|
|
118
118
|
"devDependencies": {
|
|
119
119
|
"@types/jest": "^30.0.0",
|
|
120
120
|
"jest": "^30.4.2",
|
|
121
|
-
"ts-jest": "^29.4.
|
|
121
|
+
"ts-jest": "^29.4.12",
|
|
122
|
+
"ts-morph": "^28.0.0",
|
|
122
123
|
"typescript": "7.0.2"
|
|
123
124
|
},
|
|
124
125
|
"publishConfig": {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Pure source emitter for the build-time replacement of src/lib/version.ts.
|
|
2
|
+
// Kept side-effect free, and CommonJS so both build.mjs and the Jest suite can load it.
|
|
3
|
+
|
|
4
|
+
const { Project, QuoteKind, ScriptKind, StructureKind, VariableDeclarationKind } = require('ts-morph')
|
|
5
|
+
|
|
6
|
+
const VIRTUAL_FILE_NAME = 'version.ts'
|
|
7
|
+
const BANNER = '// Build-time generated version\n'
|
|
8
|
+
|
|
9
|
+
let sharedProject = null
|
|
10
|
+
|
|
11
|
+
function getProject() {
|
|
12
|
+
if (!sharedProject) {
|
|
13
|
+
sharedProject = new Project({
|
|
14
|
+
useInMemoryFileSystem: true,
|
|
15
|
+
manipulationSettings: { quoteKind: QuoteKind.Single },
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return sharedProject
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function assertNoSyntacticDiagnostics(project, sourceFile) {
|
|
23
|
+
const diagnostics = project.getProgram().getSyntacticDiagnostics(sourceFile)
|
|
24
|
+
if (diagnostics.length === 0) return
|
|
25
|
+
|
|
26
|
+
const details = diagnostics
|
|
27
|
+
.map((diagnostic) => ` line ${diagnostic.getLineNumber() ?? 0}: ${diagnostic.getMessageText()}`)
|
|
28
|
+
.join('\n')
|
|
29
|
+
throw new Error(`[internal] Generated ${VIRTUAL_FILE_NAME} is not syntactically valid:\n${details}`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeSourceText(text) {
|
|
33
|
+
const normalized = text.replace(/\r\n/g, '\n')
|
|
34
|
+
return normalized.endsWith('\n') ? normalized : `${normalized}\n`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function buildVersionSource(version) {
|
|
38
|
+
const project = getProject()
|
|
39
|
+
const sourceFile = project.createSourceFile(VIRTUAL_FILE_NAME, '', {
|
|
40
|
+
overwrite: true,
|
|
41
|
+
scriptKind: ScriptKind.TS,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
sourceFile.addVariableStatement({
|
|
45
|
+
kind: StructureKind.VariableStatement,
|
|
46
|
+
isExported: true,
|
|
47
|
+
declarationKind: VariableDeclarationKind.Const,
|
|
48
|
+
declarations: [{ name: 'APP_VERSION', initializer: (writer) => writer.quote(String(version)) }],
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
sourceFile.addVariableStatement({
|
|
52
|
+
kind: StructureKind.VariableStatement,
|
|
53
|
+
isExported: true,
|
|
54
|
+
declarationKind: VariableDeclarationKind.Const,
|
|
55
|
+
declarations: [{ name: 'appVersion', initializer: 'APP_VERSION' }],
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
sourceFile.formatText({ indentSize: 2, convertTabsToSpaces: true })
|
|
59
|
+
sourceFile.insertText(0, BANNER)
|
|
60
|
+
assertNoSyntacticDiagnostics(project, sourceFile)
|
|
61
|
+
|
|
62
|
+
return normalizeSourceText(sourceFile.getFullText())
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { buildVersionSource }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Project, SyntaxKind } from 'ts-morph'
|
|
2
|
+
import { buildVersionSource } from '../../../scripts/versionSource.cjs'
|
|
3
|
+
|
|
4
|
+
function parse(source: string) {
|
|
5
|
+
const project = new Project({ useInMemoryFileSystem: true })
|
|
6
|
+
const sourceFile = project.createSourceFile('version.ts', source, { overwrite: true })
|
|
7
|
+
return { project, sourceFile }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function exportedLiteral(source: string, name: string): string {
|
|
11
|
+
const { sourceFile } = parse(source)
|
|
12
|
+
return sourceFile.getVariableDeclarationOrThrow(name).getInitializerOrThrow().getText()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('buildVersionSource', () => {
|
|
16
|
+
it('exports both APP_VERSION and appVersion', () => {
|
|
17
|
+
const { sourceFile } = parse(buildVersionSource('1.2.3'))
|
|
18
|
+
expect([...sourceFile.getExportedDeclarations().keys()].sort()).toEqual(['APP_VERSION', 'appVersion'])
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('writes the supplied version as the APP_VERSION literal', () => {
|
|
22
|
+
expect(exportedLiteral(buildVersionSource('1.2.3'), 'APP_VERSION')).toBe("'1.2.3'")
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('aliases appVersion to APP_VERSION rather than repeating the literal', () => {
|
|
26
|
+
expect(exportedLiteral(buildVersionSource('1.2.3'), 'appVersion')).toBe('APP_VERSION')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('escapes a version string that would otherwise break the literal', () => {
|
|
30
|
+
const source = buildVersionSource("0.0.0-dev'; drop")
|
|
31
|
+
const { sourceFile } = parse(source)
|
|
32
|
+
const literal = sourceFile
|
|
33
|
+
.getVariableDeclarationOrThrow('APP_VERSION')
|
|
34
|
+
.getInitializerIfKindOrThrow(SyntaxKind.StringLiteral)
|
|
35
|
+
expect(literal.getLiteralValue()).toBe("0.0.0-dev'; drop")
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('emits zero syntactic diagnostics', () => {
|
|
39
|
+
const { project, sourceFile } = parse(buildVersionSource('1.2.3'))
|
|
40
|
+
expect(project.getProgram().getSyntacticDiagnostics(sourceFile)).toEqual([])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('keeps the build-time banner comment', () => {
|
|
44
|
+
expect(buildVersionSource('1.2.3').startsWith('// Build-time generated version\n')).toBe(true)
|
|
45
|
+
})
|
|
46
|
+
})
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/** @jest-environment node */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ORGANIZATION_SCOPE_REQUIRED_ERROR_CODE,
|
|
5
|
+
organizationScopeRequiredResponse,
|
|
6
|
+
resolveActiveOrganizationId,
|
|
7
|
+
} from '../organizationScope'
|
|
8
|
+
|
|
9
|
+
const accountOrgId = '22222222-2222-4222-8222-222222222222'
|
|
10
|
+
const selectedOrgId = '33333333-3333-4333-8333-333333333333'
|
|
11
|
+
const actorTenantId = '44444444-4444-4444-8444-444444444444'
|
|
12
|
+
const foreignTenantId = '55555555-5555-4555-8555-555555555555'
|
|
13
|
+
|
|
14
|
+
describe('resolveActiveOrganizationId', () => {
|
|
15
|
+
it('uses the selected organization when one is set', () => {
|
|
16
|
+
expect(
|
|
17
|
+
resolveActiveOrganizationId({ orgId: selectedOrgId, actorOrgId: accountOrgId }),
|
|
18
|
+
).toBe(selectedOrgId)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
// `orgId: null` + `actorOrgId` set is exactly the shape `applySuperAdminScope` produces for an
|
|
22
|
+
// all-organizations selection. Answering 401 for it sent `apiFetch` into a refresh loop.
|
|
23
|
+
it('falls back to the actor organization for an all-organizations selection', () => {
|
|
24
|
+
expect(
|
|
25
|
+
resolveActiveOrganizationId({ orgId: null, actorOrgId: accountOrgId }),
|
|
26
|
+
).toBe(accountOrgId)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
// `actorTenantId` is only present when the super-admin cookie override switched tenants.
|
|
30
|
+
// Selecting the actor's own tenant explicitly keeps the fallback valid.
|
|
31
|
+
it('keeps the fallback when the tenant override selects the actor tenant', () => {
|
|
32
|
+
expect(
|
|
33
|
+
resolveActiveOrganizationId({
|
|
34
|
+
orgId: null,
|
|
35
|
+
actorOrgId: accountOrgId,
|
|
36
|
+
tenantId: actorTenantId,
|
|
37
|
+
actorTenantId,
|
|
38
|
+
}),
|
|
39
|
+
).toBe(accountOrgId)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// The reviewed blocker: tenant B + "all organizations" must not pair the actor's
|
|
43
|
+
// tenant-A organization with tenant B. No fallback — the route answers 400 instead.
|
|
44
|
+
it('refuses the fallback when the effective tenant is not the actor tenant', () => {
|
|
45
|
+
expect(
|
|
46
|
+
resolveActiveOrganizationId({
|
|
47
|
+
orgId: null,
|
|
48
|
+
actorOrgId: accountOrgId,
|
|
49
|
+
tenantId: foreignTenantId,
|
|
50
|
+
actorTenantId,
|
|
51
|
+
}),
|
|
52
|
+
).toBeNull()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('refuses the fallback when the tenant override cleared the tenant entirely', () => {
|
|
56
|
+
expect(
|
|
57
|
+
resolveActiveOrganizationId({
|
|
58
|
+
orgId: null,
|
|
59
|
+
actorOrgId: accountOrgId,
|
|
60
|
+
tenantId: null,
|
|
61
|
+
actorTenantId,
|
|
62
|
+
}),
|
|
63
|
+
).toBeNull()
|
|
64
|
+
expect(
|
|
65
|
+
resolveActiveOrganizationId({
|
|
66
|
+
orgId: null,
|
|
67
|
+
actorOrgId: accountOrgId,
|
|
68
|
+
tenantId: foreignTenantId,
|
|
69
|
+
actorTenantId: null,
|
|
70
|
+
}),
|
|
71
|
+
).toBeNull()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('returns null when the caller has no organization at all', () => {
|
|
75
|
+
expect(resolveActiveOrganizationId({ orgId: null })).toBeNull()
|
|
76
|
+
expect(resolveActiveOrganizationId({ orgId: null, actorOrgId: null })).toBeNull()
|
|
77
|
+
expect(resolveActiveOrganizationId(null)).toBeNull()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('ignores blank and non-string values rather than scoping to them', () => {
|
|
81
|
+
expect(resolveActiveOrganizationId({ orgId: ' ', actorOrgId: accountOrgId })).toBe(accountOrgId)
|
|
82
|
+
expect(resolveActiveOrganizationId({ orgId: null, actorOrgId: ' ' })).toBeNull()
|
|
83
|
+
expect(resolveActiveOrganizationId({ orgId: null, actorOrgId: 42 })).toBeNull()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('still honours an explicit organization selection across tenants', () => {
|
|
87
|
+
expect(
|
|
88
|
+
resolveActiveOrganizationId({
|
|
89
|
+
orgId: selectedOrgId,
|
|
90
|
+
actorOrgId: accountOrgId,
|
|
91
|
+
tenantId: foreignTenantId,
|
|
92
|
+
actorTenantId,
|
|
93
|
+
}),
|
|
94
|
+
).toBe(selectedOrgId)
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('organizationScopeRequiredResponse', () => {
|
|
99
|
+
// 401 would send `apiFetch` back into the session-refresh loop; the scope error must not.
|
|
100
|
+
it('answers 400 with a machine-readable code', async () => {
|
|
101
|
+
const response = organizationScopeRequiredResponse()
|
|
102
|
+
expect(response.status).toBe(400)
|
|
103
|
+
const body = await response.json()
|
|
104
|
+
expect(body.code).toBe(ORGANIZATION_SCOPE_REQUIRED_ERROR_CODE)
|
|
105
|
+
expect(typeof body.error).toBe('string')
|
|
106
|
+
})
|
|
107
|
+
})
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
type OrganizationScopedAuth = {
|
|
2
|
+
orgId?: string | null
|
|
3
|
+
actorOrgId?: unknown
|
|
4
|
+
tenantId?: string | null
|
|
5
|
+
actorTenantId?: unknown
|
|
6
|
+
} | null | undefined
|
|
7
|
+
|
|
8
|
+
function normalizeId(value: unknown): string | null {
|
|
9
|
+
if (typeof value !== 'string') return null
|
|
10
|
+
const trimmed = value.trim()
|
|
11
|
+
return trimmed.length > 0 ? trimmed : null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resolves the organization a request is scoped to when the caller may be viewing
|
|
16
|
+
* "all organizations".
|
|
17
|
+
*
|
|
18
|
+
* Organization-scoped configuration modules (integrations credentials/state, data sync
|
|
19
|
+
* mappings/schedules/runs) all require a non-null `organization_id`, so there is no
|
|
20
|
+
* meaningful "all organizations" view of them. When an operator selects that option the
|
|
21
|
+
* super-admin cookie override clears `auth.orgId` and preserves the actor's own
|
|
22
|
+
* organization in `actorOrgId`; fall back to it so those modules keep showing the
|
|
23
|
+
* operator's own configuration instead of failing.
|
|
24
|
+
*
|
|
25
|
+
* The fallback is only valid while the effective tenant is still the actor's own tenant.
|
|
26
|
+
* When the super-admin cookie override also switched tenants (`actorTenantId` is present
|
|
27
|
+
* and differs from `auth.tenantId`), the actor's organization belongs to another tenant —
|
|
28
|
+
* scoping to it would persist a cross-tenant `{ organizationId, tenantId }` pair. Return
|
|
29
|
+
* `null` instead and let the route answer with `organizationScopeRequiredResponse()`.
|
|
30
|
+
*
|
|
31
|
+
* Answering 401 for an unresolvable scope is not merely wrong but self-perpetuating:
|
|
32
|
+
* `apiFetch` reads 401 as an expired session and redirects through
|
|
33
|
+
* `/api/auth/session/refresh`, which succeeds and returns to the same page, reloading
|
|
34
|
+
* forever. That is why the missing-scope answer is a 400, never a 401.
|
|
35
|
+
*/
|
|
36
|
+
export function resolveActiveOrganizationId(auth: OrganizationScopedAuth): string | null {
|
|
37
|
+
if (!auth) return null
|
|
38
|
+
const selected = normalizeId(auth.orgId)
|
|
39
|
+
if (selected) return selected
|
|
40
|
+
const actorOrgId = normalizeId(auth.actorOrgId)
|
|
41
|
+
if (!actorOrgId) return null
|
|
42
|
+
if ('actorTenantId' in auth) {
|
|
43
|
+
const actorTenantId = normalizeId(auth.actorTenantId)
|
|
44
|
+
const effectiveTenantId = normalizeId(auth.tenantId)
|
|
45
|
+
if (!actorTenantId || actorTenantId !== effectiveTenantId) return null
|
|
46
|
+
}
|
|
47
|
+
return actorOrgId
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const ORGANIZATION_SCOPE_REQUIRED_ERROR_CODE = 'organization_scope_required'
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 400 response for an authenticated caller whose organization scope cannot be resolved
|
|
54
|
+
* (e.g. a super-admin viewing a foreign tenant with "all organizations" selected).
|
|
55
|
+
* Deliberately not a 401: the session is valid, so refreshing it would loop.
|
|
56
|
+
*/
|
|
57
|
+
export function organizationScopeRequiredResponse(): Response {
|
|
58
|
+
return Response.json(
|
|
59
|
+
{
|
|
60
|
+
error: 'Select an organization to access this resource',
|
|
61
|
+
code: ORGANIZATION_SCOPE_REQUIRED_ERROR_CODE,
|
|
62
|
+
},
|
|
63
|
+
{ status: 400 },
|
|
64
|
+
)
|
|
65
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { loadDictionary, registerModules } from '../server'
|
|
2
|
+
import type { Module } from '../../../modules/registry'
|
|
3
|
+
|
|
4
|
+
describe('loadDictionary memoization', () => {
|
|
5
|
+
it('returns a cached dictionary for repeated calls with the same locale', async () => {
|
|
6
|
+
registerModules([
|
|
7
|
+
{ id: 'demo', translations: { en: { 'demo.hello': 'Hello' } } },
|
|
8
|
+
] satisfies Module[])
|
|
9
|
+
|
|
10
|
+
const first = await loadDictionary('en')
|
|
11
|
+
const second = await loadDictionary('en')
|
|
12
|
+
|
|
13
|
+
expect(second).toBe(first)
|
|
14
|
+
expect(first['demo.hello']).toBe('Hello')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('busts the cache when the registered module set changes', async () => {
|
|
18
|
+
registerModules([{ id: 'a', translations: { en: { 'a.k': 'A' } } }] satisfies Module[])
|
|
19
|
+
const before = await loadDictionary('en')
|
|
20
|
+
|
|
21
|
+
registerModules([
|
|
22
|
+
{ id: 'a', translations: { en: { 'a.k': 'A' } } },
|
|
23
|
+
{ id: 'b', translations: { en: { 'b.k': 'B' } } },
|
|
24
|
+
] satisfies Module[])
|
|
25
|
+
const after = await loadDictionary('en')
|
|
26
|
+
|
|
27
|
+
expect(after).not.toBe(before)
|
|
28
|
+
expect(after['b.k']).toBe('B')
|
|
29
|
+
})
|
|
30
|
+
})
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { BasicQueryEngine } from '../engine'
|
|
2
2
|
import { SortDir } from '../types'
|
|
3
3
|
import { registerModules } from '../../i18n/server'
|
|
4
|
+
import { clearSearchTokenPresenceCache } from '../../search/availability'
|
|
5
|
+
|
|
6
|
+
// The token-presence answer is cached process-wide (TTL); without clearing it,
|
|
7
|
+
// probe-count assertions would observe hits from earlier tests in this file.
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
clearSearchTokenPresenceCache()
|
|
10
|
+
})
|
|
4
11
|
|
|
5
12
|
// Mock modules with one entity extension
|
|
6
13
|
const mockModules = [
|
|
@@ -355,6 +362,8 @@ describe('BasicQueryEngine (Kysely)', () => {
|
|
|
355
362
|
const fakeDb = createFakeKysely({
|
|
356
363
|
customer_entities: [],
|
|
357
364
|
customer_people: [],
|
|
365
|
+
search_tokens: [{ one: 1 }],
|
|
366
|
+
'information_schema.tables': [{ table_name: 'search_tokens' }],
|
|
358
367
|
'information_schema.columns': [
|
|
359
368
|
{ table_name: 'customer_entities', column_name: 'tenant_id' },
|
|
360
369
|
{ table_name: 'customer_people', column_name: 'id' },
|
|
@@ -362,8 +371,6 @@ describe('BasicQueryEngine (Kysely)', () => {
|
|
|
362
371
|
],
|
|
363
372
|
})
|
|
364
373
|
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
365
|
-
jest.spyOn(engine as any, 'tableExists').mockResolvedValue(true)
|
|
366
|
-
jest.spyOn(engine as any, 'hasSearchTokens').mockResolvedValue(true)
|
|
367
374
|
const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
|
|
368
375
|
|
|
369
376
|
await engine.query('customers:customer_entity', {
|
|
@@ -409,7 +416,6 @@ describe('BasicQueryEngine (Kysely)', () => {
|
|
|
409
416
|
],
|
|
410
417
|
})
|
|
411
418
|
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
412
|
-
jest.spyOn(engine as any, 'tableExists').mockResolvedValue(false)
|
|
413
419
|
const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
|
|
414
420
|
|
|
415
421
|
await engine.query('customers:customer_entity', {
|
|
@@ -445,10 +451,10 @@ describe('BasicQueryEngine (Kysely)', () => {
|
|
|
445
451
|
test('uses search tokens for index document fields on base entities', async () => {
|
|
446
452
|
const fakeDb = createFakeKysely({
|
|
447
453
|
todos: [],
|
|
454
|
+
search_tokens: [{ one: 1 }],
|
|
455
|
+
'information_schema.tables': [{ table_name: 'search_tokens' }],
|
|
448
456
|
})
|
|
449
457
|
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
450
|
-
const tableExistsSpy = jest.spyOn(engine as any, 'tableExists').mockResolvedValue(true)
|
|
451
|
-
const hasSearchTokensSpy = jest.spyOn(engine as any, 'hasSearchTokens').mockResolvedValue(true)
|
|
452
458
|
const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
|
|
453
459
|
|
|
454
460
|
await engine.query('example:todo', {
|
|
@@ -461,12 +467,15 @@ describe('BasicQueryEngine (Kysely)', () => {
|
|
|
461
467
|
page: { page: 1, pageSize: 10 },
|
|
462
468
|
})
|
|
463
469
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
'
|
|
467
|
-
'
|
|
468
|
-
|
|
469
|
-
)
|
|
470
|
+
const calls = fakeDb._calls as Array<{ _ops: { table: string; wheres: unknown[][] } }>
|
|
471
|
+
const tableProbe = calls.find((call) =>
|
|
472
|
+
call._ops.table === 'information_schema.tables' &&
|
|
473
|
+
call._ops.wheres.some((where) => where[0] === 'table_name' && where[2] === 'search_tokens'))
|
|
474
|
+
expect(tableProbe).toBeTruthy()
|
|
475
|
+
const tokenProbe = calls.find((call) =>
|
|
476
|
+
call._ops.table === 'search_tokens' &&
|
|
477
|
+
call._ops.wheres.some((where) => where[0] === 'entity_type' && where[2] === 'example:todo'))
|
|
478
|
+
expect(tokenProbe).toBeTruthy()
|
|
470
479
|
expect(applySearchTokensSpy).toHaveBeenCalledWith(
|
|
471
480
|
expect.anything(),
|
|
472
481
|
expect.objectContaining({
|
|
@@ -488,7 +497,6 @@ describe('BasicQueryEngine (Kysely)', () => {
|
|
|
488
497
|
],
|
|
489
498
|
})
|
|
490
499
|
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
491
|
-
const hasSearchTokensSpy = jest.spyOn(engine as any, 'hasSearchTokens').mockResolvedValue(true)
|
|
492
500
|
const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
|
|
493
501
|
|
|
494
502
|
await engine.query('example:todo', {
|
|
@@ -501,7 +509,8 @@ describe('BasicQueryEngine (Kysely)', () => {
|
|
|
501
509
|
page: { page: 1, pageSize: 10 },
|
|
502
510
|
})
|
|
503
511
|
|
|
504
|
-
|
|
512
|
+
const calls = fakeDb._calls as Array<{ _ops: { table: string } }>
|
|
513
|
+
expect(calls.some((call) => call._ops.table === 'search_tokens')).toBe(false)
|
|
505
514
|
expect(applySearchTokensSpy).not.toHaveBeenCalled()
|
|
506
515
|
const baseCall = fakeDb._calls.find((builder: any) => builder._ops.table === 'todos')
|
|
507
516
|
expect(baseCall?._ops.wheres).toContainEqual(['todos.search_text', 'ilike', '%avision%'])
|
|
@@ -933,4 +942,47 @@ describe('BasicQueryEngine (Kysely)', () => {
|
|
|
933
942
|
expect(baseCall._ops.limits).toBe(10)
|
|
934
943
|
expect(baseCall._ops.offsets).toBe(10)
|
|
935
944
|
})
|
|
945
|
+
|
|
946
|
+
describe('search_tokens coverage probe (#4723 parity)', () => {
|
|
947
|
+
type ProbeDbLog = { _calls: Array<{ _ops: { table: string } }> }
|
|
948
|
+
|
|
949
|
+
const countProbes = (fakeDb: ProbeDbLog): number =>
|
|
950
|
+
fakeDb._calls.filter((call) => call._ops.table === 'search_tokens').length
|
|
951
|
+
|
|
952
|
+
const buildEngine = (fakeDb: unknown): BasicQueryEngine => new BasicQueryEngine(
|
|
953
|
+
{} as ConstructorParameters<typeof BasicQueryEngine>[0],
|
|
954
|
+
(() => fakeDb) as unknown as NonNullable<ConstructorParameters<typeof BasicQueryEngine>[1]>,
|
|
955
|
+
)
|
|
956
|
+
|
|
957
|
+
const buildDb = () => createFakeKysely({
|
|
958
|
+
users: [],
|
|
959
|
+
'information_schema.tables': [{ table_name: 'search_tokens' }],
|
|
960
|
+
})
|
|
961
|
+
|
|
962
|
+
test('is skipped when the query carries no like/ilike filter', async () => {
|
|
963
|
+
const fakeDb = buildDb()
|
|
964
|
+
const engine = buildEngine(fakeDb)
|
|
965
|
+
|
|
966
|
+
await engine.query('auth:user', {
|
|
967
|
+
tenantId: 't1',
|
|
968
|
+
organizationId: 'org1',
|
|
969
|
+
filters: { is_active: { $eq: true } },
|
|
970
|
+
})
|
|
971
|
+
|
|
972
|
+
expect(countProbes(fakeDb)).toBe(0)
|
|
973
|
+
})
|
|
974
|
+
|
|
975
|
+
test('still runs when the query actually searches', async () => {
|
|
976
|
+
const fakeDb = buildDb()
|
|
977
|
+
const engine = buildEngine(fakeDb)
|
|
978
|
+
|
|
979
|
+
await engine.query('auth:user', {
|
|
980
|
+
tenantId: 't1',
|
|
981
|
+
organizationId: 'org1',
|
|
982
|
+
filters: { email: { $ilike: '%abc%' } },
|
|
983
|
+
})
|
|
984
|
+
|
|
985
|
+
expect(countProbes(fakeDb)).toBeGreaterThan(0)
|
|
986
|
+
})
|
|
987
|
+
})
|
|
936
988
|
})
|