tina4-nodejs 3.10.50 → 3.10.60

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.
@@ -0,0 +1,679 @@
1
+ /**
2
+ * Tina4 MongoDB Adapter — uses the `mongodb` package (optional peer dependency).
3
+ *
4
+ * Install: npm install mongodb
5
+ * URL format: mongodb://host:port/dbname or mongodb+srv://user:pass@host/dbname
6
+ */
7
+ import type { DatabaseAdapter, DatabaseResult, ColumnInfo, FieldDefinition } from "../types.js";
8
+
9
+ export interface MongoConfig {
10
+ host?: string;
11
+ port?: number;
12
+ user?: string;
13
+ password?: string;
14
+ database?: string;
15
+ connectionString?: string;
16
+ }
17
+
18
+ /**
19
+ * Parse a simple SQL SELECT/INSERT/UPDATE/DELETE into a MongoDB operation descriptor.
20
+ * Handles the most common patterns that Tina4 ORM generates internally.
21
+ */
22
+ interface MongoOperation {
23
+ type: "find" | "insertOne" | "insertMany" | "updateMany" | "deleteMany" | "aggregate" | "listCollections" | "raw";
24
+ collection?: string;
25
+ filter?: Record<string, unknown>;
26
+ document?: Record<string, unknown>;
27
+ documents?: Record<string, unknown>[];
28
+ update?: Record<string, unknown>;
29
+ projection?: Record<string, unknown>;
30
+ limit?: number;
31
+ skip?: number;
32
+ sort?: Record<string, 1 | -1>;
33
+ pipeline?: Record<string, unknown>[];
34
+ }
35
+
36
+ /** Convert SQL WHERE clause tokens into a MongoDB filter object. */
37
+ function parseWhereClause(where: string, params: unknown[], paramOffset = 0): { filter: Record<string, unknown>; consumed: number } {
38
+ const filter: Record<string, unknown> = {};
39
+ // Simple key = ? / key = 'value' patterns separated by AND
40
+ const parts = where.split(/\s+AND\s+/i);
41
+ let paramIndex = paramOffset;
42
+
43
+ for (const part of parts) {
44
+ const trimmed = part.trim();
45
+
46
+ // key = ?
47
+ const eqParam = trimmed.match(/^["']?(\w+)["']?\s*=\s*\?$/i);
48
+ if (eqParam) {
49
+ filter[eqParam[1]] = params[paramIndex++];
50
+ continue;
51
+ }
52
+
53
+ // key = 'literal' or key = 123
54
+ const eqLiteral = trimmed.match(/^["']?(\w+)["']?\s*=\s*(?:'([^']*)'|(\d+(?:\.\d+)?))$/i);
55
+ if (eqLiteral) {
56
+ filter[eqLiteral[1]] = eqLiteral[2] !== undefined ? eqLiteral[2] : Number(eqLiteral[3]);
57
+ continue;
58
+ }
59
+
60
+ // key != ? or key <> ?
61
+ const neParam = trimmed.match(/^["']?(\w+)["']?\s*(?:!=|<>)\s*\?$/i);
62
+ if (neParam) {
63
+ filter[neParam[1]] = { $ne: params[paramIndex++] };
64
+ continue;
65
+ }
66
+
67
+ // key > ?
68
+ const gtParam = trimmed.match(/^["']?(\w+)["']?\s*>\s*\?$/i);
69
+ if (gtParam) {
70
+ filter[gtParam[1]] = { $gt: params[paramIndex++] };
71
+ continue;
72
+ }
73
+
74
+ // key >= ?
75
+ const gteParam = trimmed.match(/^["']?(\w+)["']?\s*>=\s*\?$/i);
76
+ if (gteParam) {
77
+ filter[gteParam[1]] = { $gte: params[paramIndex++] };
78
+ continue;
79
+ }
80
+
81
+ // key < ?
82
+ const ltParam = trimmed.match(/^["']?(\w+)["']?\s*<\s*\?$/i);
83
+ if (ltParam) {
84
+ filter[ltParam[1]] = { $lt: params[paramIndex++] };
85
+ continue;
86
+ }
87
+
88
+ // key <= ?
89
+ const lteParam = trimmed.match(/^["']?(\w+)["']?\s*<=\s*\?$/i);
90
+ if (lteParam) {
91
+ filter[lteParam[1]] = { $lte: params[paramIndex++] };
92
+ continue;
93
+ }
94
+
95
+ // key LIKE ? (translate % wildcards to regex)
96
+ const likeParam = trimmed.match(/^["']?(\w+)["']?\s+(?:I?LIKE)\s+\?$/i);
97
+ if (likeParam) {
98
+ const val = String(params[paramIndex++]);
99
+ const regex = val.replace(/%/g, ".*").replace(/_/g, ".");
100
+ filter[likeParam[1]] = { $regex: regex, $options: "i" };
101
+ continue;
102
+ }
103
+
104
+ // key IN (?, ?, ...) — count the ?s
105
+ const inMatch = trimmed.match(/^["']?(\w+)["']?\s+IN\s*\(([^)]+)\)$/i);
106
+ if (inMatch) {
107
+ const placeholders = (inMatch[2].match(/\?/g) || []).length;
108
+ const values = params.slice(paramIndex, paramIndex + placeholders);
109
+ paramIndex += placeholders;
110
+ filter[inMatch[1]] = { $in: values };
111
+ continue;
112
+ }
113
+
114
+ // IS NULL / IS NOT NULL
115
+ const nullMatch = trimmed.match(/^["']?(\w+)["']?\s+IS\s+(NOT\s+)?NULL$/i);
116
+ if (nullMatch) {
117
+ filter[nullMatch[1]] = nullMatch[2] ? { $ne: null } : null;
118
+ continue;
119
+ }
120
+ }
121
+
122
+ return { filter, consumed: paramIndex - paramOffset };
123
+ }
124
+
125
+ /** Parse a SQL string into a MongoOperation. Returns null if parsing is not supported. */
126
+ function parseSql(sql: string, params: unknown[] = []): MongoOperation | null {
127
+ const s = sql.trim();
128
+
129
+ // ---- SELECT ----
130
+ const selectMatch = s.match(
131
+ /^SELECT\s+(.*?)\s+FROM\s+["']?(\w+)["']?(?:\s+WHERE\s+(.*?))?(?:\s+ORDER\s+BY\s+(.*?))?(?:\s+LIMIT\s+(\d+))?(?:\s+OFFSET\s+(\d+))?$/is,
132
+ );
133
+ if (selectMatch) {
134
+ const [, cols, collection, whereClause, orderBy, limitStr, skipStr] = selectMatch;
135
+
136
+ // Projection
137
+ let projection: Record<string, unknown> | undefined;
138
+ if (cols && cols.trim() !== "*") {
139
+ projection = {};
140
+ for (const col of cols.split(",")) {
141
+ const name = col.trim().replace(/^["']|["']$/g, "");
142
+ if (name && name !== "*") projection[name] = 1;
143
+ }
144
+ }
145
+
146
+ // Filter
147
+ let filter: Record<string, unknown> = {};
148
+ if (whereClause) {
149
+ const parsed = parseWhereClause(whereClause.trim(), params);
150
+ filter = parsed.filter;
151
+ }
152
+
153
+ // Sort
154
+ let sort: Record<string, 1 | -1> | undefined;
155
+ if (orderBy) {
156
+ sort = {};
157
+ for (const part of orderBy.split(",")) {
158
+ const t = part.trim();
159
+ const desc = /DESC$/i.test(t);
160
+ const col = t.replace(/\s+(ASC|DESC)$/i, "").replace(/^["']|["']$/g, "").trim();
161
+ sort[col] = desc ? -1 : 1;
162
+ }
163
+ }
164
+
165
+ return {
166
+ type: "find",
167
+ collection,
168
+ filter,
169
+ projection: projection && Object.keys(projection).length > 0 ? projection : undefined,
170
+ limit: limitStr ? parseInt(limitStr, 10) : undefined,
171
+ skip: skipStr ? parseInt(skipStr, 10) : undefined,
172
+ sort,
173
+ };
174
+ }
175
+
176
+ // ---- INSERT ----
177
+ const insertMatch = s.match(
178
+ /^INSERT\s+INTO\s+["']?(\w+)["']?\s*\(([^)]+)\)\s*VALUES\s*\(([^)]+)\)$/is,
179
+ );
180
+ if (insertMatch) {
181
+ const [, collection, colsStr, valsStr] = insertMatch;
182
+ const cols = colsStr.split(",").map((c) => c.trim().replace(/^["']|["']$/g, ""));
183
+ const valPlaceholders = valsStr.split(",").map((v) => v.trim());
184
+ let paramIndex = 0;
185
+ const doc: Record<string, unknown> = {};
186
+ for (let i = 0; i < cols.length; i++) {
187
+ if (valPlaceholders[i] === "?") {
188
+ doc[cols[i]] = params[paramIndex++];
189
+ } else if (/^'.*'$/.test(valPlaceholders[i])) {
190
+ doc[cols[i]] = valPlaceholders[i].slice(1, -1);
191
+ } else if (/^-?\d+(\.\d+)?$/.test(valPlaceholders[i])) {
192
+ doc[cols[i]] = Number(valPlaceholders[i]);
193
+ } else {
194
+ doc[cols[i]] = valPlaceholders[i];
195
+ }
196
+ }
197
+ return { type: "insertOne", collection, document: doc };
198
+ }
199
+
200
+ // ---- UPDATE ----
201
+ const updateMatch = s.match(
202
+ /^UPDATE\s+["']?(\w+)["']?\s+SET\s+(.*?)\s+WHERE\s+(.+)$/is,
203
+ );
204
+ if (updateMatch) {
205
+ const [, collection, setClause, whereClause] = updateMatch;
206
+
207
+ // Parse SET clause
208
+ const setDoc: Record<string, unknown> = {};
209
+ let setParamIndex = 0;
210
+ for (const setPart of setClause.split(",")) {
211
+ const m = setPart.trim().match(/^["']?(\w+)["']?\s*=\s*\?$/);
212
+ if (m) {
213
+ setDoc[m[1]] = params[setParamIndex++];
214
+ } else {
215
+ const mLit = setPart.trim().match(/^["']?(\w+)["']?\s*=\s*(?:'([^']*)'|(-?\d+(?:\.\d+)?))$/);
216
+ if (mLit) {
217
+ setDoc[mLit[1]] = mLit[2] !== undefined ? mLit[2] : Number(mLit[3]);
218
+ }
219
+ }
220
+ }
221
+
222
+ // Parse WHERE clause (params start after SET params)
223
+ const { filter } = parseWhereClause(whereClause.trim(), params, setParamIndex);
224
+
225
+ return { type: "updateMany", collection, filter, update: { $set: setDoc } };
226
+ }
227
+
228
+ // ---- DELETE ----
229
+ const deleteMatch = s.match(
230
+ /^DELETE\s+FROM\s+["']?(\w+)["']?(?:\s+WHERE\s+(.+))?$/is,
231
+ );
232
+ if (deleteMatch) {
233
+ const [, collection, whereClause] = deleteMatch;
234
+ const filter = whereClause
235
+ ? parseWhereClause(whereClause.trim(), params).filter
236
+ : {};
237
+ return { type: "deleteMany", collection, filter };
238
+ }
239
+
240
+ // ---- CREATE TABLE (treated as createCollection) ----
241
+ const createTableMatch = s.match(/^CREATE\s+(?:TABLE|COLLECTION)\s+(?:IF\s+NOT\s+EXISTS\s+)?["']?(\w+)["']?/i);
242
+ if (createTableMatch) {
243
+ // We handle this in createTable(); signal it as a "raw" skip
244
+ return { type: "raw", collection: createTableMatch[1] };
245
+ }
246
+
247
+ // ---- SELECT COUNT(*) ----
248
+ const countMatch = s.match(/^SELECT\s+COUNT\(\*\)\s+AS\s+(\w+)\s+FROM\s+["']?(\w+)["']?(?:\s+WHERE\s+(.+))?$/is);
249
+ if (countMatch) {
250
+ const [, alias, collection, whereClause] = countMatch;
251
+ const filter = whereClause
252
+ ? parseWhereClause(whereClause.trim(), params).filter
253
+ : {};
254
+ return {
255
+ type: "aggregate",
256
+ collection,
257
+ pipeline: [
258
+ { $match: filter },
259
+ { $count: alias },
260
+ ],
261
+ };
262
+ }
263
+
264
+ return null;
265
+ }
266
+
267
+ export class MongodbAdapter implements DatabaseAdapter {
268
+ private client: any = null;
269
+ private db: any = null;
270
+ private session: any = null;
271
+ private _lastInsertId: number | bigint | null = null;
272
+ private _inTransaction = false;
273
+ private _connectionString: string;
274
+ private _dbName: string;
275
+
276
+ constructor(private config: MongoConfig | string) {
277
+ if (typeof config === "string") {
278
+ this._connectionString = config;
279
+ // Extract database name from the URL path
280
+ try {
281
+ const url = new URL(config);
282
+ this._dbName = url.pathname.replace(/^\//, "") || "tina4";
283
+ } catch {
284
+ this._dbName = "tina4";
285
+ }
286
+ } else {
287
+ const host = config.host ?? "localhost";
288
+ const port = config.port ?? 27017;
289
+ const creds = config.user && config.password
290
+ ? `${encodeURIComponent(config.user)}:${encodeURIComponent(config.password)}@`
291
+ : "";
292
+ this._connectionString = `mongodb://${creds}${host}:${port}/${config.database ?? "tina4"}`;
293
+ this._dbName = config.database ?? "tina4";
294
+ }
295
+ }
296
+
297
+ /** Connect to MongoDB. Must be called before using the adapter. */
298
+ async connect(): Promise<void> {
299
+ let MongoClient: any;
300
+ try {
301
+ MongoClient = (await import("mongodb")).MongoClient;
302
+ } catch {
303
+ throw new Error(
304
+ "The 'mongodb' package is required for MongoDB connections. " +
305
+ "Install: npm install mongodb",
306
+ );
307
+ }
308
+
309
+ this.client = new MongoClient(this._connectionString);
310
+ await this.client.connect();
311
+ this.db = this.client.db(this._dbName);
312
+ }
313
+
314
+ private ensureConnected(): void {
315
+ if (!this.db) {
316
+ throw new Error("MongoDB adapter not connected. Call connect() first.");
317
+ }
318
+ }
319
+
320
+ /** Execute a SQL-like statement translated to a MongoDB operation. */
321
+ execute(sql: string, params?: unknown[]): unknown {
322
+ throw new Error("Use executeAsync() for MongoDB — async adapter requires async methods.");
323
+ }
324
+
325
+ async executeAsync(sql: string, params?: unknown[]): Promise<unknown> {
326
+ this.ensureConnected();
327
+ const op = parseSql(sql, params ?? []);
328
+
329
+ if (!op || op.type === "raw") {
330
+ // Unsupported SQL — log and skip (DDL, etc.)
331
+ return { acknowledged: true };
332
+ }
333
+
334
+ const col = this.db.collection(op.collection!);
335
+
336
+ switch (op.type) {
337
+ case "insertOne": {
338
+ const result = await col.insertOne(op.document!, { session: this.session });
339
+ this._lastInsertId = null; // MongoDB uses ObjectId
340
+ return result;
341
+ }
342
+ case "insertMany": {
343
+ const result = await col.insertMany(op.documents!, { session: this.session });
344
+ return result;
345
+ }
346
+ case "updateMany": {
347
+ const result = await col.updateMany(op.filter ?? {}, op.update!, { session: this.session });
348
+ return result;
349
+ }
350
+ case "deleteMany": {
351
+ const result = await col.deleteMany(op.filter ?? {}, { session: this.session });
352
+ return result;
353
+ }
354
+ case "find": {
355
+ let cursor = col.find(op.filter ?? {}, { session: this.session });
356
+ if (op.projection) cursor = cursor.project(op.projection);
357
+ if (op.sort) cursor = cursor.sort(op.sort);
358
+ if (op.skip) cursor = cursor.skip(op.skip);
359
+ if (op.limit) cursor = cursor.limit(op.limit);
360
+ return cursor.toArray();
361
+ }
362
+ case "aggregate": {
363
+ return col.aggregate(op.pipeline ?? [], { session: this.session }).toArray();
364
+ }
365
+ default:
366
+ return { acknowledged: true };
367
+ }
368
+ }
369
+
370
+ executeMany(sql: string, paramsList: unknown[][]): { totalAffected: number; lastInsertId?: number | bigint } {
371
+ throw new Error("Use executeManyAsync() for MongoDB — async adapter requires async methods.");
372
+ }
373
+
374
+ async executeManyAsync(sql: string, paramsList: unknown[][]): Promise<{ totalAffected: number; lastInsertId?: number | bigint }> {
375
+ let totalAffected = 0;
376
+ for (const params of paramsList) {
377
+ await this.executeAsync(sql, params);
378
+ totalAffected++;
379
+ }
380
+ return { totalAffected };
381
+ }
382
+
383
+ query<T = Record<string, unknown>>(sql: string, params?: unknown[]): T[] {
384
+ throw new Error("Use queryAsync() for MongoDB — async adapter requires async methods.");
385
+ }
386
+
387
+ async queryAsync<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]> {
388
+ this.ensureConnected();
389
+ const op = parseSql(sql, params ?? []);
390
+
391
+ if (!op) return [];
392
+
393
+ const col = this.db.collection(op.collection!);
394
+
395
+ switch (op.type) {
396
+ case "find": {
397
+ let cursor = col.find(op.filter ?? {}, { session: this.session });
398
+ if (op.projection) cursor = cursor.project(op.projection);
399
+ if (op.sort) cursor = cursor.sort(op.sort);
400
+ if (op.skip) cursor = cursor.skip(op.skip);
401
+ if (op.limit) cursor = cursor.limit(op.limit);
402
+ return cursor.toArray() as Promise<T[]>;
403
+ }
404
+ case "aggregate": {
405
+ return col.aggregate(op.pipeline ?? [], { session: this.session }).toArray() as Promise<T[]>;
406
+ }
407
+ default:
408
+ return [];
409
+ }
410
+ }
411
+
412
+ fetch<T = Record<string, unknown>>(sql: string, params?: unknown[], limit?: number, skip?: number): T[] {
413
+ throw new Error("Use fetchAsync() for MongoDB — async adapter requires async methods.");
414
+ }
415
+
416
+ async fetchAsync<T = Record<string, unknown>>(sql: string, params?: unknown[], limit?: number, skip?: number): Promise<T[]> {
417
+ this.ensureConnected();
418
+ const op = parseSql(sql, params ?? []);
419
+
420
+ if (!op || op.type !== "find") return this.queryAsync<T>(sql, params);
421
+
422
+ const col = this.db.collection(op.collection!);
423
+ let cursor = col.find(op.filter ?? {}, { session: this.session });
424
+ if (op.projection) cursor = cursor.project(op.projection);
425
+ if (op.sort) cursor = cursor.sort(op.sort);
426
+
427
+ const effectiveSkip = skip ?? op.skip ?? 0;
428
+ const effectiveLimit = limit ?? op.limit;
429
+ if (effectiveSkip > 0) cursor = cursor.skip(effectiveSkip);
430
+ if (effectiveLimit !== undefined) cursor = cursor.limit(effectiveLimit);
431
+
432
+ return cursor.toArray() as Promise<T[]>;
433
+ }
434
+
435
+ fetchOne<T = Record<string, unknown>>(sql: string, params?: unknown[]): T | null {
436
+ throw new Error("Use fetchOneAsync() for MongoDB — async adapter requires async methods.");
437
+ }
438
+
439
+ async fetchOneAsync<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T | null> {
440
+ const rows = await this.fetchAsync<T>(sql, params, 1);
441
+ return rows[0] ?? null;
442
+ }
443
+
444
+ insert(table: string, data: Record<string, unknown> | Record<string, unknown>[]): DatabaseResult {
445
+ throw new Error("Use insertAsync() for MongoDB — async adapter requires async methods.");
446
+ }
447
+
448
+ async insertAsync(table: string, data: Record<string, unknown> | Record<string, unknown>[]): Promise<DatabaseResult> {
449
+ this.ensureConnected();
450
+ const col = this.db.collection(table);
451
+ try {
452
+ if (Array.isArray(data)) {
453
+ if (data.length === 0) return { success: true, rowsAffected: 0 };
454
+ const result = await col.insertMany(data, { session: this.session });
455
+ return { success: true, rowsAffected: result.insertedCount };
456
+ }
457
+ const result = await col.insertOne(data, { session: this.session });
458
+ return { success: true, rowsAffected: 1, lastInsertId: undefined };
459
+ } catch (e) {
460
+ return { success: false, rowsAffected: 0, error: (e as Error).message };
461
+ }
462
+ }
463
+
464
+ update(table: string, data: Record<string, unknown>, filter: Record<string, unknown>): DatabaseResult {
465
+ throw new Error("Use updateAsync() for MongoDB — async adapter requires async methods.");
466
+ }
467
+
468
+ async updateAsync(table: string, data: Record<string, unknown>, filter: Record<string, unknown>): Promise<DatabaseResult> {
469
+ this.ensureConnected();
470
+ const col = this.db.collection(table);
471
+ try {
472
+ const result = await col.updateMany(filter, { $set: data }, { session: this.session });
473
+ return { success: true, rowsAffected: result.modifiedCount };
474
+ } catch (e) {
475
+ return { success: false, rowsAffected: 0, error: (e as Error).message };
476
+ }
477
+ }
478
+
479
+ delete(table: string, filter: Record<string, unknown> | string | Record<string, unknown>[]): DatabaseResult {
480
+ throw new Error("Use deleteAsync() for MongoDB — async adapter requires async methods.");
481
+ }
482
+
483
+ async deleteAsync(table: string, filter: Record<string, unknown> | string | Record<string, unknown>[]): Promise<DatabaseResult> {
484
+ this.ensureConnected();
485
+ const col = this.db.collection(table);
486
+ try {
487
+ if (Array.isArray(filter)) {
488
+ let total = 0;
489
+ for (const f of filter) {
490
+ const r = await col.deleteMany(f, { session: this.session });
491
+ total += r.deletedCount;
492
+ }
493
+ return { success: true, rowsAffected: total };
494
+ }
495
+
496
+ // String WHERE clause — not directly translatable; delete nothing safely
497
+ if (typeof filter === "string") {
498
+ if (!filter.trim()) {
499
+ // Empty filter = delete all documents
500
+ const r = await col.deleteMany({}, { session: this.session });
501
+ return { success: true, rowsAffected: r.deletedCount };
502
+ }
503
+ // Attempt parse via dummy SELECT wrapping
504
+ const { filter: parsedFilter } = parseWhereClause(filter, []);
505
+ const r = await col.deleteMany(parsedFilter, { session: this.session });
506
+ return { success: true, rowsAffected: r.deletedCount };
507
+ }
508
+
509
+ const result = await col.deleteMany(filter as Record<string, unknown>, { session: this.session });
510
+ return { success: true, rowsAffected: result.deletedCount };
511
+ } catch (e) {
512
+ return { success: false, rowsAffected: 0, error: (e as Error).message };
513
+ }
514
+ }
515
+
516
+ startTransaction(): void {
517
+ throw new Error("Use startTransactionAsync() for MongoDB — async adapter requires async methods.");
518
+ }
519
+
520
+ async startTransactionAsync(): Promise<void> {
521
+ this.ensureConnected();
522
+ if (this._inTransaction) return;
523
+ this.session = this.client.startSession();
524
+ this.session.startTransaction();
525
+ this._inTransaction = true;
526
+ }
527
+
528
+ commit(): void {
529
+ throw new Error("Use commitAsync() for MongoDB — async adapter requires async methods.");
530
+ }
531
+
532
+ async commitAsync(): Promise<void> {
533
+ if (!this._inTransaction || !this.session) return;
534
+ await this.session.commitTransaction();
535
+ await this.session.endSession();
536
+ this.session = null;
537
+ this._inTransaction = false;
538
+ }
539
+
540
+ rollback(): void {
541
+ throw new Error("Use rollbackAsync() for MongoDB — async adapter requires async methods.");
542
+ }
543
+
544
+ async rollbackAsync(): Promise<void> {
545
+ if (!this._inTransaction || !this.session) return;
546
+ try {
547
+ await this.session.abortTransaction();
548
+ } catch {
549
+ // Ignore rollback failures
550
+ }
551
+ await this.session.endSession();
552
+ this.session = null;
553
+ this._inTransaction = false;
554
+ }
555
+
556
+ tables(): string[] {
557
+ throw new Error("Use tablesAsync() for MongoDB — async adapter requires async methods.");
558
+ }
559
+
560
+ async tablesAsync(): Promise<string[]> {
561
+ this.ensureConnected();
562
+ const collections = await this.db.listCollections().toArray();
563
+ return collections.map((c: any) => c.name as string);
564
+ }
565
+
566
+ columns(table: string): ColumnInfo[] {
567
+ throw new Error("Use columnsAsync() for MongoDB — async adapter requires async methods.");
568
+ }
569
+
570
+ /**
571
+ * Infer column schema by sampling a document from the collection.
572
+ * MongoDB is schema-less; this returns field names and inferred JS types.
573
+ */
574
+ async columnsAsync(table: string): Promise<ColumnInfo[]> {
575
+ this.ensureConnected();
576
+ const doc = await this.db.collection(table).findOne({});
577
+ if (!doc) return [];
578
+ return Object.entries(doc).map(([key, value]) => ({
579
+ name: key,
580
+ type: typeof value === "number"
581
+ ? "number"
582
+ : typeof value === "boolean"
583
+ ? "boolean"
584
+ : value instanceof Date
585
+ ? "datetime"
586
+ : "string",
587
+ nullable: true,
588
+ default: undefined,
589
+ primaryKey: key === "_id",
590
+ }));
591
+ }
592
+
593
+ lastInsertId(): number | bigint | null {
594
+ return this._lastInsertId;
595
+ }
596
+
597
+ close(): void {
598
+ if (this.client) {
599
+ // MongoDB client.close() is async but we match the sync interface
600
+ this.client.close().catch(() => {});
601
+ this.client = null;
602
+ this.db = null;
603
+ }
604
+ }
605
+
606
+ tableExists(name: string): boolean {
607
+ throw new Error("Use tableExistsAsync() for MongoDB — async adapter requires async methods.");
608
+ }
609
+
610
+ async tableExistsAsync(name: string): Promise<boolean> {
611
+ const tables = await this.tablesAsync();
612
+ return tables.includes(name);
613
+ }
614
+
615
+ createTable(name: string, columns: Record<string, FieldDefinition>): void {
616
+ throw new Error("Use createTableAsync() for MongoDB — async adapter requires async methods.");
617
+ }
618
+
619
+ /**
620
+ * Create a MongoDB collection with optional JSON schema validation derived
621
+ * from the Tina4 field definitions.
622
+ */
623
+ async createTableAsync(name: string, columns: Record<string, FieldDefinition>): Promise<void> {
624
+ this.ensureConnected();
625
+
626
+ // Only create if not already present
627
+ const exists = await this.tableExistsAsync(name);
628
+ if (exists) return;
629
+
630
+ // Build a JSON Schema validator
631
+ const required: string[] = [];
632
+ const properties: Record<string, unknown> = {};
633
+
634
+ for (const [colName, def] of Object.entries(columns)) {
635
+ if (def.required && !def.primaryKey) {
636
+ required.push(colName);
637
+ }
638
+ properties[colName] = fieldTypeToJsonSchema(def);
639
+ }
640
+
641
+ const validator = {
642
+ $jsonSchema: {
643
+ bsonType: "object",
644
+ properties,
645
+ ...(required.length > 0 ? { required } : {}),
646
+ },
647
+ };
648
+
649
+ await this.db.createCollection(name, { validator });
650
+ }
651
+
652
+ /** Get column info as a plain array (legacy migration support). */
653
+ async getTableColumnsAsync(table: string): Promise<Array<{ name: string; type: string }>> {
654
+ const cols = await this.columnsAsync(table);
655
+ return cols.map((c) => ({ name: c.name, type: c.type }));
656
+ }
657
+ }
658
+
659
+ function fieldTypeToJsonSchema(def: FieldDefinition): Record<string, unknown> {
660
+ switch (def.type) {
661
+ case "integer":
662
+ return { bsonType: "int" };
663
+ case "number":
664
+ case "numeric":
665
+ return { bsonType: "double" };
666
+ case "boolean":
667
+ return { bsonType: "bool" };
668
+ case "datetime":
669
+ return { bsonType: "date" };
670
+ case "text":
671
+ return { bsonType: "string" };
672
+ case "string":
673
+ return def.maxLength
674
+ ? { bsonType: "string", maxLength: def.maxLength }
675
+ : { bsonType: "string" };
676
+ default:
677
+ return { bsonType: "string" };
678
+ }
679
+ }