tina4-nodejs 3.2.1 → 3.5.0

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 (34) hide show
  1. package/CLAUDE.md +1 -1
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/packages/cli/src/bin.ts +13 -1
  5. package/packages/cli/src/commands/migrate.ts +19 -5
  6. package/packages/cli/src/commands/migrateCreate.ts +29 -28
  7. package/packages/cli/src/commands/migrateRollback.ts +59 -0
  8. package/packages/cli/src/commands/migrateStatus.ts +62 -0
  9. package/packages/core/public/js/tina4-dev-admin.min.js +1 -1
  10. package/packages/core/public/js/tina4js.min.js +47 -0
  11. package/packages/core/src/auth.ts +44 -10
  12. package/packages/core/src/devAdmin.ts +14 -16
  13. package/packages/core/src/index.ts +10 -3
  14. package/packages/core/src/middleware.ts +232 -2
  15. package/packages/core/src/queue.ts +127 -25
  16. package/packages/core/src/queueBackends/mongoBackend.ts +223 -0
  17. package/packages/core/src/request.ts +3 -3
  18. package/packages/core/src/router.ts +115 -51
  19. package/packages/core/src/server.ts +47 -3
  20. package/packages/core/src/session.ts +29 -1
  21. package/packages/core/src/sessionHandlers/databaseHandler.ts +134 -0
  22. package/packages/core/src/sessionHandlers/redisHandler.ts +230 -0
  23. package/packages/core/src/types.ts +12 -6
  24. package/packages/core/src/websocket.ts +11 -2
  25. package/packages/core/src/websocketConnection.ts +4 -2
  26. package/packages/frond/src/engine.ts +66 -1
  27. package/packages/orm/src/autoCrud.ts +17 -12
  28. package/packages/orm/src/baseModel.ts +99 -21
  29. package/packages/orm/src/database.ts +197 -69
  30. package/packages/orm/src/databaseResult.ts +207 -0
  31. package/packages/orm/src/index.ts +6 -3
  32. package/packages/orm/src/migration.ts +296 -71
  33. package/packages/orm/src/model.ts +1 -0
  34. package/packages/orm/src/types.ts +1 -0
@@ -1,4 +1,5 @@
1
- import type { DatabaseAdapter } from "./types.js";
1
+ import type { DatabaseAdapter, DatabaseResult as DatabaseWriteResult } from "./types.js";
2
+ import { DatabaseResult } from "./databaseResult.js";
2
3
 
3
4
  let activeAdapter: DatabaseAdapter | null = null;
4
5
  const namedAdapters: Map<string, DatabaseAdapter> = new Map();
@@ -171,15 +172,201 @@ export function parseDatabaseUrl(url: string, username?: string, password?: stri
171
172
  return result;
172
173
  }
173
174
 
