@carbonorm/carbonnode 6.0.14 → 6.0.18

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 (77) hide show
  1. package/dist/executors/SqlExecutor.d.ts +17 -0
  2. package/dist/index.cjs.js +450 -248
  3. package/dist/index.cjs.js.map +1 -1
  4. package/dist/index.esm.js +450 -249
  5. package/dist/index.esm.js.map +1 -1
  6. package/dist/types/ormInterfaces.d.ts +1 -0
  7. package/dist/utils/cacheManager.d.ts +3 -1
  8. package/dist/utils/logLevel.d.ts +3 -3
  9. package/dist/utils/logSql.d.ts +10 -1
  10. package/package.json +2 -2
  11. package/scripts/assets/handlebars/C6.ts.handlebars +1 -1
  12. package/src/__tests__/fixtures/sqlResponses/sqlAllowList.json +1 -1
  13. package/src/__tests__/httpExecutor.cacheEviction.test.ts +70 -0
  14. package/src/__tests__/logSql.test.ts +54 -2
  15. package/src/__tests__/sakila-db/C6.js +1 -1
  16. package/src/__tests__/sakila-db/C6.mysqldump.json +1 -1
  17. package/src/__tests__/sakila-db/C6.mysqldump.sql +1 -1
  18. package/src/__tests__/sakila-db/C6.sqlAllowList.json +59 -70
  19. package/src/__tests__/sakila-db/C6.ts +2 -2
  20. package/src/__tests__/sakila-db/sqlResponses/C6.actor.post.json +3 -3
  21. package/src/__tests__/sakila-db/sqlResponses/C6.actor.post.latest.json +3 -3
  22. package/src/__tests__/sakila-db/sqlResponses/C6.actor.put.json +1 -1
  23. package/src/__tests__/sakila-db/sqlResponses/C6.actor.put.lookup.json +3 -3
  24. package/src/__tests__/sakila-db/sqlResponses/C6.address.post.json +5 -5
  25. package/src/__tests__/sakila-db/sqlResponses/C6.address.post.latest.json +5 -5
  26. package/src/__tests__/sakila-db/sqlResponses/C6.address.put.json +1 -1
  27. package/src/__tests__/sakila-db/sqlResponses/C6.address.put.lookup.json +5 -5
  28. package/src/__tests__/sakila-db/sqlResponses/C6.category.post.json +2 -2
  29. package/src/__tests__/sakila-db/sqlResponses/C6.category.post.latest.json +2 -2
  30. package/src/__tests__/sakila-db/sqlResponses/C6.category.put.json +1 -1
  31. package/src/__tests__/sakila-db/sqlResponses/C6.category.put.lookup.json +2 -2
  32. package/src/__tests__/sakila-db/sqlResponses/C6.city.post.json +2 -2
  33. package/src/__tests__/sakila-db/sqlResponses/C6.city.post.latest.json +2 -2
  34. package/src/__tests__/sakila-db/sqlResponses/C6.city.put.json +1 -1
  35. package/src/__tests__/sakila-db/sqlResponses/C6.city.put.lookup.json +2 -2
  36. package/src/__tests__/sakila-db/sqlResponses/C6.country.post.json +2 -2
  37. package/src/__tests__/sakila-db/sqlResponses/C6.country.post.latest.json +2 -2
  38. package/src/__tests__/sakila-db/sqlResponses/C6.country.put.json +1 -1
  39. package/src/__tests__/sakila-db/sqlResponses/C6.country.put.lookup.json +2 -2
  40. package/src/__tests__/sakila-db/sqlResponses/C6.customer.post.json +5 -5
  41. package/src/__tests__/sakila-db/sqlResponses/C6.customer.post.latest.json +5 -5
  42. package/src/__tests__/sakila-db/sqlResponses/C6.customer.put.json +1 -1
  43. package/src/__tests__/sakila-db/sqlResponses/C6.customer.put.lookup.json +5 -5
  44. package/src/__tests__/sakila-db/sqlResponses/C6.film.post.json +2 -2
  45. package/src/__tests__/sakila-db/sqlResponses/C6.film.post.latest.json +2 -2
  46. package/src/__tests__/sakila-db/sqlResponses/C6.film.put.json +1 -1
  47. package/src/__tests__/sakila-db/sqlResponses/C6.film.put.lookup.json +2 -2
  48. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.post.json +1 -1
  49. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.post.latest.json +1 -1
  50. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.put.json +1 -1
  51. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.put.lookup.json +1 -1
  52. package/src/__tests__/sakila-db/sqlResponses/C6.language.post.json +2 -2
  53. package/src/__tests__/sakila-db/sqlResponses/C6.language.post.latest.json +2 -2
  54. package/src/__tests__/sakila-db/sqlResponses/C6.language.put.json +1 -1
  55. package/src/__tests__/sakila-db/sqlResponses/C6.language.put.lookup.json +2 -2
  56. package/src/__tests__/sakila-db/sqlResponses/C6.payment.post.json +2 -2
  57. package/src/__tests__/sakila-db/sqlResponses/C6.payment.post.latest.json +2 -2
  58. package/src/__tests__/sakila-db/sqlResponses/C6.payment.put.lookup.json +2 -2
  59. package/src/__tests__/sakila-db/sqlResponses/C6.rental.join.json +10 -10
  60. package/src/__tests__/sakila-db/sqlResponses/C6.rental.post.json +3 -3
  61. package/src/__tests__/sakila-db/sqlResponses/C6.rental.post.latest.json +3 -3
  62. package/src/__tests__/sakila-db/sqlResponses/C6.rental.put.json +1 -1
  63. package/src/__tests__/sakila-db/sqlResponses/C6.rental.put.lookup.json +3 -3
  64. package/src/__tests__/sqlAllowList.test.ts +100 -0
  65. package/src/__tests__/sqlBuilders.test.ts +3 -4
  66. package/src/__tests__/sqlExecutor.cacheEviction.test.ts +79 -0
  67. package/src/executors/HttpExecutor.ts +20 -4
  68. package/src/executors/SqlExecutor.ts +131 -12
  69. package/src/orm/queries/DeleteQueryBuilder.ts +0 -4
  70. package/src/orm/queries/PostQueryBuilder.ts +0 -4
  71. package/src/orm/queries/SelectQueryBuilder.ts +0 -4
  72. package/src/orm/queries/UpdateQueryBuilder.ts +0 -4
  73. package/src/types/ormInterfaces.ts +4 -1
  74. package/src/utils/cacheManager.ts +26 -9
  75. package/src/utils/logLevel.ts +3 -4
  76. package/src/utils/logSql.ts +51 -6
  77. package/src/utils/sqlAllowList.ts +111 -9
