@carbonorm/carbonnode 6.0.18 → 6.0.20

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 (64) hide show
  1. package/dist/executors/SqlExecutor.d.ts +7 -0
  2. package/dist/index.cjs.js +230 -20
  3. package/dist/index.cjs.js.map +1 -1
  4. package/dist/index.esm.js +230 -20
  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 -2
  8. package/dist/utils/logSql.d.ts +1 -1
  9. package/package.json +1 -1
  10. package/src/__tests__/cacheManager.test.ts +55 -1
  11. package/src/__tests__/logSql.test.ts +16 -0
  12. package/src/__tests__/sakila-db/C6.js +1 -1
  13. package/src/__tests__/sakila-db/C6.mysqldump.json +1 -1
  14. package/src/__tests__/sakila-db/C6.mysqldump.sql +1 -1
  15. package/src/__tests__/sakila-db/C6.ts +1 -1
  16. package/src/__tests__/sakila-db/sqlResponses/C6.actor.post.json +11 -4
  17. package/src/__tests__/sakila-db/sqlResponses/C6.actor.post.latest.json +3 -3
  18. package/src/__tests__/sakila-db/sqlResponses/C6.actor.put.json +1 -1
  19. package/src/__tests__/sakila-db/sqlResponses/C6.actor.put.lookup.json +3 -3
  20. package/src/__tests__/sakila-db/sqlResponses/C6.address.post.json +18 -6
  21. package/src/__tests__/sakila-db/sqlResponses/C6.address.post.latest.json +5 -5
  22. package/src/__tests__/sakila-db/sqlResponses/C6.address.put.json +1 -1
  23. package/src/__tests__/sakila-db/sqlResponses/C6.address.put.lookup.json +5 -5
  24. package/src/__tests__/sakila-db/sqlResponses/C6.category.post.json +9 -3
  25. package/src/__tests__/sakila-db/sqlResponses/C6.category.post.latest.json +2 -2
  26. package/src/__tests__/sakila-db/sqlResponses/C6.category.put.json +1 -1
  27. package/src/__tests__/sakila-db/sqlResponses/C6.category.put.lookup.json +2 -2
  28. package/src/__tests__/sakila-db/sqlResponses/C6.city.post.json +10 -3
  29. package/src/__tests__/sakila-db/sqlResponses/C6.city.post.latest.json +2 -2
  30. package/src/__tests__/sakila-db/sqlResponses/C6.city.put.json +1 -1
  31. package/src/__tests__/sakila-db/sqlResponses/C6.city.put.lookup.json +2 -2
  32. package/src/__tests__/sakila-db/sqlResponses/C6.country.post.json +9 -3
  33. package/src/__tests__/sakila-db/sqlResponses/C6.country.post.latest.json +2 -2
  34. package/src/__tests__/sakila-db/sqlResponses/C6.country.put.json +1 -1
  35. package/src/__tests__/sakila-db/sqlResponses/C6.country.put.lookup.json +2 -2
  36. package/src/__tests__/sakila-db/sqlResponses/C6.customer.post.json +18 -6
  37. package/src/__tests__/sakila-db/sqlResponses/C6.customer.post.latest.json +5 -5
  38. package/src/__tests__/sakila-db/sqlResponses/C6.customer.put.json +1 -1
  39. package/src/__tests__/sakila-db/sqlResponses/C6.customer.put.lookup.json +5 -5
  40. package/src/__tests__/sakila-db/sqlResponses/C6.film.post.json +18 -3
  41. package/src/__tests__/sakila-db/sqlResponses/C6.film.post.latest.json +2 -2
  42. package/src/__tests__/sakila-db/sqlResponses/C6.film.put.json +1 -1
  43. package/src/__tests__/sakila-db/sqlResponses/C6.film.put.lookup.json +2 -2
  44. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.post.json +9 -2
  45. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.post.latest.json +1 -1
  46. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.put.json +1 -1
  47. package/src/__tests__/sakila-db/sqlResponses/C6.inventory.put.lookup.json +1 -1
  48. package/src/__tests__/sakila-db/sqlResponses/C6.language.post.json +9 -3
  49. package/src/__tests__/sakila-db/sqlResponses/C6.language.post.latest.json +2 -2
  50. package/src/__tests__/sakila-db/sqlResponses/C6.language.put.json +1 -1
  51. package/src/__tests__/sakila-db/sqlResponses/C6.language.put.lookup.json +2 -2
  52. package/src/__tests__/sakila-db/sqlResponses/C6.payment.post.json +13 -3
  53. package/src/__tests__/sakila-db/sqlResponses/C6.payment.post.latest.json +2 -2
  54. package/src/__tests__/sakila-db/sqlResponses/C6.payment.put.lookup.json +2 -2
  55. package/src/__tests__/sakila-db/sqlResponses/C6.rental.post.json +14 -4
  56. package/src/__tests__/sakila-db/sqlResponses/C6.rental.post.latest.json +3 -3
  57. package/src/__tests__/sakila-db/sqlResponses/C6.rental.put.json +1 -1
  58. package/src/__tests__/sakila-db/sqlResponses/C6.rental.put.lookup.json +3 -3
  59. package/src/__tests__/sqlExecutorPostUuid.test.ts +185 -0
  60. package/src/executors/HttpExecutor.ts +33 -5
  61. package/src/executors/SqlExecutor.ts +207 -3
  62. package/src/types/ormInterfaces.ts +1 -0
  63. package/src/utils/cacheManager.ts +22 -5
  64. package/src/utils/logSql.ts +3 -1