175
+ /**
176
+ * A wrapper class around a DatabaseAdapter that provides a clean, high-level API.
177
+ *
178
+ * Mirrors the Database class in Python/Ruby Tina4 implementations.
179
+ *
180
+ * Usage:
181
+ * const db = await Database.create("sqlite:///path/to/db.sqlite");
182
+ * const rows = db.fetch("SELECT * FROM users WHERE active = ?", [true], 10, 0);
183
+ * const user = db.fetchOne("SELECT * FROM users WHERE id = ?", [1]);
184
+ * db.insert("users", { name: "Alice", email: "alice@example.com" });
185
+ * db.update("users", { name: "Bob" }, { id: 1 });
186
+ * db.delete("users", { id: 1 });
187
+ * db.close();
188
+ */
189
+ export class Database {
190
+ private adapter: DatabaseAdapter;
191
+
192
+ /**
193
+ * Create a Database wrapping an existing adapter.
194
+ * For creating a Database from a URL, use the async static factories:
195
+ * Database.create(url) or Database.fromEnv()
196
+ */
197
+ constructor(adapter: DatabaseAdapter) {
198
+ this.adapter = adapter;
199
+ }
200
+
201
+ /**
202
+ * Async factory: creates a Database from a connection URL.
203
+ * Works with all adapter types (sqlite, postgres, mysql, mssql, firebird).
204
+ */
205
+ static async create(url: string, username?: string, password?: string): Promise<Database> {
206
+ const adapter = await createAdapterFromUrl(url, username, password);
207
+ setAdapter(adapter);
208
+ return new Database(adapter);
209
+ }
210
+
211
+ /**
212
+ * Create a Database from an environment variable.
213
+ * @param envKey - Name of the env var holding the connection URL. Defaults to "DATABASE_URL".
214
+ */
215
+ static async fromEnv(envKey = "DATABASE_URL"): Promise<Database> {
216
+ const url = process.env[envKey];
217
+ if (!url) {
218
+ throw new Error(`Environment variable "${envKey}" is not set.`);
219
+ }
220
+ return Database.create(url);
221
+ }
222
+
223
+ /** Get the underlying adapter (for advanced / escape-hatch usage). */
224
+ getAdapter(): DatabaseAdapter {
225
+ return this.adapter;
226
+ }
227
+
228
+ /** Query rows with optional pagination. Returns a DatabaseResult wrapper. */
229
+ fetch(sql: string, params?: unknown[], limit?: number, offset?: number): DatabaseResult {
230
+ const rows = this.adapter.fetch<Record<string, unknown>>(sql, params, limit, offset);
231
+ return new DatabaseResult(rows, undefined, undefined, limit, offset, this.adapter, sql);
232
+ }
233
+
234
+ /** Fetch a single row or null. */
235
+ fetchOne<T = Record<string, unknown>>(sql: string, params?: unknown[]): T | null {
236
+ return this.adapter.fetchOne<T>(sql, params);
237
+ }
238
+
239
+ /** Execute a statement (INSERT, UPDATE, DELETE, DDL). */
240
+ execute(sql: string, params?: unknown[]): unknown {
241
+ return this.adapter.execute(sql, params);
242
+ }
243
+
244
+ /** Insert a row into a table. */
245
+ insert(table: string, data: Record<string, unknown>): DatabaseWriteResult {
246
+ return this.adapter.insert(table, data);
247
+ }
248
+
249
+ /** Update rows in a table matching filter. */
250
+ update(table: string, data: Record<string, unknown>, filter?: Record<string, unknown>): DatabaseWriteResult {
251
+ return this.adapter.update(table, data, filter ?? {});
252
+ }
253
+
254
+ /** Delete rows from a table matching filter. */
255
+ delete(table: string, filter?: Record<string, unknown>): DatabaseWriteResult {
256
+ return this.adapter.delete(table, filter ?? {});
257
+ }
258
+
259
+ /** Close the database connection. */
260
+ close(): void {
261
+ this.adapter.close();
262
+ }
263
+
264
+ /** Start a transaction. */
265
+ startTransaction(): void {
266
+ this.adapter.startTransaction();
267
+ }
268
+
269
+ /** Commit the current transaction. */
270
+ commit(): void {
271
+ this.adapter.commit();
272
+ }
273
+
274
+ /** Rollback the current transaction. */
275
+ rollback(): void {
276
+ this.adapter.rollback();
277
+ }
278
+
279
+ /** Check if a table exists. */
280
+ tableExists(name: string): boolean {
281
+ return this.adapter.tableExists(name);
282
+ }
283
+
284
+ /** List all tables in the database. */
285
+ getTables(): string[] {
286
+ return this.adapter.tables();
287
+ }
288
+
289
+ /** Get the last auto-increment id. */
290
+ getLastId(): string | number {
291
+ const id = this.adapter.lastInsertId();
292
+ if (id === null) return 0;
293
+ return typeof id === "bigint" ? id.toString() : id;
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Internal helper: create a DatabaseAdapter from a parsed URL.
299
+ * Extracted from initDatabase so Database.create() can reuse it.
300
+ */
301
+ async function createAdapterFromUrl(url: string, username?: string, password?: string): Promise<DatabaseAdapter> {
302
+ const parsed = parseDatabaseUrl(url, username, password);
303
+
304
+ switch (parsed.type) {
305
+ case "sqlite": {
306
+ const { SQLiteAdapter } = await import("./adapters/sqlite.js");
307
+ return new SQLiteAdapter(parsed.path ?? "./data/tina4.db");
308
+ }
309
+ case "postgres": {
310
+ const { PostgresAdapter } = await import("./adapters/postgres.js");
311
+ const adapter = new PostgresAdapter({
312
+ host: parsed.host,
313
+ port: parsed.port,
314
+ user: parsed.user,
315
+ password: parsed.password,
316
+ database: parsed.database,
317
+ });
318
+ await adapter.connect();
319
+ return adapter;
320
+ }
321
+ case "mysql": {
322
+ const { MysqlAdapter } = await import("./adapters/mysql.js");
323
+ const adapter = new MysqlAdapter({
324
+ host: parsed.host,
325
+ port: parsed.port,
326
+ user: parsed.user,
327
+ password: parsed.password,
328
+ database: parsed.database,
329
+ });
330
+ await adapter.connect();
331
+ return adapter;
332
+ }
333
+ case "mssql": {
334
+ const { MssqlAdapter } = await import("./adapters/mssql.js");
335
+ const adapter = new MssqlAdapter({
336
+ host: parsed.host,
337
+ port: parsed.port,
338
+ user: parsed.user,
339
+ password: parsed.password,
340
+ database: parsed.database,
341
+ });
342
+ await adapter.connect();
343
+ return adapter;
344
+ }
345
+ case "firebird": {
346
+ const { FirebirdAdapter } = await import("./adapters/firebird.js");
347
+ const adapter = new FirebirdAdapter({
348
+ host: parsed.host,
349
+ port: parsed.port,
350
+ user: parsed.user,
351
+ password: parsed.password,
352
+ database: parsed.database,
353
+ });
354
+ await adapter.connect();
355
+ return adapter;
356
+ }
357
+ }
358
+ }
359
+
174
360
  /**
175
361
  * Initialize the database from a config object or DATABASE_URL env var.
362
+ * Now returns a Database wrapper instance.
176
363
  *
177
364
  * Priority:
178
365
  * 1. config.url (explicit URL)
179
366
  * 2. process.env.DATABASE_URL
180
367
  * 3. config.type + config.path (legacy)
181
368
  */
182
- export async function initDatabase(config?: DatabaseConfig): Promise<DatabaseAdapter> {
369
+ export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
183
370
  // Resolve credentials: config.user > config.username > env DATABASE_USERNAME
184
371
  const resolvedUser = config?.user ?? config?.username ?? process.env.DATABASE_USERNAME;
185
372
  const resolvedPassword = config?.password ?? process.env.DATABASE_PASSWORD;
@@ -188,68 +375,9 @@ export async function initDatabase(config?: DatabaseConfig): Promise<DatabaseAda
188
375
  const url = config?.url ?? process.env.DATABASE_URL;
189
376
 
190
377
  if (url) {
191
- const parsed = parseDatabaseUrl(url, resolvedUser, resolvedPassword);
192
-
193
- switch (parsed.type) {
194
- case "sqlite": {
195
- const { SQLiteAdapter } = await import("./adapters/sqlite.js");
196
- const adapter = new SQLiteAdapter(parsed.path ?? "./data/tina4.db");
197
- setAdapter(adapter);
198
- return adapter;
199
- }
200
- case "postgres": {
201
- const { PostgresAdapter } = await import("./adapters/postgres.js");
202
- const adapter = new PostgresAdapter({
203
- host: parsed.host,
204
- port: parsed.port,
205
- user: parsed.user,
206
- password: parsed.password,
207
- database: parsed.database,
208
- });
209
- await adapter.connect();
210
- setAdapter(adapter);
211
- return adapter;
212
- }
213
- case "mysql": {
214
- const { MysqlAdapter } = await import("./adapters/mysql.js");
215
- const adapter = new MysqlAdapter({
216
- host: parsed.host,
217
- port: parsed.port,
218
- user: parsed.user,
219
- password: parsed.password,
220
- database: parsed.database,
221
- });
222
- await adapter.connect();
223
- setAdapter(adapter);
224
- return adapter;
225
- }
226
- case "mssql": {
227
- const { MssqlAdapter } = await import("./adapters/mssql.js");
228
- const adapter = new MssqlAdapter({
229
- host: parsed.host,
230
- port: parsed.port,
231
- user: parsed.user,
232
- password: parsed.password,
233
- database: parsed.database,
234
- });
235
- await adapter.connect();
236
- setAdapter(adapter);
237
- return adapter;
238
- }
239
- case "firebird": {
240
- const { FirebirdAdapter } = await import("./adapters/firebird.js");
241
- const adapter = new FirebirdAdapter({
242
- host: parsed.host,
243
- port: parsed.port,
244
- user: parsed.user,
245
- password: parsed.password,
246
- database: parsed.database,
247
- });
248
- await adapter.connect();
249
- setAdapter(adapter);
250
- return adapter;
251
- }
252
- }
378
+ const adapter = await createAdapterFromUrl(url, resolvedUser, resolvedPassword);
379
+ setAdapter(adapter);
380
+ return new Database(adapter);
253
381
  }
254
382
 
255
383
  // Legacy config path — normalize "sqlserver" to "mssql"
@@ -261,7 +389,7 @@ export async function initDatabase(config?: DatabaseConfig): Promise<DatabaseAda
261
389
  const { SQLiteAdapter } = await import("./adapters/sqlite.js");
262
390
  const adapter = new SQLiteAdapter(config?.path ?? "./data/tina4.db");
263
391
  setAdapter(adapter);
264
- return adapter;
392
+ return new Database(adapter);
265
393
  }
266
394
  case "postgres": {
267
395
  const { PostgresAdapter } = await import("./adapters/postgres.js");
@@ -274,7 +402,7 @@ export async function initDatabase(config?: DatabaseConfig): Promise<DatabaseAda
274
402
  });
275
403
  await adapter.connect();
276
404
  setAdapter(adapter);
277
- return adapter;
405
+ return new Database(adapter);
278
406
  }
