@carbonorm/carbonnode 6.0.13 → 6.0.17

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.
Files changed (74) hide show
  1. package/dist/executors/SqlExecutor.d.ts +17 -0
  2. package/dist/index.cjs.js +413 -245
  3. package/dist/index.cjs.js.map +1 -1
  4. package/dist/index.esm.js +413 -245
  5. package/dist/index.esm.js.map +1 -1
  6. package/dist/utils/cacheManager.d.ts +2 -1
  7. package/dist/utils/logLevel.d.ts +3 -3
  8. package/dist/utils/logSql.d.ts +10 -1
  9. package/package.json +1 -1
  10. package/scripts/assets/handlebars/C6.ts.handlebars +1 -1
  11. package/src/__tests__/fixtures/sqlResponses/sqlAllowList.json +1 -1
  12. package/src/__tests__/httpExecutor.multiRowUpsert.test.ts +50 -0
  13. package/src/__tests__/logSql.test.ts +54 -2
  14. package/src/__tests__/sakila-db/C6.js +1 -1
  15. package/src/__tests__/sakila-db/C6.mysqldump.json +1 -1
  16. package/src/__tests__/sakila-db/C6.mysqldump.sql +1 -1
  17. package/src/__tests__/sakila-db/C6.sqlAllowList.json +59 -70
  18. package/src/__tests__/sakila-db/C6.ts +2 -2
  19. package/src/__tests__/sakila-db/sqlResponses/C6.actor.post.json +3 -3
  20. package/src/__tests__/sakila-db/sqlResponses/C6.actor.post.latest.json +3 -3
  21. package/src/__tests__/sakila-db/sqlResponses/C6.actor.put.json +1 -1
  22. package/src/__tests__/sakila-db/sqlResponses/C6.actor.put.lookup.json +3 -3
  23. package/src/__tests__/sakila-db/sqlResponses/C6.address.post.json +5 -5
  24. package/src/__tests__/sakila-db/sqlResponses/C6.address.post.latest.json +5 -5
  25. package/src/__tests__/sakila-db/sqlResponses/C6.address.put.json +1 -1
  26. package/src/__tests__/sakila-db/sqlResponses/C6.address.put.lookup.json +5 -5
  27. package/src/__tests__/sakila-db/sqlResponses/C6.category.post.json +2 -2
  28. package/src/__tests__/sakila-db/sqlResponses/C6.category.post.latest.json +2 -2
  29. package/src/__tests__/sakila-db/sqlResponses/C6.category.put.json +1 -1
  30. package/src/__tests__/sakila-db/sqlResponses/C6.category.put.lookup.json +2 -2
  31. package/src/__tests__/sakila-db/sqlResponses/C6.city.post.json +2 -2
  32. package/src/__tests__/sakila-db/sqlResponses/C6.city.post.latest.json +2 -2
  33. package/src/__tests__/sakila-db/sqlResponses/C6.city.put.json +1 -1
  34. package/src/__tests__/sakila-db/sqlResponses/C6.city.put.lookup.json +2 -2
  35. package/src/__tests__/sakila-db/sqlResponses/C6.country.post.json +2 -2
  36. package/src/__tests__/sakila-db/sqlResponses/C6.country.post.latest.json +2 -2
  37. package/src/__tests__/sakila-db/sqlResponses/C6.country.put.json +1 -1
  38. package/src/__tests__/sakila-db/sqlResponses/C6.country.put.lookup.json +2 -2
  39. package/src/__tests__/sakila-db/sqlResponses/C6.customer.post.json +5 -5
  40. package/src/__tests__/sakila-db/sqlResponses/C6.customer.post.latest.json +5 -5
  41. package/src/__tests__/sakila-db/sqlResponses/C6.customer.put.json +1 -1
  42. package/src/__tests__/sakila-db/sqlResponses/C6.customer.put.lookup.json +5 -5
  43. package/src/__tests__/sakila-db/sqlResponses/C6.film.post.json +2 -2
  44. package/src/__tests__/sakila-db/sqlResponses/C6.film.post.latest.json +2 -2
  45. package/src/__tests__/sakila-db/sqlResponses/C6.film.put.json +1 -1
  46. package/src/__tests__/sakila-db/sqlResponses/C6.film.put.lookup.json +2 -2
  47. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.post.json +1 -1
  48. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.post.latest.json +1 -1
  49. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.put.json +1 -1
  50. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.put.lookup.json +1 -1
  51. package/src/__tests__/sakila-db/sqlResponses/C6.language.post.json +2 -2
  52. package/src/__tests__/sakila-db/sqlResponses/C6.language.post.latest.json +2 -2
  53. package/src/__tests__/sakila-db/sqlResponses/C6.language.put.json +1 -1
  54. package/src/__tests__/sakila-db/sqlResponses/C6.language.put.lookup.json +2 -2
  55. package/src/__tests__/sakila-db/sqlResponses/C6.payment.post.json +2 -2
  56. package/src/__tests__/sakila-db/sqlResponses/C6.payment.post.latest.json +2 -2
  57. package/src/__tests__/sakila-db/sqlResponses/C6.payment.put.lookup.json +2 -2
  58. package/src/__tests__/sakila-db/sqlResponses/C6.rental.join.json +10 -10
  59. package/src/__tests__/sakila-db/sqlResponses/C6.rental.post.json +3 -3
  60. package/src/__tests__/sakila-db/sqlResponses/C6.rental.post.latest.json +3 -3
  61. package/src/__tests__/sakila-db/sqlResponses/C6.rental.put.json +1 -1
  62. package/src/__tests__/sakila-db/sqlResponses/C6.rental.put.lookup.json +3 -3
  63. package/src/__tests__/sqlAllowList.test.ts +100 -0
  64. package/src/__tests__/sqlBuilders.test.ts +3 -4
  65. package/src/executors/HttpExecutor.ts +7 -2
  66. package/src/executors/SqlExecutor.ts +108 -7
  67. package/src/orm/queries/DeleteQueryBuilder.ts +0 -4
  68. package/src/orm/queries/PostQueryBuilder.ts +0 -4
  69. package/src/orm/queries/SelectQueryBuilder.ts +0 -4
  70. package/src/orm/queries/UpdateQueryBuilder.ts +0 -4
  71. package/src/utils/cacheManager.ts +17 -9
  72. package/src/utils/logLevel.ts +3 -4
  73. package/src/utils/logSql.ts +51 -6
  74. package/src/utils/sqlAllowList.ts +111 -9
