@shyk/kadak 0.1.0 → 0.1.5
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.
- package/README.md +105 -31
- package/dist/index.cjs +750 -209
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +187 -251
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +187 -251
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +746 -211
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -2
package/dist/index.mjs
CHANGED
|
@@ -1,60 +1,34 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
1
|
+
import { ZodError, z } from "zod";
|
|
2
2
|
import postgres from "postgres";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
3
5
|
//#region src/schema.ts
|
|
4
|
-
/** Converts camelCase to snake_case for PostgreSQL column naming */
|
|
5
6
|
function camelToSnake(str) {
|
|
6
7
|
return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
|
|
7
8
|
}
|
|
8
|
-
/**
|
|
9
|
-
* A column definition carrying runtime metadata AND compile-time types.
|
|
10
|
-
*
|
|
11
|
-
* Three generics flow through the entire ORM:
|
|
12
|
-
* - TType: JS type (string, number, boolean, Date)
|
|
13
|
-
* - TNullable: whether NULL is allowed (adds `| null`)
|
|
14
|
-
* - THasDefault: whether INSERT can omit this (adds `?`)
|
|
15
|
-
*
|
|
16
|
-
* Every modifier returns a NEW Column — immutable builder.
|
|
17
|
-
*/
|
|
18
9
|
var Column = class Column {
|
|
19
10
|
constructor(config) {
|
|
20
11
|
this._config = Object.freeze({ ...config });
|
|
21
12
|
}
|
|
22
|
-
/** NOT NULL — already the default. Exists for explicit readability. */
|
|
23
|
-
required() {
|
|
24
|
-
return new Column({
|
|
25
|
-
...this._config,
|
|
26
|
-
nullable: false
|
|
27
|
-
});
|
|
28
|
-
}
|
|
29
|
-
/** Allow NULL — column type becomes TType | null in TypeScript */
|
|
30
13
|
optional() {
|
|
31
14
|
return new Column({
|
|
32
15
|
...this._config,
|
|
33
|
-
nullable: true
|
|
16
|
+
nullable: true,
|
|
17
|
+
isOptional: true
|
|
34
18
|
});
|
|
35
19
|
}
|
|
36
|
-
/** Add UNIQUE constraint */
|
|
37
20
|
unique() {
|
|
38
21
|
return new Column({
|
|
39
22
|
...this._config,
|
|
40
23
|
isUnique: true
|
|
41
24
|
});
|
|
42
25
|
}
|
|
43
|
-
/** Mark as PRIMARY KEY */
|
|
44
26
|
primaryKey() {
|
|
45
27
|
return new Column({
|
|
46
28
|
...this._config,
|
|
47
29
|
isPrimaryKey: true
|
|
48
30
|
});
|
|
49
31
|
}
|
|
50
|
-
/**
|
|
51
|
-
* Set default value — makes field optional in INSERT.
|
|
52
|
-
*
|
|
53
|
-
* Named shortcuts are resolved per column type to avoid ambiguity:
|
|
54
|
-
* - 'now' on TIMESTAMPTZ → SQL DEFAULT now()
|
|
55
|
-
* - 'uuid' on UUID → SQL DEFAULT gen_random_uuid()
|
|
56
|
-
* On other types, these strings are treated as literal values.
|
|
57
|
-
*/
|
|
58
32
|
default(value) {
|
|
59
33
|
const shortcuts = {
|
|
60
34
|
TIMESTAMPTZ: { now: "now()" },
|
|
@@ -68,21 +42,18 @@ var Column = class Column {
|
|
|
68
42
|
defaultSql: sqlExpr ?? this._config.defaultSql
|
|
69
43
|
});
|
|
70
44
|
}
|
|
71
|
-
/** Min constraint — string length for text, numeric value for numbers */
|
|
72
45
|
min(n) {
|
|
73
46
|
return new Column({
|
|
74
47
|
...this._config,
|
|
75
48
|
min: n
|
|
76
49
|
});
|
|
77
50
|
}
|
|
78
|
-
/** Max constraint — string length for text, numeric value for numbers */
|
|
79
51
|
max(n) {
|
|
80
52
|
return new Column({
|
|
81
53
|
...this._config,
|
|
82
54
|
max: n
|
|
83
55
|
});
|
|
84
56
|
}
|
|
85
|
-
/** Auto-update on every UPDATE query — designed for updatedAt columns */
|
|
86
57
|
autoUpdate() {
|
|
87
58
|
return new Column({
|
|
88
59
|
...this._config,
|
|
@@ -90,12 +61,6 @@ var Column = class Column {
|
|
|
90
61
|
hasDefault: true
|
|
91
62
|
});
|
|
92
63
|
}
|
|
93
|
-
/**
|
|
94
|
-
* Soft delete marker. When set on a column:
|
|
95
|
-
* - findMany() auto-adds WHERE column IS NULL
|
|
96
|
-
* - delete() sets this column to now() instead of DELETE
|
|
97
|
-
* - findMany({ withDeleted: true }) bypasses the filter
|
|
98
|
-
*/
|
|
99
64
|
softDelete() {
|
|
100
65
|
return new Column({
|
|
101
66
|
...this._config,
|
|
@@ -105,7 +70,6 @@ var Column = class Column {
|
|
|
105
70
|
});
|
|
106
71
|
}
|
|
107
72
|
};
|
|
108
|
-
/** Base config with required-by-default semantics */
|
|
109
73
|
function baseConfig(pgType) {
|
|
110
74
|
return {
|
|
111
75
|
pgType,
|
|
@@ -115,20 +79,10 @@ function baseConfig(pgType) {
|
|
|
115
79
|
isUnique: false,
|
|
116
80
|
isGenerated: false,
|
|
117
81
|
autoUpdate: false,
|
|
118
|
-
softDelete: false
|
|
82
|
+
softDelete: false,
|
|
83
|
+
isOptional: false
|
|
119
84
|
};
|
|
120
85
|
}
|
|
121
|
-
/**
|
|
122
|
-
* Column factory namespace. Every column definition starts here.
|
|
123
|
-
*
|
|
124
|
-
* ```ts
|
|
125
|
-
* const users = table('users', {
|
|
126
|
-
* id: kadak.id(),
|
|
127
|
-
* name: kadak.text().required(),
|
|
128
|
-
* email: kadak.email().unique(),
|
|
129
|
-
* })
|
|
130
|
-
* ```
|
|
131
|
-
*/
|
|
132
86
|
const kadak = {
|
|
133
87
|
id: () => new Column({
|
|
134
88
|
...baseConfig("INTEGER"),
|
|
@@ -168,10 +122,6 @@ const kadak = {
|
|
|
168
122
|
zodSchema: schema
|
|
169
123
|
})
|
|
170
124
|
};
|
|
171
|
-
/**
|
|
172
|
-
* A table definition. Created via the `table()` function.
|
|
173
|
-
* Holds column metadata and generates typed Zod validators.
|
|
174
|
-
*/
|
|
175
125
|
var Table = class {
|
|
176
126
|
constructor(name, columns, options) {
|
|
177
127
|
this._name = name;
|
|
@@ -180,35 +130,29 @@ var Table = class {
|
|
|
180
130
|
this._columnMap = {};
|
|
181
131
|
for (const key of Object.keys(columns)) this._columnMap[key] = camelToSnake(key);
|
|
182
132
|
}
|
|
183
|
-
/** Alias for insertValidator() — the most common use case (validating user input) */
|
|
184
133
|
validator() {
|
|
185
134
|
return this.insertValidator();
|
|
186
135
|
}
|
|
187
|
-
/** Validates data for INSERT — generated/defaulted fields are optional */
|
|
188
136
|
insertValidator() {
|
|
189
137
|
return z.object(this._buildShape("insert"));
|
|
190
138
|
}
|
|
191
|
-
/** Validates data for SELECT — all columns present, nullability applied */
|
|
192
139
|
selectValidator() {
|
|
193
140
|
return z.object(this._buildShape("select"));
|
|
194
141
|
}
|
|
195
|
-
/** Validates data for UPDATE — everything optional (partial update) */
|
|
196
142
|
updateValidator() {
|
|
197
143
|
return z.object(this._buildShape("update"));
|
|
198
144
|
}
|
|
199
|
-
/** @internal Builds Zod shape for a given operation mode */
|
|
200
145
|
_buildShape(mode) {
|
|
201
146
|
const shape = {};
|
|
202
147
|
for (const [key, col] of Object.entries(this._columns)) {
|
|
203
148
|
let zodType = this._baseZodType(col._config);
|
|
204
149
|
if (col._config.nullable) zodType = zodType.nullable();
|
|
205
|
-
if (mode === "insert" && (col._config.hasDefault || col._config.isGenerated)) zodType = zodType.optional();
|
|
150
|
+
if (mode === "insert" && (col._config.hasDefault || col._config.isGenerated || col._config.isOptional)) zodType = zodType.optional();
|
|
206
151
|
else if (mode === "update") zodType = zodType.optional();
|
|
207
152
|
shape[key] = zodType;
|
|
208
153
|
}
|
|
209
154
|
return shape;
|
|
210
155
|
}
|
|
211
|
-
/** @internal Creates the base Zod type for a column, without nullable/optional */
|
|
212
156
|
_baseZodType(config) {
|
|
213
157
|
if (config.pgType === "JSONB" && config.zodSchema) return config.zodSchema;
|
|
214
158
|
switch (config.pgType) {
|
|
@@ -240,25 +184,66 @@ var Table = class {
|
|
|
240
184
|
}
|
|
241
185
|
}
|
|
242
186
|
};
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
187
|
+
function fromShorthand(key, definition) {
|
|
188
|
+
const optional = definition.endsWith("?");
|
|
189
|
+
const base = optional ? definition.slice(0, -1) : definition;
|
|
190
|
+
let column;
|
|
191
|
+
switch (base) {
|
|
192
|
+
case "id":
|
|
193
|
+
column = kadak.id();
|
|
194
|
+
break;
|
|
195
|
+
case "uuidId":
|
|
196
|
+
column = kadak.uuidId();
|
|
197
|
+
break;
|
|
198
|
+
case "serialId":
|
|
199
|
+
column = kadak.serialId();
|
|
200
|
+
break;
|
|
201
|
+
case "text":
|
|
202
|
+
column = kadak.text();
|
|
203
|
+
break;
|
|
204
|
+
case "int":
|
|
205
|
+
column = kadak.int();
|
|
206
|
+
break;
|
|
207
|
+
case "number":
|
|
208
|
+
column = kadak.number();
|
|
209
|
+
break;
|
|
210
|
+
case "float":
|
|
211
|
+
column = kadak.float();
|
|
212
|
+
break;
|
|
213
|
+
case "decimal":
|
|
214
|
+
column = kadak.decimal();
|
|
215
|
+
break;
|
|
216
|
+
case "boolean":
|
|
217
|
+
column = kadak.boolean();
|
|
218
|
+
break;
|
|
219
|
+
case "timestamp":
|
|
220
|
+
column = kadak.timestamp();
|
|
221
|
+
break;
|
|
222
|
+
case "email":
|
|
223
|
+
column = kadak.email().unique();
|
|
224
|
+
break;
|
|
225
|
+
case "uuid":
|
|
226
|
+
column = kadak.uuid();
|
|
227
|
+
break;
|
|
228
|
+
default: throw new Error(`Unsupported shorthand '${definition}' for column '${key}'`);
|
|
229
|
+
}
|
|
230
|
+
return optional ? column.optional() : column;
|
|
231
|
+
}
|
|
232
|
+
function normalizeColumns(columns, options) {
|
|
233
|
+
const normalized = {};
|
|
234
|
+
for (const [key, value] of Object.entries(columns)) normalized[key] = typeof value === "string" ? fromShorthand(key, value) : value;
|
|
235
|
+
if (options?.timestamps) {
|
|
236
|
+
if (!("createdAt" in normalized)) normalized.createdAt = kadak.timestamp().default("now");
|
|
237
|
+
if (!("updatedAt" in normalized)) normalized.updatedAt = kadak.timestamp().default("now").autoUpdate();
|
|
238
|
+
}
|
|
239
|
+
if (options?.softDelete && !("deletedAt" in normalized)) normalized.deletedAt = kadak.timestamp().softDelete();
|
|
240
|
+
return normalized;
|
|
241
|
+
}
|
|
256
242
|
function table(name, columns, options) {
|
|
257
|
-
return new Table(name, columns, options);
|
|
243
|
+
return new Table(name, normalizeColumns(columns, options), options);
|
|
258
244
|
}
|
|
259
245
|
//#endregion
|
|
260
246
|
//#region src/error.ts
|
|
261
|
-
/** Maps PostgreSQL error codes to human-readable KadakORM codes + hints */
|
|
262
247
|
const PG_ERROR_MAP = {
|
|
263
248
|
"23505": {
|
|
264
249
|
code: "UNIQUE_VIOLATION",
|
|
@@ -266,7 +251,7 @@ const PG_ERROR_MAP = {
|
|
|
266
251
|
},
|
|
267
252
|
"23502": {
|
|
268
253
|
code: "NOT_NULL_VIOLATION",
|
|
269
|
-
hint: "A required column is missing. Add
|
|
254
|
+
hint: "A required column is missing. Add ? in schema if it should be optional."
|
|
270
255
|
},
|
|
271
256
|
"23503": {
|
|
272
257
|
code: "FOREIGN_KEY_VIOLATION",
|
|
@@ -301,10 +286,6 @@ const PG_ERROR_MAP = {
|
|
|
301
286
|
hint: "Query took too long. Consider adding an index or simplifying the query."
|
|
302
287
|
}
|
|
303
288
|
};
|
|
304
|
-
/**
|
|
305
|
-
* The one error class users ever see from KadakORM.
|
|
306
|
-
* Always includes: what went wrong, which table/column, and how to fix it.
|
|
307
|
-
*/
|
|
308
289
|
var KadakError = class extends Error {
|
|
309
290
|
constructor(opts) {
|
|
310
291
|
super(opts.message);
|
|
@@ -317,7 +298,27 @@ var KadakError = class extends Error {
|
|
|
317
298
|
this.originalError = opts.originalError;
|
|
318
299
|
}
|
|
319
300
|
};
|
|
320
|
-
|
|
301
|
+
function fromValidationError(error, tableName) {
|
|
302
|
+
if (error instanceof ZodError) {
|
|
303
|
+
const issue = error.issues[0];
|
|
304
|
+
const path = issue?.path.join(".") || "payload";
|
|
305
|
+
return new KadakError({
|
|
306
|
+
code: "VALIDATION_ERROR",
|
|
307
|
+
message: `Missing or invalid field: ${path}`,
|
|
308
|
+
hint: `Fix: ${issue?.message ?? "Check the value against your schema."}`,
|
|
309
|
+
table: tableName,
|
|
310
|
+
column: path,
|
|
311
|
+
originalError: error
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
return new KadakError({
|
|
315
|
+
code: "VALIDATION_ERROR",
|
|
316
|
+
message: "Invalid input for this query.",
|
|
317
|
+
hint: "Check the payload against your schema validators.",
|
|
318
|
+
table: tableName,
|
|
319
|
+
originalError: error
|
|
320
|
+
});
|
|
321
|
+
}
|
|
321
322
|
function wrapPgError(err, tableName) {
|
|
322
323
|
const pgErr = err;
|
|
323
324
|
const mapped = PG_ERROR_MAP[pgErr?.code ?? ""] ?? {
|
|
@@ -328,147 +329,553 @@ function wrapPgError(err, tableName) {
|
|
|
328
329
|
const column = pgErr?.column_name;
|
|
329
330
|
const constraint = pgErr?.constraint_name;
|
|
330
331
|
let message = pgErr?.message ?? "Unknown database error";
|
|
331
|
-
if (mapped.code === "NOT_NULL_VIOLATION" && column) message = `
|
|
332
|
-
else if (mapped.code === "UNIQUE_VIOLATION"
|
|
333
|
-
else if (mapped.code === "TABLE_NOT_FOUND") message = `Table '${table ?? "unknown"}' does not exist
|
|
332
|
+
if (mapped.code === "NOT_NULL_VIOLATION" && column) message = `Missing required field: ${column}`;
|
|
333
|
+
else if (mapped.code === "UNIQUE_VIOLATION") message = constraint ? `A record already exists with this value (violates '${constraint}')` : "A duplicate record already exists.";
|
|
334
|
+
else if (mapped.code === "TABLE_NOT_FOUND") message = `Table '${table ?? "unknown"}' does not exist.`;
|
|
334
335
|
return new KadakError({
|
|
335
336
|
code: mapped.code,
|
|
336
337
|
message,
|
|
337
338
|
hint: mapped.hint,
|
|
338
339
|
table,
|
|
339
|
-
column,
|
|
340
|
+
column: column || (constraint?.includes(table || "") ? constraint.replace(table + "_", "").replace("_key", "") : void 0),
|
|
340
341
|
constraint,
|
|
341
342
|
originalError: err
|
|
342
343
|
});
|
|
343
344
|
}
|
|
344
345
|
//#endregion
|
|
345
|
-
//#region src/query.ts
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
346
|
+
//#region src/query/analyzer.ts
|
|
347
|
+
function collectFields(condition) {
|
|
348
|
+
if (!condition) return [];
|
|
349
|
+
if (condition.isInternal) return [];
|
|
350
|
+
switch (condition.type) {
|
|
351
|
+
case "eq":
|
|
352
|
+
case "isNull":
|
|
353
|
+
case "in":
|
|
354
|
+
case "gt":
|
|
355
|
+
case "gte":
|
|
356
|
+
case "lt":
|
|
357
|
+
case "lte": return [condition.field];
|
|
358
|
+
case "and":
|
|
359
|
+
case "or": return condition.conditions.flatMap((entry) => collectFields(entry));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
function isIndexedField(table, dbField) {
|
|
363
|
+
const jsField = Object.entries(table._columnMap).find(([, value]) => value === dbField)?.[0];
|
|
364
|
+
if (!jsField) return false;
|
|
365
|
+
const column = table._columns[jsField];
|
|
366
|
+
if (!column) return false;
|
|
367
|
+
return column._config.isPrimaryKey || column._config.isUnique;
|
|
368
|
+
}
|
|
369
|
+
function hasUserFilter(condition) {
|
|
370
|
+
if (!condition) return false;
|
|
371
|
+
if (condition.isInternal) return false;
|
|
372
|
+
if (condition.type === "and" || condition.type === "or") return condition.conditions.some((c) => hasUserFilter(c));
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
function analyzeAst(ast, table, config) {
|
|
376
|
+
const warnings = [];
|
|
377
|
+
const where = ast.type === "insert" ? void 0 : ast.where;
|
|
378
|
+
const hasFilter = hasUserFilter(where);
|
|
379
|
+
if (ast.type === "select" && !hasFilter) warnings.push({
|
|
380
|
+
code: "full_scan",
|
|
381
|
+
message: `Query on "${ast.table}" has no filter. This may cause a full table scan.`,
|
|
382
|
+
suggestion: "Add a filter like { id: 1 } or another narrowing field."
|
|
383
|
+
});
|
|
384
|
+
if (ast.type === "select" && ast.limit === void 0) warnings.push({
|
|
385
|
+
code: "missing_limit",
|
|
386
|
+
message: `Query on "${ast.table}" has no limit. This may load large datasets.`,
|
|
387
|
+
suggestion: "Add $limit: 100"
|
|
388
|
+
});
|
|
389
|
+
if (ast.type === "select" && ast.limit !== void 0 && ast.limit > config.largeLimitThreshold) warnings.push({
|
|
390
|
+
code: "large_limit",
|
|
391
|
+
message: `Query on "${ast.table}" uses a large limit (${ast.limit}).`,
|
|
392
|
+
suggestion: "Reduce the limit or paginate in smaller batches."
|
|
393
|
+
});
|
|
394
|
+
if ((ast.type === "update" || ast.type === "delete") && !hasFilter) warnings.push({
|
|
395
|
+
code: "unsafe_mutation",
|
|
396
|
+
message: `${ast.type.toUpperCase()} on "${ast.table}" has no filter and will affect every row.`,
|
|
397
|
+
suggestion: `Add a filter to ${ast.type} only the rows you intend to change.`
|
|
398
|
+
});
|
|
399
|
+
const nonIndexedFields = Array.from(new Set(collectFields(where))).filter((field) => !isIndexedField(table, field));
|
|
400
|
+
if (nonIndexedFields.length > 0 && (ast.type === "select" || ast.type === "update" || ast.type === "delete")) warnings.push({
|
|
401
|
+
code: "index_hint",
|
|
402
|
+
message: `Query on "${ast.table}" filters on non-indexed fields: ${nonIndexedFields.join(", ")}.`,
|
|
403
|
+
suggestion: "Consider adding an index if this query is common."
|
|
404
|
+
});
|
|
405
|
+
return warnings;
|
|
406
|
+
}
|
|
407
|
+
function analyzeExecutionWithConfig(ast, metrics, config) {
|
|
408
|
+
const warnings = [];
|
|
409
|
+
if (metrics.durationMs >= config.slowQueryMs) warnings.push({
|
|
410
|
+
code: "slow_query",
|
|
411
|
+
message: `Query on "${ast.table}" took ${metrics.durationMs}ms.`,
|
|
412
|
+
suggestion: "Check filters, indexes, or reduce the result size."
|
|
413
|
+
});
|
|
414
|
+
if (metrics.rows >= config.largeResultRows) warnings.push({
|
|
415
|
+
code: "large_result",
|
|
416
|
+
message: `Query on "${ast.table}" returned ${metrics.rows} rows.`,
|
|
417
|
+
suggestion: "Add a stricter filter or a smaller $limit."
|
|
418
|
+
});
|
|
419
|
+
return warnings;
|
|
420
|
+
}
|
|
421
|
+
function repeatedQueryWarning(table, count) {
|
|
422
|
+
return {
|
|
423
|
+
code: "repeated_query",
|
|
424
|
+
message: `Query pattern on "${table}" repeated ${count} times in this process.`,
|
|
425
|
+
suggestion: "Consider caching, batching, or moving this pattern behind a single query."
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
function formatWarning(warning) {
|
|
429
|
+
const lines = ["Kadak Warning:", warning.message];
|
|
430
|
+
if (warning.suggestion) lines.push("", "Suggestion:", `-> ${warning.suggestion}`);
|
|
431
|
+
return lines.join("\n");
|
|
432
|
+
}
|
|
433
|
+
//#endregion
|
|
434
|
+
//#region src/query/ast.ts
|
|
435
|
+
function andConditions(conditions) {
|
|
436
|
+
const compact = conditions.filter((condition) => Boolean(condition));
|
|
437
|
+
if (compact.length === 0) return void 0;
|
|
438
|
+
if (compact.length === 1) return compact[0];
|
|
439
|
+
return {
|
|
440
|
+
type: "and",
|
|
441
|
+
conditions: compact
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
function orConditions(conditions) {
|
|
445
|
+
const compact = conditions.filter((condition) => Boolean(condition));
|
|
446
|
+
if (compact.length === 0) return void 0;
|
|
447
|
+
if (compact.length === 1) return compact[0];
|
|
448
|
+
return {
|
|
449
|
+
type: "or",
|
|
450
|
+
conditions: compact
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
//#endregion
|
|
454
|
+
//#region src/query/normalize.ts
|
|
455
|
+
function isOperatorObject(value) {
|
|
456
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || value instanceof Date) return false;
|
|
457
|
+
return Object.keys(value).some((key) => key.startsWith("$"));
|
|
458
|
+
}
|
|
459
|
+
function normalizeFieldValue(field, value) {
|
|
460
|
+
if (value === void 0) return void 0;
|
|
461
|
+
if (value === null) return {
|
|
462
|
+
type: "isNull",
|
|
463
|
+
field
|
|
464
|
+
};
|
|
465
|
+
if (Array.isArray(value)) return {
|
|
466
|
+
type: "in",
|
|
467
|
+
field,
|
|
468
|
+
values: value
|
|
469
|
+
};
|
|
470
|
+
if (!isOperatorObject(value)) return {
|
|
471
|
+
type: "eq",
|
|
472
|
+
field,
|
|
473
|
+
value
|
|
474
|
+
};
|
|
475
|
+
const operatorValue = value;
|
|
476
|
+
return andConditions([
|
|
477
|
+
operatorValue.$in ? {
|
|
478
|
+
type: "in",
|
|
479
|
+
field,
|
|
480
|
+
values: operatorValue.$in
|
|
481
|
+
} : void 0,
|
|
482
|
+
operatorValue.$gt !== void 0 ? {
|
|
483
|
+
type: "gt",
|
|
484
|
+
field,
|
|
485
|
+
value: operatorValue.$gt
|
|
486
|
+
} : void 0,
|
|
487
|
+
operatorValue.$gte !== void 0 ? {
|
|
488
|
+
type: "gte",
|
|
489
|
+
field,
|
|
490
|
+
value: operatorValue.$gte
|
|
491
|
+
} : void 0,
|
|
492
|
+
operatorValue.$lt !== void 0 ? {
|
|
493
|
+
type: "lt",
|
|
494
|
+
field,
|
|
495
|
+
value: operatorValue.$lt
|
|
496
|
+
} : void 0,
|
|
497
|
+
operatorValue.$lte !== void 0 ? {
|
|
498
|
+
type: "lte",
|
|
499
|
+
field,
|
|
500
|
+
value: operatorValue.$lte
|
|
501
|
+
} : void 0
|
|
502
|
+
]);
|
|
503
|
+
}
|
|
504
|
+
function normalizeWhere(table, input) {
|
|
505
|
+
if (!input) return void 0;
|
|
506
|
+
const { $or, $limit, $order, ...plainFields } = input;
|
|
507
|
+
const conditions = [];
|
|
508
|
+
for (const [key, value] of Object.entries(plainFields)) {
|
|
509
|
+
const field = table._columnMap[key] ?? key;
|
|
510
|
+
conditions.push(normalizeFieldValue(field, value));
|
|
511
|
+
}
|
|
512
|
+
if (Array.isArray($or)) conditions.push(orConditions($or.map((entry) => normalizeWhere(table, entry))));
|
|
513
|
+
return andConditions(conditions);
|
|
514
|
+
}
|
|
515
|
+
function normalizeOrder(table, order) {
|
|
516
|
+
return Object.entries(order ?? {}).map(([key, direction]) => ({
|
|
517
|
+
field: table._columnMap[key] ?? key,
|
|
518
|
+
direction: direction === "desc" ? "desc" : "asc"
|
|
519
|
+
}));
|
|
520
|
+
}
|
|
521
|
+
function normalizeSelect(table, query, options) {
|
|
522
|
+
const softDeleteCondition = options?.includeSoftDeleted ? void 0 : normalizeSoftDelete(table);
|
|
523
|
+
const explicitLimit = typeof query?.$limit === "number" ? query.$limit : void 0;
|
|
524
|
+
return {
|
|
525
|
+
type: "select",
|
|
526
|
+
table: table._name,
|
|
527
|
+
where: andConditions([normalizeWhere(table, query), softDeleteCondition]),
|
|
528
|
+
order: normalizeOrder(table, query?.$order),
|
|
529
|
+
limit: explicitLimit ?? options?.defaultLimit
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
function normalizeUpdate(table, filter, data) {
|
|
533
|
+
const entries = Object.entries(data).filter(([, value]) => value !== void 0).map(([key, value]) => ({
|
|
534
|
+
field: table._columnMap[key] ?? key,
|
|
535
|
+
value
|
|
536
|
+
}));
|
|
537
|
+
return {
|
|
538
|
+
type: "update",
|
|
539
|
+
table: table._name,
|
|
540
|
+
where: normalizeWhere(table, filter),
|
|
541
|
+
data: entries
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
function normalizeDelete(table, filter) {
|
|
545
|
+
return {
|
|
546
|
+
type: "delete",
|
|
547
|
+
table: table._name,
|
|
548
|
+
where: normalizeWhere(table, filter)
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
function normalizeInsert(table, rows) {
|
|
552
|
+
const jsColumns = Array.from(new Set(rows.flatMap((row) => Object.keys(row))));
|
|
553
|
+
return {
|
|
554
|
+
type: "insert",
|
|
555
|
+
table: table._name,
|
|
556
|
+
columns: jsColumns.map((key) => table._columnMap[key] ?? key),
|
|
557
|
+
rows: rows.map((row) => jsColumns.map((columnKey) => columnKey in row ? {
|
|
558
|
+
kind: "value",
|
|
559
|
+
value: row[columnKey]
|
|
560
|
+
} : { kind: "default" }))
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
function normalizeSoftDelete(table) {
|
|
564
|
+
for (const [key, column] of Object.entries(table._columns)) if (column._config.softDelete) return {
|
|
565
|
+
type: "isNull",
|
|
566
|
+
field: table._columnMap[key],
|
|
567
|
+
isInternal: true
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
//#endregion
|
|
571
|
+
//#region src/query/sql.ts
|
|
572
|
+
function quoteIdentifier(value) {
|
|
573
|
+
return `"${value.replace(/"/g, "\"\"")}"`;
|
|
574
|
+
}
|
|
575
|
+
function buildCondition(condition, params) {
|
|
576
|
+
if (!condition) return "";
|
|
577
|
+
switch (condition.type) {
|
|
578
|
+
case "eq":
|
|
579
|
+
params.push(condition.value);
|
|
580
|
+
return `${quoteIdentifier(condition.field)} = $${params.length}`;
|
|
581
|
+
case "isNull": return `${quoteIdentifier(condition.field)} IS NULL`;
|
|
582
|
+
case "in":
|
|
583
|
+
if (condition.values.length === 0) return "FALSE";
|
|
584
|
+
return `${quoteIdentifier(condition.field)} IN (${condition.values.map((value) => {
|
|
585
|
+
params.push(value);
|
|
586
|
+
return `$${params.length}`;
|
|
587
|
+
}).join(", ")})`;
|
|
588
|
+
case "gt":
|
|
589
|
+
params.push(condition.value);
|
|
590
|
+
return `${quoteIdentifier(condition.field)} > $${params.length}`;
|
|
591
|
+
case "gte":
|
|
592
|
+
params.push(condition.value);
|
|
593
|
+
return `${quoteIdentifier(condition.field)} >= $${params.length}`;
|
|
594
|
+
case "lt":
|
|
595
|
+
params.push(condition.value);
|
|
596
|
+
return `${quoteIdentifier(condition.field)} < $${params.length}`;
|
|
597
|
+
case "lte":
|
|
598
|
+
params.push(condition.value);
|
|
599
|
+
return `${quoteIdentifier(condition.field)} <= $${params.length}`;
|
|
600
|
+
case "and": return `(${condition.conditions.map((entry) => buildCondition(entry, params)).join(" AND ")})`;
|
|
601
|
+
case "or": return `(${condition.conditions.map((entry) => buildCondition(entry, params)).join(" OR ")})`;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
function buildWhere(where, params) {
|
|
605
|
+
const sql = buildCondition(where, params);
|
|
606
|
+
return sql ? ` WHERE ${sql}` : "";
|
|
607
|
+
}
|
|
608
|
+
function buildSelect(ast) {
|
|
609
|
+
const params = [];
|
|
610
|
+
const where = buildWhere(ast.where, params);
|
|
611
|
+
const order = ast.order.length > 0 ? ` ORDER BY ${ast.order.map((entry) => `${quoteIdentifier(entry.field)} ${entry.direction.toUpperCase()}`).join(", ")}` : "";
|
|
612
|
+
const limit = ast.limit !== void 0 ? ` LIMIT ${ast.limit}` : "";
|
|
613
|
+
return {
|
|
614
|
+
text: `SELECT * FROM ${quoteIdentifier(ast.table)}${where}${order}${limit}`,
|
|
615
|
+
params
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
function buildUpdate(ast) {
|
|
619
|
+
const params = [];
|
|
620
|
+
const setSql = ast.data.map((entry) => {
|
|
621
|
+
params.push(entry.value);
|
|
622
|
+
return `${quoteIdentifier(entry.field)} = $${params.length}`;
|
|
623
|
+
}).join(", ");
|
|
624
|
+
const where = ast.where ? ` WHERE ${buildCondition(ast.where, params)}` : "";
|
|
625
|
+
return {
|
|
626
|
+
text: `UPDATE ${quoteIdentifier(ast.table)} SET ${setSql}${where} RETURNING *`,
|
|
627
|
+
params
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
function buildDelete(ast) {
|
|
631
|
+
const params = [];
|
|
632
|
+
const where = buildWhere(ast.where, params);
|
|
633
|
+
return {
|
|
634
|
+
text: `DELETE FROM ${quoteIdentifier(ast.table)}${where} RETURNING *`,
|
|
635
|
+
params
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
function buildInsert(ast) {
|
|
639
|
+
if (ast.columns.length === 0) return {
|
|
640
|
+
text: `INSERT INTO ${quoteIdentifier(ast.table)} DEFAULT VALUES RETURNING *`,
|
|
641
|
+
params: []
|
|
642
|
+
};
|
|
643
|
+
const params = [];
|
|
644
|
+
const values = ast.rows.map((row) => `(${row.map((cell) => {
|
|
645
|
+
if (cell.kind === "default") return "DEFAULT";
|
|
646
|
+
params.push(cell.value);
|
|
647
|
+
return `$${params.length}`;
|
|
648
|
+
}).join(", ")})`).join(", ");
|
|
649
|
+
return {
|
|
650
|
+
text: `INSERT INTO ${quoteIdentifier(ast.table)} (${ast.columns.map(quoteIdentifier).join(", ")}) VALUES ${values} RETURNING *`,
|
|
651
|
+
params
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
function buildSql(ast) {
|
|
655
|
+
switch (ast.type) {
|
|
656
|
+
case "select": return buildSelect(ast);
|
|
657
|
+
case "update": return buildUpdate(ast);
|
|
658
|
+
case "delete": return buildDelete(ast);
|
|
659
|
+
case "insert": return buildInsert(ast);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
//#endregion
|
|
663
|
+
//#region src/query/index.ts
|
|
350
664
|
var TableClient = class {
|
|
351
|
-
constructor(sql, table) {
|
|
665
|
+
constructor(sql, table, config = {}) {
|
|
352
666
|
this._sql = sql;
|
|
667
|
+
this._readSql = config.readSql ?? [];
|
|
353
668
|
this._table = table;
|
|
669
|
+
this._debug = config.debug ?? false;
|
|
670
|
+
this._warn = config.warn ?? ((message) => console.warn(message));
|
|
671
|
+
this._onQuery = config.onQuery;
|
|
672
|
+
this._warningMode = config.warningMode ?? "relaxed";
|
|
673
|
+
this._retryAttempts = Math.max(0, config.retryAttempts ?? 0);
|
|
674
|
+
this._retryDelayMs = Math.max(0, config.retryDelayMs ?? 200);
|
|
675
|
+
this._retryOnCodes = new Set(config.retryOnCodes ?? [
|
|
676
|
+
"08006",
|
|
677
|
+
"08001",
|
|
678
|
+
"57P01",
|
|
679
|
+
"40001",
|
|
680
|
+
"40P01"
|
|
681
|
+
]);
|
|
682
|
+
this._retryWrites = config.retryWrites ?? false;
|
|
683
|
+
this._defaultLimit = config.defaultLimit;
|
|
684
|
+
this._validateResults = config.validateResults ?? false;
|
|
685
|
+
this._analyzerConfig = {
|
|
686
|
+
slowQueryMs: config.slowQueryMs ?? 250,
|
|
687
|
+
largeResultRows: config.largeResultRows ?? 1e3,
|
|
688
|
+
largeLimitThreshold: config.largeLimitThreshold ?? 1e3
|
|
689
|
+
};
|
|
690
|
+
this._repeatedQueryThreshold = config.repeatedQueryThreshold ?? 25;
|
|
691
|
+
this._queryFingerprintCount = /* @__PURE__ */ new Map();
|
|
692
|
+
this._selectValidator = table.selectValidator();
|
|
693
|
+
this._explainAnalyze = config.explainAnalyze ?? false;
|
|
694
|
+
this._explainThresholdMs = config.explainThresholdMs ?? 300;
|
|
695
|
+
this._metrics = config.metrics;
|
|
696
|
+
this._replicaIndex = 0;
|
|
354
697
|
this._reverseMap = {};
|
|
355
698
|
for (const [camel, snake] of Object.entries(table._columnMap)) this._reverseMap[snake] = camel;
|
|
356
699
|
}
|
|
357
|
-
async findMany(
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
const t = this._table;
|
|
361
|
-
const cols = options?.select ? Object.keys(options.select).filter((k) => options.select[k]).map((k) => t._columnMap[k] ?? k) : Object.values(t._columnMap);
|
|
362
|
-
const whereFragment = this._buildWhere(options?.where, options?.withDeleted);
|
|
363
|
-
const orderFragment = this._buildOrderBy(options?.orderBy);
|
|
364
|
-
const limitFragment = options?.limit !== void 0 ? sql`LIMIT ${options.limit}` : sql``;
|
|
365
|
-
const offsetFragment = options?.offset !== void 0 ? sql`OFFSET ${options.offset}` : sql``;
|
|
366
|
-
return (await sql`
|
|
367
|
-
SELECT ${sql(cols)} FROM ${sql(t._name)}
|
|
368
|
-
${whereFragment} ${orderFragment} ${limitFragment} ${offsetFragment}
|
|
369
|
-
`).map((row) => this._toJs(row));
|
|
370
|
-
} catch (err) {
|
|
371
|
-
throw wrapPgError(err, this._table._name);
|
|
372
|
-
}
|
|
700
|
+
async findMany(query) {
|
|
701
|
+
const ast = normalizeSelect(this._table, query, { defaultLimit: this._defaultLimit });
|
|
702
|
+
return this._executeAst(ast);
|
|
373
703
|
}
|
|
374
|
-
async findFirst(
|
|
704
|
+
async findFirst(query) {
|
|
375
705
|
return (await this.findMany({
|
|
376
|
-
...
|
|
377
|
-
limit: 1
|
|
706
|
+
...query ?? {},
|
|
707
|
+
$limit: 1
|
|
378
708
|
}))[0] ?? null;
|
|
379
709
|
}
|
|
380
710
|
async insert(data) {
|
|
711
|
+
const isBulk = Array.isArray(data);
|
|
712
|
+
const validator = this._table.insertValidator();
|
|
713
|
+
const rawRows = (isBulk ? data : [data]).map((row) => this._applyInsertAutomation(this._parseOrThrow(validator, row)));
|
|
714
|
+
const ast = normalizeInsert(this._table, rawRows);
|
|
715
|
+
const rows = await this._executeAst(ast);
|
|
716
|
+
return isBulk ? rows : rows[0];
|
|
717
|
+
}
|
|
718
|
+
async update(filter, data) {
|
|
719
|
+
const parsed = this._applyUpdateAutomation(this._parseOrThrow(this._table.updateValidator(), data));
|
|
720
|
+
const ast = normalizeUpdate(this._table, filter, parsed);
|
|
721
|
+
if (ast.data.length === 0) throw new KadakError({
|
|
722
|
+
code: "VALIDATION_ERROR",
|
|
723
|
+
message: `Nothing to update in table '${this._table._name}'.`,
|
|
724
|
+
hint: "Pass at least one defined field in the update payload.",
|
|
725
|
+
table: this._table._name,
|
|
726
|
+
originalError: null
|
|
727
|
+
});
|
|
728
|
+
return this._executeAst(ast);
|
|
729
|
+
}
|
|
730
|
+
async delete(filter) {
|
|
731
|
+
const softDeleteKey = this._getSoftDeleteKey();
|
|
732
|
+
if (softDeleteKey) return this.update(filter, { [softDeleteKey]: /* @__PURE__ */ new Date() });
|
|
733
|
+
return this.hardDelete(filter);
|
|
734
|
+
}
|
|
735
|
+
async hardDelete(filter) {
|
|
736
|
+
const ast = normalizeDelete(this._table, filter);
|
|
737
|
+
return this._executeAst(ast);
|
|
738
|
+
}
|
|
739
|
+
async _executeAst(ast) {
|
|
740
|
+
const statement = buildSql(ast);
|
|
741
|
+
this._registerQueryFingerprint(ast, statement.text);
|
|
742
|
+
const astWarnings = analyzeAst(ast, this._table, this._analyzerConfig);
|
|
743
|
+
this._handleWarnings(astWarnings, ast);
|
|
744
|
+
const startedAt = Date.now();
|
|
381
745
|
try {
|
|
382
|
-
const
|
|
383
|
-
const
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
746
|
+
const rows = await this._executeWithRetry(ast, () => this._pickSqlClient(ast).unsafe(statement.text, statement.params));
|
|
747
|
+
const durationMs = Date.now() - startedAt;
|
|
748
|
+
const executionWarnings = analyzeExecutionWithConfig(ast, {
|
|
749
|
+
durationMs,
|
|
750
|
+
rows: rows.length
|
|
751
|
+
}, this._analyzerConfig);
|
|
752
|
+
this._handleWarnings(executionWarnings, ast);
|
|
753
|
+
if (this._explainAnalyze && ast.type === "select" && durationMs >= this._explainThresholdMs) {
|
|
754
|
+
const explainWarnings = await this._explainWarnings(statement.text, statement.params, ast.table);
|
|
755
|
+
this._handleWarnings(explainWarnings, ast);
|
|
756
|
+
}
|
|
757
|
+
if (this._debug) this._logDebug(ast.type, statement.text, durationMs, rows.length);
|
|
758
|
+
this._recordTelemetry({
|
|
759
|
+
table: ast.table,
|
|
760
|
+
operation: ast.type,
|
|
761
|
+
sql: statement.text,
|
|
762
|
+
durationMs,
|
|
763
|
+
rows: rows.length,
|
|
764
|
+
warned: astWarnings.length + executionWarnings.length > 0,
|
|
765
|
+
warningCount: astWarnings.length + executionWarnings.length
|
|
766
|
+
});
|
|
767
|
+
return rows.map((row) => this._parseResultOrThrow(this._toJs(row)));
|
|
768
|
+
} catch (error) {
|
|
769
|
+
throw wrapPgError(error, this._table._name);
|
|
393
770
|
}
|
|
394
771
|
}
|
|
395
|
-
|
|
772
|
+
_pickSqlClient(ast) {
|
|
773
|
+
if (ast.type !== "select") return this._sql;
|
|
774
|
+
if (this._readSql.length === 0) return this._sql;
|
|
775
|
+
const client = this._readSql[this._replicaIndex % this._readSql.length];
|
|
776
|
+
this._replicaIndex += 1;
|
|
777
|
+
return client;
|
|
778
|
+
}
|
|
779
|
+
async _explainWarnings(sqlText, params, table) {
|
|
396
780
|
try {
|
|
397
|
-
const
|
|
398
|
-
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
} catch
|
|
407
|
-
|
|
781
|
+
const plan = (await this._sql.unsafe(`EXPLAIN (ANALYZE, FORMAT JSON) ${sqlText}`, params))?.[0]?.["QUERY PLAN"]?.[0]?.Plan;
|
|
782
|
+
if (!plan) return [];
|
|
783
|
+
const warnings = [];
|
|
784
|
+
if (plan["Node Type"] === "Seq Scan") warnings.push({
|
|
785
|
+
code: "index_hint",
|
|
786
|
+
message: `EXPLAIN on "${table}" shows a sequential scan.`,
|
|
787
|
+
suggestion: "Consider adding an index for frequent filter columns."
|
|
788
|
+
});
|
|
789
|
+
return warnings;
|
|
790
|
+
} catch {
|
|
791
|
+
return [];
|
|
408
792
|
}
|
|
409
793
|
}
|
|
410
|
-
async
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
794
|
+
async _executeWithRetry(ast, fn) {
|
|
795
|
+
let attempt = 0;
|
|
796
|
+
while (true) try {
|
|
797
|
+
return await fn();
|
|
798
|
+
} catch (error) {
|
|
799
|
+
if (!this._shouldRetry(ast, error, attempt)) throw error;
|
|
800
|
+
attempt += 1;
|
|
801
|
+
await this._sleep(this._retryDelayMs * attempt);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
_shouldRetry(ast, error, attempt) {
|
|
805
|
+
if (attempt >= this._retryAttempts) return false;
|
|
806
|
+
if (ast.type !== "select" && !this._retryWrites) return false;
|
|
807
|
+
const code = error?.code;
|
|
808
|
+
return Boolean(code && this._retryOnCodes.has(code));
|
|
809
|
+
}
|
|
810
|
+
_sleep(ms) {
|
|
811
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
812
|
+
}
|
|
813
|
+
_registerQueryFingerprint(ast, sqlText) {
|
|
814
|
+
const key = `${ast.type}:${sqlText}`;
|
|
815
|
+
const count = (this._queryFingerprintCount.get(key) ?? 0) + 1;
|
|
816
|
+
this._queryFingerprintCount.set(key, count);
|
|
817
|
+
if (count === this._repeatedQueryThreshold) this._handleWarnings([repeatedQueryWarning(ast.table, count)], ast);
|
|
818
|
+
}
|
|
819
|
+
_handleWarnings(warnings, ast) {
|
|
820
|
+
if (this._warningMode === "silent") return;
|
|
821
|
+
for (const warning of warnings) {
|
|
822
|
+
const message = formatWarning(warning);
|
|
823
|
+
if (this._warningMode === "strict") throw new KadakError({
|
|
824
|
+
code: "QUERY_WARNING",
|
|
825
|
+
message,
|
|
826
|
+
hint: `Fix the warning or switch warnings mode from 'strict'.`,
|
|
827
|
+
table: ast.table,
|
|
828
|
+
originalError: warning
|
|
418
829
|
});
|
|
830
|
+
this._warn(message);
|
|
419
831
|
}
|
|
420
|
-
return this.hardDelete(options);
|
|
421
832
|
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
`).map((row) => this._toJs(row));
|
|
430
|
-
} catch (err) {
|
|
431
|
-
throw wrapPgError(err, this._table._name);
|
|
833
|
+
_applyInsertAutomation(data) {
|
|
834
|
+
const next = { ...data };
|
|
835
|
+
for (const [key, column] of Object.entries(this._table._columns)) {
|
|
836
|
+
const config = column._config;
|
|
837
|
+
if (next[key] !== void 0) continue;
|
|
838
|
+
if (config.autoUpdate) next[key] = /* @__PURE__ */ new Date();
|
|
839
|
+
else if (config.defaultValue !== void 0 && !config.defaultSql) next[key] = config.defaultValue;
|
|
432
840
|
}
|
|
841
|
+
return next;
|
|
433
842
|
}
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
843
|
+
_applyUpdateAutomation(data) {
|
|
844
|
+
const next = { ...data };
|
|
845
|
+
for (const [key, column] of Object.entries(this._table._columns)) if (column._config.autoUpdate) next[key] = /* @__PURE__ */ new Date();
|
|
846
|
+
return next;
|
|
847
|
+
}
|
|
848
|
+
_parseOrThrow(validator, data) {
|
|
849
|
+
try {
|
|
850
|
+
return validator.parse(data);
|
|
851
|
+
} catch (error) {
|
|
852
|
+
throw fromValidationError(error, this._table._name);
|
|
443
853
|
}
|
|
444
|
-
|
|
445
|
-
|
|
854
|
+
}
|
|
855
|
+
_parseResultOrThrow(row) {
|
|
856
|
+
if (!this._validateResults) return row;
|
|
857
|
+
try {
|
|
858
|
+
return this._selectValidator.parse(row);
|
|
859
|
+
} catch (error) {
|
|
860
|
+
throw fromValidationError(error, this._table._name);
|
|
446
861
|
}
|
|
447
|
-
if (conditions.length === 0) return sql``;
|
|
448
|
-
let combined = conditions[0];
|
|
449
|
-
for (let i = 1; i < conditions.length; i++) combined = sql`${combined} AND ${conditions[i]}`;
|
|
450
|
-
return sql`WHERE ${combined}`;
|
|
451
|
-
}
|
|
452
|
-
/** Builds an ORDER BY fragment */
|
|
453
|
-
_buildOrderBy(orderBy) {
|
|
454
|
-
const sql = this._sql;
|
|
455
|
-
if (!orderBy) return sql``;
|
|
456
|
-
const entries = Object.entries(orderBy);
|
|
457
|
-
if (entries.length === 0) return sql``;
|
|
458
|
-
const parts = entries.map(([key, dir]) => {
|
|
459
|
-
return sql`${sql(this._table._columnMap[key] ?? key)} ${dir === "desc" ? sql`DESC` : sql`ASC`}`;
|
|
460
|
-
});
|
|
461
|
-
let combined = parts[0];
|
|
462
|
-
for (let i = 1; i < parts.length; i++) combined = sql`${combined}, ${parts[i]}`;
|
|
463
|
-
return sql`ORDER BY ${combined}`;
|
|
464
862
|
}
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
863
|
+
_recordTelemetry(event) {
|
|
864
|
+
this._metrics?.record(event);
|
|
865
|
+
this._onQuery?.(event);
|
|
866
|
+
}
|
|
867
|
+
_getSoftDeleteKey() {
|
|
868
|
+
for (const [key, column] of Object.entries(this._table._columns)) if (column._config.softDelete) return key;
|
|
869
|
+
}
|
|
870
|
+
_logDebug(operation, sqlText, durationMs, rows) {
|
|
871
|
+
console.log([
|
|
872
|
+
"[Kadak]",
|
|
873
|
+
`Query: ${this._table._name}.${operation}`,
|
|
874
|
+
`SQL: ${sqlText}`,
|
|
875
|
+
`Time: ${durationMs}ms`,
|
|
876
|
+
`Rows: ${rows}`
|
|
877
|
+
].join("\n"));
|
|
470
878
|
}
|
|
471
|
-
/** Converts a DB row (snake_case) back to JS format (camelCase) */
|
|
472
879
|
_toJs(row) {
|
|
473
880
|
const result = {};
|
|
474
881
|
for (const [snake, value] of Object.entries(row)) result[this._reverseMap[snake] ?? snake] = value;
|
|
@@ -477,40 +884,168 @@ var TableClient = class {
|
|
|
477
884
|
};
|
|
478
885
|
//#endregion
|
|
479
886
|
//#region src/connect.ts
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
887
|
+
function buildDatabase(sql, readSql, tables, runtimeConfig, canClose) {
|
|
888
|
+
const db = {};
|
|
889
|
+
for (const [key, table] of Object.entries(tables)) db[key] = new TableClient(sql, table, {
|
|
890
|
+
readSql,
|
|
891
|
+
debug: runtimeConfig.debug,
|
|
892
|
+
warn: runtimeConfig.onWarn,
|
|
893
|
+
onQuery: runtimeConfig.onQuery,
|
|
894
|
+
warningMode: runtimeConfig.warnings,
|
|
895
|
+
retryAttempts: runtimeConfig.retryAttempts,
|
|
896
|
+
retryDelayMs: runtimeConfig.retryDelayMs,
|
|
897
|
+
retryOnCodes: runtimeConfig.retryOnCodes,
|
|
898
|
+
retryWrites: runtimeConfig.retryWrites,
|
|
899
|
+
defaultLimit: runtimeConfig.defaultLimit,
|
|
900
|
+
validateResults: runtimeConfig.validateResults,
|
|
901
|
+
slowQueryMs: runtimeConfig.slowQueryMs,
|
|
902
|
+
largeResultRows: runtimeConfig.largeResultRows,
|
|
903
|
+
largeLimitThreshold: runtimeConfig.largeLimitThreshold,
|
|
904
|
+
repeatedQueryThreshold: runtimeConfig.repeatedQueryThreshold,
|
|
905
|
+
explainAnalyze: runtimeConfig.explainAnalyze,
|
|
906
|
+
explainThresholdMs: runtimeConfig.explainThresholdMs,
|
|
907
|
+
metrics: runtimeConfig.metrics
|
|
908
|
+
});
|
|
909
|
+
db.close = async () => {
|
|
910
|
+
if (!canClose) return;
|
|
911
|
+
await Promise.all([sql.end(), ...readSql.map((replica) => replica.end())]);
|
|
912
|
+
};
|
|
913
|
+
db.sql = sql;
|
|
914
|
+
db.readSql = readSql;
|
|
915
|
+
db.transaction = async (fn) => {
|
|
916
|
+
return sql.begin(async (txSql) => {
|
|
917
|
+
return fn(buildDatabase(txSql, [], tables, runtimeConfig, false));
|
|
918
|
+
});
|
|
919
|
+
};
|
|
920
|
+
return db;
|
|
921
|
+
}
|
|
489
922
|
function connect(config, tables) {
|
|
490
923
|
const isString = typeof config === "string";
|
|
491
924
|
const url = isString ? config : config.url;
|
|
492
925
|
const opts = isString ? {} : config;
|
|
493
926
|
const isLocal = url.includes("localhost") || url.includes("127.0.0.1");
|
|
494
927
|
const ssl = opts.ssl ?? (isLocal ? false : "require");
|
|
495
|
-
|
|
928
|
+
return buildDatabase(postgres(url, {
|
|
496
929
|
max: opts.max ?? 10,
|
|
497
930
|
idle_timeout: 20,
|
|
498
|
-
connect_timeout: 30,
|
|
931
|
+
connect_timeout: opts.timeout ?? 30,
|
|
499
932
|
ssl,
|
|
500
933
|
max_lifetime: 1800,
|
|
501
934
|
onnotice: () => {}
|
|
502
|
-
})
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
935
|
+
}), (opts.readReplicas ?? []).map((replicaUrl) => postgres(replicaUrl, {
|
|
936
|
+
max: opts.max ?? 10,
|
|
937
|
+
idle_timeout: 20,
|
|
938
|
+
connect_timeout: opts.timeout ?? 30,
|
|
939
|
+
ssl,
|
|
940
|
+
max_lifetime: 1800,
|
|
941
|
+
onnotice: () => {}
|
|
942
|
+
})), tables, opts, true);
|
|
943
|
+
}
|
|
944
|
+
//#endregion
|
|
945
|
+
//#region src/migrate.ts
|
|
946
|
+
async function ensureMigrationTable(sql, tableName) {
|
|
947
|
+
await sql.unsafe(`
|
|
948
|
+
CREATE TABLE IF NOT EXISTS "${tableName}" (
|
|
949
|
+
id BIGSERIAL PRIMARY KEY,
|
|
950
|
+
name TEXT NOT NULL UNIQUE,
|
|
951
|
+
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
952
|
+
)
|
|
953
|
+
`);
|
|
954
|
+
}
|
|
955
|
+
async function runMigrations(config) {
|
|
956
|
+
const dir = config.dir ?? path.resolve(process.cwd(), "migrations");
|
|
957
|
+
const tableName = config.tableName ?? "kadak_migrations";
|
|
958
|
+
const sql = postgres(config.url);
|
|
959
|
+
try {
|
|
960
|
+
await ensureMigrationTable(sql, tableName);
|
|
961
|
+
const files = (await fs.readdir(dir).catch(() => [])).filter((entry) => entry.endsWith(".sql")).sort();
|
|
962
|
+
const rows = await sql.unsafe(`SELECT name FROM "${tableName}"`);
|
|
963
|
+
const appliedSet = new Set(rows.map((row) => row.name));
|
|
964
|
+
const applied = [];
|
|
965
|
+
const skipped = [];
|
|
966
|
+
for (const file of files) {
|
|
967
|
+
if (appliedSet.has(file)) {
|
|
968
|
+
skipped.push(file);
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
const sqlText = await fs.readFile(path.join(dir, file), "utf8");
|
|
972
|
+
await sql.begin(async (tx) => {
|
|
973
|
+
await tx.unsafe(sqlText);
|
|
974
|
+
await tx.unsafe(`INSERT INTO "${tableName}" (name) VALUES ($1)`, [file]);
|
|
975
|
+
});
|
|
976
|
+
applied.push(file);
|
|
977
|
+
}
|
|
978
|
+
return {
|
|
979
|
+
applied,
|
|
980
|
+
skipped
|
|
981
|
+
};
|
|
982
|
+
} finally {
|
|
983
|
+
await sql.end();
|
|
508
984
|
}
|
|
509
|
-
db.close = () => sql.end();
|
|
510
|
-
db.sql = sql;
|
|
511
|
-
return db;
|
|
512
985
|
}
|
|
986
|
+
async function createMigration(name, dir = path.resolve(process.cwd(), "migrations")) {
|
|
987
|
+
const safeName = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
|
988
|
+
const fileName = `${(/* @__PURE__ */ new Date()).toISOString().replace(/[-:TZ.]/g, "").slice(0, 14)}_${safeName || "migration"}.sql`;
|
|
989
|
+
const filePath = path.join(dir, fileName);
|
|
990
|
+
await fs.mkdir(dir, { recursive: true });
|
|
991
|
+
await fs.writeFile(filePath, "-- Write your SQL migration here\n");
|
|
992
|
+
return filePath;
|
|
993
|
+
}
|
|
994
|
+
//#endregion
|
|
995
|
+
//#region src/observability.ts
|
|
996
|
+
var KadakMetrics = class {
|
|
997
|
+
constructor() {
|
|
998
|
+
this.totalQueries = 0;
|
|
999
|
+
this.totalDurationMs = 0;
|
|
1000
|
+
this.warningQueries = 0;
|
|
1001
|
+
this.byOperation = {};
|
|
1002
|
+
}
|
|
1003
|
+
record(event) {
|
|
1004
|
+
this.totalQueries += 1;
|
|
1005
|
+
this.totalDurationMs += event.durationMs;
|
|
1006
|
+
if (event.warned) this.warningQueries += 1;
|
|
1007
|
+
const bucket = this.byOperation[event.operation] ?? {
|
|
1008
|
+
count: 0,
|
|
1009
|
+
totalDurationMs: 0,
|
|
1010
|
+
rows: 0
|
|
1011
|
+
};
|
|
1012
|
+
bucket.count += 1;
|
|
1013
|
+
bucket.totalDurationMs += event.durationMs;
|
|
1014
|
+
bucket.rows += event.rows;
|
|
1015
|
+
this.byOperation[event.operation] = bucket;
|
|
1016
|
+
}
|
|
1017
|
+
snapshot() {
|
|
1018
|
+
return {
|
|
1019
|
+
totalQueries: this.totalQueries,
|
|
1020
|
+
totalDurationMs: this.totalDurationMs,
|
|
1021
|
+
avgDurationMs: this.totalQueries === 0 ? 0 : this.totalDurationMs / this.totalQueries,
|
|
1022
|
+
warningQueries: this.warningQueries,
|
|
1023
|
+
byOperation: { ...this.byOperation }
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
exportPrometheus(prefix = "kadak") {
|
|
1027
|
+
const lines = [];
|
|
1028
|
+
lines.push(`# HELP ${prefix}_queries_total Total queries executed`);
|
|
1029
|
+
lines.push(`# TYPE ${prefix}_queries_total counter`);
|
|
1030
|
+
lines.push(`${prefix}_queries_total ${this.totalQueries}`);
|
|
1031
|
+
lines.push(`# HELP ${prefix}_query_duration_ms_total Total query duration in milliseconds`);
|
|
1032
|
+
lines.push(`# TYPE ${prefix}_query_duration_ms_total counter`);
|
|
1033
|
+
lines.push(`${prefix}_query_duration_ms_total ${this.totalDurationMs}`);
|
|
1034
|
+
lines.push(`# HELP ${prefix}_warning_queries_total Queries that emitted at least one warning`);
|
|
1035
|
+
lines.push(`# TYPE ${prefix}_warning_queries_total counter`);
|
|
1036
|
+
lines.push(`${prefix}_warning_queries_total ${this.warningQueries}`);
|
|
1037
|
+
for (const [operation, value] of Object.entries(this.byOperation)) {
|
|
1038
|
+
lines.push(`${prefix}_operation_queries_total{operation="${operation}"} ${value.count}`);
|
|
1039
|
+
lines.push(`${prefix}_operation_duration_ms_total{operation="${operation}"} ${value.totalDurationMs}`);
|
|
1040
|
+
lines.push(`${prefix}_operation_rows_total{operation="${operation}"} ${value.rows}`);
|
|
1041
|
+
}
|
|
1042
|
+
return lines.join("\n");
|
|
1043
|
+
}
|
|
1044
|
+
};
|
|
1045
|
+
//#endregion
|
|
1046
|
+
//#region src/version.ts
|
|
1047
|
+
const KADAK_API_VERSION = "1.0.0";
|
|
513
1048
|
//#endregion
|
|
514
|
-
export { Column, KadakError, Table, TableClient, camelToSnake, connect, kadak, table, wrapPgError };
|
|
1049
|
+
export { Column, KADAK_API_VERSION, KadakError, KadakMetrics, Table, TableClient, camelToSnake, connect, createMigration, kadak, runMigrations, table, wrapPgError };
|
|
515
1050
|
|
|
516
1051
|
//# sourceMappingURL=index.mjs.map
|