279
407
  case "mysql": {
280
408
  const { MysqlAdapter } = await import("./adapters/mysql.js");
@@ -287,7 +415,7 @@ export async function initDatabase(config?: DatabaseConfig): Promise<DatabaseAda
287
415
  });
288
416
  await adapter.connect();
289
417
  setAdapter(adapter);
290
- return adapter;
418
+ return new Database(adapter);
291
419
  }
292
420
  case "mssql": {
293
421
  const { MssqlAdapter } = await import("./adapters/mssql.js");
@@ -300,7 +428,7 @@ export async function initDatabase(config?: DatabaseConfig): Promise<DatabaseAda
300
428
  });
301
429
  await adapter.connect();
302
430
  setAdapter(adapter);
303
- return adapter;
431
+ return new Database(adapter);
304
432
  }
305
433
  case "firebird": {
306
434
  const { FirebirdAdapter } = await import("./adapters/firebird.js");
@@ -313,7 +441,7 @@ export async function initDatabase(config?: DatabaseConfig): Promise<DatabaseAda
313
441
  });
314
442
  await adapter.connect();
315
443
  setAdapter(adapter);
316
- return adapter;
444
+ return new Database(adapter);
317
445
  }
318
446
  default:
319
447
  throw new Error(`Unknown database type: ${type}`);