@@ -16,15 +16,76 @@ import namedPlaceholders from 'named-placeholders';
16
16
  import type { PoolConnection } from 'mysql2/promise';
17
17
  import { Buffer } from 'buffer';
18
18
  import { Executor } from "./Executor";
19
- import {checkCache, setCache} from "../utils/cacheManager";
19
+ import {checkCache, evictCacheEntry, setCache} from "../utils/cacheManager";
20
+ import logSql, {
21
+ SqlAllowListStatus,
22
+ } from "../utils/logSql";
20
23
  import { normalizeSingularRequest } from "../utils/normalizeSingularRequest";
21
24
  import {sortAndSerializeQueryObject} from "../utils/sortAndSerializeQueryObject";
22
25
  import { loadSqlAllowList, normalizeSql } from "../utils/sqlAllowList";
23
26
  import { getLogContext, LogLevel, logWithLevel } from "../utils/logLevel";
24
27
 
28
+ const SQL_ALLOWLIST_BLOCKED_CODE = "SQL_ALLOWLIST_BLOCKED";
29
+
30
+ export type SqlAllowListBlockedError = Error & {
31
+ code: typeof SQL_ALLOWLIST_BLOCKED_CODE;
32
+ tableName?: string;
33
+ method?: string;
34
+ normalizedSql: string;
35
+ allowListPath: string;
36
+ sqlAllowList: {
37
+ sql: string;
38
+ table: string | null;
39
+ method: string | null;
40
+ allowListPath: string;
41
+ canAdd: boolean;
42
+ };
43
+ };
44
+
45
+ const createSqlAllowListBlockedError = (args: {
46
+ tableName?: string;
47
+ method?: string;
48
+ normalizedSql: string;
49
+ allowListPath: string;
50
+ }): SqlAllowListBlockedError => {
51
+ const error = new Error(
52
+ `SQL statement is not permitted by allowlist (${args.allowListPath}).`,
53
+ ) as SqlAllowListBlockedError;
54
+
55
+ error.name = "SqlAllowListBlockedError";
56
+ error.code = SQL_ALLOWLIST_BLOCKED_CODE;
57
+ error.tableName = args.tableName;
58
+ error.method = args.method;
59
+ error.normalizedSql = args.normalizedSql;
60
+ error.allowListPath = args.allowListPath;
61
+ error.sqlAllowList = {
62
+ sql: args.normalizedSql,
63
+ table: args.tableName ?? null,
64
+ method: args.method ?? null,
65
+ allowListPath: args.allowListPath,
66
+ canAdd: true,
67
+ };
68
+
69
+ return error;
70
+ };
71
+
25
72
  export class SqlExecutor<
