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.
Files changed (119) hide show
  1. package/BENCHMARK_REPORT.md +96 -0
  2. package/CARBONAH.md +140 -0
  3. package/CLAUDE.md +599 -0
  4. package/COMPARISON.md +194 -0
  5. package/README.md +595 -0
  6. package/package.json +59 -0
  7. package/packages/cli/src/bin.ts +110 -0
  8. package/packages/cli/src/commands/init.ts +194 -0
  9. package/packages/cli/src/commands/migrate.ts +96 -0
  10. package/packages/cli/src/commands/migrateCreate.ts +59 -0
  11. package/packages/cli/src/commands/routes.ts +61 -0
  12. package/packages/cli/src/commands/serve.ts +58 -0
  13. package/packages/cli/src/commands/test.ts +83 -0
  14. package/packages/core/gallery/auth/meta.json +1 -0
  15. package/packages/core/gallery/auth/src/routes/api/gallery/auth/login/post.ts +22 -0
  16. package/packages/core/gallery/auth/src/routes/api/gallery/auth/verify/get.ts +16 -0
  17. package/packages/core/gallery/auth/src/routes/gallery/auth/get.ts +97 -0
  18. package/packages/core/gallery/database/meta.json +1 -0
  19. package/packages/core/gallery/database/src/routes/api/gallery/db/notes/get.ts +13 -0
  20. package/packages/core/gallery/database/src/routes/api/gallery/db/notes/post.ts +17 -0
  21. package/packages/core/gallery/database/src/routes/api/gallery/db/tables/get.ts +23 -0
  22. package/packages/core/gallery/error-overlay/meta.json +1 -0
  23. package/packages/core/gallery/error-overlay/src/routes/api/gallery/crash/get.ts +17 -0
  24. package/packages/core/gallery/orm/meta.json +1 -0
  25. package/packages/core/gallery/orm/src/routes/api/gallery/products/get.ts +12 -0
  26. package/packages/core/gallery/orm/src/routes/api/gallery/products/post.ts +7 -0
  27. package/packages/core/gallery/queue/meta.json +1 -0
  28. package/packages/core/gallery/queue/src/routes/api/gallery/queue/produce/post.ts +16 -0
  29. package/packages/core/gallery/queue/src/routes/api/gallery/queue/status/get.ts +10 -0
  30. package/packages/core/gallery/rest-api/meta.json +1 -0
  31. package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/get.ts +6 -0
  32. package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/post.ts +7 -0
  33. package/packages/core/gallery/templates/meta.json +1 -0
  34. package/packages/core/gallery/templates/src/routes/gallery/page/get.ts +15 -0
  35. package/packages/core/gallery/templates/src/templates/gallery_page.twig +257 -0
  36. package/packages/core/public/css/tina4.css +2463 -0
  37. package/packages/core/public/css/tina4.min.css +1 -0
  38. package/packages/core/public/favicon.ico +0 -0
  39. package/packages/core/public/images/logo.svg +5 -0
  40. package/packages/core/public/images/tina4-logo-icon.webp +0 -0
  41. package/packages/core/public/js/frond.min.js +420 -0
  42. package/packages/core/public/js/tina4-dev-admin.min.js +327 -0
  43. package/packages/core/public/js/tina4.min.js +93 -0
  44. package/packages/core/public/swagger/index.html +90 -0
  45. package/packages/core/public/swagger/oauth2-redirect.html +63 -0
  46. package/packages/core/src/ai.ts +359 -0
  47. package/packages/core/src/api.ts +248 -0
  48. package/packages/core/src/auth.ts +287 -0
  49. package/packages/core/src/cache.ts +121 -0
  50. package/packages/core/src/constants.ts +48 -0
  51. package/packages/core/src/container.ts +90 -0
  52. package/packages/core/src/devAdmin.ts +2024 -0
  53. package/packages/core/src/devMailbox.ts +316 -0
  54. package/packages/core/src/dotenv.ts +172 -0
  55. package/packages/core/src/errorOverlay.test.ts +122 -0
  56. package/packages/core/src/errorOverlay.ts +278 -0
  57. package/packages/core/src/events.ts +112 -0
  58. package/packages/core/src/fakeData.ts +309 -0
  59. package/packages/core/src/graphql.ts +812 -0
  60. package/packages/core/src/health.ts +31 -0
  61. package/packages/core/src/htmlElement.ts +172 -0
  62. package/packages/core/src/i18n.ts +136 -0
  63. package/packages/core/src/index.ts +88 -0
  64. package/packages/core/src/logger.ts +226 -0
  65. package/packages/core/src/messenger.ts +822 -0
  66. package/packages/core/src/middleware.ts +138 -0
  67. package/packages/core/src/queue.ts +481 -0
  68. package/packages/core/src/queueBackends/kafkaBackend.ts +348 -0
  69. package/packages/core/src/queueBackends/rabbitmqBackend.ts +479 -0
  70. package/packages/core/src/rateLimiter.ts +107 -0
  71. package/packages/core/src/request.ts +189 -0
  72. package/packages/core/src/response.ts +146 -0
  73. package/packages/core/src/routeDiscovery.ts +87 -0
  74. package/packages/core/src/router.ts +398 -0
  75. package/packages/core/src/scss.ts +366 -0
  76. package/packages/core/src/server.ts +610 -0
  77. package/packages/core/src/service.ts +380 -0
  78. package/packages/core/src/session.ts +480 -0
  79. package/packages/core/src/sessionHandlers/mongoHandler.ts +286 -0
  80. package/packages/core/src/sessionHandlers/valkeyHandler.ts +184 -0
  81. package/packages/core/src/static.ts +58 -0
  82. package/packages/core/src/testing.ts +233 -0
  83. package/packages/core/src/types.ts +98 -0
  84. package/packages/core/src/watcher.ts +37 -0
  85. package/packages/core/src/websocket.ts +408 -0
  86. package/packages/core/src/wsdl.ts +546 -0
  87. package/packages/core/templates/errors/302.twig +14 -0
  88. package/packages/core/templates/errors/401.twig +9 -0
  89. package/packages/core/templates/errors/403.twig +29 -0
  90. package/packages/core/templates/errors/404.twig +29 -0
  91. package/packages/core/templates/errors/500.twig +38 -0
  92. package/packages/core/templates/errors/502.twig +9 -0
  93. package/packages/core/templates/errors/503.twig +12 -0
  94. package/packages/core/templates/errors/base.twig +37 -0
  95. package/packages/frond/src/engine.ts +1475 -0
  96. package/packages/frond/src/index.ts +2 -0
  97. package/packages/orm/src/adapters/firebird.ts +455 -0
  98. package/packages/orm/src/adapters/mssql.ts +440 -0
  99. package/packages/orm/src/adapters/mysql.ts +355 -0
  100. package/packages/orm/src/adapters/postgres.ts +362 -0
  101. package/packages/orm/src/adapters/sqlite.ts +270 -0
  102. package/packages/orm/src/autoCrud.ts +231 -0
  103. package/packages/orm/src/baseModel.ts +536 -0
  104. package/packages/orm/src/database.ts +321 -0
  105. package/packages/orm/src/fakeData.ts +118 -0
  106. package/packages/orm/src/index.ts +49 -0
  107. package/packages/orm/src/migration.ts +392 -0
  108. package/packages/orm/src/model.ts +56 -0
  109. package/packages/orm/src/query.ts +113 -0
  110. package/packages/orm/src/seeder.ts +120 -0
  111. package/packages/orm/src/sqlTranslation.ts +272 -0
  112. package/packages/orm/src/types.ts +110 -0
  113. package/packages/orm/src/validation.ts +93 -0
  114. package/packages/swagger/src/generator.ts +189 -0
  115. package/packages/swagger/src/index.ts +2 -0
  116. package/packages/swagger/src/ui.ts +48 -0
  117. package/skills/tina4-developer.skill +0 -0
  118. package/skills/tina4-js.skill +0 -0
  119. 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
+ }