@@ -0,0 +1,207 @@
1
+ import type { DatabaseAdapter, ColumnInfo } from "./types.js";
2
+
3
+ /** Column metadata returned by columnInfo(). */
4
+ export interface ColumnInfoResult {
5
+ name: string;
6
+ type: string;
7
+ size: number | null;
8
+ decimals: number | null;
9
+ nullable: boolean;
10
+ primary_key: boolean;
11
+ }
12
+
13
+ /**
14
+ * DatabaseResult — wraps fetched rows with convenience methods.
15
+ *
16
+ * Mirrors Python's `DatabaseResult` dataclass from tina4_python.database.adapter.
17
+ * Provides iteration, JSON/CSV export, pagination metadata, and array-like access.
18
+ */
19
+ export class DatabaseResult implements Iterable<Record<string, unknown>> {
20
+ readonly records: Record<string, unknown>[];
21
+ readonly columns: string[];
22
+ readonly count: number;
23
+ readonly limit: number;
24
+ readonly offset: number;
25
+ private readonly _adapter?: DatabaseAdapter;
26
+ private readonly _sql?: string;
27
+ private _columnInfoCache?: ColumnInfoResult[];
28
+
29
+ constructor(
30
+ records?: Record<string, unknown>[],
31
+ columns?: string[],
32
+ count?: number,
33
+ limit?: number,
34
+ offset?: number,
35
+ adapter?: DatabaseAdapter,
36
+ sql?: string,
37
+ ) {
38
+ this.records = records ?? [];
39
+ this.columns =
40
+ columns ?? (this.records.length > 0 ? Object.keys(this.records[0]) : []);
41
+ this.count = count ?? this.records.length;
42
+ this.limit = limit ?? this.records.length;
43
+ this.offset = offset ?? 0;
44
+ this._adapter = adapter;
45
+ this._sql = sql;
46
+ }
47
+
48
+ /** JSON string of records. */
49
+ toJson(): string {
50
+ return JSON.stringify(this.records);
51
+ }
52
+
53
+ /** CSV with header row. */
54
+ toCsv(): string {
55
+ if (this.columns.length === 0) return "";
56
+ const escape = (val: unknown): string => {
57
+ if (val === null || val === undefined) return "";
58
+ const str = String(val);
59
+ if (str.includes(",") || str.includes('"') || str.includes("\n")) {
60
+ return `"${str.replace(/"/g, '""')}"`;
61
+ }
62
+ return str;
63
+ };
64
+ const header = this.columns.map(escape).join(",");
65
+ const rows = this.records.map((row) =>
66
+ this.columns.map((col) => escape(row[col])).join(","),
67
+ );
68
+ return [header, ...rows].join("\n");
69
+ }
70
+
71
+ /** Same as records — plain array of row objects. */
72
+ toArray(): Record<string, unknown>[] {
73
+ return this.records;
74
+ }
75
+
76
+ /** Pagination envelope. */
77
+ toPaginate(): {
78
+ records: Record<string, unknown>[];
79
+ count: number;
80
+ limit: number;
81
+ offset: number;
82
+ } {
83
+ return {
84
+ records: this.records,
85
+ count: this.count,
86
+ limit: this.limit,
87
+ offset: this.offset,
88
+ };
89
+ }
90
+
91
+ /** Iterable — for (const row of result) */
92
+ [Symbol.iterator](): Iterator<Record<string, unknown>> {
93
+ return this.records[Symbol.iterator]();
94
+ }
95
+
96
+ /** Number of records in this page. */
97
+ get length(): number {
98
+ return this.records.length;
99
+ }
100
+
101
+ /** Array-like indexed access with negative index support. */
102
+ at(index: number): Record<string, unknown> | undefined {
103
+ if (index < 0) {
104
+ index = this.records.length + index;
105
+ }
106
+ return this.records[index];
107
+ }
108
+
109
+ /** JSON.stringify support — serialises as the records array. */
110
+ toJSON(): Record<string, unknown>[] {
111
+ return this.records;
112
+ }
113
+
114
+ /**
115
+ * Return column metadata for the query's table.
116
+ *
117
+ * Lazy — only queries the database when explicitly called. Caches the
118
+ * result so subsequent calls return immediately without re-querying.
119
+ */
120
+ columnInfo(): ColumnInfoResult[] {
121
+ if (this._columnInfoCache !== undefined) {
122
+ return this._columnInfoCache;
123
+ }
124
+
125
+ const table = this._extractTableFromSql();
126
+
127
+ if (this._adapter && table) {
128
+ try {
129
+ this._columnInfoCache = this._queryColumnMetadata(table);
130
+ return this._columnInfoCache;
131
+ } catch {
132
+ // Fall through to fallback
133
+ }
134
+ }
135
+
136
+ this._columnInfoCache = this._fallbackColumnInfo();
137
+ return this._columnInfoCache;
138
+ }
139
+
140
+ /** Extract table name from a SQL query using simple regex. */
141
+ private _extractTableFromSql(): string | null {
142
+ if (!this._sql) return null;
143
+
144
+ let m = this._sql.match(/\bFROM\s+["']?(\w+)["']?/i);
145
+ if (m) return m[1];
146
+
147
+ m = this._sql.match(/\bINSERT\s+INTO\s+["']?(\w+)["']?/i);
148
+ if (m) return m[1];
149
+
150
+ m = this._sql.match(/\bUPDATE\s+["']?(\w+)["']?/i);
151
+ if (m) return m[1];
152
+
153
+ return null;
154
+ }
155
+
156
+ /** Query the database adapter for column metadata. */
157
+ private _queryColumnMetadata(table: string): ColumnInfoResult[] {
158
+ if (!this._adapter) return this._fallbackColumnInfo();
159
+
160
+ try {
161
+ const rawCols: ColumnInfo[] = this._adapter.columns(table);
162
+ return this._normalizeColumns(rawCols);
163
+ } catch {
164
+ return this._fallbackColumnInfo();
165
+ }
166
+ }
167
+
168
+ /** Normalize adapter column info to standard format. */
169
+ private _normalizeColumns(rawCols: ColumnInfo[]): ColumnInfoResult[] {
170
+ return rawCols.map((col) => {
171
+ const colType = (col.type ?? "UNKNOWN").toUpperCase();
172
+ const [size, decimals] = this._parseTypeSize(colType);
173
+ return {
174
+ name: col.name,
175
+ type: colType.replace(/\(.*\)/, ""),
176
+ size,
177
+ decimals,
178
+ nullable: col.nullable ?? true,
179
+ primary_key: col.primaryKey ?? false,
180
+ };
181
+ });
182
+ }
183
+
184
+ /** Parse size and decimals from a type string like VARCHAR(255) or NUMERIC(10,2). */
185
+ private _parseTypeSize(typeStr: string): [number | null, number | null] {
186
+ const m = typeStr.match(/\((\d+)(?:\s*,\s*(\d+))?\)/);
187
+ if (m) {
188
+ const size = parseInt(m[1], 10);
189
+ const decimals = m[2] ? parseInt(m[2], 10) : null;
190
+ return [size, decimals];
191
+ }
192
+ return [null, null];
193
+ }
194
+
195
+ /** Derive basic column info from record keys when no adapter is available. */
196
+ private _fallbackColumnInfo(): ColumnInfoResult[] {
197
+ if (this.columns.length === 0) return [];
198
+ return this.columns.map((name) => ({
199
+ name,
200
+ type: "UNKNOWN",
201
+ size: null,
202
+ decimals: null,
203
+ nullable: true,
204
+ primary_key: false,
205
+ }));
206
+ }
207
+ }
@@ -3,7 +3,7 @@ export type {
3
3
  FieldDefinition,
4
4
  ModelDefinition,
5
5
  DatabaseAdapter,
6
- DatabaseResult,
6
+ DatabaseResult as DatabaseWriteResult,
7
7
  ColumnInfo,
8
8
  QueryOptions,
9
9
  RelationshipDefinition,
@@ -12,7 +12,9 @@ export type {
12
12
 
13
13
  export { FetchResult } from "./types.js";
14
14
 
15
- export { initDatabase, getAdapter, setAdapter, closeDatabase, parseDatabaseUrl, setNamedAdapter, getNamedAdapter } from "./database.js";
15
+ export { DatabaseResult } from "./databaseResult.js";
16
+ export type { ColumnInfoResult } from "./databaseResult.js";
17
+ export { Database, initDatabase, getAdapter, setAdapter, closeDatabase, parseDatabaseUrl, setNamedAdapter, getNamedAdapter } from "./database.js";
16
18
  export type { DatabaseConfig, ParsedDatabaseUrl } from "./database.js";
17
19
  export { discoverModels } from "./model.js";
18
20
  export type { DiscoveredModel } from "./model.js";
@@ -29,8 +31,9 @@ export {
29
31
  removeMigrationRecord,
30
32
  migrate,
31
33
  createMigration,
34
+ status,
32
35
  } from "./migration.js";
33
- export type { MigrationResult } from "./migration.js";
36
+ export type { MigrationResult, MigrationStatus } from "./migration.js";
34
37
  export { generateCrudRoutes } from "./autoCrud.js";
35
38
  export { buildQuery, parseQueryString } from "./query.js";
36
39
  export { validate } from "./validation.js";