@open-mercato/shared 0.6.7-develop.6834.1.e76f4b8cbc → 0.6.7-develop.6842.1.8cd8a8883c
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/dist/lib/i18n/config.js +1 -1
- package/dist/lib/i18n/config.js.map +2 -2
- package/dist/lib/redis/connection.js +7 -2
- package/dist/lib/redis/connection.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/bootstrap/__tests__/dynamicLoader.generatedCacheRecovery.test.ts +192 -0
- package/src/lib/i18n/__tests__/config.test.ts +8 -0
- package/src/lib/i18n/config.ts +2 -2
- package/src/lib/redis/__tests__/connection.test.ts +16 -0
- package/src/lib/redis/connection.ts +10 -7
package/dist/lib/i18n/config.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/i18n/config.ts"],
|
|
4
|
-
"sourcesContent": ["export type Locale = 'en' | 'pl' | 'es' | 'de'\n\nexport const locales: Locale[] = ['en', 'pl', 'es', 'de']\nexport const defaultLocale: Locale = 'en'\n"],
|
|
5
|
-
"mappings": "AAEO,MAAM,UAAoB,CAAC,MAAM,MAAM,MAAM,IAAI;
|
|
4
|
+
"sourcesContent": ["export type Locale = 'en' | 'pl' | 'es' | 'de' | 'ko'\n\nexport const locales: Locale[] = ['en', 'pl', 'es', 'de', 'ko']\nexport const defaultLocale: Locale = 'en'\n"],
|
|
5
|
+
"mappings": "AAEO,MAAM,UAAoB,CAAC,MAAM,MAAM,MAAM,MAAM,IAAI;AACvD,MAAM,gBAAwB;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -21,12 +21,17 @@ function parseRedisUrl(url) {
|
|
|
21
21
|
const dbStr = parsed.pathname ? parsed.pathname.slice(1) : "";
|
|
22
22
|
const dbParsed = dbStr !== "" ? parseInt(dbStr, 10) : NaN;
|
|
23
23
|
const db = Number.isNaN(dbParsed) ? void 0 : dbParsed;
|
|
24
|
+
const familyValue = parsed.searchParams.get("family");
|
|
25
|
+
const familyParsed = familyValue !== null ? parseInt(familyValue, 10) : NaN;
|
|
26
|
+
const family = [0, 4, 6].includes(familyParsed) ? familyParsed : void 0;
|
|
24
27
|
return {
|
|
25
28
|
host: parsed.hostname || "localhost",
|
|
26
29
|
port: parseInt(parsed.port, 10) || 6379,
|
|
27
|
-
|
|
30
|
+
username: parsed.username ? decodeURIComponent(parsed.username) : void 0,
|
|
31
|
+
password: parsed.password ? decodeURIComponent(parsed.password) : void 0,
|
|
28
32
|
db,
|
|
29
|
-
tls: parsed.protocol === "rediss:" ? {} : void 0
|
|
33
|
+
tls: parsed.protocol === "rediss:" ? {} : void 0,
|
|
34
|
+
family
|
|
30
35
|
};
|
|
31
36
|
} catch {
|
|
32
37
|
const safeUrl = url.replace(/\/\/[^:]*:[^@]*@/, "//<redacted>@");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/redis/connection.ts"],
|
|
4
|
-
"sourcesContent": ["import { createLogger } from '../logger'\n\nconst logger = createLogger('shared').child({ component: 'redis' })\n\n/**\n * Shared Redis connection utilities.\n *\n * Every package that needs a Redis URL or parsed connection options\n * should import from here instead of reading env vars directly.\n *\n * The `prefix` parameter lets each subsystem define its own override:\n * getRedisUrl('QUEUE') \u2192 QUEUE_REDIS_URL > REDIS_URL > null\n * getRedisUrl('CACHE') \u2192 CACHE_REDIS_URL > REDIS_URL > null\n * getRedisUrl() \u2192 REDIS_URL > null\n *\n * Returns null when Redis is not configured so callers can explicitly\n * decide whether to fall back to a non-Redis strategy or fail loudly.\n * Use getRedisUrlOrThrow() when Redis is mandatory for the caller.\n */\n\nexport type ParsedRedisConnection = {\n host: string\n port: number\n password?: string\n db?: number\n tls?: Record<string, unknown>\n}\n\n/**\n * Resolve a Redis URL from environment variables.\n *\n * Priority: <PREFIX>_REDIS_URL \u2192 REDIS_URL \u2192 null\n *\n * Returns null when no env var is set. Callers MUST NOT assume a\n * localhost default \u2014 silently connecting to localhost masks missing\n * configuration and stalls on platforms where nothing listens there\n * (e.g., WSL2 without a local Redis).\n */\nexport function getRedisUrl(prefix?: string): string | null {\n if (prefix) {\n const prefixed = process.env[`${prefix}_REDIS_URL`]\n if (prefixed) return prefixed\n }\n return process.env.REDIS_URL || null\n}\n\n/**\n * Like getRedisUrl, but throws a descriptive error when Redis is not\n * configured. Use from code paths that require Redis (e.g. BullMQ\n * async queue, Redis cache strategy, scheduler service).\n */\nexport function getRedisUrlOrThrow(prefix?: string): string {\n const url = getRedisUrl(prefix)\n if (url) return url\n const which = prefix ? `${prefix}_REDIS_URL or REDIS_URL` : 'REDIS_URL'\n throw new Error(\n `Redis URL is not configured. Set ${which} in your environment to use a Redis-backed strategy.`\n )\n}\n\n/**\n * Parse a redis:// URL into
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,QAAQ,EAAE,MAAM,EAAE,WAAW,QAAQ,CAAC;
|
|
4
|
+
"sourcesContent": ["import { createLogger } from '../logger'\n\nconst logger = createLogger('shared').child({ component: 'redis' })\n\n/**\n * Shared Redis connection utilities.\n *\n * Every package that needs a Redis URL or parsed connection options\n * should import from here instead of reading env vars directly.\n *\n * The `prefix` parameter lets each subsystem define its own override:\n * getRedisUrl('QUEUE') \u2192 QUEUE_REDIS_URL > REDIS_URL > null\n * getRedisUrl('CACHE') \u2192 CACHE_REDIS_URL > REDIS_URL > null\n * getRedisUrl() \u2192 REDIS_URL > null\n *\n * Returns null when Redis is not configured so callers can explicitly\n * decide whether to fall back to a non-Redis strategy or fail loudly.\n * Use getRedisUrlOrThrow() when Redis is mandatory for the caller.\n */\n\nexport type ParsedRedisConnection = {\n host: string\n port: number\n username?: string\n password?: string\n db?: number\n tls?: Record<string, unknown>\n family?: number\n}\n\n/**\n * Resolve a Redis URL from environment variables.\n *\n * Priority: <PREFIX>_REDIS_URL \u2192 REDIS_URL \u2192 null\n *\n * Returns null when no env var is set. Callers MUST NOT assume a\n * localhost default \u2014 silently connecting to localhost masks missing\n * configuration and stalls on platforms where nothing listens there\n * (e.g., WSL2 without a local Redis).\n */\nexport function getRedisUrl(prefix?: string): string | null {\n if (prefix) {\n const prefixed = process.env[`${prefix}_REDIS_URL`]\n if (prefixed) return prefixed\n }\n return process.env.REDIS_URL || null\n}\n\n/**\n * Like getRedisUrl, but throws a descriptive error when Redis is not\n * configured. Use from code paths that require Redis (e.g. BullMQ\n * async queue, Redis cache strategy, scheduler service).\n */\nexport function getRedisUrlOrThrow(prefix?: string): string {\n const url = getRedisUrl(prefix)\n if (url) return url\n const which = prefix ? `${prefix}_REDIS_URL or REDIS_URL` : 'REDIS_URL'\n throw new Error(\n `Redis URL is not configured. Set ${which} in your environment to use a Redis-backed strategy.`\n )\n}\n\n/**\n * Parse a redis:// URL into structured connection options suitable for\n * BullMQ and ioredis.\n */\nexport function parseRedisUrl(url: string): ParsedRedisConnection {\n try {\n const parsed = new URL(url)\n const dbStr = parsed.pathname ? parsed.pathname.slice(1) : ''\n const dbParsed = dbStr !== '' ? parseInt(dbStr, 10) : NaN\n const db = Number.isNaN(dbParsed) ? undefined : dbParsed\n const familyValue = parsed.searchParams.get('family')\n const familyParsed = familyValue !== null ? parseInt(familyValue, 10) : NaN\n const family = [0, 4, 6].includes(familyParsed) ? familyParsed : undefined\n return {\n host: parsed.hostname || 'localhost',\n port: parseInt(parsed.port, 10) || 6379,\n username: parsed.username ? decodeURIComponent(parsed.username) : undefined,\n password: parsed.password ? decodeURIComponent(parsed.password) : undefined,\n db,\n tls: parsed.protocol === 'rediss:' ? {} : undefined,\n family,\n }\n } catch {\n const safeUrl = url.replace(/\\/\\/[^:]*:[^@]*@/, '//<redacted>@')\n logger.warn('Failed to parse Redis URL, falling back to localhost:6379', { url: safeUrl })\n return { host: 'localhost', port: 6379 }\n }\n}\n\n/**\n * Convenience: resolve the URL from env and parse it in one step.\n * Returns null when Redis is not configured.\n */\nexport function resolveRedisConnection(\n prefix?: string,\n): (ParsedRedisConnection & { url: string }) | null {\n const url = getRedisUrl(prefix)\n if (!url) return null\n return { url, ...parseRedisUrl(url) }\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,QAAQ,EAAE,MAAM,EAAE,WAAW,QAAQ,CAAC;AAsC3D,SAAS,YAAY,QAAgC;AAC1D,MAAI,QAAQ;AACV,UAAM,WAAW,QAAQ,IAAI,GAAG,MAAM,YAAY;AAClD,QAAI,SAAU,QAAO;AAAA,EACvB;AACA,SAAO,QAAQ,IAAI,aAAa;AAClC;AAOO,SAAS,mBAAmB,QAAyB;AAC1D,QAAM,MAAM,YAAY,MAAM;AAC9B,MAAI,IAAK,QAAO;AAChB,QAAM,QAAQ,SAAS,GAAG,MAAM,4BAA4B;AAC5D,QAAM,IAAI;AAAA,IACR,oCAAoC,KAAK;AAAA,EAC3C;AACF;AAMO,SAAS,cAAc,KAAoC;AAChE,MAAI;AACF,UAAM,SAAS,IAAI,IAAI,GAAG;AAC1B,UAAM,QAAQ,OAAO,WAAW,OAAO,SAAS,MAAM,CAAC,IAAI;AAC3D,UAAM,WAAW,UAAU,KAAK,SAAS,OAAO,EAAE,IAAI;AACtD,UAAM,KAAK,OAAO,MAAM,QAAQ,IAAI,SAAY;AAChD,UAAM,cAAc,OAAO,aAAa,IAAI,QAAQ;AACpD,UAAM,eAAe,gBAAgB,OAAO,SAAS,aAAa,EAAE,IAAI;AACxE,UAAM,SAAS,CAAC,GAAG,GAAG,CAAC,EAAE,SAAS,YAAY,IAAI,eAAe;AACjE,WAAO;AAAA,MACL,MAAM,OAAO,YAAY;AAAA,MACzB,MAAM,SAAS,OAAO,MAAM,EAAE,KAAK;AAAA,MACnC,UAAU,OAAO,WAAW,mBAAmB,OAAO,QAAQ,IAAI;AAAA,MAClE,UAAU,OAAO,WAAW,mBAAmB,OAAO,QAAQ,IAAI;AAAA,MAClE;AAAA,MACA,KAAK,OAAO,aAAa,YAAY,CAAC,IAAI;AAAA,MAC1C;AAAA,IACF;AAAA,EACF,QAAQ;AACN,UAAM,UAAU,IAAI,QAAQ,oBAAoB,eAAe;AAC/D,WAAO,KAAK,6DAA6D,EAAE,KAAK,QAAQ,CAAC;AACzF,WAAO,EAAE,MAAM,aAAa,MAAM,KAAK;AAAA,EACzC;AACF;AAMO,SAAS,uBACd,QACkD;AAClD,QAAM,MAAM,YAAY,MAAM;AAC9B,MAAI,CAAC,IAAK,QAAO;AACjB,SAAO,EAAE,KAAK,GAAG,cAAc,GAAG,EAAE;AACtC;",
|
|
6
6
|
"names": []
|
|
7
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.6842.1.8cd8a8883c';\nexport const appVersion = APP_VERSION;\n"],
|
|
5
5
|
"mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
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.6842.1.8cd8a8883c",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -105,7 +105,7 @@
|
|
|
105
105
|
"@mikro-orm/core": "^7.1.8",
|
|
106
106
|
"@mikro-orm/decorators": "^7.1.8",
|
|
107
107
|
"@mikro-orm/postgresql": "^7.1.8",
|
|
108
|
-
"@open-mercato/cache": "0.6.7-develop.
|
|
108
|
+
"@open-mercato/cache": "0.6.7-develop.6842.1.8cd8a8883c",
|
|
109
109
|
"@types/sanitize-html": "^2.16.1",
|
|
110
110
|
"dotenv": "^17.4.2",
|
|
111
111
|
"pino": "^10.3.1",
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*
|
|
4
|
+
* Integration guard for #4526/#4693: compileAndImport must await its dynamic
|
|
5
|
+
* import so that an import-time rejection settles inside the try block.
|
|
6
|
+
* Returning the promise unawaited let the rejection escape the surrounding try,
|
|
7
|
+
* which made the MikroORM v7 generated-cache recovery in the catch dead code for
|
|
8
|
+
* exactly the failure it exists to repair. The one-line fix shipped in #4540;
|
|
9
|
+
* this file is the end-to-end coverage for it.
|
|
10
|
+
*
|
|
11
|
+
* Two sibling suites cover this area and neither subsumes the other:
|
|
12
|
+
*
|
|
13
|
+
* - dynamicLoader.cacheRecovery.test.ts mocks ../generatedCacheRecovery in full
|
|
14
|
+
* and asserts how the loader *reacts* to a rejecting import (recovery is
|
|
15
|
+
* invoked once, the retry is capped at one, the error propagates when no
|
|
16
|
+
* recovery applies).
|
|
17
|
+
* - generatedCacheRecovery.test.ts exercises the recovery module directly,
|
|
18
|
+
* without going through the loader.
|
|
19
|
+
* - This file runs the REAL recovery module from the loader's catch, so the
|
|
20
|
+
* loader ↔ recovery seam is covered end to end: the on-disk marker, its
|
|
21
|
+
* `runtime-import-error` reason and the deleted-file list are asserted against
|
|
22
|
+
* what the production module actually writes. Nothing else in the repository
|
|
23
|
+
* reaches that reason.
|
|
24
|
+
*
|
|
25
|
+
* The fixture reproduces the window the catch exists for: a stale compiled file
|
|
26
|
+
* that the startup scan (ensureMikroOrmV7GeneratedCacheCompatibility) cannot see
|
|
27
|
+
* because it appears only once loading is already under way. The staged .mjs
|
|
28
|
+
* therefore carries no decorator import at scan time — it writes a stale sibling
|
|
29
|
+
* and then rejects with the error Node raises for a v6 decorator import, which
|
|
30
|
+
* is what the import-time recovery is meant to repair.
|
|
31
|
+
*
|
|
32
|
+
* The assertions stop at the recovery boundary on purpose. Jest's module
|
|
33
|
+
* registry keeps serving a file it has already evaluated, so the guarded retry
|
|
34
|
+
* re-runs the rejecting module from memory no matter what recovery wrote to
|
|
35
|
+
* disk — the outcome of the retry is a property of the test runner, not of the
|
|
36
|
+
* loader. What #4526 broke, and what this guards, is that the catch runs at
|
|
37
|
+
* all: before the fix the rejection escaped the try, so no import-time recovery
|
|
38
|
+
* ever happened.
|
|
39
|
+
*/
|
|
40
|
+
import crypto from 'node:crypto'
|
|
41
|
+
import fs from 'node:fs'
|
|
42
|
+
import os from 'node:os'
|
|
43
|
+
import path from 'node:path'
|
|
44
|
+
import { loadBootstrapData } from '../dynamicLoader'
|
|
45
|
+
|
|
46
|
+
const STALE_SIBLING_BASENAME = 'stale-entity.generated.mjs'
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The decorator import is assembled at runtime rather than written literally:
|
|
50
|
+
* a literal would make this fixture itself match the startup scan's staleness
|
|
51
|
+
* pattern, so the scan would delete the cache before the import ever runs and
|
|
52
|
+
* the import-time path under test would never be exercised.
|
|
53
|
+
*/
|
|
54
|
+
const REJECTING_COMPILED_SOURCE = [
|
|
55
|
+
"const nodeFs = require('node:fs')",
|
|
56
|
+
"const nodePath = require('node:path')",
|
|
57
|
+
"const quote = String.fromCharCode(39)",
|
|
58
|
+
"const staleImport = 'import { Entity, PrimaryKey } from ' + quote + '@mikro-orm/core' + quote",
|
|
59
|
+
`nodeFs.writeFileSync(nodePath.join(__dirname, ${JSON.stringify(STALE_SIBLING_BASENAME)}), staleImport + '\\nexport const stale = true\\n')`,
|
|
60
|
+
`throw new Error('The requested module ' + quote + '@mikro-orm/core' + quote + ' does not provide an export named ' + quote + 'Entity' + quote)`,
|
|
61
|
+
].join('\n')
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Jest resolves the loader's dynamic import through its own CommonJS registry,
|
|
65
|
+
* so a staged .mjs has to be CommonJS to evaluate at all — esbuild's real ESM
|
|
66
|
+
* output fails under the runner with "Unexpected token 'export'". The .ts
|
|
67
|
+
* sources stay honest ESM: they are what the loader recompiles from once
|
|
68
|
+
* recovery has deleted the cache.
|
|
69
|
+
*/
|
|
70
|
+
const GENERATED_MODULES: Record<string, { ts: string; compiled: string }> = {
|
|
71
|
+
'entities.ids.generated': { ts: 'export const E = {}', compiled: 'module.exports = { E: {} }' },
|
|
72
|
+
'modules.cli.generated': { ts: 'export const modules = []', compiled: 'module.exports = { modules: [] }' },
|
|
73
|
+
'entities.generated': { ts: 'export const entities = []', compiled: 'module.exports = { entities: [] }' },
|
|
74
|
+
'di.generated': { ts: 'export const diRegistrars = []', compiled: 'module.exports = { diRegistrars: [] }' },
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function contentHash(content: Buffer | string): string {
|
|
78
|
+
return crypto.createHash('sha256').update(content).digest('hex')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Replace a compiled cache entry with staged content the runner can evaluate,
|
|
83
|
+
* keeping the loader's own cache metadata authoritative.
|
|
84
|
+
*
|
|
85
|
+
* The loader validates a cache entry by hashing (#4724): it recompiles unless
|
|
86
|
+
* the sibling `.cache.json` matches both the source and the compiled output.
|
|
87
|
+
* Only `outputHash` is rewritten here — `version`, `inputHash` and
|
|
88
|
+
* `dependencies` stay exactly as the loader wrote them, so the fixture models a
|
|
89
|
+
* cache that is internally consistent and simply stale, and it cannot drift out
|
|
90
|
+
* of step with the cache format the way a hand-built metadata file would.
|
|
91
|
+
*/
|
|
92
|
+
function stageCompiledCache(generatedDir: string, baseName: string, compiled: string) {
|
|
93
|
+
const jsPath = path.join(generatedDir, `${baseName}.mjs`)
|
|
94
|
+
const metadataPath = `${jsPath}.cache.json`
|
|
95
|
+
|
|
96
|
+
fs.writeFileSync(jsPath, compiled)
|
|
97
|
+
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'))
|
|
98
|
+
metadata.outputHash = contentHash(fs.readFileSync(jsPath))
|
|
99
|
+
fs.writeFileSync(metadataPath, JSON.stringify(metadata))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function hasCacheMetadata(generatedDir: string, baseName: string): boolean {
|
|
103
|
+
return fs.existsSync(path.join(generatedDir, `${baseName}.mjs.cache.json`))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
describe('compileAndImport — generated-cache recovery is reachable (#4526)', () => {
|
|
107
|
+
let appRoot: string
|
|
108
|
+
let generatedDir: string
|
|
109
|
+
let warnSpy: jest.SpyInstance
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Let the loader compile the generated sources so it writes real cache
|
|
113
|
+
* metadata for each of them, replacing each compiled output with the
|
|
114
|
+
* runner-evaluable staged content as it appears.
|
|
115
|
+
*
|
|
116
|
+
* The loader writes a module's metadata before importing it, so an import
|
|
117
|
+
* failing here (esbuild's ESM output under Jest's CommonJS registry) is
|
|
118
|
+
* expected and irrelevant — the metadata is the only thing this step is
|
|
119
|
+
* after. That failure does abort the rest of the load, though, so one pass
|
|
120
|
+
* only reaches the modules the loader got to: entities.ids first and alone,
|
|
121
|
+
* then the remainder together. Repeating the pass lets each newly staged
|
|
122
|
+
* module unblock the next batch, and the loop ends on the first pass that
|
|
123
|
+
* loads cleanly, which is precisely when every module is staged.
|
|
124
|
+
*
|
|
125
|
+
* jest.resetModules() between passes is load-bearing. Jest keys its registry
|
|
126
|
+
* on the resolved path and ignores the loader's `?cache=` query, so without
|
|
127
|
+
* the reset a module would keep being served from its first (ESM, failing)
|
|
128
|
+
* evaluation and no later pass could make progress.
|
|
129
|
+
*
|
|
130
|
+
* None of these failures carries a decorator-export error, so no recovery
|
|
131
|
+
* runs and no marker is written during setup; both tests below would fail
|
|
132
|
+
* loudly if one were.
|
|
133
|
+
*/
|
|
134
|
+
async function primeAndStageCompiledCaches() {
|
|
135
|
+
const baseNames = Object.keys(GENERATED_MODULES)
|
|
136
|
+
for (let pass = 0; pass <= baseNames.length; pass += 1) {
|
|
137
|
+
let loadedCleanly = true
|
|
138
|
+
await loadBootstrapData(appRoot).catch(() => { loadedCleanly = false })
|
|
139
|
+
jest.resetModules()
|
|
140
|
+
if (loadedCleanly) return
|
|
141
|
+
for (const baseName of baseNames) {
|
|
142
|
+
if (!hasCacheMetadata(generatedDir, baseName)) continue
|
|
143
|
+
stageCompiledCache(generatedDir, baseName, GENERATED_MODULES[baseName].compiled)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
throw new Error('[internal] loader never reached a fully staged generated cache')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
beforeEach(async () => {
|
|
150
|
+
appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'om-bootstrap-4526-'))
|
|
151
|
+
generatedDir = path.join(appRoot, '.mercato', 'generated')
|
|
152
|
+
fs.mkdirSync(generatedDir, { recursive: true })
|
|
153
|
+
fs.writeFileSync(
|
|
154
|
+
path.join(appRoot, 'tsconfig.json'),
|
|
155
|
+
JSON.stringify({ compilerOptions: { target: 'ES2022', module: 'ESNext' } }),
|
|
156
|
+
)
|
|
157
|
+
for (const [baseName, source] of Object.entries(GENERATED_MODULES)) {
|
|
158
|
+
fs.writeFileSync(path.join(generatedDir, `${baseName}.ts`), source.ts)
|
|
159
|
+
}
|
|
160
|
+
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
|
|
161
|
+
|
|
162
|
+
await primeAndStageCompiledCaches()
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
afterEach(() => {
|
|
166
|
+
warnSpy.mockRestore()
|
|
167
|
+
fs.rmSync(appRoot, { recursive: true, force: true })
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('runs cache recovery when the compiled cache rejects on import', async () => {
|
|
171
|
+
const rejectingPath = path.join(generatedDir, 'entities.ids.generated.mjs')
|
|
172
|
+
stageCompiledCache(generatedDir, 'entities.ids.generated', REJECTING_COMPILED_SOURCE)
|
|
173
|
+
|
|
174
|
+
await loadBootstrapData(appRoot).catch(() => undefined)
|
|
175
|
+
|
|
176
|
+
const markerPath = path.join(generatedDir, '.mikro-orm-v7-cache-recovery.json')
|
|
177
|
+
expect(fs.existsSync(markerPath)).toBe(true)
|
|
178
|
+
|
|
179
|
+
const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8'))
|
|
180
|
+
expect(marker.reason).toBe('runtime-import-error')
|
|
181
|
+
expect(marker.deletedFiles).toContain(rejectingPath)
|
|
182
|
+
expect(marker.deletedFiles).toContain(path.join(generatedDir, STALE_SIBLING_BASENAME))
|
|
183
|
+
|
|
184
|
+
expect(fs.readFileSync(rejectingPath, 'utf8')).not.toContain('does not provide an export named')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('does not run recovery when the compiled cache imports cleanly', async () => {
|
|
188
|
+
await loadBootstrapData(appRoot)
|
|
189
|
+
|
|
190
|
+
expect(fs.existsSync(path.join(generatedDir, '.mikro-orm-v7-cache-recovery.json'))).toBe(false)
|
|
191
|
+
})
|
|
192
|
+
})
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { defaultLocale, locales, type Locale } from '../config'
|
|
2
|
+
|
|
3
|
+
describe('supported application locales', () => {
|
|
4
|
+
it('registers Korean without changing the default locale', () => {
|
|
5
|
+
expect(locales).toEqual<Locale[]>(['en', 'pl', 'es', 'de', 'ko'])
|
|
6
|
+
expect(defaultLocale).toBe('en')
|
|
7
|
+
})
|
|
8
|
+
})
|
package/src/lib/i18n/config.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type Locale = 'en' | 'pl' | 'es' | 'de'
|
|
1
|
+
export type Locale = 'en' | 'pl' | 'es' | 'de' | 'ko'
|
|
2
2
|
|
|
3
|
-
export const locales: Locale[] = ['en', 'pl', 'es', 'de']
|
|
3
|
+
export const locales: Locale[] = ['en', 'pl', 'es', 'de', 'ko']
|
|
4
4
|
export const defaultLocale: Locale = 'en'
|
|
@@ -5,9 +5,11 @@ describe('parseRedisUrl', () => {
|
|
|
5
5
|
expect(parseRedisUrl('rediss://:secret@example.cache.amazonaws.com:6379/2')).toEqual({
|
|
6
6
|
host: 'example.cache.amazonaws.com',
|
|
7
7
|
port: 6379,
|
|
8
|
+
username: undefined,
|
|
8
9
|
password: 'secret',
|
|
9
10
|
db: 2,
|
|
10
11
|
tls: {},
|
|
12
|
+
family: undefined,
|
|
11
13
|
})
|
|
12
14
|
})
|
|
13
15
|
|
|
@@ -15,9 +17,23 @@ describe('parseRedisUrl', () => {
|
|
|
15
17
|
expect(parseRedisUrl('redis://:secret@localhost:6379/0')).toEqual({
|
|
16
18
|
host: 'localhost',
|
|
17
19
|
port: 6379,
|
|
20
|
+
username: undefined,
|
|
18
21
|
password: 'secret',
|
|
19
22
|
db: 0,
|
|
20
23
|
tls: undefined,
|
|
24
|
+
family: undefined,
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('preserves ACL credentials and supported address-family options', () => {
|
|
29
|
+
expect(parseRedisUrl('rediss://app%2Duser:p%40ss@example.com:6380/4?family=6')).toEqual({
|
|
30
|
+
host: 'example.com',
|
|
31
|
+
port: 6380,
|
|
32
|
+
username: 'app-user',
|
|
33
|
+
password: 'p@ss',
|
|
34
|
+
db: 4,
|
|
35
|
+
tls: {},
|
|
36
|
+
family: 6,
|
|
21
37
|
})
|
|
22
38
|
})
|
|
23
39
|
})
|
|
@@ -21,9 +21,11 @@ const logger = createLogger('shared').child({ component: 'redis' })
|
|
|
21
21
|
export type ParsedRedisConnection = {
|
|
22
22
|
host: string
|
|
23
23
|
port: number
|
|
24
|
+
username?: string
|
|
24
25
|
password?: string
|
|
25
26
|
db?: number
|
|
26
27
|
tls?: Record<string, unknown>
|
|
28
|
+
family?: number
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
/**
|
|
@@ -59,12 +61,8 @@ export function getRedisUrlOrThrow(prefix?: string): string {
|
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
/**
|
|
62
|
-
* Parse a redis:// URL into
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* @deprecated Prefer passing the full URL via `{ url: getRedisUrl(...) }` to
|
|
66
|
-
* BullMQ/ioredis — this preserves rediss://, username, database, and query
|
|
67
|
-
* params that structured parsing may lose. Kept for backward compatibility.
|
|
64
|
+
* Parse a redis:// URL into structured connection options suitable for
|
|
65
|
+
* BullMQ and ioredis.
|
|
68
66
|
*/
|
|
69
67
|
export function parseRedisUrl(url: string): ParsedRedisConnection {
|
|
70
68
|
try {
|
|
@@ -72,12 +70,17 @@ export function parseRedisUrl(url: string): ParsedRedisConnection {
|
|
|
72
70
|
const dbStr = parsed.pathname ? parsed.pathname.slice(1) : ''
|
|
73
71
|
const dbParsed = dbStr !== '' ? parseInt(dbStr, 10) : NaN
|
|
74
72
|
const db = Number.isNaN(dbParsed) ? undefined : dbParsed
|
|
73
|
+
const familyValue = parsed.searchParams.get('family')
|
|
74
|
+
const familyParsed = familyValue !== null ? parseInt(familyValue, 10) : NaN
|
|
75
|
+
const family = [0, 4, 6].includes(familyParsed) ? familyParsed : undefined
|
|
75
76
|
return {
|
|
76
77
|
host: parsed.hostname || 'localhost',
|
|
77
78
|
port: parseInt(parsed.port, 10) || 6379,
|
|
78
|
-
|
|
79
|
+
username: parsed.username ? decodeURIComponent(parsed.username) : undefined,
|
|
80
|
+
password: parsed.password ? decodeURIComponent(parsed.password) : undefined,
|
|
79
81
|
db,
|
|
80
82
|
tls: parsed.protocol === 'rediss:' ? {} : undefined,
|
|
83
|
+
family,
|
|
81
84
|
}
|
|
82
85
|
} catch {
|
|
83
86
|
const safeUrl = url.replace(/\/\/[^:]*:[^@]*@/, '//<redacted>@')
|