@open-mercato/shared 0.7.1-develop.7153.1.7145d295e6 → 0.7.1-develop.7170.1.d95074d7ba

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.
@@ -5,6 +5,42 @@ function isUniqueViolation(err) {
5
5
  const message = err.message;
6
6
  return typeof message === "string" && /duplicate key value|unique constraint/i.test(message);
7
7
  }
8
+ const FOREIGN_KEY_VIOLATION_MESSAGE = /violates foreign key constraint(?: "([^"]+)")?/i;
9
+ const MAX_ERROR_CHAIN_DEPTH = 4;
10
+ function pgErrorCandidates(err) {
11
+ const found = [];
12
+ const seen = /* @__PURE__ */ new Set();
13
+ let layer = [err];
14
+ for (let depth = 0; depth < MAX_ERROR_CHAIN_DEPTH && layer.length > 0; depth += 1) {
15
+ const next = [];
16
+ for (const candidate of layer) {
17
+ if (!candidate || typeof candidate !== "object" || seen.has(candidate)) continue;
18
+ seen.add(candidate);
19
+ const record = candidate;
20
+ found.push(record);
21
+ next.push(record.cause, record.previous);
22
+ }
23
+ layer = next;
24
+ }
25
+ return found;
26
+ }
27
+ function isForeignKeyViolation(err) {
28
+ return pgErrorCandidates(err).some((candidate) => {
29
+ if (candidate.code === "23503") return true;
30
+ return typeof candidate.message === "string" && FOREIGN_KEY_VIOLATION_MESSAGE.test(candidate.message);
31
+ });
32
+ }
33
+ function getForeignKeyViolationConstraint(err) {
34
+ for (const candidate of pgErrorCandidates(err)) {
35
+ if (typeof candidate.constraint === "string" && candidate.constraint.length > 0) return candidate.constraint;
36
+ }
37
+ for (const candidate of pgErrorCandidates(err)) {
38
+ if (typeof candidate.message !== "string") continue;
39
+ const match = FOREIGN_KEY_VIOLATION_MESSAGE.exec(candidate.message);
40
+ if (match?.[1]) return match[1];
41
+ }
42
+ return null;
43
+ }
8
44
  const TRANSIENT_CONNECTION_SQLSTATES = /* @__PURE__ */ new Set([
9
45
  "53300",
10
46
  // too_many_connections
@@ -47,6 +83,8 @@ function isTransientDbError(err) {
47
83
  return false;
48
84
  }
49
85
  export {
86
+ getForeignKeyViolationConstraint,
87
+ isForeignKeyViolation,
50
88
  isTransientDbError,
51
89
  isUniqueViolation
52
90
  };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/db/pg-errors.ts"],
4
- "sourcesContent": ["/**\n * Detect a Postgres unique-constraint violation (SQLSTATE 23505) regardless of\n * the ORM/driver layer that surfaces it. Shared across modules so duplicate-insert\n * handling stays consistent platform-wide.\n */\nexport function isUniqueViolation(err: unknown): boolean {\n if (!err || typeof err !== 'object') return false\n const code = (err as { code?: string }).code\n if (code === '23505') return true // Postgres unique_violation\n const message = (err as { message?: string }).message\n return typeof message === 'string' && /duplicate key value|unique constraint/i.test(message)\n}\n\n/**\n * Postgres SQLSTATEs for transient connection / availability failures \u2014 the\n * database (or its connection pool) is temporarily unreachable and the request\n * can succeed on retry. Deliberately scoped to connection/availability codes;\n * query-level conflicts (deadlock 40P01, serialization 40001, lock_not_available\n * 55P03) are NOT included because they do not mean \"service unavailable\".\n */\nconst TRANSIENT_CONNECTION_SQLSTATES = new Set([\n '53300', // too_many_connections\n '53400', // configuration_limit_exceeded\n '57P01', // admin_shutdown\n '57P02', // crash_shutdown\n '57P03', // cannot_connect_now (db starting up)\n '08000', // connection_exception\n '08001', // sqlclient_unable_to_establish_sqlconnection\n '08003', // connection_does_not_exist\n '08006', // connection_failure\n])\n\n/**\n * Postgres-driver / connection-pool messages that describe a transient DB\n * connection failure when the SQLSTATE is dropped by the ORM wrapper. These are\n * intentionally DB-specific phrases only. Bare socket codes (ECONNREFUSED,\n * ETIMEDOUT, \u2026) are deliberately NOT matched: they can originate from any\n * outbound socket (HTTP, cache, queue), so keying off them would falsely\n * attribute unrelated failures to the database.\n */\nconst TRANSIENT_DB_MESSAGE_PATTERNS = [\n /too many clients already/i,\n /unable to acquire a connection/i,\n /timeout acquiring a connection/i,\n /connection terminated/i,\n /the database system is (starting up|shutting down|in recovery)/i,\n]\n\n/**\n * Detect a transient Postgres connection / availability failure (pool exhaustion,\n * `max_connections` reached, DB restarting) via its SQLSTATE or a DB-specific\n * driver message. Gates retryable 503 responses so callers do not report a\n * temporary infrastructure blip as an auth failure (401) or an unexpected server\n * error (500). Scoped to unambiguous DB signals so generic socket errors from\n * non-DB calls are never misclassified.\n */\nexport function isTransientDbError(err: unknown): boolean {\n if (!err || typeof err !== 'object') {\n return false\n }\n const code = (err as { code?: string }).code\n if (typeof code === 'string' && TRANSIENT_CONNECTION_SQLSTATES.has(code)) {\n return true\n }\n const message = (err as { message?: string }).message\n if (typeof message === 'string' && TRANSIENT_DB_MESSAGE_PATTERNS.some((pattern) => pattern.test(message))) {\n return true\n }\n return false\n}\n"],
5
- "mappings": "AAKO,SAAS,kBAAkB,KAAuB;AACvD,MAAI,CAAC,OAAO,OAAO,QAAQ,SAAU,QAAO;AAC5C,QAAM,OAAQ,IAA0B;AACxC,MAAI,SAAS,QAAS,QAAO;AAC7B,QAAM,UAAW,IAA6B;AAC9C,SAAO,OAAO,YAAY,YAAY,yCAAyC,KAAK,OAAO;AAC7F;AASA,MAAM,iCAAiC,oBAAI,IAAI;AAAA,EAC7C;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AACF,CAAC;AAUD,MAAM,gCAAgC;AAAA,EACpC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAUO,SAAS,mBAAmB,KAAuB;AACxD,MAAI,CAAC,OAAO,OAAO,QAAQ,UAAU;AACnC,WAAO;AAAA,EACT;AACA,QAAM,OAAQ,IAA0B;AACxC,MAAI,OAAO,SAAS,YAAY,+BAA+B,IAAI,IAAI,GAAG;AACxE,WAAO;AAAA,EACT;AACA,QAAM,UAAW,IAA6B;AAC9C,MAAI,OAAO,YAAY,YAAY,8BAA8B,KAAK,CAAC,YAAY,QAAQ,KAAK,OAAO,CAAC,GAAG;AACzG,WAAO;AAAA,EACT;AACA,SAAO;AACT;",
4
+ "sourcesContent": ["/**\n * Detect a Postgres unique-constraint violation (SQLSTATE 23505) regardless of\n * the ORM/driver layer that surfaces it. Shared across modules so duplicate-insert\n * handling stays consistent platform-wide.\n */\nexport function isUniqueViolation(err: unknown): boolean {\n if (!err || typeof err !== 'object') return false\n const code = (err as { code?: string }).code\n if (code === '23505') return true // Postgres unique_violation\n const message = (err as { message?: string }).message\n return typeof message === 'string' && /duplicate key value|unique constraint/i.test(message)\n}\n\nconst FOREIGN_KEY_VIOLATION_MESSAGE = /violates foreign key constraint(?: \"([^\"]+)\")?/i\n\nconst MAX_ERROR_CHAIN_DEPTH = 4\n\n/**\n * MikroORM wraps driver errors and copies the pg fields onto the wrapper, but\n * the original error may also sit behind `cause` (Node) or `previous`\n * (MikroORM), possibly re-wrapped by a transaction helper. Walk that chain,\n * breadth-first with a small depth cap, so a check works on any layer.\n */\nfunction pgErrorCandidates(err: unknown): Array<Record<string, unknown>> {\n const found: Array<Record<string, unknown>> = []\n const seen = new Set<unknown>()\n let layer: unknown[] = [err]\n for (let depth = 0; depth < MAX_ERROR_CHAIN_DEPTH && layer.length > 0; depth += 1) {\n const next: unknown[] = []\n for (const candidate of layer) {\n if (!candidate || typeof candidate !== 'object' || seen.has(candidate)) continue\n seen.add(candidate)\n const record = candidate as Record<string, unknown>\n found.push(record)\n next.push(record.cause, record.previous)\n }\n layer = next\n }\n return found\n}\n\n/**\n * Detect a Postgres foreign-key violation (SQLSTATE 23503): the row is still\n * referenced by a dependent table, or the payload references a parent that\n * does not exist. Looks through MikroORM's driver-error wrapping.\n */\nexport function isForeignKeyViolation(err: unknown): boolean {\n return pgErrorCandidates(err).some((candidate) => {\n if (candidate.code === '23503') return true // Postgres foreign_key_violation\n return typeof candidate.message === 'string' && FOREIGN_KEY_VIOLATION_MESSAGE.test(candidate.message)\n })\n}\n\n/**\n * Name of the constraint behind a foreign-key violation, read from the pg\n * `constraint` field on any layer of the wrapper chain, or parsed out of the\n * quoted constraint in the driver message when the field is missing.\n */\nexport function getForeignKeyViolationConstraint(err: unknown): string | null {\n for (const candidate of pgErrorCandidates(err)) {\n if (typeof candidate.constraint === 'string' && candidate.constraint.length > 0) return candidate.constraint\n }\n for (const candidate of pgErrorCandidates(err)) {\n if (typeof candidate.message !== 'string') continue\n const match = FOREIGN_KEY_VIOLATION_MESSAGE.exec(candidate.message)\n if (match?.[1]) return match[1]\n }\n return null\n}\n\n/**\n * Postgres SQLSTATEs for transient connection / availability failures \u2014 the\n * database (or its connection pool) is temporarily unreachable and the request\n * can succeed on retry. Deliberately scoped to connection/availability codes;\n * query-level conflicts (deadlock 40P01, serialization 40001, lock_not_available\n * 55P03) are NOT included because they do not mean \"service unavailable\".\n */\nconst TRANSIENT_CONNECTION_SQLSTATES = new Set([\n '53300', // too_many_connections\n '53400', // configuration_limit_exceeded\n '57P01', // admin_shutdown\n '57P02', // crash_shutdown\n '57P03', // cannot_connect_now (db starting up)\n '08000', // connection_exception\n '08001', // sqlclient_unable_to_establish_sqlconnection\n '08003', // connection_does_not_exist\n '08006', // connection_failure\n])\n\n/**\n * Postgres-driver / connection-pool messages that describe a transient DB\n * connection failure when the SQLSTATE is dropped by the ORM wrapper. These are\n * intentionally DB-specific phrases only. Bare socket codes (ECONNREFUSED,\n * ETIMEDOUT, \u2026) are deliberately NOT matched: they can originate from any\n * outbound socket (HTTP, cache, queue), so keying off them would falsely\n * attribute unrelated failures to the database.\n */\nconst TRANSIENT_DB_MESSAGE_PATTERNS = [\n /too many clients already/i,\n /unable to acquire a connection/i,\n /timeout acquiring a connection/i,\n /connection terminated/i,\n /the database system is (starting up|shutting down|in recovery)/i,\n]\n\n/**\n * Detect a transient Postgres connection / availability failure (pool exhaustion,\n * `max_connections` reached, DB restarting) via its SQLSTATE or a DB-specific\n * driver message. Gates retryable 503 responses so callers do not report a\n * temporary infrastructure blip as an auth failure (401) or an unexpected server\n * error (500). Scoped to unambiguous DB signals so generic socket errors from\n * non-DB calls are never misclassified.\n */\nexport function isTransientDbError(err: unknown): boolean {\n if (!err || typeof err !== 'object') {\n return false\n }\n const code = (err as { code?: string }).code\n if (typeof code === 'string' && TRANSIENT_CONNECTION_SQLSTATES.has(code)) {\n return true\n }\n const message = (err as { message?: string }).message\n if (typeof message === 'string' && TRANSIENT_DB_MESSAGE_PATTERNS.some((pattern) => pattern.test(message))) {\n return true\n }\n return false\n}\n"],
5
+ "mappings": "AAKO,SAAS,kBAAkB,KAAuB;AACvD,MAAI,CAAC,OAAO,OAAO,QAAQ,SAAU,QAAO;AAC5C,QAAM,OAAQ,IAA0B;AACxC,MAAI,SAAS,QAAS,QAAO;AAC7B,QAAM,UAAW,IAA6B;AAC9C,SAAO,OAAO,YAAY,YAAY,yCAAyC,KAAK,OAAO;AAC7F;AAEA,MAAM,gCAAgC;AAEtC,MAAM,wBAAwB;AAQ9B,SAAS,kBAAkB,KAA8C;AACvE,QAAM,QAAwC,CAAC;AAC/C,QAAM,OAAO,oBAAI,IAAa;AAC9B,MAAI,QAAmB,CAAC,GAAG;AAC3B,WAAS,QAAQ,GAAG,QAAQ,yBAAyB,MAAM,SAAS,GAAG,SAAS,GAAG;AACjF,UAAM,OAAkB,CAAC;AACzB,eAAW,aAAa,OAAO;AAC7B,UAAI,CAAC,aAAa,OAAO,cAAc,YAAY,KAAK,IAAI,SAAS,EAAG;AACxE,WAAK,IAAI,SAAS;AAClB,YAAM,SAAS;AACf,YAAM,KAAK,MAAM;AACjB,WAAK,KAAK,OAAO,OAAO,OAAO,QAAQ;AAAA,IACzC;AACA,YAAQ;AAAA,EACV;AACA,SAAO;AACT;AAOO,SAAS,sBAAsB,KAAuB;AAC3D,SAAO,kBAAkB,GAAG,EAAE,KAAK,CAAC,cAAc;AAChD,QAAI,UAAU,SAAS,QAAS,QAAO;AACvC,WAAO,OAAO,UAAU,YAAY,YAAY,8BAA8B,KAAK,UAAU,OAAO;AAAA,EACtG,CAAC;AACH;AAOO,SAAS,iCAAiC,KAA6B;AAC5E,aAAW,aAAa,kBAAkB,GAAG,GAAG;AAC9C,QAAI,OAAO,UAAU,eAAe,YAAY,UAAU,WAAW,SAAS,EAAG,QAAO,UAAU;AAAA,EACpG;AACA,aAAW,aAAa,kBAAkB,GAAG,GAAG;AAC9C,QAAI,OAAO,UAAU,YAAY,SAAU;AAC3C,UAAM,QAAQ,8BAA8B,KAAK,UAAU,OAAO;AAClE,QAAI,QAAQ,CAAC,EAAG,QAAO,MAAM,CAAC;AAAA,EAChC;AACA,SAAO;AACT;AASA,MAAM,iCAAiC,oBAAI,IAAI;AAAA,EAC7C;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AACF,CAAC;AAUD,MAAM,gCAAgC;AAAA,EACpC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAUO,SAAS,mBAAmB,KAAuB;AACxD,MAAI,CAAC,OAAO,OAAO,QAAQ,UAAU;AACnC,WAAO;AAAA,EACT;AACA,QAAM,OAAQ,IAA0B;AACxC,MAAI,OAAO,SAAS,YAAY,+BAA+B,IAAI,IAAI,GAAG;AACxE,WAAO;AAAA,EACT;AACA,QAAM,UAAW,IAA6B;AAC9C,MAAI,OAAO,YAAY,YAAY,8BAA8B,KAAK,CAAC,YAAY,QAAQ,KAAK,OAAO,CAAC,GAAG;AACzG,WAAO;AAAA,EACT;AACA,SAAO;AACT;",
6
6
  "names": []
7
7
  }
@@ -1,3 +1,67 @@
1
+ const DEFAULT_SEPARATORS = { group: ",", decimal: "." };
2
+ const separatorCache = /* @__PURE__ */ new Map();
3
+ const UNICODE_MINUS_SIGNS = /[−‒–—]/g;
4
+ const GROUP_LIKE_CHARACTER = /[\s'’ʼ]/;
5
+ const GROUP_LIKE_SEPARATOR_IN_POSITION = /(\d)[\s'’ʼ](?=\d{3}(?!\d))/g;
6
+ const NORMALIZED_NUMBER = /^[+-]?(\d+(\.\d*)?|\.\d+)(e[+-]?\d+)?$/i;
7
+ function resolveLocaleNumberSeparators(locale) {
8
+ const cacheKey = locale ?? "";
9
+ const cached = separatorCache.get(cacheKey);
10
+ if (cached) return cached;
11
+ let resolved = DEFAULT_SEPARATORS;
12
+ try {
13
+ const parts = new Intl.NumberFormat(locale, {
14
+ useGrouping: true,
15
+ minimumFractionDigits: 1
16
+ }).formatToParts(12345.6);
17
+ const group = parts.find((part) => part.type === "group")?.value ?? DEFAULT_SEPARATORS.group;
18
+ const decimal = parts.find((part) => part.type === "decimal")?.value ?? DEFAULT_SEPARATORS.decimal;
19
+ resolved = { group, decimal };
20
+ } catch {
21
+ resolved = DEFAULT_SEPARATORS;
22
+ }
23
+ separatorCache.set(cacheKey, resolved);
24
+ return resolved;
25
+ }
26
+ function isValidGrouping(integerPart, separator) {
27
+ const digits = integerPart.replace(/^[+-]/, "");
28
+ const segments = digits.split(separator);
29
+ if (segments.length < 2) return true;
30
+ const [first, ...rest] = segments;
31
+ if (!/^\d{1,3}$/.test(first)) return false;
32
+ return rest.every((segment) => /^\d{3}$/.test(segment));
33
+ }
34
+ function parseLocaleNumber(input, locale) {
35
+ if (input == null) return null;
36
+ const trimmed = input.trim();
37
+ if (!trimmed) return null;
38
+ const { group } = resolveLocaleNumberSeparators(locale);
39
+ let candidate = trimmed.replace(UNICODE_MINUS_SIGNS, "-");
40
+ if (group && group !== "," && group !== "." && !GROUP_LIKE_CHARACTER.test(group)) {
41
+ candidate = candidate.split(group).join(" ");
42
+ }
43
+ candidate = candidate.replace(GROUP_LIKE_SEPARATOR_IN_POSITION, "$1");
44
+ if (!candidate) return null;
45
+ const hasComma = candidate.includes(",");
46
+ const hasDot = candidate.includes(".");
47
+ let decimalSeparator = null;
48
+ if (hasComma && hasDot) {
49
+ decimalSeparator = candidate.lastIndexOf(",") > candidate.lastIndexOf(".") ? "," : ".";
50
+ } else if (hasComma || hasDot) {
51
+ const separator = hasComma ? "," : ".";
52
+ const segments = candidate.split(separator);
53
+ decimalSeparator = segments.length > 2 ? null : separator;
54
+ }
55
+ const groupSeparator = decimalSeparator ? decimalSeparator === "," ? "." : "," : hasComma ? "," : ".";
56
+ const [integerPart, ...fractionParts] = decimalSeparator ? candidate.split(decimalSeparator) : [candidate];
57
+ if (fractionParts.length > 1) return null;
58
+ if (!isValidGrouping(integerPart, groupSeparator)) return null;
59
+ if (fractionParts.length && fractionParts[0].includes(groupSeparator)) return null;
60
+ const normalized = `${integerPart.split(groupSeparator).join("")}${fractionParts.length ? `.${fractionParts[0]}` : ""}`;
61
+ if (!NORMALIZED_NUMBER.test(normalized)) return null;
62
+ const parsed = Number(normalized);
63
+ return Number.isFinite(parsed) ? parsed : null;
64
+ }
1
65
  function parseNumberWithDefault(raw, fallback, options) {
2
66
  if (raw == null) return fallback;
3
67
  const trimmed = raw.trim();
@@ -9,6 +73,8 @@ function parseNumberWithDefault(raw, fallback, options) {
9
73
  return value;
10
74
  }
11
75
  export {
12
- parseNumberWithDefault
76
+ parseLocaleNumber,
77
+ parseNumberWithDefault,
78
+ resolveLocaleNumberSeparators
13
79
  };
14
80
  //# sourceMappingURL=number.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/number.ts"],
4
- "sourcesContent": ["export function parseNumberWithDefault(\n raw: string | null | undefined,\n fallback: number,\n options?: { min?: number; integer?: boolean },\n): number {\n if (raw == null) return fallback\n const trimmed = raw.trim()\n if (!trimmed) return fallback\n const value = options?.integer ? Number.parseInt(trimmed, 10) : Number(trimmed)\n if (!Number.isFinite(value)) return fallback\n const min = options?.min ?? -Infinity\n if (value < min) return fallback\n return value\n}\n"],
5
- "mappings": "AAAO,SAAS,uBACd,KACA,UACA,SACQ;AACR,MAAI,OAAO,KAAM,QAAO;AACxB,QAAM,UAAU,IAAI,KAAK;AACzB,MAAI,CAAC,QAAS,QAAO;AACrB,QAAM,QAAQ,SAAS,UAAU,OAAO,SAAS,SAAS,EAAE,IAAI,OAAO,OAAO;AAC9E,MAAI,CAAC,OAAO,SAAS,KAAK,EAAG,QAAO;AACpC,QAAM,MAAM,SAAS,OAAO;AAC5B,MAAI,QAAQ,IAAK,QAAO;AACxB,SAAO;AACT;",
4
+ "sourcesContent": ["type LocaleNumberSeparators = { group: string; decimal: string }\n\nconst DEFAULT_SEPARATORS: LocaleNumberSeparators = { group: ',', decimal: '.' }\nconst separatorCache = new Map<string, LocaleNumberSeparators>()\n\nconst UNICODE_MINUS_SIGNS = /[\u2212\u2012\u2013\u2014]/g\nconst GROUP_LIKE_CHARACTER = /[\\s'\u2019\u02BC]/\nconst GROUP_LIKE_SEPARATOR_IN_POSITION = /(\\d)[\\s'\u2019\u02BC](?=\\d{3}(?!\\d))/g\nconst NORMALIZED_NUMBER = /^[+-]?(\\d+(\\.\\d*)?|\\.\\d+)(e[+-]?\\d+)?$/i\n\n/**\n * Group and decimal separators the given locale uses, derived from `Intl` rather than\n * assumed, so grouping characters such as the narrow no-break space (`fr-FR`) are covered.\n */\nexport function resolveLocaleNumberSeparators(locale?: string): LocaleNumberSeparators {\n const cacheKey = locale ?? ''\n const cached = separatorCache.get(cacheKey)\n if (cached) return cached\n let resolved = DEFAULT_SEPARATORS\n try {\n const parts = new Intl.NumberFormat(locale, {\n useGrouping: true,\n minimumFractionDigits: 1,\n }).formatToParts(12345.6)\n const group = parts.find((part) => part.type === 'group')?.value ?? DEFAULT_SEPARATORS.group\n const decimal = parts.find((part) => part.type === 'decimal')?.value ?? DEFAULT_SEPARATORS.decimal\n resolved = { group, decimal }\n } catch {\n resolved = DEFAULT_SEPARATORS\n }\n separatorCache.set(cacheKey, resolved)\n return resolved\n}\n\nfunction isValidGrouping(integerPart: string, separator: string): boolean {\n const digits = integerPart.replace(/^[+-]/, '')\n const segments = digits.split(separator)\n if (segments.length < 2) return true\n const [first, ...rest] = segments\n if (!/^\\d{1,3}$/.test(first)) return false\n return rest.every((segment) => /^\\d{3}$/.test(segment))\n}\n\n/**\n * Parses a user-typed number written in the conventions of `locale` \u2014 `110,70` under `pl-PL`,\n * `1 234,56` under `fr-FR`, `1,234.56` under `en-US`. Returns `null` when the input is not a\n * number, never a silent `0`, so callers can tell \"unparseable\" apart from \"zero\".\n *\n * Both `,` and `.` are accepted as the decimal separator whichever way the locale runs, because\n * users type the shape their keyboard offers. A SINGLE `,` or `.` is therefore always the decimal\n * point, in every locale: `1.500` is 1.5 under `de-DE` just as it is under `en-US`. Reading a lone\n * separator as grouping instead would turn `1.500` into 1500 with no visible cue \u2014 a silent 1000\u00D7\n * on a money field, and three- and four-decimal unit prices are ordinary here. Grouping is\n * recognized only where it is unambiguous: at least two separators (`1.234.567`), or a whitespace\n * or apostrophe separator standing in a valid group-of-three position (`1 234,56`, `1\u2019234.5`).\n * Whitespace and apostrophes anywhere else are not absorbed \u2014 `1 2` is rejected rather than read\n * as 12 \u2014 so a mistyped or pasted value surfaces as an error instead of a different number.\n *\n * Use it only on strings a user typed. Values arriving from an API or the database are already\n * numbers and MUST NOT go through it.\n */\nexport function parseLocaleNumber(input: string | null | undefined, locale?: string): number | null {\n if (input == null) return null\n const trimmed = input.trim()\n if (!trimmed) return null\n\n const { group } = resolveLocaleNumberSeparators(locale)\n let candidate = trimmed.replace(UNICODE_MINUS_SIGNS, '-')\n if (group && group !== ',' && group !== '.' && !GROUP_LIKE_CHARACTER.test(group)) {\n candidate = candidate.split(group).join(' ')\n }\n candidate = candidate.replace(GROUP_LIKE_SEPARATOR_IN_POSITION, '$1')\n if (!candidate) return null\n\n const hasComma = candidate.includes(',')\n const hasDot = candidate.includes('.')\n let decimalSeparator: string | null = null\n if (hasComma && hasDot) {\n decimalSeparator = candidate.lastIndexOf(',') > candidate.lastIndexOf('.') ? ',' : '.'\n } else if (hasComma || hasDot) {\n const separator = hasComma ? ',' : '.'\n const segments = candidate.split(separator)\n decimalSeparator = segments.length > 2 ? null : separator\n }\n\n const groupSeparator = decimalSeparator\n ? decimalSeparator === ','\n ? '.'\n : ','\n : hasComma\n ? ','\n : '.'\n const [integerPart, ...fractionParts] = decimalSeparator ? candidate.split(decimalSeparator) : [candidate]\n if (fractionParts.length > 1) return null\n if (!isValidGrouping(integerPart, groupSeparator)) return null\n if (fractionParts.length && fractionParts[0].includes(groupSeparator)) return null\n\n const normalized = `${integerPart.split(groupSeparator).join('')}${fractionParts.length ? `.${fractionParts[0]}` : ''}`\n if (!NORMALIZED_NUMBER.test(normalized)) return null\n const parsed = Number(normalized)\n return Number.isFinite(parsed) ? parsed : null\n}\n\nexport function parseNumberWithDefault(\n raw: string | null | undefined,\n fallback: number,\n options?: { min?: number; integer?: boolean },\n): number {\n if (raw == null) return fallback\n const trimmed = raw.trim()\n if (!trimmed) return fallback\n const value = options?.integer ? Number.parseInt(trimmed, 10) : Number(trimmed)\n if (!Number.isFinite(value)) return fallback\n const min = options?.min ?? -Infinity\n if (value < min) return fallback\n return value\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.7.1-develop.7153.1.7145d295e6";
1
+ const APP_VERSION = "0.7.1-develop.7170.1.d95074d7ba";
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.7.1-develop.7153.1.7145d295e6';\nexport const appVersion = APP_VERSION;\n"],
4
+ "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.7.1-develop.7170.1.d95074d7ba';\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.7.1-develop.7153.1.7145d295e6",
3
+ "version": "0.7.1-develop.7170.1.d95074d7ba",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -113,7 +113,7 @@
113
113
  "@mikro-orm/core": "^7.1.8",
114
114
  "@mikro-orm/decorators": "^7.1.8",
115
115
  "@mikro-orm/postgresql": "^7.1.8",
116
- "@open-mercato/cache": "0.7.1-develop.7153.1.7145d295e6",
116
+ "@open-mercato/cache": "0.7.1-develop.7170.1.d95074d7ba",
117
117
  "@types/html-to-text": "^9.0.4",
118
118
  "@types/sanitize-html": "^2.16.1",
119
119
  "dotenv": "^17.4.2",
@@ -1,4 +1,4 @@
1
- import { parseNumberWithDefault } from '../number'
1
+ import { parseLocaleNumber, parseNumberWithDefault, resolveLocaleNumberSeparators } from '../number'
2
2
 
3
3
  describe('parseNumberWithDefault', () => {
4
4
  it('returns the fallback when raw is missing or blank', () => {
@@ -31,3 +31,93 @@ describe('parseNumberWithDefault', () => {
31
31
  expect(parseNumberWithDefault('-5', 0)).toBe(-5)
32
32
  })
33
33
  })
34
+
35
+ describe('resolveLocaleNumberSeparators', () => {
36
+ it('derives the separators from Intl rather than assuming a comma/dot pair', () => {
37
+ expect(resolveLocaleNumberSeparators('en-US')).toEqual({ group: ',', decimal: '.' })
38
+ expect(resolveLocaleNumberSeparators('de-DE')).toEqual({ group: '.', decimal: ',' })
39
+ expect(resolveLocaleNumberSeparators('pl-PL').decimal).toBe(',')
40
+ expect(resolveLocaleNumberSeparators('fr-FR').decimal).toBe(',')
41
+ })
42
+
43
+ it('reports a whitespace group separator for locales that group with spaces', () => {
44
+ expect(resolveLocaleNumberSeparators('fr-FR').group).toMatch(/^\s$/)
45
+ expect(resolveLocaleNumberSeparators('pl-PL').group).toMatch(/^\s$/)
46
+ })
47
+ })
48
+
49
+ describe('parseLocaleNumber', () => {
50
+ it('accepts the locale decimal separator the UI displays (issue #5552)', () => {
51
+ expect(parseLocaleNumber('110,70', 'pl-PL')).toBe(110.7)
52
+ expect(parseLocaleNumber('2,5', 'pl-PL')).toBe(2.5)
53
+ expect(parseLocaleNumber('110,70', 'de-DE')).toBe(110.7)
54
+ expect(parseLocaleNumber('110,70', 'fr-FR')).toBe(110.7)
55
+ expect(parseLocaleNumber('110.70', 'en-US')).toBe(110.7)
56
+ })
57
+
58
+ it('keeps accepting a dot under a comma-decimal locale, so the old input still works', () => {
59
+ expect(parseLocaleNumber('110.70', 'pl-PL')).toBe(110.7)
60
+ expect(parseLocaleNumber('2.5', 'de-DE')).toBe(2.5)
61
+ expect(parseLocaleNumber('0.01', 'fr-FR')).toBe(0.01)
62
+ })
63
+
64
+ it('parses grouped input, including whitespace and apostrophe group separators', () => {
65
+ expect(parseLocaleNumber('1\u00A0234,56', 'pl-PL')).toBe(1234.56)
66
+ expect(parseLocaleNumber('1\u202F234,56', 'pl-PL')).toBe(1234.56)
67
+ expect(parseLocaleNumber('1 234,56', 'fr-FR')).toBe(1234.56)
68
+ expect(parseLocaleNumber('1.234.567,89', 'de-DE')).toBe(1234567.89)
69
+ expect(parseLocaleNumber('1,234,567.89', 'en-US')).toBe(1234567.89)
70
+ expect(parseLocaleNumber('1’234.5', 'de-CH')).toBe(1234.5)
71
+ })
72
+
73
+ it('reads a lone separator as the decimal point, never as grouping', () => {
74
+ // A dot-grouping locale reading `1.500` as 1500 is a silent 1000x on a money field,
75
+ // and three- and four-decimal unit prices are ordinary here.
76
+ expect(parseLocaleNumber('1.500', 'de-DE')).toBe(1.5)
77
+ expect(parseLocaleNumber('2.500', 'es')).toBe(2.5)
78
+ expect(parseLocaleNumber('1.23', 'de-DE')).toBe(1.23)
79
+ expect(parseLocaleNumber('1,500', 'en-US')).toBe(1.5)
80
+ expect(parseLocaleNumber('1,23', 'en-US')).toBe(1.23)
81
+ })
82
+
83
+ it('recognizes grouping only where it is unambiguous', () => {
84
+ expect(parseLocaleNumber('1.234.567', 'de-DE')).toBe(1234567)
85
+ expect(parseLocaleNumber('1,234,567', 'en-US')).toBe(1234567)
86
+ expect(parseLocaleNumber('1 234', 'de-DE')).toBe(1234)
87
+ expect(parseLocaleNumber('1’234', 'de-CH')).toBe(1234)
88
+ })
89
+
90
+ it('does not absorb whitespace or apostrophes outside a valid grouping position', () => {
91
+ expect(parseLocaleNumber('1 2', 'en-US')).toBeNull()
92
+ expect(parseLocaleNumber("1'2", 'pl-PL')).toBeNull()
93
+ expect(parseLocaleNumber('1 2345', 'fr-FR')).toBeNull()
94
+ })
95
+
96
+ it('handles signs, blank fractions and exponent notation', () => {
97
+ expect(parseLocaleNumber('-110,70', 'pl-PL')).toBe(-110.7)
98
+ expect(parseLocaleNumber('−110,70', 'pl-PL')).toBe(-110.7)
99
+ expect(parseLocaleNumber('+7', 'pl-PL')).toBe(7)
100
+ expect(parseLocaleNumber('110,', 'pl-PL')).toBe(110)
101
+ expect(parseLocaleNumber(',5', 'pl-PL')).toBe(0.5)
102
+ expect(parseLocaleNumber('1e3', 'en-US')).toBe(1000)
103
+ expect(parseLocaleNumber('0', 'pl-PL')).toBe(0)
104
+ })
105
+
106
+ it('returns null instead of a silent zero for unparseable input', () => {
107
+ expect(parseLocaleNumber('', 'pl-PL')).toBeNull()
108
+ expect(parseLocaleNumber(' ', 'pl-PL')).toBeNull()
109
+ expect(parseLocaleNumber(null, 'pl-PL')).toBeNull()
110
+ expect(parseLocaleNumber(undefined, 'pl-PL')).toBeNull()
111
+ expect(parseLocaleNumber('abc', 'pl-PL')).toBeNull()
112
+ expect(parseLocaleNumber('-', 'pl-PL')).toBeNull()
113
+ expect(parseLocaleNumber('110,70,5', 'pl-PL')).toBeNull()
114
+ expect(parseLocaleNumber('1.2.3', 'en-US')).toBeNull()
115
+ expect(parseLocaleNumber('12,34 PLN', 'pl-PL')).toBeNull()
116
+ expect(parseLocaleNumber('1,23,456', 'en-US')).toBeNull()
117
+ })
118
+
119
+ it('falls back to comma-group/dot-decimal when the locale tag is unusable', () => {
120
+ expect(parseLocaleNumber('1,234.5', 'not a locale')).toBe(1234.5)
121
+ expect(parseLocaleNumber('110.70', undefined)).toBe(110.7)
122
+ })
123
+ })
@@ -0,0 +1,279 @@
1
+ /**
2
+ * @jest-environment node
3
+ *
4
+ * Regression guard for #5582: the worker/CLI/scheduler bootstrap path (`bootstrapFromAppRoot`)
5
+ * must dispatch `entry.overrides` declared in the app's own `src/modules.ts` the same way the
6
+ * Next.js runtime does through `bootstrap-common.ts`'s `applyModuleOverridesFromEnabledModules`
7
+ * call. Without it, the override side-registry stays empty for every CLI/worker process, so
8
+ * `registerCliModules()` (called by `packages/cli/src/bin.ts` right after `bootstrapFromAppRoot`
9
+ * returns) applies no overrides — `seed-encryption` then seeds the base `defaultEncryptionMaps`
10
+ * instead of the app's `overrides.encryption.maps`.
11
+ *
12
+ * Like the #4327/#4491 guards next to it, this test authors both the `.ts` sources and fresh
13
+ * compiled `.mjs` siblings so `compileAndImport` takes its cache path and never invokes esbuild.
14
+ * The `.mjs` stubs use `module.exports` because Jest's CJS runtime handles the dynamic `import()`.
15
+ *
16
+ * That stubbing is a hard constraint of this tier, not a shortcut: letting esbuild compile a real
17
+ * `src/modules.ts` here emits genuine ESM, which Jest's CJS runtime then refuses to `import()`
18
+ * (`SyntaxError: Unexpected token 'export'`). So the esbuild compile path and the cross-module
19
+ * singleton it depends on are covered at the integration tier instead (#5855).
20
+ *
21
+ * `bootstrapFromAppRoot` reaches the factory through `import('./factory.js')`, and the `ai` override
22
+ * applier through `import('@open-mercato/ai-assistant/...')` — specifiers with no on-disk counterpart
23
+ * under Jest's CJS resolver, so both are mocked virtually.
24
+ */
25
+ jest.mock('../../logger', () => {
26
+ const logger = {
27
+ debug: jest.fn(),
28
+ info: jest.fn(),
29
+ warn: jest.fn(),
30
+ error: jest.fn(),
31
+ child: () => logger,
32
+ }
33
+ return { createLogger: () => logger }
34
+ })
35
+
36
+ jest.mock(
37
+ '../factory.js',
38
+ () => ({
39
+ createBootstrap: () => () => {},
40
+ waitForAsyncRegistration: async () => {},
41
+ }),
42
+ { virtual: true },
43
+ )
44
+
45
+ const mockAiOverrideEntries: unknown[] = []
46
+
47
+ jest.mock(
48
+ '@open-mercato/ai-assistant/modules/ai_assistant/lib/ai-overrides',
49
+ () => {
50
+ const { registerModuleOverrideApplier } = jest.requireActual('../../../modules/overrides')
51
+ registerModuleOverrideApplier('ai', (entries: unknown[]) => {
52
+ mockAiOverrideEntries.push(...entries)
53
+ })
54
+ return {}
55
+ },
56
+ { virtual: true },
57
+ )
58
+
59
+ import fs from 'node:fs'
60
+ import os from 'node:os'
61
+ import path from 'node:path'
62
+ import crypto from 'node:crypto'
63
+ import { createLogger } from '../../logger'
64
+ import { bootstrapFromAppRoot } from '../dynamicLoader'
65
+ import { resetModuleContractOverridesForTests } from '../../../modules/overrides'
66
+ import { registerCliModules, getCliModules, getDefaultEncryptionMaps } from '../../../modules/registry'
67
+ import type { Module } from '../../../modules/registry'
68
+
69
+ const mockedLogger = createLogger('shared') as unknown as {
70
+ debug: jest.Mock
71
+ error: jest.Mock
72
+ }
73
+
74
+ const BASE_ENCRYPTION_MAP = { entityId: 'test_module.widget', fields: [{ field: 'email' }] }
75
+ const OVERRIDE_ENCRYPTION_MAP = {
76
+ entityId: 'test_module.widget',
77
+ fields: [{ field: 'email' }, { field: 'ssn' }],
78
+ }
79
+
80
+ const MODULES_CLI_GENERATED = {
81
+ ts: `export const modules = [{ id: 'test_module', defaultEncryptionMaps: [${JSON.stringify(BASE_ENCRYPTION_MAP)}] }]`,
82
+ compiled: `module.exports = { modules: [{ id: 'test_module', defaultEncryptionMaps: [${JSON.stringify(BASE_ENCRYPTION_MAP)}] }] }`,
83
+ }
84
+
85
+ const GENERATED_MODULES: Record<string, { ts: string; compiled: string }> = {
86
+ 'entities.ids.generated': { ts: 'export const E = {}', compiled: 'module.exports = { E: {} }' },
87
+ 'modules.cli.generated': MODULES_CLI_GENERATED,
88
+ 'entities.generated': { ts: 'export const entities = []', compiled: 'module.exports = { entities: [] }' },
89
+ 'di.generated': { ts: 'export const diRegistrars = []', compiled: 'module.exports = { diRegistrars: [] }' },
90
+ }
91
+
92
+ const APP_TSCONFIG = JSON.stringify({
93
+ compilerOptions: {
94
+ experimentalDecorators: true,
95
+ emitDecoratorMetadata: true,
96
+ useDefineForClassFields: false,
97
+ target: 'ES2022',
98
+ },
99
+ })
100
+
101
+ const APP_MODULES_TS_WITH_OVERRIDE = [
102
+ "export const enabledModules = [{",
103
+ " id: 'test_module',",
104
+ ' overrides: {',
105
+ ' encryption: {',
106
+ ` maps: { 'test_module.widget': ${JSON.stringify(OVERRIDE_ENCRYPTION_MAP)} },`,
107
+ ' },',
108
+ ' },',
109
+ '}]',
110
+ ].join('\n')
111
+
112
+ const APP_MODULES_COMPILED_WITH_OVERRIDE = `module.exports = { enabledModules: [{ id: 'test_module', overrides: { encryption: { maps: { 'test_module.widget': ${JSON.stringify(OVERRIDE_ENCRYPTION_MAP)} } } } }] }`
113
+
114
+ const APP_MODULES_TS_WITHOUT_OVERRIDE = "export const enabledModules = [{ id: 'test_module' }]"
115
+ const APP_MODULES_COMPILED_WITHOUT_OVERRIDE = "module.exports = { enabledModules: [{ id: 'test_module' }] }"
116
+
117
+ const AI_AGENT_KEY = 'catalog.catalog_assistant'
118
+ const APP_MODULES_TS_WITH_AI_OVERRIDE = `export const enabledModules = [{ id: 'test_module', overrides: { ai: { agents: { '${AI_AGENT_KEY}': null } } } }]`
119
+ const APP_MODULES_COMPILED_WITH_AI_OVERRIDE = `module.exports = { enabledModules: [{ id: 'test_module', overrides: { ai: { agents: { '${AI_AGENT_KEY}': null } } } }] }`
120
+
121
+ function hash(content: string): string {
122
+ return crypto.createHash('sha256').update(content).digest('hex')
123
+ }
124
+
125
+ function writeCompiledPair(
126
+ appRoot: string,
127
+ sourcePath: string,
128
+ compiledPath: string,
129
+ source: { ts: string; compiled: string },
130
+ ) {
131
+ fs.writeFileSync(sourcePath, source.ts)
132
+ fs.writeFileSync(compiledPath, source.compiled)
133
+ const sourceRelativePath = path.relative(appRoot, sourcePath).split(path.sep).join('/')
134
+ fs.writeFileSync(`${compiledPath}.cache.json`, JSON.stringify({
135
+ version: 4,
136
+ inputHash: hash(JSON.stringify({
137
+ version: 4,
138
+ sourceHash: hash(source.ts),
139
+ tsconfigHashes: {
140
+ 'tsconfig.json': hash(APP_TSCONFIG),
141
+ },
142
+ })),
143
+ outputHash: hash(source.compiled),
144
+ dependencies: {
145
+ [sourceRelativePath]: hash(source.ts),
146
+ },
147
+ }))
148
+ }
149
+
150
+ const createdAppRoots: string[] = []
151
+
152
+ function createAppRoot(appModules: { ts: string; compiled: string } | null): string {
153
+ const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'om-bootstrap-app-modules-'))
154
+ const generatedDir = path.join(appRoot, '.mercato', 'generated')
155
+ fs.mkdirSync(generatedDir, { recursive: true })
156
+ fs.writeFileSync(path.join(appRoot, 'tsconfig.json'), APP_TSCONFIG)
157
+
158
+ for (const [baseName, source] of Object.entries(GENERATED_MODULES)) {
159
+ writeCompiledPair(
160
+ appRoot,
161
+ path.join(generatedDir, `${baseName}.ts`),
162
+ path.join(generatedDir, `${baseName}.mjs`),
163
+ source,
164
+ )
165
+ }
166
+
167
+ if (appModules) {
168
+ fs.mkdirSync(path.join(appRoot, 'src'), { recursive: true })
169
+ writeCompiledPair(
170
+ appRoot,
171
+ path.join(appRoot, 'src', 'modules.ts'),
172
+ path.join(generatedDir, 'app-modules-overrides.compiled.mjs'),
173
+ appModules,
174
+ )
175
+ }
176
+
177
+ createdAppRoots.push(appRoot)
178
+ return appRoot
179
+ }
180
+
181
+ describe('bootstrapFromAppRoot — src/modules.ts entry.overrides reach CLI/worker processes', () => {
182
+ afterAll(() => {
183
+ for (const root of createdAppRoots) {
184
+ fs.rmSync(root, { recursive: true, force: true })
185
+ }
186
+ })
187
+
188
+ beforeEach(() => {
189
+ mockedLogger.debug.mockClear()
190
+ mockedLogger.error.mockClear()
191
+ mockAiOverrideEntries.length = 0
192
+ resetModuleContractOverridesForTests()
193
+ })
194
+
195
+ afterEach(() => {
196
+ resetModuleContractOverridesForTests()
197
+ })
198
+
199
+ it('dispatches overrides.encryption.maps so getDefaultEncryptionMaps reflects it after registerCliModules', async () => {
200
+ const appRoot = createAppRoot({ ts: APP_MODULES_TS_WITH_OVERRIDE, compiled: APP_MODULES_COMPILED_WITH_OVERRIDE })
201
+
202
+ const data = await bootstrapFromAppRoot(appRoot)
203
+ registerCliModules(data.modules as Module[])
204
+
205
+ const maps = getDefaultEncryptionMaps(getCliModules())
206
+ const widgetMap = maps.find((entry) => entry.entityId === 'test_module.widget')
207
+
208
+ expect(widgetMap).toBeDefined()
209
+ expect(widgetMap?.fields.map((field) => field.field)).toEqual(['email', 'ssn'])
210
+ expect(mockedLogger.error).not.toHaveBeenCalled()
211
+ })
212
+
213
+ it('keeps the base defaultEncryptionMaps when src/modules.ts declares no overrides', async () => {
214
+ const appRoot = createAppRoot({
215
+ ts: APP_MODULES_TS_WITHOUT_OVERRIDE,
216
+ compiled: APP_MODULES_COMPILED_WITHOUT_OVERRIDE,
217
+ })
218
+
219
+ const data = await bootstrapFromAppRoot(appRoot)
220
+ registerCliModules(data.modules as Module[])
221
+
222
+ const maps = getDefaultEncryptionMaps(getCliModules())
223
+ const widgetMap = maps.find((entry) => entry.entityId === 'test_module.widget')
224
+
225
+ expect(widgetMap).toBeDefined()
226
+ expect(widgetMap?.fields.map((field) => field.field)).toEqual(['email'])
227
+ expect(mockedLogger.error).not.toHaveBeenCalled()
228
+ })
229
+
230
+ it('refuses to bootstrap when a present src/modules.ts fails to load', async () => {
231
+ const appRoot = createAppRoot({
232
+ ts: APP_MODULES_TS_WITH_OVERRIDE,
233
+ compiled: "throw new Error('src/modules.ts is broken')",
234
+ })
235
+
236
+ // Degrading here is what #5582 looked like: seed-encryption would seed the base maps and
237
+ // still print success, so a present-but-unloadable modules file must stop the bootstrap.
238
+ await expect(bootstrapFromAppRoot(appRoot)).rejects.toThrow(
239
+ /Failed to load the app-level modules file[\s\S]*Refusing to bootstrap with a partial override set/,
240
+ )
241
+ })
242
+
243
+ it('refuses to bootstrap when src/modules.ts exports no enabledModules array', async () => {
244
+ const appRoot = createAppRoot({
245
+ ts: 'export const somethingElse = []',
246
+ compiled: 'module.exports = { somethingElse: [] }',
247
+ })
248
+
249
+ await expect(bootstrapFromAppRoot(appRoot)).rejects.toThrow(/exports no enabledModules array/)
250
+ })
251
+
252
+ it('skips the dispatch without error when the app has no src/modules.ts at all', async () => {
253
+ const appRoot = createAppRoot(null)
254
+
255
+ const data = await bootstrapFromAppRoot(appRoot)
256
+ registerCliModules(data.modules as Module[])
257
+
258
+ const maps = getDefaultEncryptionMaps(getCliModules())
259
+ expect(maps.find((entry) => entry.entityId === 'test_module.widget')?.fields).toHaveLength(1)
260
+ expect(mockedLogger.error).not.toHaveBeenCalled()
261
+ })
262
+
263
+ it('resolves the ai applier on demand so overrides.ai is not dropped in the CLI path', async () => {
264
+ const appRoot = createAppRoot({
265
+ ts: APP_MODULES_TS_WITH_AI_OVERRIDE,
266
+ compiled: APP_MODULES_COMPILED_WITH_AI_OVERRIDE,
267
+ })
268
+
269
+ await bootstrapFromAppRoot(appRoot)
270
+
271
+ // registerBuiltInModuleOverrideAppliers() does not register `ai`; without the on-demand
272
+ // import the dispatcher takes its unwired branch and discards the entry entirely.
273
+ expect(mockAiOverrideEntries).toEqual([
274
+ { moduleId: 'test_module', overrides: { agents: { [AI_AGENT_KEY]: null } } },
275
+ ])
276
+ expect(mockedLogger.error).not.toHaveBeenCalled()
277
+ })
278
+
279
+ })