@@ -2,19 +2,33 @@ import { getEnvBool } from "../variables/getEnv";
2
2
  import colorSql from "./colorSql";
3
3
  import { version } from "../../package.json";
4
4
  import versionToRgb from "./versionColor";
5
- import type {LogContext} from "./logLevel";
6
- import {LogLevel, shouldLog} from "./logLevel";
5
+ import type { LogContext } from "./logLevel";
6
+ import { LogLevel, shouldLog } from "./logLevel";
7
+
8
+ export type SqlAllowListStatus = "allowed" | "denied" | "not verified";
9
+ export type SqlCacheStatus = "hit" | "miss" | "ignored";
10
+
11
+ export type LogSqlContextOptions = {
12
+ cacheStatus: SqlCacheStatus;
13
+ allowListStatus: SqlAllowListStatus;
14
+ method: string,
15
+ sql: string,
16
+ context?: LogContext,
17
+ };
7
18
 
8
19
  const C = {
9
20
  SSR: "\x1b[95m", // bright magenta
10
21
  HTTP: "\x1b[94m", // bright blue
11
22
  SQL: "\x1b[96m", // bright cyan
23
+ WARN: "\x1b[93m", // yellow
24
+ ORANGE: "\x1b[38;2;255;165;0m", // orange (truecolor)
25
+ ERROR: "\x1b[91m", // red
12
26
  METHOD_COLORS: {
13
27
  SELECT: "\x1b[92m", // green
14
28
  INSERT: "\x1b[96m", // cyan
15
29
  REPLACE: "\x1b[96m", // cyan
16
30
  UPDATE: "\x1b[95m", // magenta
17
- DELETE: "\x1b[91m", // red
31
+ DELETE: "\x1b[38;2;255;179;179m", // very light red (truecolor)
18
32
  },
19
33
  METHOD_FALLBACK: [
20
34
  "\x1b[92m", // green
@@ -24,6 +38,7 @@ const C = {
24
38
  "\x1b[94m", // blue
25
39
  "\x1b[97m", // white
26
40
  ],
41
+ GREY: "\x1b[90m", // light grey
27
42
  RESET: "\x1b[0m",
28
43
  };
29
44
 
@@ -47,13 +62,43 @@ function methodColor(method: string): string {
47
62
  return C.METHOD_FALLBACK[idx];
48
63
  }
49
64
 
50
- export default function logSql(method: string, sql: string, context?: LogContext): void {
51
- if (!shouldLog(LogLevel.INFO, context)) return;
65
+ const cacheLabel = (cacheStatus: SqlCacheStatus): string => {
66
+ switch (cacheStatus) {
67
+ case "hit":
68
+ return `${C.METHOD_COLORS.SELECT}[CACHE HIT]${C.RESET}`;
69
+ case "ignored":
70
+ return `${C.WARN}[CACHE IGNORED]${C.RESET}`;
71
+ default:
72
+ return `${C.ORANGE}[CACHE MISS]${C.RESET}`;
73
+ }
74
+ };
75
+
76
+ const allowListLabel = (status: SqlAllowListStatus): string => {
77
+ switch (status) {
78
+ case "allowed":
79
+ return `${C.METHOD_COLORS.SELECT}[VERIFIED]${C.RESET}`;
80
+ case "denied":
81
+ return `${C.ERROR}[DENIED]${C.RESET}`;
82
+ default:
83
+ return `${C.GREY}[NOT VERIFIED]${C.RESET}`;
84
+ }
85
+ };
86
+
87
+ export default function logSql(
88
+ options: LogSqlContextOptions,
89
+ ): void {
90
+ const method = options.method.toUpperCase();
91
+
92
+ if (!shouldLog(LogLevel.INFO, options.context)) return;
52
93
  const preText = getEnvBool("SSR", false)
53
94
  ? `${C.SSR}[SSR]${C.RESET} `
54
95
  : `${C.HTTP}[API]${C.RESET} `;
55
96
 
56
97
  const labelColor = methodColor(method);
57
98
  const versionColor = rgbAnsi(versionToRgb(version));
58
- console.log(`${versionColor}[${version}]${C.RESET} ${preText}${labelColor}[${method}]${C.RESET} ${colorSql(sql)}`);
99
+ const cacheText = cacheLabel(options.cacheStatus);
100
+ const allowListText = allowListLabel(options.allowListStatus);
101
+ console.log(
102
+ `${versionColor}[${version}]${C.RESET} ${cacheText} ${allowListText} ${preText}${labelColor}[${method}]${C.RESET} ${colorSql(options.sql)}`,
103
+ );
59
104
  }
@@ -1,9 +1,95 @@
1
1
  import isNode from "../variables/isNode";
2
2
 
3
- const allowListCache = new Map<string, Set<string>>();
3
+ type AllowListCacheEntry = {
4
+ allowList: Set<string>;
5
+ mtimeMs: number;
6
+ size: number;
7
+ };
4
8
 
5
- export const normalizeSql = (sql: string): string =>
6
- sql.replace(/\s+/g, " ").trim();
9
+ const allowListCache = new Map<string, AllowListCacheEntry>();
10
+
11
+ const ANSI_ESCAPE_REGEX = /\x1b\[[0-9;]*m/g;
12
+ const COLLAPSED_BIND_ROW_REGEX = /\(\?\s*×\d+\)/g;
13
+
14
+ function collapseBindGroups(sql: string): string {
15
+ let normalized = sql.replace(
16
+ /\(\s*(\?(?:\s*,\s*\?)*)\s*\)/g,
17
+ (_match, binds: string) => {
18
+ const bindCount = (binds.match(/\?/g) ?? []).length;
19
+ return `(? ×${bindCount})`;
20
+ },
21
+ );
22
+
23
+ normalized = normalized.replace(
24
+ /(\(\?\s*×\d+\))(?:\s*,\s*\1)+/g,
25
+ (_match, row) => `${row} ×*`,
26
+ );
27
+
28
+ normalized = normalized.replace(
29
+ /\b(VALUES|VALUE)\s+(\(\?\s*×\d+\))(?:\s*×\d+|\s*×\*)?/gi,
30
+ (_match, keyword: string, row: string) => `${keyword} ${row} ×*`,
31
+ );
32
+
33
+ normalized = normalized.replace(
34
+ /\bIN\s*\(\?\s*×\d+\)/gi,
35
+ "IN (? ×*)",
36
+ );
37
+
38
+ normalized = normalized.replace(
39
+ /\(\?\s*×\d+\)\s*×\d+/g,
40
+ (match) => {
41
+ const row = match.match(COLLAPSED_BIND_ROW_REGEX)?.[0];
42
+ return row ? `${row} ×*` : match;
43
+ },
44
+ );
45
+
46
+ return normalized;
47
+ }
48
+
49
+ function normalizeLimitOffset(sql: string): string {
50
+ return sql
51
+ .replace(/\bLIMIT\s+\d+\s*,\s*\d+\b/gi, "LIMIT ?, ?")
52
+ .replace(/\bLIMIT\s+\d+\s+OFFSET\s+\d+\b/gi, "LIMIT ? OFFSET ?")
53
+ .replace(/\bLIMIT\s+\d+\b/gi, "LIMIT ?")
54
+ .replace(/\bOFFSET\s+\d+\b/gi, "OFFSET ?");
55
+ }
56
+
57
+ function normalizeGeomFromTextLiterals(sql: string): string {
58
+ let normalized = sql.replace(
59
+ /ST_GEOMFROMTEXT\(\s*'POINT\([^']*\)'\s*,\s*(?:\d+|\?)\s*\)/gi,
60
+ "ST_GEOMFROMTEXT('POINT(? ?)', ?)",
61
+ );
62
+
63
+ normalized = normalized.replace(
64
+ /ST_GEOMFROMTEXT\(\s*'POLYGON\(\([^']*\)\)'\s*,\s*(?:\d+|\?)\s*\)/gi,
65
+ "ST_GEOMFROMTEXT('POLYGON((?))', ?)",
66
+ );
67
+
68
+ return normalized;
69
+ }
70
+
71
+ function normalizeGeoFunctionNames(sql: string): string {
72
+ return sql
73
+ .replace(/\bST_DISTANCE_SPHERE\b/gi, "ST_DISTANCE_SPHERE")
74
+ .replace(/\bST_GEOMFROMTEXT\b/gi, "ST_GEOMFROMTEXT")
75
+ .replace(/\bMBRCONTAINS\b/gi, "MBRCONTAINS");
76
+ }
77
+
78
+ function normalizeTokenPunctuationSpacing(sql: string): string {
79
+ return sql.replace(/`,\s*`/g, "`, `");
80
+ }
81
+
82
+ export const normalizeSql = (sql: string): string => {
83
+ let normalized = sql.replace(ANSI_ESCAPE_REGEX, " ");
84
+ normalized = normalized.replace(/\s+/g, " ").trim();
85
+ normalized = normalizeGeoFunctionNames(normalized);
86
+ normalized = normalizeTokenPunctuationSpacing(normalized);
87
+ normalized = collapseBindGroups(normalized);
88
+ normalized = normalizeLimitOffset(normalized);
89
+ normalized = normalizeGeomFromTextLiterals(normalized);
90
+ normalized = normalized.replace(/;\s*$/, "");
91
+ return normalized.replace(/\s+/g, " ").trim();
92
+ };
7
93
 