@@ -27,6 +27,43 @@ import { getLogContext, LogLevel, logWithLevel } from "../utils/logLevel";
27
27
 
28
28
  const SQL_ALLOWLIST_BLOCKED_CODE = "SQL_ALLOWLIST_BLOCKED";
29
29
 
30
+ const fillRandomBytes = (bytes: Uint8Array): void => {
31
+ const cryptoRef = (globalThis as { crypto?: Crypto }).crypto;
32
+ if (!cryptoRef || typeof cryptoRef.getRandomValues !== "function") {
33
+ throw new Error("Secure random source unavailable: crypto.getRandomValues is required for UUID generation.");
34
+ }
35
+ cryptoRef.getRandomValues(bytes);
36
+ };
37
+
38
+ const generateUuidV7 = (): string => {
39
+ const bytes = new Uint8Array(16);
40
+ const random = new Uint8Array(10);
41
+ fillRandomBytes(random);
42
+
43
+ const timestampMs = Date.now();
44
+ bytes[0] = Math.floor(timestampMs / 1099511627776) & 0xff; // 2^40
45
+ bytes[1] = Math.floor(timestampMs / 4294967296) & 0xff; // 2^32
46
+ bytes[2] = Math.floor(timestampMs / 16777216) & 0xff; // 2^24
47
+ bytes[3] = Math.floor(timestampMs / 65536) & 0xff; // 2^16
48
+ bytes[4] = Math.floor(timestampMs / 256) & 0xff; // 2^8
49
+ bytes[5] = timestampMs & 0xff;
50
+
51
+ // RFC 9562 UUIDv7 layout
52
+ bytes[6] = 0x70 | (random[0] & 0x0f); // version 7 + rand_a high bits
53
+ bytes[7] = random[1]; // rand_a low bits
54
+ bytes[8] = 0x80 | (random[2] & 0x3f); // variant + rand_b high bits
55
+ bytes[9] = random[3];
56
+ bytes[10] = random[4];
57
+ bytes[11] = random[5];
58
+ bytes[12] = random[6];
59
+ bytes[13] = random[7];
60
+ bytes[14] = random[8];
61
+ bytes[15] = random[9];
62
+
63
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
64
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
65
+ };
66
+
30
67
  export type SqlAllowListBlockedError = Error & {
31
68
  code: typeof SQL_ALLOWLIST_BLOCKED_CODE;
32
69
  tableName?: string;
@@ -72,6 +109,157 @@ const createSqlAllowListBlockedError = (args: {
72
109
  export class SqlExecutor<
73
110
  G extends OrmGenerics
74
111
  > extends Executor<G> {
112
+ private getPostRequestRows(): Record<string, any>[] {
113
+ const request = this.request as any;
114
+ if (!request) return [];
115
+
116
+ if (Array.isArray(request)) {
117
+ return request as Record<string, any>[];
118
+ }
119
+
120
+ if (
121
+ Array.isArray(request.dataInsertMultipleRows)
122
+ && request.dataInsertMultipleRows.length > 0
123
+ ) {
124
+ return request.dataInsertMultipleRows as Record<string, any>[];
125
+ }
126
+
127
+ const verb = C6C.REPLACE in request ? C6C.REPLACE : C6C.INSERT;
128
+ if (verb in request && request[verb] && typeof request[verb] === "object") {
129
+ return [request[verb] as Record<string, any>];
130
+ }
131
+
132
+ if (typeof request === "object") {
133
+ return [request as Record<string, any>];
134
+ }
135
+
136
+ return [];
137
+ }
138
+
139
+ private getTypeValidationForColumn(shortKey: string, fullKey: string): Record<string, any> | undefined {
140
+ const validation = (this.config.restModel as any)?.TYPE_VALIDATION;
141
+ if (!validation || typeof validation !== "object") return undefined;
142
+ return validation[shortKey] ?? validation[fullKey];
143
+ }
144
+
145
+ private isUuidLikePrimaryColumn(columnDef: Record<string, any> | undefined): boolean {
146
+ if (!columnDef || typeof columnDef !== "object") return false;
147
+ if (columnDef.AUTO_INCREMENT === true) return false;
148
+
149
+ const mysqlType = String(columnDef.MYSQL_TYPE ?? "").toLowerCase();
150
+ const maxLength = String(columnDef.MAX_LENGTH ?? "").trim();
151
+
152
+ if (mysqlType.includes("uuid")) return true;
153
+
154
+ const isBinary16 = mysqlType.includes("binary")
155
+ && (maxLength === "16" || /\b16\b/.test(mysqlType) || mysqlType === "binary");
156
+ const isUuidString = (mysqlType.includes("char") || mysqlType.includes("varchar"))
157
+ && (maxLength === "32" || maxLength === "36");
158
+
159
+ return isBinary16 || isUuidString;
160
+ }
161
+
162
+ private hasDefinedValue(value: unknown): boolean {
163
+ if (value === undefined || value === null) return false;
164
+ if (typeof value === "string" && value.trim() === "") return false;
165
+ return true;
166
+ }
167
+
168
+ private generatePrimaryUuidValue(columnDef: Record<string, any>): string {
169
+ const mysqlType = String(columnDef.MYSQL_TYPE ?? "").toLowerCase();
170
+ const maxLength = String(columnDef.MAX_LENGTH ?? "").trim();
171
+ const uuid = generateUuidV7();
172
+
173
+ // BINARY(16) and CHAR/VARCHAR(32) commonly persist UUIDs as 32-hex.
174
+ if (mysqlType.includes("binary") || maxLength === "32") {
175
+ return uuid.replace(/-/g, "").toUpperCase();
176
+ }
177
+
178
+ return uuid;
179
+ }
180
+
181
+ private assignMissingPostPrimaryUuids(): void {
182
+ if (this.config.requestMethod !== C6C.POST) return;
183
+
184
+ const rows = this.getPostRequestRows();
185
+ if (rows.length === 0) return;
186
+
187
+ const columns = this.config.restModel.COLUMNS as Record<string, string>;
188
+ const tableName = this.config.restModel.TABLE_NAME as string;
189
+ const primaryShorts = this.config.restModel.PRIMARY_SHORT ?? [];
190
+
191
+ const primaryColumns = primaryShorts
192
+ .map((shortKey) => {
193
+ const fullKey = Object.keys(columns).find((key) => columns[key] === shortKey)
194
+ ?? `${tableName}.${shortKey}`;
195
+ const columnDef = this.getTypeValidationForColumn(shortKey, fullKey);
196
+ return { shortKey, fullKey, columnDef };
197
+ })
198
+ .filter(({ columnDef }) => this.isUuidLikePrimaryColumn(columnDef));
199
+
200
+ if (primaryColumns.length === 0) return;
201
+
202
+ for (const row of rows) {
203
+ if (!row || typeof row !== "object") continue;
204
+
205
+ const useQualifiedKeyByDefault = Object.keys(row).some((key) => key.includes("."));
206
+
207
+ for (const primaryColumn of primaryColumns) {
208
+ const existing = row[primaryColumn.shortKey] ?? row[primaryColumn.fullKey];
209
+ if (this.hasDefinedValue(existing)) continue;
210
+
211
+ const generated = this.generatePrimaryUuidValue(primaryColumn.columnDef!);
212
+ if (Object.prototype.hasOwnProperty.call(row, primaryColumn.shortKey)) {
213
+ row[primaryColumn.shortKey] = generated;
214
+ continue;
215
+ }
216
+ if (Object.prototype.hasOwnProperty.call(row, primaryColumn.fullKey)) {
217
+ row[primaryColumn.fullKey] = generated;
218
+ continue;
219
+ }
220
+
221
+ row[useQualifiedKeyByDefault ? primaryColumn.fullKey : primaryColumn.shortKey] = generated;
222
+ }
223
+ }
224
+ }
225
+
226
+ private buildPostResponseRows(insertId?: number | string): Record<string, unknown>[] {
227
+ const rows = this.getPostRequestRows();
228
+ if (rows.length === 0) return [];
229
+
230
+ const columns = this.config.restModel.COLUMNS as Record<string, string>;
231
+ const validColumns = new Set(Object.values(columns));
232
+ const pkShorts = this.config.restModel.PRIMARY_SHORT ?? [];
233
+ const now = new Date().toISOString();
234
+
235
+ return rows.map((row, index) => {
236
+ const normalized = this.normalizeRequestPayload(row ?? {});
237
+
238
+ if (validColumns.has("changed_at") && normalized.changed_at === undefined) {
239
+ normalized.changed_at = now;
240
+ }
241
+ if (validColumns.has("created_at") && normalized.created_at === undefined) {
242
+ normalized.created_at = now;
243
+ }
244
+ if (validColumns.has("updated_at") && normalized.updated_at === undefined) {
245
+ normalized.updated_at = now;
246
+ }
247
+
248
+ // When DB generated PK is numeric/autoincrement, expose it for the single-row insert.
249
+ if (
250
+ index === 0
251
+ && insertId !== undefined
252
+ && insertId !== null
253
+ && pkShorts.length === 1
254
+ && !this.hasDefinedValue(normalized[pkShorts[0]])
255
+ ) {
256
+ normalized[pkShorts[0]] = insertId;
257
+ }
258
+
259
+ return normalized;
260
+ });
261
+ }
262
+
75
263
  private resolveSqlLogMethod(method: iRestMethods, sql: string): string {
76
264
  const token = sql.trim().split(/\s+/, 1)[0]?.toUpperCase();
77
265
  if (token) return token;
@@ -112,6 +300,8 @@ export class SqlExecutor<
112
300
  throw e;
113
301
  }
114
302
 
303
+ this.assignMissingPostPrimaryUuids();
304
+
115
305
  const logContext = getLogContext(this.config, this.request);
116
306
  logWithLevel(
117
307
  LogLevel.DEBUG,
@@ -549,6 +739,9 @@ export class SqlExecutor<
549
739
  const logContext = getLogContext(this.config, this.request);
550
740
  const cacheResults = method === C6C.GET
551
741
  && (this.request.cacheResults ?? true);
742
+ const cacheAllowListStatus: SqlAllowListStatus = this.config.sqlAllowListPath
743
+ ? "allowed"
744
+ : "not verified";
552
745
 
553
746
  const cacheRequestData = cacheResults
554
747
  ? JSON.parse(JSON.stringify(this.request ?? {}))
@@ -560,7 +753,13 @@ export class SqlExecutor<
560
753
 
561
754
  const evictFromCache =
562
755
  method === C6C.GET && cacheResults && cacheRequestData
563
- ? () => evictCacheEntry(method, tableName, cacheRequestData)
756
+ ? () => evictCacheEntry(
757
+ method,
758
+ tableName,
759
+ cacheRequestData,
760
+ logContext,
761
+ cacheAllowListStatus,
762
+ )
564
763
  : undefined;
565
764
 
566
765
  if (cacheResults) {
@@ -568,7 +767,8 @@ export class SqlExecutor<
568
767
  method,
569
768
  tableName,
570
769
  cacheRequestData,
571
- logContext
770
+ logContext,
771
+ cacheAllowListStatus,
572
772
  );
573
773
  if (cachedRequest) {
574
774
  const cachedData = (await cachedRequest).data;
@@ -611,12 +811,14 @@ export class SqlExecutor<
611
811
  setCache(method, tableName, cacheRequestData, {
612
812
  requestArgumentsSerialized,
613
813
  request: cacheRequest,
814
+ allowListStatus: cacheAllowListStatus,
614
815
  });
615
816
 
616
817
  const cacheResponse = await cacheRequest;
617
818
  setCache(method, tableName, cacheRequestData, {
618
819
  requestArgumentsSerialized,
619
820
  request: cacheRequest,
821
+ allowListStatus: cacheAllowListStatus,
620
822
  response: cacheResponse,
621
823
  final: true,
622
824
  });
@@ -695,7 +897,9 @@ export class SqlExecutor<
695
897
  return {
696
898
  affected: result.affectedRows as number,
697
899
  insertId: result.insertId as number,
698
- rest: [],
900
+ rest: method === C6C.POST
901
+ ? this.buildPostResponseRows(result.insertId as number | string | undefined)
902
+ : [],
699
903
  sql: { sql: sqlExecution.sql, values: sqlExecution.values },
700
904
  } as DetermineResponseDataType<G['RequestMethod'], G['RestTableInterface']>;
701
905
  }
@@ -116,6 +116,7 @@ export interface iCacheResponse<ResponseDataType = any> {
116
116
  export interface iCacheAPI<ResponseDataType = any> {
117
117
  requestArgumentsSerialized: string;
118
118
  request: Promise<iCacheResponse<ResponseDataType>>;
119
+ allowListStatus?: "allowed" | "denied" | "not verified";
119
120
  response?: iCacheResponse<ResponseDataType> & {
120
121
  __carbonTiming?: {
121
122
  start: number;
@@ -1,6 +1,6 @@
1
1
  import type {iCacheAPI, iCacheResponse} from "../types/ormInterfaces";
2
2
  import {LogContext, LogLevel, logWithLevel, shouldLog} from "./logLevel";
3
- import logSql from "./logSql";
3
+ import logSql, { SqlAllowListStatus } from "./logSql";
4
4
 
5
5
  // -----------------------------------------------------------------------------
6
6
  // Cache Storage
@@ -61,13 +61,13 @@ export function checkCache<ResponseDataType = any>(
61
61
  method: string,
62
62
  tableName: string | string[],
63
63
  requestData: any,
64
- logContext: LogContext,
64
+ logContext?: LogContext,
65
+ allowListStatus?: SqlAllowListStatus,
65
66
  ): Promise<iCacheResponse<ResponseDataType>> | false {
66
67
  const key = makeCacheKey(method, tableName, requestData);
67
68
  const cached = apiRequestCache.get(key);
68
69
 
69
70
  if (!cached) {
70
- console.log('apiRequestCache.size', apiRequestCache.size)
71
71
  return false;
72
72
  }
73
73
 
@@ -75,7 +75,7 @@ export function checkCache<ResponseDataType = any>(
75
75
  const sql = cached.response?.data?.sql?.sql ?? "";
76
76
  const sqlMethod = sql.trim().split(/\s+/, 1)[0]?.toUpperCase() || method;
77
77
  logSql({
78
- allowListStatus: "not verified",
78
+ allowListStatus: cached.allowListStatus ?? allowListStatus ?? "not verified",
79
79
  cacheStatus: "hit",
80
80
  context: logContext,
81
81
  method: sqlMethod,
@@ -103,7 +103,24 @@ export function evictCacheEntry(
103
103
  method: string,
104
104
  tableName: string | string[],
105
105
  requestData: any,
106
+ logContext?: LogContext,
107
+ allowListStatus?: SqlAllowListStatus,
106
108
  ): boolean {
107
109
  const key = makeCacheKey(method, tableName, requestData);
108
- return apiRequestCache.delete(key);
110
+ const cached = apiRequestCache.get(key);
111
+ const deleted = apiRequestCache.delete(key);
112
+
113
+ if (deleted && shouldLog(LogLevel.INFO, logContext)) {
114
+ const sql = cached?.response?.data?.sql?.sql ?? "";
115
+ const sqlMethod = sql.trim().split(/\s+/, 1)[0]?.toUpperCase() || method;
116
+ logSql({
117
+ allowListStatus: cached?.allowListStatus ?? allowListStatus ?? "not verified",
118
+ cacheStatus: "evicted",
119
+ context: logContext,
120
+ method: sqlMethod,
121
+ sql,
122
+ });
123
+ }
124
+
125
+ return deleted;
109
126
  }
@@ -6,7 +6,7 @@ import type { LogContext } from "./logLevel";
6
6
  import { LogLevel, shouldLog } from "./logLevel";
7
7
 
8
8
  export type SqlAllowListStatus = "allowed" | "denied" | "not verified";
9
- export type SqlCacheStatus = "hit" | "miss" | "ignored";
9
+ export type SqlCacheStatus = "hit" | "miss" | "ignored" | "evicted";
10
10
 
11
11
  export type LogSqlContextOptions = {
12
12
  cacheStatus: SqlCacheStatus;
@@ -66,6 +66,8 @@ const cacheLabel = (cacheStatus: SqlCacheStatus): string => {
66
66
  switch (cacheStatus) {
67
67
  case "hit":
68
68
  return `${C.METHOD_COLORS.SELECT}[CACHE HIT]${C.RESET}`;
69
+ case "evicted":
70
+ return `${C.WARN}[CACHE EVICTED]${C.RESET}`;
69
71
  case "ignored":
70
72
  return `${C.WARN}[CACHE IGNORED]${C.RESET}`;
71
73
  default: