tina4-nodejs 3.0.0-rc.2
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/BENCHMARK_REPORT.md +96 -0
- package/CARBONAH.md +140 -0
- package/CLAUDE.md +599 -0
- package/COMPARISON.md +194 -0
- package/README.md +595 -0
- package/package.json +59 -0
- package/packages/cli/src/bin.ts +110 -0
- package/packages/cli/src/commands/init.ts +194 -0
- package/packages/cli/src/commands/migrate.ts +96 -0
- package/packages/cli/src/commands/migrateCreate.ts +59 -0
- package/packages/cli/src/commands/routes.ts +61 -0
- package/packages/cli/src/commands/serve.ts +58 -0
- package/packages/cli/src/commands/test.ts +83 -0
- package/packages/core/gallery/auth/meta.json +1 -0
- package/packages/core/gallery/auth/src/routes/api/gallery/auth/login/post.ts +22 -0
- package/packages/core/gallery/auth/src/routes/api/gallery/auth/verify/get.ts +16 -0
- package/packages/core/gallery/auth/src/routes/gallery/auth/get.ts +97 -0
- package/packages/core/gallery/database/meta.json +1 -0
- package/packages/core/gallery/database/src/routes/api/gallery/db/notes/get.ts +13 -0
- package/packages/core/gallery/database/src/routes/api/gallery/db/notes/post.ts +17 -0
- package/packages/core/gallery/database/src/routes/api/gallery/db/tables/get.ts +23 -0
- package/packages/core/gallery/error-overlay/meta.json +1 -0
- package/packages/core/gallery/error-overlay/src/routes/api/gallery/crash/get.ts +17 -0
- package/packages/core/gallery/orm/meta.json +1 -0
- package/packages/core/gallery/orm/src/routes/api/gallery/products/get.ts +12 -0
- package/packages/core/gallery/orm/src/routes/api/gallery/products/post.ts +7 -0
- package/packages/core/gallery/queue/meta.json +1 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/produce/post.ts +16 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/status/get.ts +10 -0
- package/packages/core/gallery/rest-api/meta.json +1 -0
- package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/get.ts +6 -0
- package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/post.ts +7 -0
- package/packages/core/gallery/templates/meta.json +1 -0
- package/packages/core/gallery/templates/src/routes/gallery/page/get.ts +15 -0
- package/packages/core/gallery/templates/src/templates/gallery_page.twig +257 -0
- package/packages/core/public/css/tina4.css +2463 -0
- package/packages/core/public/css/tina4.min.css +1 -0
- package/packages/core/public/favicon.ico +0 -0
- package/packages/core/public/images/logo.svg +5 -0
- package/packages/core/public/images/tina4-logo-icon.webp +0 -0
- package/packages/core/public/js/frond.min.js +420 -0
- package/packages/core/public/js/tina4-dev-admin.min.js +327 -0
- package/packages/core/public/js/tina4.min.js +93 -0
- package/packages/core/public/swagger/index.html +90 -0
- package/packages/core/public/swagger/oauth2-redirect.html +63 -0
- package/packages/core/src/ai.ts +359 -0
- package/packages/core/src/api.ts +248 -0
- package/packages/core/src/auth.ts +287 -0
- package/packages/core/src/cache.ts +121 -0
- package/packages/core/src/constants.ts +48 -0
- package/packages/core/src/container.ts +90 -0
- package/packages/core/src/devAdmin.ts +2024 -0
- package/packages/core/src/devMailbox.ts +316 -0
- package/packages/core/src/dotenv.ts +172 -0
- package/packages/core/src/errorOverlay.test.ts +122 -0
- package/packages/core/src/errorOverlay.ts +278 -0
- package/packages/core/src/events.ts +112 -0
- package/packages/core/src/fakeData.ts +309 -0
- package/packages/core/src/graphql.ts +812 -0
- package/packages/core/src/health.ts +31 -0
- package/packages/core/src/htmlElement.ts +172 -0
- package/packages/core/src/i18n.ts +136 -0
- package/packages/core/src/index.ts +88 -0
- package/packages/core/src/logger.ts +226 -0
- package/packages/core/src/messenger.ts +822 -0
- package/packages/core/src/middleware.ts +138 -0
- package/packages/core/src/queue.ts +481 -0
- package/packages/core/src/queueBackends/kafkaBackend.ts +348 -0
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +479 -0
- package/packages/core/src/rateLimiter.ts +107 -0
- package/packages/core/src/request.ts +189 -0
- package/packages/core/src/response.ts +146 -0
- package/packages/core/src/routeDiscovery.ts +87 -0
- package/packages/core/src/router.ts +398 -0
- package/packages/core/src/scss.ts +366 -0
- package/packages/core/src/server.ts +610 -0
- package/packages/core/src/service.ts +380 -0
- package/packages/core/src/session.ts +480 -0
- package/packages/core/src/sessionHandlers/mongoHandler.ts +286 -0
- package/packages/core/src/sessionHandlers/valkeyHandler.ts +184 -0
- package/packages/core/src/static.ts +58 -0
- package/packages/core/src/testing.ts +233 -0
- package/packages/core/src/types.ts +98 -0
- package/packages/core/src/watcher.ts +37 -0
- package/packages/core/src/websocket.ts +408 -0
- package/packages/core/src/wsdl.ts +546 -0
- package/packages/core/templates/errors/302.twig +14 -0
- package/packages/core/templates/errors/401.twig +9 -0
- package/packages/core/templates/errors/403.twig +29 -0
- package/packages/core/templates/errors/404.twig +29 -0
- package/packages/core/templates/errors/500.twig +38 -0
- package/packages/core/templates/errors/502.twig +9 -0
- package/packages/core/templates/errors/503.twig +12 -0
- package/packages/core/templates/errors/base.twig +37 -0
- package/packages/frond/src/engine.ts +1475 -0
- package/packages/frond/src/index.ts +2 -0
- package/packages/orm/src/adapters/firebird.ts +455 -0
- package/packages/orm/src/adapters/mssql.ts +440 -0
- package/packages/orm/src/adapters/mysql.ts +355 -0
- package/packages/orm/src/adapters/postgres.ts +362 -0
- package/packages/orm/src/adapters/sqlite.ts +270 -0
- package/packages/orm/src/autoCrud.ts +231 -0
- package/packages/orm/src/baseModel.ts +536 -0
- package/packages/orm/src/database.ts +321 -0
- package/packages/orm/src/fakeData.ts +118 -0
- package/packages/orm/src/index.ts +49 -0
- package/packages/orm/src/migration.ts +392 -0
- package/packages/orm/src/model.ts +56 -0
- package/packages/orm/src/query.ts +113 -0
- package/packages/orm/src/seeder.ts +120 -0
- package/packages/orm/src/sqlTranslation.ts +272 -0
- package/packages/orm/src/types.ts +110 -0
- package/packages/orm/src/validation.ts +93 -0
- package/packages/swagger/src/generator.ts +189 -0
- package/packages/swagger/src/index.ts +2 -0
- package/packages/swagger/src/ui.ts +48 -0
- package/skills/tina4-developer.skill +0 -0
- package/skills/tina4-js.skill +0 -0
- package/skills/tina4-maintainer.skill +0 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import type { FieldDefinition, DatabaseAdapter } from "./types.js";
|
|
4
|
+
import type { SQLiteAdapter } from "./adapters/sqlite.js";
|
|
5
|
+
import type { DiscoveredModel } from "./model.js";
|
|
6
|
+
import { getAdapter } from "./database.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Sync model definitions to the database (create tables, add columns).
|
|
10
|
+
*/
|
|
11
|
+
export function syncModels(models: DiscoveredModel[]): void {
|
|
12
|
+
const adapter = getAdapter() as SQLiteAdapter;
|
|
13
|
+
|
|
14
|
+
for (const { definition } of models) {
|
|
15
|
+
const { tableName, fields, softDelete } = definition;
|
|
16
|
+
|
|
17
|
+
// If softDelete is enabled, ensure is_deleted field exists
|
|
18
|
+
const allFields = { ...fields };
|
|
19
|
+
if (softDelete && !allFields.is_deleted) {
|
|
20
|
+
allFields.is_deleted = {
|
|
21
|
+
type: "integer",
|
|
22
|
+
default: 0,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (!adapter.tableExists(tableName)) {
|
|
27
|
+
adapter.createTable(tableName, allFields);
|
|
28
|
+
console.log(` Created table: ${tableName}`);
|
|
29
|
+
} else {
|
|
30
|
+
// Check for new columns
|
|
31
|
+
const existing = adapter.getTableColumns(tableName);
|
|
32
|
+
const existingNames = new Set(existing.map((c) => c.name));
|
|
33
|
+
|
|
34
|
+
for (const [colName, def] of Object.entries(allFields)) {
|
|
35
|
+
if (!existingNames.has(colName)) {
|
|
36
|
+
adapter.addColumn(tableName, colName, def);
|
|
37
|
+
console.log(` Added column: ${tableName}.${colName}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Migration tracking table name.
|
|
46
|
+
*/
|
|
47
|
+
const MIGRATION_TABLE = "tina4_migration";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Ensure the migration tracking table exists.
|
|
51
|
+
*/
|
|
52
|
+
export function ensureMigrationTable(): void {
|
|
53
|
+
const adapter = getAdapter() as SQLiteAdapter;
|
|
54
|
+
if (!adapter.tableExists(MIGRATION_TABLE)) {
|
|
55
|
+
adapter.createTable(MIGRATION_TABLE, {
|
|
56
|
+
id: { type: "integer", primaryKey: true, autoIncrement: true },
|
|
57
|
+
name: { type: "string", required: true },
|
|
58
|
+
batch: { type: "integer", required: true },
|
|
59
|
+
applied_at: { type: "datetime", default: "now" },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get the current batch number (max batch + 1).
|
|
66
|
+
*/
|
|
67
|
+
export function getNextBatch(): number {
|
|
68
|
+
const adapter = getAdapter();
|
|
69
|
+
const rows = adapter.query<{ max_batch: number | null }>(
|
|
70
|
+
`SELECT MAX(batch) as max_batch FROM "${MIGRATION_TABLE}"`,
|
|
71
|
+
);
|
|
72
|
+
return (rows[0]?.max_batch ?? 0) + 1;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Check if a migration has already been applied.
|
|
77
|
+
*/
|
|
78
|
+
export function isMigrationApplied(name: string): boolean {
|
|
79
|
+
const adapter = getAdapter();
|
|
80
|
+
const rows = adapter.query(
|
|
81
|
+
`SELECT id FROM "${MIGRATION_TABLE}" WHERE name = ?`,
|
|
82
|
+
[name],
|
|
83
|
+
);
|
|
84
|
+
return rows.length > 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Record a migration as applied.
|
|
89
|
+
*/
|
|
90
|
+
export function recordMigration(name: string, batch: number): void {
|
|
91
|
+
const adapter = getAdapter();
|
|
92
|
+
adapter.execute(
|
|
93
|
+
`INSERT INTO "${MIGRATION_TABLE}" (name, batch) VALUES (?, ?)`,
|
|
94
|
+
[name, batch],
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Apply a migration (run its up function and record it).
|
|
100
|
+
*/
|
|
101
|
+
export function applyMigration(
|
|
102
|
+
name: string,
|
|
103
|
+
up: () => void,
|
|
104
|
+
batch: number,
|
|
105
|
+
): void {
|
|
106
|
+
if (isMigrationApplied(name)) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
up();
|
|
110
|
+
recordMigration(name, batch);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Get all migrations from the last batch.
|
|
115
|
+
*/
|
|
116
|
+
export function getLastBatchMigrations(): Array<{ id: number; name: string; batch: number }> {
|
|
117
|
+
const adapter = getAdapter();
|
|
118
|
+
const rows = adapter.query<{ max_batch: number | null }>(
|
|
119
|
+
`SELECT MAX(batch) as max_batch FROM "${MIGRATION_TABLE}"`,
|
|
120
|
+
);
|
|
121
|
+
const lastBatch = rows[0]?.max_batch;
|
|
122
|
+
if (lastBatch === null || lastBatch === undefined) return [];
|
|
123
|
+
|
|
124
|
+
return adapter.query<{ id: number; name: string; batch: number }>(
|
|
125
|
+
`SELECT id, name, batch FROM "${MIGRATION_TABLE}" WHERE batch = ? ORDER BY id DESC`,
|
|
126
|
+
[lastBatch],
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Remove a migration record (used during rollback).
|
|
132
|
+
*/
|
|
133
|
+
export function removeMigrationRecord(name: string): void {
|
|
134
|
+
const adapter = getAdapter();
|
|
135
|
+
adapter.execute(
|
|
136
|
+
`DELETE FROM "${MIGRATION_TABLE}" WHERE name = ?`,
|
|
137
|
+
[name],
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Rollback the last batch of migrations.
|
|
143
|
+
* Expects a map of migration name -> down function.
|
|
144
|
+
*/
|
|
145
|
+
export function rollback(
|
|
146
|
+
downFunctions: Map<string, () => void>,
|
|
147
|
+
): string[] {
|
|
148
|
+
const migrations = getLastBatchMigrations();
|
|
149
|
+
const rolledBack: string[] = [];
|
|
150
|
+
|
|
151
|
+
for (const migration of migrations) {
|
|
152
|
+
const down = downFunctions.get(migration.name);
|
|
153
|
+
if (down) {
|
|
154
|
+
down();
|
|
155
|
+
}
|
|
156
|
+
removeMigrationRecord(migration.name);
|
|
157
|
+
rolledBack.push(migration.name);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return rolledBack;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Get all applied migrations.
|
|
165
|
+
*/
|
|
166
|
+
export function getAppliedMigrations(): Array<{ id: number; name: string; batch: number; applied_at: string }> {
|
|
167
|
+
const adapter = getAdapter();
|
|
168
|
+
return adapter.query<{ id: number; name: string; batch: number; applied_at: string }>(
|
|
169
|
+
`SELECT * FROM "${MIGRATION_TABLE}" ORDER BY id ASC`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// SQL-file-based migration system (matches Python's tina4_python.migration API)
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Result returned by the `migrate()` function.
|
|
179
|
+
*/
|
|
180
|
+
export interface MigrationResult {
|
|
181
|
+
/** Filenames of successfully applied migrations. */
|
|
182
|
+
applied: string[];
|
|
183
|
+
/** Filenames that were already applied (skipped). */
|
|
184
|
+
skipped: string[];
|
|
185
|
+
/** Filenames that failed with error details. */
|
|
186
|
+
failed: string[];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Split SQL text into individual statements on the given delimiter.
|
|
191
|
+
*
|
|
192
|
+
* Strips line comments (`-- ...`) and block comments, handles stored
|
|
193
|
+
* procedure blocks delimited by `$$` or `//`.
|
|
194
|
+
*/
|
|
195
|
+
function splitStatements(sql: string, delimiter = ";"): string[] {
|
|
196
|
+
// Extract blocks delimited by $$ or // first, replacing with placeholders
|
|
197
|
+
const blocks: string[] = [];
|
|
198
|
+
const saveBlock = (_match: string, _p1: string): string => {
|
|
199
|
+
blocks.push(_match);
|
|
200
|
+
return `__BLOCK_${blocks.length - 1}__`;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
let processed = sql.replace(/\$\$([\s\S]*?)\$\$/g, saveBlock);
|
|
204
|
+
processed = processed.replace(/\/\/([\s\S]*?)\/\//g, saveBlock);
|
|
205
|
+
|
|
206
|
+
// Remove block comments (/* ... */)
|
|
207
|
+
const clean = processed.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
208
|
+
|
|
209
|
+
const statements: string[] = [];
|
|
210
|
+
for (const part of clean.split(delimiter)) {
|
|
211
|
+
const lines: string[] = [];
|
|
212
|
+
for (const line of part.split("\n")) {
|
|
213
|
+
const stripped = line.trim();
|
|
214
|
+
if (!stripped || stripped.startsWith("--")) continue;
|
|
215
|
+
// Remove inline comments
|
|
216
|
+
const commentPos = line.indexOf("--");
|
|
217
|
+
lines.push(commentPos >= 0 ? line.slice(0, commentPos) : line);
|
|
218
|
+
}
|
|
219
|
+
let cleaned = lines.join("\n").trim();
|
|
220
|
+
|
|
221
|
+
// Restore block placeholders
|
|
222
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
223
|
+
cleaned = cleaned.replace(`__BLOCK_${i}__`, blocks[i]);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (cleaned) statements.push(cleaned);
|
|
227
|
+
}
|
|
228
|
+
return statements;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Run all pending SQL-file migrations.
|
|
233
|
+
*
|
|
234
|
+
* Matches the Python `migrate(db, migration_folder, delimiter)` API.
|
|
235
|
+
*
|
|
236
|
+
* 1. Creates the `tina4_migration` tracking table if it doesn't exist.
|
|
237
|
+
* 2. Scans `migrationsDir` for `NNNNNN_description.sql` files (sorted).
|
|
238
|
+
* 3. Skips files already recorded as applied.
|
|
239
|
+
* 4. Splits file content on `delimiter` and executes each statement.
|
|
240
|
+
* 5. On success records the migration; on error logs and continues.
|
|
241
|
+
* 6. Returns a summary of applied / skipped / failed files.
|
|
242
|
+
*
|
|
243
|
+
* @param adapter - A DatabaseAdapter instance (or omit to use the global adapter).
|
|
244
|
+
* @param options - Optional configuration.
|
|
245
|
+
*/
|
|
246
|
+
export async function migrate(
|
|
247
|
+
adapter?: DatabaseAdapter,
|
|
248
|
+
options?: { migrationsDir?: string; delimiter?: string },
|
|
249
|
+
): Promise<MigrationResult> {
|
|
250
|
+
const db = adapter ?? getAdapter();
|
|
251
|
+
const dir = resolve(options?.migrationsDir ?? "migrations");
|
|
252
|
+
const delimiter = options?.delimiter ?? ";";
|
|
253
|
+
|
|
254
|
+
const result: MigrationResult = { applied: [], skipped: [], failed: [] };
|
|
255
|
+
|
|
256
|
+
if (!existsSync(dir)) {
|
|
257
|
+
return result;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Ensure tracking table
|
|
261
|
+
if (!db.tableExists(MIGRATION_TABLE)) {
|
|
262
|
+
db.execute(`CREATE TABLE IF NOT EXISTS "${MIGRATION_TABLE}" (
|
|
263
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
264
|
+
description TEXT NOT NULL,
|
|
265
|
+
content TEXT,
|
|
266
|
+
passed INTEGER NOT NULL DEFAULT 0,
|
|
267
|
+
run_at TEXT NOT NULL
|
|
268
|
+
)`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Collect .sql files (exclude .down.sql), sorted alphabetically
|
|
272
|
+
const files = readdirSync(dir)
|
|
273
|
+
.filter((f) => f.endsWith(".sql") && !f.endsWith(".down.sql"))
|
|
274
|
+
.sort();
|
|
275
|
+
|
|
276
|
+
if (files.length === 0) return result;
|
|
277
|
+
|
|
278
|
+
for (const file of files) {
|
|
279
|
+
const migrationId = file.replace(/\.sql$/, "");
|
|
280
|
+
|
|
281
|
+
// Check if already applied (passed = 1)
|
|
282
|
+
const existing = db.query<{ id: number; passed: number }>(
|
|
283
|
+
`SELECT id, passed FROM "${MIGRATION_TABLE}" WHERE description = ?`,
|
|
284
|
+
[migrationId],
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
if (existing.length > 0 && existing[0].passed === 1) {
|
|
288
|
+
result.skipped.push(file);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// If there's a failed record (passed = 0), remove it so we can retry
|
|
293
|
+
if (existing.length > 0 && existing[0].passed === 0) {
|
|
294
|
+
db.execute(
|
|
295
|
+
`DELETE FROM "${MIGRATION_TABLE}" WHERE description = ?`,
|
|
296
|
+
[migrationId],
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const sqlContent = readFileSync(join(dir, file), "utf-8").trim();
|
|
301
|
+
if (!sqlContent) {
|
|
302
|
+
result.skipped.push(file);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const statements = splitStatements(sqlContent, delimiter);
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
db.startTransaction();
|
|
310
|
+
|
|
311
|
+
for (const stmt of statements) {
|
|
312
|
+
db.execute(stmt);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Record as passed
|
|
316
|
+
const now = new Date().toISOString();
|
|
317
|
+
db.execute(
|
|
318
|
+
`INSERT INTO "${MIGRATION_TABLE}" (description, content, passed, run_at) VALUES (?, ?, 1, ?)`,
|
|
319
|
+
[migrationId, sqlContent, now],
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
db.commit();
|
|
323
|
+
result.applied.push(file);
|
|
324
|
+
} catch (err) {
|
|
325
|
+
try {
|
|
326
|
+
db.rollback();
|
|
327
|
+
} catch {
|
|
328
|
+
// rollback may fail if transaction was auto-rolled-back
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
332
|
+
console.error(` Migration failed: ${file} — ${msg}`);
|
|
333
|
+
result.failed.push(file);
|
|
334
|
+
// Continue to next file (matching Python behaviour)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return result;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Create a new empty SQL migration file with the next sequence number.
|
|
343
|
+
*
|
|
344
|
+
* Matches the Python `create_migration(description, migration_folder)` API.
|
|
345
|
+
*
|
|
346
|
+
* @param description - Human-readable description (used in filename).
|
|
347
|
+
* @param options - Optional configuration.
|
|
348
|
+
* @returns The absolute path to the created file.
|
|
349
|
+
*/
|
|
350
|
+
export async function createMigration(
|
|
351
|
+
description: string,
|
|
352
|
+
options?: { migrationsDir?: string },
|
|
353
|
+
): Promise<string> {
|
|
354
|
+
const dir = resolve(options?.migrationsDir ?? "migrations");
|
|
355
|
+
|
|
356
|
+
// Ensure directory exists
|
|
357
|
+
if (!existsSync(dir)) {
|
|
358
|
+
mkdirSync(dir, { recursive: true });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Determine next sequence number
|
|
362
|
+
const existing = existsSync(dir)
|
|
363
|
+
? readdirSync(dir)
|
|
364
|
+
.filter((f) => f.endsWith(".sql") && !f.endsWith(".down.sql"))
|
|
365
|
+
.sort()
|
|
366
|
+
: [];
|
|
367
|
+
|
|
368
|
+
let nextSeq = 1;
|
|
369
|
+
if (existing.length > 0) {
|
|
370
|
+
const last = existing[existing.length - 1];
|
|
371
|
+
const match = last.match(/^(\d+)/);
|
|
372
|
+
if (match) {
|
|
373
|
+
nextSeq = parseInt(match[1], 10) + 1;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Sanitise description for filename
|
|
378
|
+
const safeName = description
|
|
379
|
+
.toLowerCase()
|
|
380
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
381
|
+
.replace(/^_|_$/g, "");
|
|
382
|
+
|
|
383
|
+
const seqStr = String(nextSeq).padStart(6, "0");
|
|
384
|
+
const fileName = `${seqStr}_${safeName}.sql`;
|
|
385
|
+
const filePath = join(dir, fileName);
|
|
386
|
+
|
|
387
|
+
const template = `-- Migration: ${description}\n-- Created: ${new Date().toISOString()}\n\n`;
|
|
388
|
+
|
|
389
|
+
writeFileSync(filePath, template, "utf-8");
|
|
390
|
+
|
|
391
|
+
return filePath;
|
|
392
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join, extname } from "node:path";
|
|
3
|
+
import type { ModelDefinition, FieldDefinition, RelationshipDefinition } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export interface DiscoveredModel {
|
|
6
|
+
definition: ModelDefinition;
|
|
7
|
+
filePath: string;
|
|
8
|
+
modelClass: any;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function discoverModels(modelsDir: string): Promise<DiscoveredModel[]> {
|
|
12
|
+
const models: DiscoveredModel[] = [];
|
|
13
|
+
let files: string[];
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
files = readdirSync(modelsDir);
|
|
17
|
+
} catch {
|
|
18
|
+
return models;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
for (const file of files) {
|
|
22
|
+
const filePath = join(modelsDir, file);
|
|
23
|
+
const stat = statSync(filePath);
|
|
24
|
+
if (!stat.isFile()) continue;
|
|
25
|
+
|
|
26
|
+
const ext = extname(file);
|
|
27
|
+
if (ext !== ".ts" && ext !== ".js") continue;
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const moduleUrl = `file://${filePath}?t=${Date.now()}`;
|
|
31
|
+
const mod = await import(moduleUrl);
|
|
32
|
+
const ModelClass = mod.default ?? mod;
|
|
33
|
+
|
|
34
|
+
if (!ModelClass.tableName || !ModelClass.fields) {
|
|
35
|
+
console.warn(` Warning: ${file} does not export a valid model (needs static tableName and fields), skipping`);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const definition: ModelDefinition = {
|
|
40
|
+
tableName: ModelClass.tableName,
|
|
41
|
+
fields: ModelClass.fields as Record<string, FieldDefinition>,
|
|
42
|
+
softDelete: ModelClass.softDelete ?? false,
|
|
43
|
+
tableFilter: ModelClass.tableFilter,
|
|
44
|
+
hasOne: ModelClass.hasOne as RelationshipDefinition[] | undefined,
|
|
45
|
+
hasMany: ModelClass.hasMany as RelationshipDefinition[] | undefined,
|
|
46
|
+
dbName: ModelClass._db,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
models.push({ definition, filePath, modelClass: ModelClass });
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.error(` Error loading model ${file}:`, err);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return models;
|
|
56
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { QueryOptions } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export interface ParsedQuery {
|
|
4
|
+
where: string;
|
|
5
|
+
orderBy: string;
|
|
6
|
+
limit: number;
|
|
7
|
+
offset: number;
|
|
8
|
+
params: unknown[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function buildQuery(
|
|
12
|
+
tableName: string,
|
|
13
|
+
options: QueryOptions,
|
|
14
|
+
extraConditions?: string[],
|
|
15
|
+
): { sql: string; countSql: string; params: unknown[] } {
|
|
16
|
+
const conditions: string[] = [];
|
|
17
|
+
const params: unknown[] = [];
|
|
18
|
+
|
|
19
|
+
// Add extra conditions (soft delete, table filter)
|
|
20
|
+
if (extraConditions) {
|
|
21
|
+
conditions.push(...extraConditions);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Parse filters
|
|
25
|
+
if (options.filter) {
|
|
26
|
+
for (const [field, value] of Object.entries(options.filter)) {
|
|
27
|
+
if (typeof value === "object" && value !== null) {
|
|
28
|
+
// Operator filters: filter[age][gt]=25
|
|
29
|
+
const ops = value as Record<string, unknown>;
|
|
30
|
+
for (const [op, opVal] of Object.entries(ops)) {
|
|
31
|
+
const sqlOp = operatorMap[op];
|
|
32
|
+
if (sqlOp) {
|
|
33
|
+
conditions.push(`"${field}" ${sqlOp} ?`);
|
|
34
|
+
params.push(opVal);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
} else {
|
|
38
|
+
// Exact match: filter[name]=John
|
|
39
|
+
conditions.push(`"${field}" = ?`);
|
|
40
|
+
params.push(value);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
46
|
+
|
|
47
|
+
// Sort
|
|
48
|
+
let orderClause = "";
|
|
49
|
+
if (options.sort) {
|
|
50
|
+
const parts = options.sort.split(",").map((s) => {
|
|
51
|
+
const trimmed = s.trim();
|
|
52
|
+
if (trimmed.startsWith("-")) {
|
|
53
|
+
return `"${trimmed.slice(1)}" DESC`;
|
|
54
|
+
}
|
|
55
|
+
return `"${trimmed}" ASC`;
|
|
56
|
+
});
|
|
57
|
+
orderClause = `ORDER BY ${parts.join(", ")}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Pagination
|
|
61
|
+
const limit = options.limit ?? 20;
|
|
62
|
+
const page = options.page ?? 1;
|
|
63
|
+
const offset = (page - 1) * limit;
|
|
64
|
+
|
|
65
|
+
const sql = `SELECT * FROM "${tableName}" ${whereClause} ${orderClause} LIMIT ? OFFSET ?`;
|
|
66
|
+
const countSql = `SELECT COUNT(*) as total FROM "${tableName}" ${whereClause}`;
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
sql,
|
|
70
|
+
countSql,
|
|
71
|
+
params: [...params, limit, offset],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function parseQueryString(query: Record<string, string>): QueryOptions {
|
|
76
|
+
const options: QueryOptions = {};
|
|
77
|
+
|
|
78
|
+
// Parse filter params: filter[name]=John or filter[age][gt]=25
|
|
79
|
+
const filter: Record<string, unknown> = {};
|
|
80
|
+
for (const [key, value] of Object.entries(query)) {
|
|
81
|
+
const filterMatch = key.match(/^filter\[(\w+)\](?:\[(\w+)\])?$/);
|
|
82
|
+
if (filterMatch) {
|
|
83
|
+
const field = filterMatch[1];
|
|
84
|
+
const operator = filterMatch[2];
|
|
85
|
+
if (operator) {
|
|
86
|
+
if (!filter[field] || typeof filter[field] !== "object") {
|
|
87
|
+
filter[field] = {};
|
|
88
|
+
}
|
|
89
|
+
(filter[field] as Record<string, string>)[operator] = value;
|
|
90
|
+
} else {
|
|
91
|
+
filter[field] = value;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (Object.keys(filter).length > 0) {
|
|
96
|
+
options.filter = filter;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (query.sort) options.sort = query.sort;
|
|
100
|
+
if (query.page) options.page = parseInt(query.page, 10);
|
|
101
|
+
if (query.limit) options.limit = parseInt(query.limit, 10);
|
|
102
|
+
|
|
103
|
+
return options;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const operatorMap: Record<string, string> = {
|
|
107
|
+
gt: ">",
|
|
108
|
+
gte: ">=",
|
|
109
|
+
lt: "<",
|
|
110
|
+
lte: "<=",
|
|
111
|
+
ne: "!=",
|
|
112
|
+
like: "LIKE",
|
|
113
|
+
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Tina4 Seeder — seed database tables and ORM models with fake data.
|
|
2
|
+
// Zero external dependencies.
|
|
3
|
+
|
|
4
|
+
import { FakeData } from "./fakeData.js";
|
|
5
|
+
import type { DatabaseAdapter, FieldDefinition } from "./types.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Seed a database table with fake data using raw SQL inserts.
|
|
9
|
+
*
|
|
10
|
+
* @param db - A DatabaseAdapter instance
|
|
11
|
+
* @param tableName - The table to insert into
|
|
12
|
+
* @param count - Number of rows to insert (default 10)
|
|
13
|
+
* @param fieldMap - Dict of column_name -> callable that generates a value.
|
|
14
|
+
* If not provided, no rows are inserted.
|
|
15
|
+
* @param overrides - Static values applied to every row (overrides fieldMap)
|
|
16
|
+
* @returns Number of rows inserted
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* const fake = new FakeData();
|
|
20
|
+
* await seedTable(db, "users", 50, {
|
|
21
|
+
* name: () => fake.name(),
|
|
22
|
+
* email: () => fake.email(),
|
|
23
|
+
* });
|
|
24
|
+
*/
|
|
25
|
+
export async function seedTable(
|
|
26
|
+
db: DatabaseAdapter,
|
|
27
|
+
tableName: string,
|
|
28
|
+
count = 10,
|
|
29
|
+
fieldMap?: Record<string, (() => unknown) | unknown>,
|
|
30
|
+
overrides?: Record<string, unknown>,
|
|
31
|
+
): Promise<number> {
|
|
32
|
+
if (!fieldMap || Object.keys(fieldMap).length === 0) {
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (let i = 0; i < count; i++) {
|
|
37
|
+
const row: Record<string, unknown> = {};
|
|
38
|
+
|
|
39
|
+
// Generate values from fieldMap
|
|
40
|
+
for (const [col, generator] of Object.entries(fieldMap)) {
|
|
41
|
+
row[col] = typeof generator === "function" ? (generator as () => unknown)() : generator;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Apply static overrides
|
|
45
|
+
if (overrides) {
|
|
46
|
+
for (const [col, value] of Object.entries(overrides)) {
|
|
47
|
+
row[col] = value;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Build INSERT SQL
|
|
52
|
+
const columns = Object.keys(row);
|
|
53
|
+
const colList = columns.map((c) => `"${c}"`).join(", ");
|
|
54
|
+
const placeholders = columns.map(() => "?").join(", ");
|
|
55
|
+
const values = columns.map((c) => row[c]);
|
|
56
|
+
|
|
57
|
+
db.execute(
|
|
58
|
+
`INSERT INTO "${tableName}" (${colList}) VALUES (${placeholders})`,
|
|
59
|
+
values,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return count;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Seed an ORM model class with fake data, auto-generating values
|
|
68
|
+
* based on field definitions.
|
|
69
|
+
*
|
|
70
|
+
* @param ormClass - A model class with static `tableName`, `fields`, and optionally `_db`
|
|
71
|
+
* @param count - Number of rows to insert (default 10)
|
|
72
|
+
* @param overrides - Static values applied to every row (override auto-generated)
|
|
73
|
+
* @param seed - Optional PRNG seed for deterministic output
|
|
74
|
+
* @returns Number of rows inserted
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* import User from "./src/models/User.js";
|
|
78
|
+
* await seedOrm(User, 100, { role: "user" });
|
|
79
|
+
*/
|
|
80
|
+
export async function seedOrm(
|
|
81
|
+
ormClass: {
|
|
82
|
+
tableName: string;
|
|
83
|
+
fields: Record<string, FieldDefinition>;
|
|
84
|
+
_db?: string;
|
|
85
|
+
getDb?: () => DatabaseAdapter;
|
|
86
|
+
},
|
|
87
|
+
count = 10,
|
|
88
|
+
overrides?: Record<string, unknown>,
|
|
89
|
+
seed?: number,
|
|
90
|
+
): Promise<number> {
|
|
91
|
+
const fake = new FakeData(seed);
|
|
92
|
+
const fields = ormClass.fields;
|
|
93
|
+
|
|
94
|
+
// Build a fieldMap from the model's field definitions
|
|
95
|
+
const fieldMap: Record<string, () => unknown> = {};
|
|
96
|
+
|
|
97
|
+
for (const [colName, fieldDef] of Object.entries(fields)) {
|
|
98
|
+
// Skip auto-increment primary keys
|
|
99
|
+
if (fieldDef.primaryKey && fieldDef.autoIncrement) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
// Skip fields that have an override
|
|
103
|
+
if (overrides && colName in overrides) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
fieldMap[colName] = () => fake.forField(fieldDef, colName);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Get the database adapter — try the model's own getDb, then fall back to import
|
|
110
|
+
let db: DatabaseAdapter;
|
|
111
|
+
if (typeof (ormClass as any).getDb === "function") {
|
|
112
|
+
db = (ormClass as any).getDb();
|
|
113
|
+
} else {
|
|
114
|
+
// Dynamic import to avoid circular dependency issues
|
|
115
|
+
const { getAdapter, getNamedAdapter } = await import("./database.js");
|
|
116
|
+
db = ormClass._db ? getNamedAdapter(ormClass._db) : getAdapter();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return seedTable(db, ormClass.tableName, count, fieldMap, overrides);
|
|
120
|
+
}
|