8
94
  const parseAllowList = (raw: string, sourcePath: string): string[] => {
9
95
  let parsed: unknown;
@@ -30,15 +116,27 @@ const parseAllowList = (raw: string, sourcePath: string): string[] => {
30
116
  };
31
117
 
32
118
  export const loadSqlAllowList = async (allowListPath: string): Promise<Set<string>> => {
33
- if (allowListCache.has(allowListPath)) {
34
- return allowListCache.get(allowListPath)!;
35
- }
36
-
37
119
  if (!isNode()) {
38
120
  throw new Error("SQL allowlist validation requires a Node runtime.");
39
121
  }
40
122
 
41
- const {readFile} = await import("node:fs/promises");
123
+ const {readFile, stat} = await import("node:fs/promises");
124
+
125
+ let fileStat: { mtimeMs: number; size: number };
126
+ try {
127
+ fileStat = await stat(allowListPath);
128
+ } catch (error) {
129
+ throw new Error(`SQL allowlist file not found at ${allowListPath}.`);
130
+ }
131
+
132
+ const cached = allowListCache.get(allowListPath);
133
+ if (
134
+ cached &&
135
+ cached.mtimeMs === fileStat.mtimeMs &&
136
+ cached.size === fileStat.size
137
+ ) {
138
+ return cached.allowList;
139
+ }
42
140
 
43
141
  let raw: string;
44
142
  try {
@@ -49,7 +147,11 @@ export const loadSqlAllowList = async (allowListPath: string): Promise<Set<strin
49
147
 
50
148
  const sqlEntries = parseAllowList(raw, allowListPath);
51
149
  const allowList = new Set(sqlEntries);
52
- allowListCache.set(allowListPath, allowList);
150
+ allowListCache.set(allowListPath, {
151
+ allowList,
152
+ mtimeMs: fileStat.mtimeMs,
153
+ size: fileStat.size,
154
+ });
53
155
  return allowList;
54
156
  };
55
157