26
73
  G extends OrmGenerics
27
74
  > extends Executor<G> {
75
+ private resolveSqlLogMethod(method: iRestMethods, sql: string): string {
76
+ const token = sql.trim().split(/\s+/, 1)[0]?.toUpperCase();
77
+ if (token) return token;
78
+ switch (method) {
79
+ case C6C.GET:
80
+ return "SELECT";
81
+ case C6C.POST:
82
+ return "INSERT";
83
+ case C6C.PUT:
84
+ return "UPDATE";
85
+ default:
86
+ return "DELETE";
87
+ }
88
+ }
28
89
 
29
90
  async execute(): Promise<DetermineResponseDataType<G['RequestMethod'], G['RestTableInterface']>> {
30
91
  const { TABLE_NAME } = this.config.restModel;
@@ -487,8 +548,7 @@ export class SqlExecutor<
487
548
  const tableName = this.config.restModel.TABLE_NAME;
488
549
  const logContext = getLogContext(this.config, this.request);
489
550
  const cacheResults = method === C6C.GET
490
- && !this.config.sqlAllowListPath
491
- && (this.request as { cacheResults?: boolean })?.cacheResults !== false;
551
+ && (this.request.cacheResults ?? true);
492
552
 
493
553
  const cacheRequestData = cacheResults
494
554
  ? JSON.parse(JSON.stringify(this.request ?? {}))
@@ -498,29 +558,55 @@ export class SqlExecutor<
498
558
  ? sortAndSerializeQueryObject(tableName, cacheRequestData ?? {})
499
559
  : undefined;
500
560
 
561
+ const evictFromCache =
562
+ method === C6C.GET && cacheResults && cacheRequestData
563
+ ? () => evictCacheEntry(method, tableName, cacheRequestData)
564
+ : undefined;
565
+
501
566
  if (cacheResults) {
502
567
  const cachedRequest = checkCache<DetermineResponseDataType<G['RequestMethod'], G['RestTableInterface']>>(
503
568
  method,
504
569
  tableName,
505
570
  cacheRequestData,
571
+ logContext
506
572
  );
507
573
  if (cachedRequest) {
508
- return (await cachedRequest).data;
574
+ const cachedData = (await cachedRequest).data;
575
+ if (evictFromCache
576
+ && cachedData
577
+ && typeof cachedData === "object"
578
+ && Array.isArray((cachedData as DetermineResponseDataType<'GET', G['RestTableInterface']>).rest)) {
579
+ (cachedData as DetermineResponseDataType<'GET', G['RestTableInterface']>).evictFromCache = evictFromCache;
580
+ }
581
+ return cachedData;
509
582
  }
510
583
  }
511
584
 
512
585
  const sqlExecution = this.buildSqlExecutionContext(method, tableName, logContext);
586
+ const sqlMethod = this.resolveSqlLogMethod(method, sqlExecution.sql);
513
587
  const queryPromise = this.withConnection(async (conn) =>
514
- this.executeQueryWithLifecycle(conn, method, sqlExecution, logContext),
588
+ this.executeQueryWithLifecycle(
589
+ conn,
590
+ method,
591
+ sqlExecution,
592
+ logContext,
593
+ sqlMethod
594
+ ),
515
595
  );
516
596
 
517
597
  if (!cacheResults || !cacheRequestData || !requestArgumentsSerialized) {
518
598
  return await queryPromise;
519
599
  }
520
600
 
521
- const cacheRequest = queryPromise.then((data) =>
522
- this.createCacheResponseEnvelope(method, tableName, data),
523
- );
601
+ const cacheRequest = queryPromise.then((data) => {
602
+ if (evictFromCache
603
+ && data
604
+ && typeof data === "object"
605
+ && Array.isArray((data as DetermineResponseDataType<'GET', G['RestTableInterface']>).rest)) {
606
+ (data as DetermineResponseDataType<'GET', G['RestTableInterface']>).evictFromCache = evictFromCache;
607
+ }
608
+ return this.createCacheResponseEnvelope(method, tableName, data);
609
+ });
524
610
 
525
611
  setCache(method, tableName, cacheRequestData, {
526
612
  requestArgumentsSerialized,
@@ -640,6 +726,7 @@ export class SqlExecutor<
640
726
  method: iRestMethods,
641
727
  sqlExecution: iRestSqlExecutionContext,
642
728
  logContext: ReturnType<typeof getLogContext>,
729
+ sqlMethod: string
643
730
  ): Promise<DetermineResponseDataType<G['RequestMethod'], G['RestTableInterface']>> {
644
731
  const useTransaction = method !== C6C.GET;
645
732
  let committed = false;
@@ -655,7 +742,27 @@ export class SqlExecutor<
655
742
  await conn.beginTransaction();
656
743
  }
657
744
 
658
- await this.validateSqlAllowList(sqlExecution.sql);
745
+ let allowListStatus: SqlAllowListStatus = "not verified";
746
+ try {
747
+ allowListStatus = await this.validateSqlAllowList(sqlExecution.sql);
748
+ } catch (error) {
749
+ logSql({
750
+ method: sqlMethod,
751
+ sql: sqlExecution.sql,
752
+ context: logContext,
753
+ cacheStatus: this.request.cacheResults === false ? "ignored" : "miss",
754
+ allowListStatus: "denied",
755
+ });
756
+ throw error;
757
+ }
758
+
759
+ logSql({
760
+ method: sqlMethod,
761
+ sql: sqlExecution.sql,
762
+ context: logContext,
763
+ cacheStatus: this.request.cacheResults === false ? "ignored" : "miss",
764
+ allowListStatus,
765
+ });
659
766
 
660
767
  await this.runLifecycleHooks<"beforeExecution">(
661
768
  "beforeExecution",
@@ -729,17 +836,29 @@ export class SqlExecutor<
729
836
  }
730
837
  }
731
838
 
732
- private async validateSqlAllowList(sql: string): Promise<void> {
839
+ private async validateSqlAllowList(sql: string): Promise<SqlAllowListStatus> {
733
840
  const allowListPath = this.config.sqlAllowListPath;
734
841
  if (!allowListPath) {
735
- return;
842
+ return "not verified";
736
843
  }
737
844
 
738
845
  const allowList = await loadSqlAllowList(allowListPath);
739
846
  const normalized = normalizeSql(sql);
740
847
  if (!allowList.has(normalized)) {
741
- throw new Error(`SQL statement is not permitted by allowlist (${allowListPath}).`);
848
+ throw createSqlAllowListBlockedError({
849
+ tableName:
850
+ typeof this.config.restModel?.TABLE_NAME === "string"
851
+ ? this.config.restModel.TABLE_NAME
852
+ : undefined,
853
+ method:
854
+ typeof this.config.requestMethod === "string"
855
+ ? this.config.requestMethod
856
+ : undefined,
857
+ normalizedSql: normalized,
858
+ allowListPath,
859
+ });
742
860
  }
861
+ return "allowed";
743
862
  }
744
863
 
745
864
 
@@ -2,8 +2,6 @@ import { OrmGenerics } from "../../types/ormGenerics";
2
2
  import { SqlBuilderResult } from "../utils/sqlUtils";
3
3
  import { JoinBuilder } from "../builders/JoinBuilder";
4
4
  import { SelectQueryBuilder } from "./SelectQueryBuilder";
5
- import logSql from "../../utils/logSql";
6
- import {getLogContext} from "../../utils/logLevel";
7
5
 
8
6
  export class DeleteQueryBuilder<G extends OrmGenerics> extends JoinBuilder<G> {
9
7
  protected createSelectBuilder(request: any) {
@@ -27,8 +25,6 @@ export class DeleteQueryBuilder<G extends OrmGenerics> extends JoinBuilder<G> {
27
25
  sql += this.buildWhereClause(this.request.WHERE, params);
28
26
  }
29
27
 
30
- logSql("DELETE", sql, getLogContext(this.config, this.request));
31
-
32
28
  return { sql, params };
33
29
  }
34
30
  }
@@ -1,8 +1,6 @@
1
1
  import {C6C} from "../../constants/C6Constants";
2
2
  import {ConditionBuilder} from "../builders/ConditionBuilder";
3
3
  import {OrmGenerics} from "../../types/ormGenerics";
4
- import logSql from "../../utils/logSql";
5
- import {getLogContext} from "../../utils/logLevel";
6
4
 
7
5
  export class PostQueryBuilder<G extends OrmGenerics> extends ConditionBuilder<G>{
8
6
 
@@ -61,8 +59,6 @@ export class PostQueryBuilder<G extends OrmGenerics> extends ConditionBuilder<G>
61
59
  sql += ` ON DUPLICATE KEY UPDATE ${updateClause}`;
62
60
  }
63
61
 
64
- logSql(verb, sql, getLogContext(this.config, this.request));
65
-
66
62
  return {sql, params};
67
63
  }
68
64
  }
@@ -1,8 +1,6 @@
1
1
  import {OrmGenerics} from "../../types/ormGenerics";
2
2
  import {PaginationBuilder} from "../builders/PaginationBuilder";
3
3
  import {SqlBuilderResult} from "../utils/sqlUtils";
4
- import logSql from "../../utils/logSql";
5
- import {getLogContext} from "../../utils/logLevel";
6
4
 
7
5
  export class SelectQueryBuilder<G extends OrmGenerics> extends PaginationBuilder<G>{
8
6
 
@@ -58,8 +56,6 @@ export class SelectQueryBuilder<G extends OrmGenerics> extends PaginationBuilder
58
56
  sql += ` LIMIT 100`;
59
57
  }
60
58
 
61
- logSql("SELECT", sql, getLogContext(this.config, this.request));
62
-
63
59
  return { sql, params };
64
60
  }
