@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.
@@ -1,4 +1,4 @@
1
- const locales = ["en", "pl", "es", "de"];
1
+ const locales = ["en", "pl", "es", "de", "ko"];
2
2
  const defaultLocale = "en";
3
3
  export {
4
4
  defaultLocale,
@@ -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;AACjD,MAAM,gBAAwB;",
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
- password: parsed.password || void 0,
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 a {host, port, password, db} object\n * suitable for BullMQ / ioredis structured connection options.\n *\n * @deprecated Prefer passing the full URL via `{ url: getRedisUrl(...) }` to\n * BullMQ/ioredis \u2014 this preserves rediss://, username, database, and query\n * params that structured parsing may lose. Kept for backward compatibility.\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 return {\n host: parsed.hostname || 'localhost',\n port: parseInt(parsed.port, 10) || 6379,\n password: parsed.password || undefined,\n db,\n tls: parsed.protocol === 'rediss:' ? {} : undefined,\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;AAoC3D,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;AAUO,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,WAAO;AAAA,MACL,MAAM,OAAO,YAAY;AAAA,MACzB,MAAM,SAAS,OAAO,MAAM,EAAE,KAAK;AAAA,MACnC,UAAU,OAAO,YAAY;AAAA,MAC7B;AAAA,MACA,KAAK,OAAO,aAAa,YAAY,CAAC,IAAI;AAAA,IAC5C;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;",
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
  }
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.6.7-develop.6834.1.e76f4b8cbc";
1
+ const APP_VERSION = "0.6.7-develop.6842.1.8cd8a8883c";
2
2
  const appVersion = APP_VERSION;
3
3
  export {
4
4
  APP_VERSION,
@@ -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.6834.1.e76f4b8cbc';\nexport const appVersion = APP_VERSION;\n"],
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.6834.1.e76f4b8cbc",
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.6834.1.e76f4b8cbc",
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
+ })
@@ -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 a {host, port, password, db} object
63
- * suitable for BullMQ / ioredis structured connection options.
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
- password: parsed.password || undefined,
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>@')