tina4-nodejs 3.10.90 → 3.10.92
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/package.json +1 -1
- package/packages/core/src/ai.ts +1 -1
- package/packages/core/src/api.ts +6 -6
- package/packages/core/src/auth.ts +28 -15
- package/packages/core/src/cache.ts +9 -0
- package/packages/core/src/devAdmin.ts +85 -7
- package/packages/core/src/devMailbox.ts +21 -21
- package/packages/core/src/fakeData.ts +24 -14
- package/packages/core/src/graphql.ts +37 -1
- package/packages/core/src/i18n.ts +1 -1
- package/packages/core/src/index.ts +6 -6
- package/packages/core/src/mcp.ts +3 -0
- package/packages/core/src/messenger.ts +52 -4
- package/packages/core/src/middleware.ts +61 -0
- package/packages/core/src/queue.ts +103 -30
- package/packages/core/src/queueBackends/liteBackend.ts +43 -0
- package/packages/core/src/rateLimiter.ts +88 -1
- package/packages/core/src/request.ts +24 -1
- package/packages/core/src/response.ts +54 -10
- package/packages/core/src/router.ts +32 -14
- package/packages/core/src/scss.ts +44 -2
- package/packages/core/src/server.ts +26 -4
- package/packages/core/src/service.ts +7 -0
- package/packages/core/src/session.ts +4 -4
- package/packages/core/src/testClient.ts +2 -2
- package/packages/core/src/testing.ts +6 -6
- package/packages/core/src/types.ts +8 -1
- package/packages/core/src/watcher.ts +66 -0
- package/packages/core/src/websocket.ts +24 -3
- package/packages/core/src/websocketConnection.ts +4 -0
- package/packages/core/src/wsdl.ts +12 -12
- package/packages/frond/src/engine.ts +6 -0
- package/packages/orm/src/adapters/firebird.ts +2 -2
- package/packages/orm/src/adapters/mssql.ts +2 -2
- package/packages/orm/src/adapters/mysql.ts +2 -2
- package/packages/orm/src/adapters/postgres.ts +2 -2
- package/packages/orm/src/adapters/sqlite.ts +3 -3
- package/packages/orm/src/autoCrud.ts +117 -74
- package/packages/orm/src/baseModel.ts +44 -7
- package/packages/orm/src/database.ts +58 -15
- package/packages/orm/src/databaseResult.ts +5 -0
- package/packages/orm/src/fakeData.ts +1 -11
- package/packages/orm/src/index.ts +1 -1
- package/packages/orm/src/migration.ts +78 -5
- package/packages/orm/src/queryBuilder.ts +2 -2
- package/packages/orm/src/sqlTranslation.ts +20 -3
- package/packages/orm/src/types.ts +2 -2
- package/packages/swagger/src/generator.ts +2 -2
- package/packages/swagger/src/index.ts +1 -1
|
@@ -4,6 +4,70 @@ import { getAdapter } from "./database.js";
|
|
|
4
4
|
import { buildQuery, parseQueryString } from "./query.js";
|
|
5
5
|
import { validate } from "./validation.js";
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Auto-CRUD — discovers ORM models and auto-generates REST endpoints.
|
|
9
|
+
*
|
|
10
|
+
* Generated endpoints per model:
|
|
11
|
+
* GET /api/{table} — list with pagination, filtering, sorting
|
|
12
|
+
* GET /api/{table}/{id} — get single record
|
|
13
|
+
* POST /api/{table} — create record
|
|
14
|
+
* PUT /api/{table}/{id} — update record
|
|
15
|
+
* DELETE /api/{table}/{id} — delete record
|
|
16
|
+
*/
|
|
17
|
+
export class AutoCrud {
|
|
18
|
+
private static registered: Map<string, DiscoveredModel> = new Map();
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Register a model for auto-CRUD.
|
|
22
|
+
*/
|
|
23
|
+
static register(model: DiscoveredModel, prefix: string = "/api"): void {
|
|
24
|
+
const tableName = model.definition.tableName;
|
|
25
|
+
if (!tableName) {
|
|
26
|
+
throw new Error(`AutoCrud: model has no tableName set.`);
|
|
27
|
+
}
|
|
28
|
+
AutoCrud.registered.set(tableName, model);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Discover models from the provided array and register them.
|
|
33
|
+
* (In Node.js, models are discovered by the server and passed in.)
|
|
34
|
+
*/
|
|
35
|
+
static discover(discoveredModels: DiscoveredModel[], prefix: string = "/api"): string[] {
|
|
36
|
+
const names: string[] = [];
|
|
37
|
+
for (const model of discoveredModels) {
|
|
38
|
+
AutoCrud.register(model, prefix);
|
|
39
|
+
names.push(model.definition.tableName);
|
|
40
|
+
}
|
|
41
|
+
return names;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Return all registered models.
|
|
46
|
+
*/
|
|
47
|
+
static models(): Map<string, DiscoveredModel> {
|
|
48
|
+
return new Map(AutoCrud.registered);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Clear all registered models (useful for testing).
|
|
53
|
+
*/
|
|
54
|
+
static clear(): void {
|
|
55
|
+
AutoCrud.registered.clear();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Generate route definitions for all registered models.
|
|
60
|
+
*/
|
|
61
|
+
static generateRoutes(): RouteDefinition[] {
|
|
62
|
+
const models = Array.from(AutoCrud.registered.values());
|
|
63
|
+
return generateCrudRoutes(models);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Generate CRUD route definitions for the given models.
|
|
69
|
+
* (Standalone function for backward compatibility.)
|
|
70
|
+
*/
|
|
7
71
|
export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[] {
|
|
8
72
|
const routes: RouteDefinition[] = [];
|
|
9
73
|
|
|
@@ -38,33 +102,28 @@ export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[]
|
|
|
38
102
|
},
|
|
39
103
|
handler: async (req: Tina4Request, res: Tina4Response) => {
|
|
40
104
|
const adapter = getAdapter();
|
|
41
|
-
const options = parseQueryString(req.query);
|
|
42
|
-
const { sql, countSql, params } = buildQuery(tableName, options, extraConditions);
|
|
43
105
|
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
const
|
|
106
|
+
// Parse query params for filtering / sorting / pagination
|
|
107
|
+
const qp = parseQueryString(req.query ?? {});
|
|
108
|
+
const { sql, countSql, params } = buildQuery(tableName, qp, extraConditions);
|
|
109
|
+
|
|
110
|
+
// params includes limit and offset at the end; countSql doesn't need them
|
|
111
|
+
const countParams = params.slice(0, -2);
|
|
112
|
+
const rows = adapter.query(sql, params);
|
|
47
113
|
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
const
|
|
114
|
+
const countRow = adapter.query(countSql, countParams);
|
|
115
|
+
const total = Number(countRow[0]?.total ?? 0);
|
|
116
|
+
const limit = qp.limit ?? 100;
|
|
117
|
+
const page = qp.page ?? 1;
|
|
52
118
|
|
|
53
119
|
res.json({
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
page,
|
|
62
|
-
per_page: limit,
|
|
63
|
-
perPage: limit,
|
|
64
|
-
totalPages,
|
|
65
|
-
total_pages: totalPages,
|
|
66
|
-
// Legacy nested meta (kept for any clients that use it)
|
|
67
|
-
meta: { total, page, limit, totalPages },
|
|
120
|
+
data: rows,
|
|
121
|
+
meta: {
|
|
122
|
+
total,
|
|
123
|
+
page,
|
|
124
|
+
limit,
|
|
125
|
+
totalPages: Math.ceil(total / limit),
|
|
126
|
+
},
|
|
68
127
|
});
|
|
69
128
|
},
|
|
70
129
|
});
|
|
@@ -79,16 +138,19 @@ export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[]
|
|
|
79
138
|
},
|
|
80
139
|
handler: async (req: Tina4Request, res: Tina4Response) => {
|
|
81
140
|
const adapter = getAdapter();
|
|
141
|
+
|
|
82
142
|
const conditions = [`"${pkColumn}" = ?`, ...extraConditions];
|
|
83
|
-
const
|
|
143
|
+
const rows = adapter.query(
|
|
84
144
|
`SELECT * FROM "${tableName}" WHERE ${conditions.join(" AND ")}`,
|
|
85
145
|
[req.params.id],
|
|
86
146
|
);
|
|
87
|
-
|
|
147
|
+
|
|
148
|
+
if (rows.length === 0) {
|
|
88
149
|
res.status(404).json({ error: "Not Found", statusCode: 404 });
|
|
89
150
|
return;
|
|
90
151
|
}
|
|
91
|
-
|
|
152
|
+
|
|
153
|
+
res.json({ data: rows[0] });
|
|
92
154
|
},
|
|
93
155
|
});
|
|
94
156
|
|
|
@@ -101,47 +163,39 @@ export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[]
|
|
|
101
163
|
tags: [tableName],
|
|
102
164
|
},
|
|
103
165
|
handler: async (req: Tina4Request, res: Tina4Response) => {
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
res.status(400).json({ error: "Request body is required", statusCode: 400 });
|
|
107
|
-
return;
|
|
108
|
-
}
|
|
166
|
+
const adapter = getAdapter();
|
|
167
|
+
const body = req.body as Record<string, unknown>;
|
|
109
168
|
|
|
110
|
-
|
|
169
|
+
// Validate against field definitions
|
|
170
|
+
const errors = validate(body, fields);
|
|
111
171
|
if (errors.length > 0) {
|
|
112
|
-
res.status(422).json({ error: "Validation failed",
|
|
172
|
+
res.status(422).json({ error: "Validation failed", errors });
|
|
113
173
|
return;
|
|
114
174
|
}
|
|
115
175
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const def = fields[key];
|
|
121
|
-
return def && !(def.primaryKey && def.autoIncrement);
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
// Add is_deleted = 0 for soft delete models
|
|
125
|
-
if (softDelete) {
|
|
126
|
-
insertFields.push(["is_deleted", 0]);
|
|
176
|
+
// Map JS property names to DB column names
|
|
177
|
+
const dbBody: Record<string, unknown> = {};
|
|
178
|
+
for (const [key, value] of Object.entries(body)) {
|
|
179
|
+
dbBody[getDbCol(key)] = value;
|
|
127
180
|
}
|
|
128
181
|
|
|
129
|
-
const columns =
|
|
130
|
-
const
|
|
131
|
-
const
|
|
182
|
+
const columns = Object.keys(dbBody);
|
|
183
|
+
const values = Object.values(dbBody);
|
|
184
|
+
const placeholders = columns.map(() => "?").join(", ");
|
|
132
185
|
|
|
133
|
-
|
|
134
|
-
`INSERT INTO "${tableName}" (${columns}) VALUES (${placeholders})`,
|
|
186
|
+
adapter.execute(
|
|
187
|
+
`INSERT INTO "${tableName}" (${columns.map((c) => `"${c}"`).join(", ")}) VALUES (${placeholders})`,
|
|
135
188
|
values,
|
|
136
|
-
)
|
|
189
|
+
);
|
|
137
190
|
|
|
138
|
-
|
|
191
|
+
// Fetch the created record to include auto-generated fields (e.g. id)
|
|
192
|
+
const lastId = adapter.lastInsertId();
|
|
139
193
|
const created = adapter.query(
|
|
140
194
|
`SELECT * FROM "${tableName}" WHERE "${pkColumn}" = ?`,
|
|
141
|
-
[
|
|
195
|
+
[lastId],
|
|
142
196
|
);
|
|
143
197
|
|
|
144
|
-
res.status(201).json({ data: created[0] });
|
|
198
|
+
res.status(201).json({ data: created[0] ?? { ...body, [pkField]: lastId } });
|
|
145
199
|
},
|
|
146
200
|
});
|
|
147
201
|
|
|
@@ -154,21 +208,9 @@ export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[]
|
|
|
154
208
|
tags: [tableName],
|
|
155
209
|
},
|
|
156
210
|
handler: async (req: Tina4Request, res: Tina4Response) => {
|
|
157
|
-
const body = req.body as Record<string, unknown> | undefined;
|
|
158
|
-
if (!body || typeof body !== "object") {
|
|
159
|
-
res.status(400).json({ error: "Request body is required", statusCode: 400 });
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const errors = validate(body, fields, true);
|
|
164
|
-
if (errors.length > 0) {
|
|
165
|
-
res.status(422).json({ error: "Validation failed", statusCode: 422, errors });
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
211
|
const adapter = getAdapter();
|
|
212
|
+
const body = req.body as Record<string, unknown>;
|
|
170
213
|
|
|
171
|
-
// Check exists (respect soft delete)
|
|
172
214
|
const conditions = [`"${pkColumn}" = ?`, ...extraConditions];
|
|
173
215
|
const existing = adapter.query(
|
|
174
216
|
`SELECT * FROM "${tableName}" WHERE ${conditions.join(" AND ")}`,
|
|
@@ -179,18 +221,19 @@ export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[]
|
|
|
179
221
|
return;
|
|
180
222
|
}
|
|
181
223
|
|
|
182
|
-
//
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
return;
|
|
224
|
+
// Map JS property names to DB column names
|
|
225
|
+
const dbBody: Record<string, unknown> = {};
|
|
226
|
+
for (const [key, value] of Object.entries(body)) {
|
|
227
|
+
dbBody[getDbCol(key)] = value;
|
|
187
228
|
}
|
|
188
229
|
|
|
189
|
-
const
|
|
190
|
-
|
|
230
|
+
const setClauses = Object.keys(dbBody)
|
|
231
|
+
.map((col) => `"${col}" = ?`)
|
|
232
|
+
.join(", ");
|
|
233
|
+
const values = [...Object.values(dbBody), req.params.id];
|
|
191
234
|
|
|
192
235
|
adapter.execute(
|
|
193
|
-
`UPDATE "${tableName}" SET ${
|
|
236
|
+
`UPDATE "${tableName}" SET ${setClauses} WHERE "${pkColumn}" = ?`,
|
|
194
237
|
values,
|
|
195
238
|
);
|
|
196
239
|
|
|
@@ -201,7 +201,7 @@ export class BaseModel {
|
|
|
201
201
|
* @returns A QueryBuilder instance bound to this model's table and database.
|
|
202
202
|
*/
|
|
203
203
|
static query(): QueryBuilder {
|
|
204
|
-
return QueryBuilder.
|
|
204
|
+
return QueryBuilder.fromTable(this.tableName, this.getDb());
|
|
205
205
|
}
|
|
206
206
|
|
|
207
207
|
/**
|
|
@@ -285,7 +285,7 @@ export class BaseModel {
|
|
|
285
285
|
*/
|
|
286
286
|
static create<T extends BaseModel>(
|
|
287
287
|
this: new (data?: Record<string, unknown>) => T,
|
|
288
|
-
data: Record<string, unknown
|
|
288
|
+
data: Record<string, unknown> = {},
|
|
289
289
|
): T {
|
|
290
290
|
const instance = new this(data) as T;
|
|
291
291
|
instance.save();
|
|
@@ -403,6 +403,7 @@ export class BaseModel {
|
|
|
403
403
|
where?: string,
|
|
404
404
|
params?: unknown[],
|
|
405
405
|
include?: string[],
|
|
406
|
+
orderBy?: string,
|
|
406
407
|
): T[] {
|
|
407
408
|
const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
|
|
408
409
|
const db = ModelClass.getDb();
|
|
@@ -419,7 +420,8 @@ export class BaseModel {
|
|
|
419
420
|
}
|
|
420
421
|
|
|
421
422
|
const whereClause = conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : "";
|
|
422
|
-
const
|
|
423
|
+
const orderClause = orderBy ? ` ORDER BY ${orderBy}` : "";
|
|
424
|
+
const sql = `SELECT * FROM "${ModelClass.tableName}"${whereClause}${orderClause}`;
|
|
423
425
|
|
|
424
426
|
const rows = db.query(sql, params);
|
|
425
427
|
const instances = rows.map((row) => new ModelClass(row as Record<string, unknown>) as T);
|
|
@@ -755,28 +757,46 @@ export class BaseModel {
|
|
|
755
757
|
/**
|
|
756
758
|
* Return true if a record with the given primary key exists.
|
|
757
759
|
*/
|
|
758
|
-
static exists(
|
|
760
|
+
static exists(pkValue: unknown): boolean {
|
|
759
761
|
const ModelClass = this as unknown as typeof BaseModel;
|
|
760
|
-
return ModelClass.findById(
|
|
762
|
+
return ModelClass.findById(pkValue) !== null;
|
|
761
763
|
}
|
|
762
764
|
|
|
763
765
|
/**
|
|
764
766
|
* Run a raw SQL query with results cached by TTL. Cache is per-model-class.
|
|
767
|
+
*
|
|
768
|
+
* @param sql SQL query string.
|
|
769
|
+
* @param params Bind parameters.
|
|
770
|
+
* @param ttl Cache TTL in seconds (default 60).
|
|
771
|
+
* @param limit Max records to return (default 20).
|
|
772
|
+
* @param offset Records to skip (default 0).
|
|
773
|
+
* @param include Relationship names to eager-load on cache miss.
|
|
765
774
|
*/
|
|
766
775
|
static cached<T extends BaseModel>(
|
|
767
776
|
this: new (data?: Record<string, unknown>) => T,
|
|
768
777
|
sql: string,
|
|
769
778
|
params?: unknown[],
|
|
770
779
|
ttl = 60,
|
|
780
|
+
limit = 20,
|
|
781
|
+
offset = 0,
|
|
782
|
+
include?: string[],
|
|
771
783
|
): T[] {
|
|
772
784
|
const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
|
|
773
785
|
if (!ModelClass._queryCache) {
|
|
774
786
|
ModelClass._queryCache = new QueryCache({ defaultTtl: ttl, maxSize: 500 });
|
|
775
787
|
}
|
|
776
|
-
const
|
|
788
|
+
const cacheKey = `${ModelClass.tableName}:${sql}:${limit}:${offset}`;
|
|
789
|
+
const key = QueryCache.queryKey(cacheKey, params ?? []);
|
|
777
790
|
const hit = ModelClass._queryCache.get(key) as T[] | undefined;
|
|
778
791
|
if (hit !== undefined) return hit;
|
|
779
|
-
|
|
792
|
+
|
|
793
|
+
const db = ModelClass.getDb();
|
|
794
|
+
const querySql = `${sql} LIMIT ${limit} OFFSET ${offset}`;
|
|
795
|
+
const rows = db.query(querySql, params);
|
|
796
|
+
const results = rows.map((row) => new ModelClass(row as Record<string, unknown>) as T);
|
|
797
|
+
if (include && results.length > 0) {
|
|
798
|
+
ModelClass._eagerLoad(results as BaseModel[], include);
|
|
799
|
+
}
|
|
780
800
|
ModelClass._queryCache.set(key, results, ttl);
|
|
781
801
|
return results;
|
|
782
802
|
}
|
|
@@ -1260,6 +1280,23 @@ export class BaseModel {
|
|
|
1260
1280
|
}
|
|
1261
1281
|
}
|
|
1262
1282
|
|
|
1283
|
+
/**
|
|
1284
|
+
* Public alias for _eagerLoad. Eagerly loads relationships for a list of instances,
|
|
1285
|
+
* preventing N+1 queries.
|
|
1286
|
+
*
|
|
1287
|
+
* Usage:
|
|
1288
|
+
* const users = User.all();
|
|
1289
|
+
* await User.eagerLoad(users, ["posts", "profile"]);
|
|
1290
|
+
*
|
|
1291
|
+
* @param instances Array of model instances to load relationships onto.
|
|
1292
|
+
* @param includeList Array of relationship names (supports dot notation for nesting).
|
|
1293
|
+
*/
|
|
1294
|
+
static eagerLoad(instances: BaseModel[], includeList: string[]): Promise<void> {
|
|
1295
|
+
const ModelClass = this as unknown as typeof BaseModel;
|
|
1296
|
+
ModelClass._eagerLoad(instances, includeList);
|
|
1297
|
+
return Promise.resolve();
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1263
1300
|
/**
|
|
1264
1301
|
* Clear the relationship cache.
|
|
1265
1302
|
*/
|
|
@@ -95,7 +95,12 @@ export function parseDatabaseUrl(url: string, username?: string, password?: stri
|
|
|
95
95
|
// Handle sqlite:// separately because URL class mangles the path
|
|
96
96
|
if (url.startsWith("sqlite:///")) {
|
|
97
97
|
// sqlite:///absolute/path — three slashes means absolute
|
|
98
|
-
|
|
98
|
+
let path = url.slice("sqlite://".length);
|
|
99
|
+
// Windows: sqlite:///C:/Users/app.db → /C:/Users/app.db after slicing.
|
|
100
|
+
// The leading / before the drive letter must be removed.
|
|
101
|
+
if (/^\/[A-Za-z]:/.test(path)) {
|
|
102
|
+
path = path.slice(1);
|
|
103
|
+
}
|
|
99
104
|
result = { type: "sqlite", path };
|
|
100
105
|
} else if (url.startsWith("sqlite://")) {
|
|
101
106
|
// sqlite://./relative or sqlite://relative
|
|
@@ -223,7 +228,7 @@ export class Database {
|
|
|
223
228
|
private pool: (DatabaseAdapter | null)[] = [];
|
|
224
229
|
|
|
225
230
|
/** Pool size (0 = single connection) */
|
|
226
|
-
private
|
|
231
|
+
private _poolSize: number = 0;
|
|
227
232
|
|
|
228
233
|
/** Round-robin index */
|
|
229
234
|
private poolIndex: number = 0;
|
|
@@ -270,7 +275,7 @@ export class Database {
|
|
|
270
275
|
setAdapter(adapters[0]);
|
|
271
276
|
|
|
272
277
|
const db = new Database(adapters[0]);
|
|
273
|
-
db.
|
|
278
|
+
db._poolSize = pool;
|
|
274
279
|
db.pool = adapters;
|
|
275
280
|
db.poolIndex = 0;
|
|
276
281
|
db.adapter = null; // Don't use single-adapter path
|
|
@@ -304,9 +309,9 @@ export class Database {
|
|
|
304
309
|
* Get the next adapter — from pool (round-robin) or single connection.
|
|
305
310
|
*/
|
|
306
311
|
private getNextAdapter(): DatabaseAdapter {
|
|
307
|
-
if (this.
|
|
312
|
+
if (this._poolSize > 0) {
|
|
308
313
|
const idx = this.poolIndex;
|
|
309
|
-
this.poolIndex = (this.poolIndex + 1) % this.
|
|
314
|
+
this.poolIndex = (this.poolIndex + 1) % this._poolSize;
|
|
310
315
|
return this.pool[idx] as DatabaseAdapter;
|
|
311
316
|
}
|
|
312
317
|
|
|
@@ -319,16 +324,46 @@ export class Database {
|
|
|
319
324
|
}
|
|
320
325
|
|
|
321
326
|
/** Get the pool size (0 = single connection mode). */
|
|
322
|
-
|
|
323
|
-
return this.
|
|
327
|
+
poolSize(): number {
|
|
328
|
+
return this._poolSize;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Alias for poolSize() — returns total pool size (0 = single connection mode). */
|
|
332
|
+
size(): number {
|
|
333
|
+
return this._poolSize;
|
|
324
334
|
}
|
|
325
335
|
|
|
326
336
|
/** Get the number of active (created) connections in the pool. */
|
|
327
|
-
|
|
328
|
-
if (this.
|
|
337
|
+
activeCount(): number {
|
|
338
|
+
if (this._poolSize === 0) return this.adapter ? 1 : 0;
|
|
329
339
|
return this.pool.filter(a => a !== null).length;
|
|
330
340
|
}
|
|
331
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Borrow a connection from the pool (or the single adapter).
|
|
344
|
+
* The caller is responsible for returning it via checkin().
|
|
345
|
+
*/
|
|
346
|
+
checkout(): DatabaseAdapter {
|
|
347
|
+
return this.getNextAdapter();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Return a borrowed connection to the pool.
|
|
352
|
+
* For round-robin pools this is a no-op (connections stay in the pool array),
|
|
353
|
+
* but the method exists for API parity and future pooling strategies.
|
|
354
|
+
*/
|
|
355
|
+
checkin(_adapter: DatabaseAdapter): void {
|
|
356
|
+
// No-op for round-robin pool — connections are not removed on checkout.
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Close all pooled connections and clear the pool.
|
|
361
|
+
* Equivalent to close() but named for explicit pool teardown.
|
|
362
|
+
*/
|
|
363
|
+
closeAll(): void {
|
|
364
|
+
this.close();
|
|
365
|
+
}
|
|
366
|
+
|
|
332
367
|
/** Query rows with optional pagination. Returns a DatabaseResult wrapper. */
|
|
333
368
|
fetch(sql: string, params?: unknown[], limit?: number, offset?: number): DatabaseResult {
|
|
334
369
|
const adapter = this.getNextAdapter();
|
|
@@ -376,9 +411,9 @@ export class Database {
|
|
|
376
411
|
}
|
|
377
412
|
|
|
378
413
|
/** Update rows in a table matching filter. */
|
|
379
|
-
update(table: string, data: Record<string, unknown>, filter?: Record<string, unknown
|
|
414
|
+
update(table: string, data: Record<string, unknown>, filter?: Record<string, unknown>, params?: unknown[]): DatabaseWriteResult {
|
|
380
415
|
const adapter = this.getNextAdapter();
|
|
381
|
-
const result = adapter.update(table, data, filter ?? {});
|
|
416
|
+
const result = adapter.update(table, data, filter ?? {}, params);
|
|
382
417
|
if (this.autoCommit) {
|
|
383
418
|
try { adapter.commit(); } catch { /* no active transaction */ }
|
|
384
419
|
}
|
|
@@ -386,9 +421,9 @@ export class Database {
|
|
|
386
421
|
}
|
|
387
422
|
|
|
388
423
|
/** Delete rows from a table matching filter. */
|
|
389
|
-
delete(table: string, filter?: Record<string, unknown
|
|
424
|
+
delete(table: string, filter?: Record<string, unknown>, params?: unknown[]): DatabaseWriteResult {
|
|
390
425
|
const adapter = this.getNextAdapter();
|
|
391
|
-
const result = adapter.delete(table, filter ?? {});
|
|
426
|
+
const result = adapter.delete(table, filter ?? {}, params);
|
|
392
427
|
if (this.autoCommit) {
|
|
393
428
|
try { adapter.commit(); } catch { /* no active transaction */ }
|
|
394
429
|
}
|
|
@@ -397,7 +432,7 @@ export class Database {
|
|
|
397
432
|
|
|
398
433
|
/** Close all database connections (pool or single). */
|
|
399
434
|
close(): void {
|
|
400
|
-
if (this.
|
|
435
|
+
if (this._poolSize > 0) {
|
|
401
436
|
for (let i = 0; i < this.pool.length; i++) {
|
|
402
437
|
if (this.pool[i] !== null) {
|
|
403
438
|
this.pool[i]!.close();
|
|
@@ -454,7 +489,7 @@ export class Database {
|
|
|
454
489
|
* @param paramSets - Array of parameter arrays, one per execution.
|
|
455
490
|
* @returns Array of results from each execution.
|
|
456
491
|
*/
|
|
457
|
-
executeMany(sql: string, paramSets: unknown[][]): unknown[] {
|
|
492
|
+
executeMany(sql: string, paramSets: unknown[][] = []): unknown[] {
|
|
458
493
|
const adapter = this.getNextAdapter();
|
|
459
494
|
const results: unknown[] = [];
|
|
460
495
|
|
|
@@ -486,6 +521,14 @@ export class Database {
|
|
|
486
521
|
};
|
|
487
522
|
}
|
|
488
523
|
|
|
524
|
+
/** Clear the query result cache. */
|
|
525
|
+
cacheClear(): void {
|
|
526
|
+
// Node database layer does not maintain an internal query cache at this
|
|
527
|
+
// level (caching lives in the SQLTranslation layer). This method exists
|
|
528
|
+
// for API parity with PHP, Python, and Ruby.
|
|
529
|
+
// To clear the SQLTranslation query cache use: QueryCache.clear()
|
|
530
|
+
}
|
|
531
|
+
|
|
489
532
|
/** Get the last auto-increment id. */
|
|
490
533
|
getLastId(): string | number {
|
|
491
534
|
const id = this.getNextAdapter().lastInsertId();
|
|
@@ -122,6 +122,11 @@ export class DatabaseResult implements Iterable<Record<string, unknown>> {
|
|
|
122
122
|
return this.records[Symbol.iterator]();
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/** Total count — cross-framework parity with Python/Ruby. */
|
|
126
|
+
size(): number {
|
|
127
|
+
return this.count;
|
|
128
|
+
}
|
|
129
|
+
|
|
125
130
|
/** Number of records in this page. */
|
|
126
131
|
get length(): number {
|
|
127
132
|
return this.records.length;
|
|
@@ -14,16 +14,6 @@ export class FakeData extends CoreFakeData {
|
|
|
14
14
|
super(seed);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
/** Alias for fullName() to match the Python API. */
|
|
18
|
-
name(): string {
|
|
19
|
-
return this.fullName();
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Alias for float() to match the Python API. */
|
|
23
|
-
numeric(min = 0, max = 1000, decimals = 2): number {
|
|
24
|
-
return this.float(min, max, decimals);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
17
|
/**
|
|
28
18
|
* Generate a Date object within a year range.
|
|
29
19
|
* Matches the Python API's datetime() method.
|
|
@@ -69,7 +59,7 @@ export class FakeData extends CoreFakeData {
|
|
|
69
59
|
if (col.includes("company") || col.includes("org")) return this.company();
|
|
70
60
|
if (col.includes("job") || col.includes("title") || col.includes("position")) return this.jobTitle();
|
|
71
61
|
if (col.includes("url") || col.includes("website") || col.includes("link")) return this.url();
|
|
72
|
-
if (col.includes("color") || col.includes("colour")) return this.
|
|
62
|
+
if (col.includes("color") || col.includes("colour")) return this.colorHex();
|
|
73
63
|
if (col.includes("uuid") || col === "guid") return this.uuid();
|
|
74
64
|
if (col === "ip" || col === "ip_address" || col === "ipaddress") return this.ipAddress();
|
|
75
65
|
if (col.includes("currency")) return this.currency();
|
|
@@ -35,7 +35,7 @@ export {
|
|
|
35
35
|
Migration,
|
|
36
36
|
} from "./migration.js";
|
|
37
37
|
export type { MigrationResult, MigrationStatus } from "./migration.js";
|
|
38
|
-
export { generateCrudRoutes } from "./autoCrud.js";
|
|
38
|
+
export { AutoCrud, generateCrudRoutes } from "./autoCrud.js";
|
|
39
39
|
export { buildQuery, parseQueryString } from "./query.js";
|
|
40
40
|
export { validate } from "./validation.js";
|
|
41
41
|
export type { ValidationError } from "./validation.js";
|
|
@@ -191,7 +191,7 @@ export function isMigrationApplied(name: string): boolean {
|
|
|
191
191
|
/**
|
|
192
192
|
* Record a migration as applied.
|
|
193
193
|
*/
|
|
194
|
-
export function recordMigration(name: string, batch: number): void {
|
|
194
|
+
export function recordMigration(name: string, batch: number, passed: number = 1): void {
|
|
195
195
|
const adapter = getAdapter();
|
|
196
196
|
if (isFirebirdAdapter(adapter)) {
|
|
197
197
|
// Firebird: generate ID from sequence
|
|
@@ -706,8 +706,11 @@ export async function status(
|
|
|
706
706
|
*/
|
|
707
707
|
export async function createMigration(
|
|
708
708
|
description: string,
|
|
709
|
-
options?: { migrationsDir?: string },
|
|
710
|
-
): Promise<{ upPath: string; downPath: string }> {
|
|
709
|
+
options?: { migrationsDir?: string; kind?: "sql" | "class" },
|
|
710
|
+
): Promise<string | { upPath: string; downPath: string }> {
|
|
711
|
+
if (options?.kind === "class") {
|
|
712
|
+
return createClassMigration(description, options);
|
|
713
|
+
}
|
|
711
714
|
const dir = resolve(options?.migrationsDir ?? "migrations");
|
|
712
715
|
|
|
713
716
|
// Ensure directory exists
|
|
@@ -746,6 +749,66 @@ export async function createMigration(
|
|
|
746
749
|
return { upPath, downPath };
|
|
747
750
|
}
|
|
748
751
|
|
|
752
|
+
/**
|
|
753
|
+
* Create a new TypeScript class-based migration file with a timestamp prefix.
|
|
754
|
+
*
|
|
755
|
+
* @param description - Human-readable description (used in filename and class name).
|
|
756
|
+
* @param options - Optional configuration.
|
|
757
|
+
* @returns Path to the created file.
|
|
758
|
+
*/
|
|
759
|
+
export async function createClassMigration(
|
|
760
|
+
description: string,
|
|
761
|
+
options?: { migrationsDir?: string },
|
|
762
|
+
): Promise<string> {
|
|
763
|
+
const dir = resolve(options?.migrationsDir ?? "migrations");
|
|
764
|
+
|
|
765
|
+
if (!existsSync(dir)) {
|
|
766
|
+
mkdirSync(dir, { recursive: true });
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const safeName = description
|
|
770
|
+
.toLowerCase()
|
|
771
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
772
|
+
.replace(/^_|_$/g, "");
|
|
773
|
+
|
|
774
|
+
// Derive PascalCase class name
|
|
775
|
+
const className = description
|
|
776
|
+
.replace(/[^a-zA-Z0-9 ]+/g, " ")
|
|
777
|
+
.trim()
|
|
778
|
+
.split(/\s+/)
|
|
779
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
|
780
|
+
.join("");
|
|
781
|
+
|
|
782
|
+
const now = new Date();
|
|
783
|
+
const timestamp = [
|
|
784
|
+
now.getFullYear(),
|
|
785
|
+
String(now.getMonth() + 1).padStart(2, "0"),
|
|
786
|
+
String(now.getDate()).padStart(2, "0"),
|
|
787
|
+
String(now.getHours()).padStart(2, "0"),
|
|
788
|
+
String(now.getMinutes()).padStart(2, "0"),
|
|
789
|
+
String(now.getSeconds()).padStart(2, "0"),
|
|
790
|
+
].join("");
|
|
791
|
+
|
|
792
|
+
const fileName = `${timestamp}_${safeName}.ts`;
|
|
793
|
+
const filePath = join(dir, fileName);
|
|
794
|
+
|
|
795
|
+
const content =
|
|
796
|
+
`// Migration: ${description}\n` +
|
|
797
|
+
`// Created: ${now.toISOString()}\n\n` +
|
|
798
|
+
`import type { DatabaseAdapter } from "@tina4/orm";\n\n` +
|
|
799
|
+
`export class ${className} {\n` +
|
|
800
|
+
` async up(db: DatabaseAdapter): Promise<void> {\n` +
|
|
801
|
+
` // db.execute("CREATE TABLE ...");\n` +
|
|
802
|
+
` }\n\n` +
|
|
803
|
+
` async down(db: DatabaseAdapter): Promise<void> {\n` +
|
|
804
|
+
` // db.execute("DROP TABLE IF EXISTS ...");\n` +
|
|
805
|
+
` }\n` +
|
|
806
|
+
`}\n`;
|
|
807
|
+
|
|
808
|
+
writeFileSync(filePath, content, "utf-8");
|
|
809
|
+
return filePath;
|
|
810
|
+
}
|
|
811
|
+
|
|
749
812
|
/**
|
|
750
813
|
* Object-oriented Migration class — canonical Tina4 Migration API.
|
|
751
814
|
*
|
|
@@ -800,8 +863,18 @@ export class Migration {
|
|
|
800
863
|
return status(this.db, { migrationsDir: this.dir });
|
|
801
864
|
}
|
|
802
865
|
|
|
803
|
-
/**
|
|
804
|
-
|
|
866
|
+
/**
|
|
867
|
+
* Scaffold a new migration file.
|
|
868
|
+
*
|
|
869
|
+
* kind="sql" — creates {timestamp}_{description}.sql + .down.sql (default)
|
|
870
|
+
* kind="class" — creates {timestamp}_{description}.ts with a TypeScript class template
|
|
871
|
+
*
|
|
872
|
+
* Returns the path to the created up file (or class file).
|
|
873
|
+
*/
|
|
874
|
+
async create(description: string, kind: "sql" | "class" = "sql"): Promise<string | { upPath: string; downPath: string }> {
|
|
875
|
+
if (kind === "class") {
|
|
876
|
+
return createClassMigration(description, { migrationsDir: this.dir });
|
|
877
|
+
}
|
|
805
878
|
return createMigration(description, { migrationsDir: this.dir });
|
|
806
879
|
}
|
|
807
880
|
|