65
61
  }
@@ -3,8 +3,6 @@ import {OrmGenerics} from "../../types/ormGenerics";
3
3
  import { PaginationBuilder } from '../builders/PaginationBuilder';
4
4
  import {SqlBuilderResult} from "../utils/sqlUtils";
5
5
  import {SelectQueryBuilder} from "./SelectQueryBuilder";
6
- import logSql from "../../utils/logSql";
7
- import {getLogContext} from "../../utils/logLevel";
8
6
 
9
7
  export class UpdateQueryBuilder<G extends OrmGenerics> extends PaginationBuilder<G>{
10
8
  protected createSelectBuilder(request: any) {
@@ -56,8 +54,6 @@ export class UpdateQueryBuilder<G extends OrmGenerics> extends PaginationBuilder
56
54
  sql += this.buildPaginationClause(args.PAGINATION, params);
57
55
  }
58
56
 
59
- logSql("UPDATE", sql, getLogContext(this.config, this.request));
60
-
61
57
  return { sql, params };
62
58
  }
63
59
  }
@@ -140,7 +140,10 @@ export type C6RestResponse<
140
140
  session?: any;
141
141
  sql?: any;
142
142
  } & (Method extends 'GET'
143
- ? { next?: () => Promise<DetermineResponseDataType<'GET', RestData, Overrides>> }
143
+ ? {
144
+ next?: () => Promise<DetermineResponseDataType<'GET', RestData, Overrides>>,
145
+ evictFromCache?: () => boolean
146
+ }
144
147
  : {
145
148
  affected: number,
146
149
  insertId?: number | string,
@@ -1,5 +1,6 @@
1
1
  import type {iCacheAPI, iCacheResponse} from "../types/ormInterfaces";
2
- import {LogLevel, logWithLevel, shouldLog} from "./logLevel";
2
+ import {LogContext, LogLevel, logWithLevel, shouldLog} from "./logLevel";
3
+ import logSql from "./logSql";
3
4
 
4
5
  // -----------------------------------------------------------------------------
5
6
  // Cache Storage
@@ -60,19 +61,26 @@ export function checkCache<ResponseDataType = any>(
60
61
  method: string,
61
62
  tableName: string | string[],
62
63
  requestData: any,
64
+ logContext: LogContext,
63
65
  ): Promise<iCacheResponse<ResponseDataType>> | false {
64
66
  const key = makeCacheKey(method, tableName, requestData);
65
67
  const cached = apiRequestCache.get(key);
66
68
 
67
- if (!cached) return false;
69
+ if (!cached) {
70
+ console.log('apiRequestCache.size', apiRequestCache.size)
71
+ return false;
72
+ }
68
73
 
69
- if (shouldLog(LogLevel.INFO, undefined)) {
70
- console.groupCollapsed(
71
- `%c API cache hit for ${method} ${tableName}`,
72
- "color:#0c0",
73
- );
74
- console.log("Request Data:", requestData);
75
- console.groupEnd();
74
+ if (shouldLog(LogLevel.INFO, logContext)) {
75
+ const sql = cached.response?.data?.sql?.sql ?? "";
76
+ const sqlMethod = sql.trim().split(/\s+/, 1)[0]?.toUpperCase() || method;
77
+ logSql({
78
+ allowListStatus: "not verified",
79
+ cacheStatus: "hit",
80
+ context: logContext,
81
+ method: sqlMethod,
82
+ sql
83
+ });
76
84
  }
77
85
 
78
86
  return cached.request;
@@ -90,3 +98,12 @@ export function setCache<ResponseDataType = any>(
90
98
  const key = makeCacheKey(method, tableName, requestData);
91
99
  apiRequestCache.set(key, cacheEntry);
92
100
  }
101
+
102
+ export function evictCacheEntry(
103
+ method: string,
104
+ tableName: string | string[],
105
+ requestData: any,
106
+ ): boolean {
107
+ const key = makeCacheKey(method, tableName, requestData);
108
+ return apiRequestCache.delete(key);
109
+ }
@@ -135,10 +135,9 @@ export const applyLogLevelDefaults = (
135
135
  };
136
136
 
137
137
  export const getLogContext = (
138
- config?: { logLevel?: number | null; verbose?: boolean | null },
139
- request?: { debug?: boolean } | null,
140
- ): LogContext | undefined => {
141
- if (!config && !request) return undefined;
138
+ config: { logLevel?: number | null; verbose?: boolean | null },
139
+ request: { debug?: boolean } | null,
140
+ ): LogContext => {
142
141
  return {
143
142
  logLevel: config?.logLevel ?? undefined,
144
143
  verbose: config?.verbose ?? undefined,
@@ -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