@arcote.tech/arc-adapter-db-postgres 0.3.2 → 0.3.4

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.
@@ -1,1002 +0,0 @@
1
- import {
2
- type ArcContextAny,
3
- type DatabaseAdapter,
4
- type DatabaseAgnosticColumnInfo,
5
- type DatabaseStoreData,
6
- type DatabaseStoreSchema,
7
- type DBAdapterFactory,
8
- type FindOptions,
9
- type ReadTransaction,
10
- type ReadWriteTransaction,
11
- type StoreColumn,
12
- type StoreTable,
13
- type WhereCondition,
14
- extractDatabaseAgnosticSchema,
15
- } from "@arcote.tech/arc";
16
-
17
- export interface PostgreSQLDatabase {
18
- exec(sql: string, params?: any[]): Promise<any>;
19
- execBatch(queries: Array<{ sql: string; params?: any[] }>): Promise<any>;
20
- }
21
-
22
- class PostgreSQLReadTransaction implements ReadTransaction {
23
- constructor(
24
- protected db: PostgreSQLDatabase,
25
- protected tables: Map<string, StoreTable>,
26
- protected adapter?: PostgreSQLAdapter,
27
- ) {}
28
-
29
- protected hasSoftDelete(tableName: string): boolean {
30
- if (this.adapter) {
31
- return this.adapter.hasSoftDelete(tableName);
32
- }
33
- // Fallback: check if table has deleted column
34
- const table = this.tables.get(tableName);
35
- if (!table) return false;
36
- return table.columns.some((col) => col.name === "deleted");
37
- }
38
-
39
- protected deserializeValue(value: any, column: StoreColumn): any {
40
- if (value === null || value === undefined) return null;
41
-
42
- switch (column.type.toLowerCase()) {
43
- case "json":
44
- case "jsonb":
45
- // PostgreSQL JSON/JSONB columns should always be parsed
46
- if (typeof value === "string") {
47
- try {
48
- return JSON.parse(value);
49
- } catch {
50
- return value;
51
- }
52
- }
53
- return value; // Already parsed by PostgreSQL driver
54
- case "text":
55
- // For text columns, only attempt JSON parsing if it looks like JSON
56
- if (
57
- typeof value === "string" &&
58
- (value.startsWith("{") || value.startsWith("["))
59
- ) {
60
- try {
61
- const parsed = JSON.parse(value);
62
- if (typeof parsed === "object" || Array.isArray(parsed)) {
63
- return parsed;
64
- }
65
- } catch {
66
- // Not valid JSON, return as string
67
- }
68
- }
69
- return value;
70
- case "date":
71
- case "datetime":
72
- case "timestamp":
73
- // PostgreSQL returns Date objects directly via postgres.js driver
74
- if (value instanceof Date) {
75
- return value;
76
- }
77
- if (typeof value === "string") {
78
- // If the string doesn't have timezone info, treat as UTC
79
- if (
80
- !value.endsWith("Z") &&
81
- !value.includes("+") &&
82
- !value.includes("-", 10)
83
- ) {
84
- return new Date(value + "Z");
85
- }
86
- return new Date(value);
87
- }
88
- return new Date(value);
89
- default:
90
- return value;
91
- }
92
- }
93
-
94
- protected deserializeRow(row: any, table: StoreTable): any {
95
- const result: any = {};
96
- for (const column of table.columns) {
97
- const value = row[column.name];
98
- result[column.name] = this.deserializeValue(value, column);
99
- }
100
- return result;
101
- }
102
-
103
- protected getId(store: string, id: any) {
104
- return id;
105
- }
106
-
107
- protected buildWhereClause(
108
- where?: WhereCondition,
109
- tableName?: string,
110
- ): {
111
- sql: string;
112
- params: any[];
113
- } {
114
- const conditions: string[] = [];
115
- const params: any[] = [];
116
- let paramIndex = 1;
117
-
118
- // Debug logging
119
- if (where && (Array.isArray(where) || typeof where !== "object")) {
120
- console.error(
121
- "[buildWhereClause] Invalid where clause type:",
122
- typeof where,
123
- Array.isArray(where) ? "(array)" : "",
124
- );
125
- console.error(
126
- "[buildWhereClause] where value:",
127
- JSON.stringify(where, null, 2),
128
- );
129
- console.error("[buildWhereClause] tableName:", tableName);
130
- console.trace("[buildWhereClause] Stack trace:");
131
- }
132
-
133
- // Only add deleted condition if table has soft delete enabled
134
- if (tableName && this.hasSoftDelete(tableName)) {
135
- conditions.push('"deleted" = $1');
136
- params.push(false);
137
- paramIndex = 2;
138
- }
139
-
140
- if (!where) {
141
- return {
142
- sql: conditions.length > 0 ? conditions.join(" AND ") : "1=1",
143
- params,
144
- };
145
- }
146
-
147
- Object.entries(where).forEach(([key, value]) => {
148
- if (typeof value === "object" && value !== null) {
149
- Object.entries(value as Record<string, unknown>).forEach(
150
- ([operator, operand]) => {
151
- switch (operator) {
152
- case "$eq":
153
- case "$ne":
154
- case "$gt":
155
- case "$gte":
156
- case "$lt":
157
- case "$lte":
158
- conditions.push(
159
- `"${key}" ${this.getOperatorSymbol(operator)} $${paramIndex}`,
160
- );
161
- params.push(operand);
162
- paramIndex++;
163
- break;
164
- case "$in":
165
- case "$nin":
166
- if (Array.isArray(operand)) {
167
- const placeholders = operand
168
- .map(() => `$${paramIndex++}`)
169
- .join(", ");
170
- conditions.push(
171
- `"${key}" ${operator === "$in" ? "IN" : "NOT IN"} (${placeholders})`,
172
- );
173
- params.push(...operand);
174
- }
175
- break;
176
- case "$exists":
177
- if (typeof operand === "boolean") {
178
- conditions.push(
179
- operand ? `"${key}" IS NOT NULL` : `"${key}" IS NULL`,
180
- );
181
- }
182
- break;
183
- }
184
- },
185
- );
186
- } else {
187
- conditions.push(`"${key}" = $${paramIndex}`);
188
- params.push(value);
189
- paramIndex++;
190
- }
191
- });
192
-
193
- return {
194
- sql: conditions.length > 0 ? conditions.join(" AND ") : "1=1",
195
- params,
196
- };
197
- }
198
-
199
- protected getOperatorSymbol(operator: string): string {
200
- const operators: Record<string, string> = {
201
- $eq: "=",
202
- $ne: "!=",
203
- $gt: ">",
204
- $gte: ">=",
205
- $lt: "<",
206
- $lte: "<=",
207
- };
208
- return operators[operator] || "=";
209
- }
210
-
211
- protected buildOrderByClause(
212
- orderBy?: Record<string, "asc" | "desc">,
213
- ): string {
214
- if (!orderBy) return "";
215
-
216
- const orderClauses = Object.entries(orderBy)
217
- .map(([key, direction]) => `"${key}" ${direction.toUpperCase()}`)
218
- .join(", ");
219
-
220
- return orderClauses ? `ORDER BY ${orderClauses}` : "";
221
- }
222
-
223
- async find<T>(store: string, options: FindOptions<T>): Promise<T[]> {
224
- const { where, limit, offset, orderBy } = options || {};
225
- const whereClause = this.buildWhereClause(where, store);
226
- const orderByClause = this.buildOrderByClause(orderBy);
227
- const table = this.tables.get(store);
228
-
229
- if (!table) {
230
- throw new Error(`Store ${store} not found`);
231
- }
232
-
233
- let query = `
234
- SELECT *
235
- FROM "${table.name}"
236
- WHERE ${whereClause.sql}
237
- ${orderByClause}
238
- `;
239
-
240
- let params = whereClause.params;
241
- let paramIndex = params.length + 1;
242
-
243
- if (limit) {
244
- query += ` LIMIT $${paramIndex}`;
245
- params.push(limit);
246
- paramIndex++;
247
- }
248
-
249
- if (offset) {
250
- query += ` OFFSET $${paramIndex}`;
251
- params.push(offset);
252
- }
253
-
254
- const rows = await this.db.exec(query, params);
255
- return rows.map((row: any) => this.deserializeRow(row, table));
256
- }
257
- }
258
-
259
- class PostgreSQLReadWriteTransaction
260
- extends PostgreSQLReadTransaction
261
- implements ReadWriteTransaction
262
- {
263
- private queries: Array<{ sql: string; params?: any[] }> = [];
264
-
265
- constructor(
266
- db: PostgreSQLDatabase,
267
- tables: Map<string, StoreTable>,
268
- protected adapter: PostgreSQLAdapter,
269
- ) {
270
- super(db, tables);
271
- }
272
- async remove(store: string, id: any) {
273
- const table = this.tables.get(store);
274
- if (!table) {
275
- throw new Error(`Store ${store} not found`);
276
- }
277
- const query = `UPDATE "${table.name}" SET "deleted" = $1, "lastUpdate" = $2 WHERE "${table.primaryKey}" = $3`;
278
- this.queries.push({
279
- sql: query,
280
- params: [true, new Date().toISOString(), id],
281
- });
282
- }
283
-
284
- async set(store: string, item: any) {
285
- const table = this.tables.get(store);
286
- if (!table) {
287
- throw new Error(`Store ${store} not found`);
288
- }
289
-
290
- const hasVersioning = this.adapter.hasVersioning(store);
291
-
292
- try {
293
- if (hasVersioning) {
294
- // For versioned tables, use a transaction with version increment
295
- await this.setWithVersioning(store, item, table);
296
- } else {
297
- // For non-versioned tables, use simple insert
298
- await this.setWithoutVersioning(store, item, table);
299
- }
300
- } catch (error) {
301
- console.error(`[PostgreSQL] Error in set() for store "${store}":`);
302
- console.error(
303
- `[PostgreSQL] Item data:`,
304
- JSON.stringify(
305
- item,
306
- (key, value) => {
307
- if (value instanceof Date) {
308
- return `[Date: ${value.toString()} | ISO: ${isNaN(value.getTime()) ? "Invalid Date" : value.toISOString()}]`;
309
- }
310
- return value;
311
- },
312
- 2,
313
- ),
314
- );
315
- throw error;
316
- }
317
- }
318
-
319
- private async setWithoutVersioning(
320
- store: string,
321
- item: any,
322
- table: StoreTable,
323
- ) {
324
- const columnNames = table.columns.map((col) => col.name);
325
- const values = table.columns.map((column) => {
326
- let value = item[column.name];
327
- if (value === undefined && column.default !== undefined) {
328
- value = column.default;
329
- }
330
- return this.serializeValue(value, column);
331
- });
332
-
333
- const placeholders = columnNames.map((_, i) => `$${i + 1}`).join(", ");
334
- const updateColumns = columnNames
335
- .filter((col) => col !== table.primaryKey)
336
- .map((col) => `"${col}" = EXCLUDED."${col}"`)
337
- .join(", ");
338
-
339
- // For events table, use simple INSERT since events are typically append-only
340
- if (store === "events") {
341
- const simpleInsertSql = `
342
- INSERT INTO "${table.name}"
343
- (${columnNames.map((c) => `"${c}"`).join(", ")})
344
- VALUES (${placeholders})
345
- `;
346
- this.queries.push({
347
- sql: simpleInsertSql,
348
- params: values,
349
- });
350
- } else {
351
- const sql = `
352
- INSERT INTO "${table.name}"
353
- (${columnNames.map((c) => `"${c}"`).join(", ")})
354
- VALUES (${placeholders})
355
- ON CONFLICT ("${table.primaryKey}")
356
- DO UPDATE SET ${updateColumns}
357
- `;
358
- this.queries.push({
359
- sql: sql,
360
- params: values,
361
- });
362
- }
363
- }
364
-
365
- private async setWithVersioning(store: string, item: any, table: StoreTable) {
366
- // Filter out __version from regular columns and handle it separately
367
- const regularColumns = table.columns.filter(
368
- (col) => col.name !== "__version",
369
- );
370
- const columnNames = regularColumns.map((col) => col.name);
371
- const values = regularColumns.map((column) => {
372
- let value = item[column.name];
373
- if (value === undefined && column.default !== undefined) {
374
- value = column.default;
375
- }
376
- return this.serializeValue(value, column);
377
- });
378
-
379
- // Add __version column
380
- columnNames.push("__version");
381
- const placeholders = regularColumns.map((_, i) => `$${i + 1}`).join(", ");
382
- const updateColumns = regularColumns
383
- .filter((col) => col.name !== table.primaryKey)
384
- .map((col) => `"${col.name}" = EXCLUDED."${col.name}"`)
385
- .join(", ");
386
-
387
- // Use a simpler approach: always increment and return the new version
388
- const sql = `
389
- WITH next_version AS (
390
- INSERT INTO __arc_version_counters (table_name, last_version)
391
- VALUES ($${values.length + 1}, 1)
392
- ON CONFLICT(table_name)
393
- DO UPDATE SET last_version = __arc_version_counters.last_version + 1
394
- RETURNING last_version
395
- ),
396
- upsert AS (
397
- INSERT INTO "${table.name}"
398
- (${columnNames.map((c) => `"${c}"`).join(", ")})
399
- VALUES (${placeholders}, (SELECT last_version FROM next_version))
400
- ON CONFLICT ("${table.primaryKey}")
401
- DO UPDATE SET ${updateColumns}${updateColumns ? ", " : ""}"__version" = (SELECT last_version FROM next_version)
402
- RETURNING *
403
- )
404
- SELECT * FROM upsert
405
- `;
406
-
407
- this.queries.push({
408
- sql: sql,
409
- params: [...values, store],
410
- });
411
- }
412
-
413
- async commit() {
414
- if (this.queries.length === 0) {
415
- return Promise.resolve();
416
- }
417
-
418
- try {
419
- await this.db.execBatch(this.queries);
420
- this.queries = [];
421
- } catch (error) {
422
- this.queries = [];
423
- throw error;
424
- }
425
- }
426
-
427
- private serializeValue(value: any, column: StoreColumn): any {
428
- if (value === null || value === undefined) return null;
429
-
430
- switch (column.type.toLowerCase()) {
431
- case "timestamp":
432
- case "datetime":
433
- case "timestamptz":
434
- // Handle various timestamp formats
435
- if (value instanceof Date) {
436
- if (isNaN(value.getTime())) {
437
- console.error(
438
- `[PostgreSQL] Invalid Date in column "${column.name}":`,
439
- value,
440
- );
441
- console.error(`[PostgreSQL] Column type: ${column.type}`);
442
- throw new Error(`Invalid Date value in column "${column.name}"`);
443
- }
444
- return value.toISOString();
445
- }
446
- if (typeof value === "number") {
447
- // Unix timestamp (seconds or milliseconds)
448
- const date = value > 1e10 ? new Date(value) : new Date(value * 1000);
449
- if (isNaN(date.getTime())) {
450
- console.error(
451
- `[PostgreSQL] Invalid Date from number in column "${column.name}":`,
452
- value,
453
- );
454
- throw new Error(
455
- `Invalid Date value (from number) in column "${column.name}"`,
456
- );
457
- }
458
- return date.toISOString();
459
- }
460
- if (typeof value === "string") {
461
- // Already an ISO string or date string, validate and normalize
462
- const date = new Date(value);
463
- if (!isNaN(date.getTime())) {
464
- return date.toISOString();
465
- }
466
- console.error(
467
- `[PostgreSQL] Invalid Date string in column "${column.name}":`,
468
- value,
469
- );
470
- throw new Error(
471
- `Invalid Date string in column "${column.name}": "${value}"`,
472
- );
473
- }
474
- console.error(
475
- `[PostgreSQL] Unexpected date value type in column "${column.name}":`,
476
- typeof value,
477
- value,
478
- );
479
- throw new Error(
480
- `Unexpected date value type in column "${column.name}": ${typeof value}`,
481
- );
482
- case "json":
483
- case "jsonb":
484
- return JSON.stringify(value);
485
- default:
486
- if (value instanceof Date) {
487
- if (isNaN(value.getTime())) {
488
- console.error(
489
- `[PostgreSQL] Invalid Date in column "${column.name}" (default case):`,
490
- value,
491
- );
492
- throw new Error(`Invalid Date value in column "${column.name}"`);
493
- }
494
- return value.toISOString();
495
- }
496
- if (Array.isArray(value) || typeof value === "object") {
497
- return JSON.stringify(value);
498
- }
499
- return value;
500
- }
501
- }
502
- }
503
-
504
- export class PostgreSQLAdapter implements DatabaseAdapter {
505
- private tables: Map<string, StoreTable> = new Map();
506
- private tableSchemas: Map<string, any[]> = new Map();
507
- private pendingReinitTables: Array<{
508
- tableName: string;
509
- reinitFn: (tableName: string, dataStorage: any) => Promise<void>;
510
- }> = [];
511
-
512
- private mapType(arcType: string, storeData?: DatabaseStoreData): string {
513
- // Check for database-specific type override
514
- if (storeData?.databaseType?.postgresql) {
515
- return storeData.databaseType.postgresql;
516
- }
517
-
518
- switch (arcType) {
519
- case "string":
520
- case "stringEnum":
521
- return "TEXT";
522
- case "id":
523
- case "customId":
524
- // Use TEXT for regular string IDs, UUID only if explicitly specified
525
- return storeData?.databaseType?.postgresql || "TEXT";
526
- case "number":
527
- return storeData?.isAutoIncrement ? "SERIAL" : "BIGINT";
528
- case "boolean":
529
- return "BOOLEAN";
530
- case "date":
531
- return "TIMESTAMPTZ";
532
- case "object":
533
- case "array":
534
- case "record":
535
- return "JSONB";
536
- case "blob":
537
- return "BYTEA";
538
- default:
539
- return "TEXT";
540
- }
541
- }
542
-
543
- private buildConstraints(
544
- storeData?: DatabaseStoreData,
545
- skipForeignKey = false,
546
- ): string[] {
547
- const constraints: string[] = [];
548
-
549
- if (storeData?.isPrimaryKey) {
550
- constraints.push("PRIMARY KEY");
551
- }
552
- if (storeData?.isUnique) {
553
- constraints.push("UNIQUE");
554
- }
555
- // Foreign keys are now added separately after all tables are created
556
- // to avoid dependency ordering issues
557
-
558
- return constraints;
559
- }
560
-
561
- private generateColumnSQL(
562
- columnInfo: DatabaseAgnosticColumnInfo & { name: string },
563
- ): string {
564
- const type = this.mapType(columnInfo.type, columnInfo.storeData);
565
- const constraints: string[] = [];
566
-
567
- if (!columnInfo.storeData?.isNullable) {
568
- constraints.push("NOT NULL");
569
- }
570
-
571
- constraints.push(...this.buildConstraints(columnInfo.storeData));
572
-
573
- if (columnInfo.defaultValue !== undefined) {
574
- if (type === "UUID" && columnInfo.defaultValue === "auto") {
575
- constraints.push("DEFAULT gen_random_uuid()");
576
- } else if (typeof columnInfo.defaultValue === "string") {
577
- constraints.push(`DEFAULT '${columnInfo.defaultValue}'`);
578
- } else {
579
- constraints.push(`DEFAULT ${columnInfo.defaultValue}`);
580
- }
581
- }
582
-
583
- return `"${columnInfo.name}" ${type} ${constraints.join(" ")}`.trim();
584
- }
585
-
586
- private generateCreateTableSQL(
587
- tableName: string,
588
- columns: (DatabaseAgnosticColumnInfo & { name: string })[],
589
- ): { createTableSql: string; foreignKeySql: string[] } {
590
- const columnDefinitions = columns.map((col) => this.generateColumnSQL(col));
591
- const indexes = columns
592
- .filter((col) => col.storeData?.hasIndex && !col.storeData?.isPrimaryKey)
593
- .map(
594
- (col) =>
595
- `CREATE INDEX IF NOT EXISTS idx_${tableName}_${col.name} ON "${tableName}"("${col.name}");`,
596
- );
597
-
598
- // Collect foreign key constraints to add after table creation
599
- const foreignKeySql: string[] = [];
600
- for (const col of columns) {
601
- if (col.storeData?.foreignKey) {
602
- const { table, column, onDelete, onUpdate } = col.storeData.foreignKey;
603
- let fkSql = `ALTER TABLE "${tableName}" ADD CONSTRAINT fk_${tableName}_${col.name} FOREIGN KEY ("${col.name}") REFERENCES "${table}"("${column}")`;
604
- if (onDelete) fkSql += ` ON DELETE ${onDelete}`;
605
- if (onUpdate) fkSql += ` ON UPDATE ${onUpdate}`;
606
- foreignKeySql.push(fkSql);
607
- }
608
- }
609
-
610
- let sql = `CREATE TABLE IF NOT EXISTS "${tableName}" (\n ${columnDefinitions.join(",\n ")}\n)`;
611
-
612
- if (indexes.length > 0) {
613
- sql += ";\n" + indexes.join("\n");
614
- }
615
-
616
- return { createTableSql: sql, foreignKeySql };
617
- }
618
-
619
- constructor(
620
- private db: PostgreSQLDatabase,
621
- private context: ArcContextAny,
622
- ) {
623
- this.context.elements.forEach((element) => {
624
- if (
625
- "databaseStoreSchema" in element &&
626
- typeof element.databaseStoreSchema === "function"
627
- ) {
628
- const databaseSchema =
629
- element.databaseStoreSchema() as DatabaseStoreSchema;
630
-
631
- databaseSchema.tables.forEach((dbTable) => {
632
- // Extract database-agnostic schema from ArcObject
633
- const agnosticSchema = extractDatabaseAgnosticSchema(
634
- dbTable.schema,
635
- dbTable.name,
636
- );
637
-
638
- // Convert to database-specific columns
639
- const columns = agnosticSchema.columns.map((columnInfo) => ({
640
- name: columnInfo.name,
641
- type: this.mapType(columnInfo.type, columnInfo.storeData),
642
- constraints: this.buildConstraints(columnInfo.storeData),
643
- isNullable: columnInfo.storeData?.isNullable || false,
644
- defaultValue: columnInfo.defaultValue,
645
- isPrimaryKey: columnInfo.storeData?.isPrimaryKey || false,
646
- isAutoIncrement: columnInfo.storeData?.isAutoIncrement || false,
647
- isUnique: columnInfo.storeData?.isUnique || false,
648
- hasIndex: columnInfo.storeData?.hasIndex || false,
649
- foreignKey: columnInfo.storeData?.foreignKey,
650
- }));
651
-
652
- this.tableSchemas.set(dbTable.name, columns);
653
-
654
- // Convert to legacy StoreTable format for compatibility with existing transaction code
655
- const legacyTable: StoreTable = {
656
- name: dbTable.name,
657
- primaryKey: columns.find((col) => col.isPrimaryKey)?.name || "_id",
658
- columns: columns.map((col) => ({
659
- name: col.name,
660
- type: col.type,
661
- isOptional: col.isNullable,
662
- default: col.defaultValue,
663
- })),
664
- };
665
- this.tables.set(dbTable.name, legacyTable);
666
- });
667
- }
668
- });
669
- }
670
-
671
- private pendingForeignKeys: string[] = [];
672
-
673
- public async initialize() {
674
- // Create the version counter table first
675
- await this.createVersionCounterTable();
676
- // Create the table versions tracking table
677
- await this.createTableVersionsTable();
678
-
679
- const processedSchemas = new Set<DatabaseStoreSchema>();
680
- const processedTables = new Set<string>();
681
-
682
- // Clear pending foreign keys
683
- this.pendingForeignKeys = [];
684
-
685
- for (const element of this.context.elements) {
686
- if (
687
- "databaseStoreSchema" in element &&
688
- typeof element.databaseStoreSchema === "function"
689
- ) {
690
- const databaseSchema =
691
- element.databaseStoreSchema() as DatabaseStoreSchema;
692
-
693
- // Skip if we've already processed this exact schema reference
694
- if (processedSchemas.has(databaseSchema)) {
695
- continue;
696
- }
697
- processedSchemas.add(databaseSchema);
698
-
699
- for (const dbTable of databaseSchema.tables) {
700
- const tableKey = dbTable.version
701
- ? `${dbTable.name}_v${dbTable.version}`
702
- : dbTable.name;
703
-
704
- if (!processedTables.has(tableKey)) {
705
- // Extract database-agnostic schema from ArcObject
706
- const agnosticSchema = extractDatabaseAgnosticSchema(
707
- dbTable.schema,
708
- dbTable.name,
709
- );
710
-
711
- // Add system columns if enabled
712
- let allColumns = [...agnosticSchema.columns];
713
-
714
- if (dbTable.options?.versioning) {
715
- allColumns.push({
716
- name: "__version",
717
- type: "number",
718
- storeData: { isNullable: false, hasIndex: true },
719
- defaultValue: 1,
720
- });
721
- }
722
-
723
- if (dbTable.options?.softDelete) {
724
- allColumns.push({
725
- name: "deleted",
726
- type: "boolean",
727
- storeData: { isNullable: false, hasIndex: true },
728
- defaultValue: false,
729
- });
730
- }
731
-
732
- // Determine physical table name based on version
733
- const physicalTableName = this.getPhysicalTableName(
734
- dbTable.name,
735
- dbTable.version,
736
- );
737
-
738
- // Check if versioned table already exists
739
- if (
740
- dbTable.version &&
741
- (await this.checkVersionedTableExists(
742
- dbTable.name,
743
- dbTable.version,
744
- ))
745
- ) {
746
- console.log(
747
- `Versioned table ${physicalTableName} already exists, skipping creation`,
748
- );
749
- } else {
750
- await this.createTableIfNotExistsNew(
751
- physicalTableName,
752
- allColumns,
753
- );
754
-
755
- // Register the new version if it's a versioned table
756
- if (dbTable.version) {
757
- await this.registerTableVersion(
758
- dbTable.name,
759
- dbTable.version,
760
- physicalTableName,
761
- );
762
-
763
- // Store reinit function for later execution
764
- if (databaseSchema.reinitTable) {
765
- this.pendingReinitTables.push({
766
- tableName: physicalTableName,
767
- reinitFn: databaseSchema.reinitTable,
768
- });
769
- }
770
- }
771
- }
772
-
773
- // Update the legacy table mapping with the actual columns (including system columns)
774
- const legacyTable: StoreTable = {
775
- name: physicalTableName, // Use physical table name for actual queries
776
- primaryKey:
777
- allColumns.find((col) => col.storeData?.isPrimaryKey)?.name ||
778
- "_id",
779
- columns: allColumns.map((col) => ({
780
- name: col.name,
781
- type: this.mapType(col.type, col.storeData),
782
- isOptional: col.storeData?.isNullable || false,
783
- default: col.defaultValue,
784
- })),
785
- };
786
- this.tables.set(dbTable.name, legacyTable); // Still use logical name as key
787
-
788
- processedTables.add(tableKey);
789
- }
790
- }
791
- }
792
- }
793
-
794
- // Apply all foreign key constraints after all tables are created
795
- for (const fkSql of this.pendingForeignKeys) {
796
- try {
797
- await this.db.exec(fkSql);
798
- } catch (error: any) {
799
- // Ignore if constraint already exists
800
- if (!error.message?.includes("already exists")) {
801
- console.warn(
802
- `Failed to add foreign key constraint: ${error.message}`,
803
- );
804
- }
805
- }
806
- }
807
- this.pendingForeignKeys = [];
808
- }
809
-
810
- private async createTableIfNotExistsNew(
811
- tableName: string,
812
- columns: (DatabaseAgnosticColumnInfo & { name: string })[],
813
- ) {
814
- const { createTableSql, foreignKeySql } = this.generateCreateTableSQL(
815
- tableName,
816
- columns,
817
- );
818
- await this.db.exec(createTableSql);
819
- // Collect foreign keys to be added after all tables are created
820
- this.pendingForeignKeys.push(...foreignKeySql);
821
- }
822
-
823
- /**
824
- * Create the version counter table for tracking per-table version sequences
825
- */
826
- private async createVersionCounterTable() {
827
- const sql = `
828
- CREATE TABLE IF NOT EXISTS __arc_version_counters (
829
- table_name TEXT PRIMARY KEY,
830
- last_version BIGINT NOT NULL DEFAULT 0
831
- )
832
- `;
833
- await this.db.exec(sql);
834
- }
835
-
836
- /**
837
- * Create the table versions tracking table for schema versioning
838
- */
839
- private async createTableVersionsTable() {
840
- const sql = `
841
- CREATE TABLE IF NOT EXISTS __arc_table_versions (
842
- table_name TEXT NOT NULL,
843
- version INTEGER NOT NULL,
844
- physical_table_name TEXT NOT NULL,
845
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
846
- is_active BOOLEAN DEFAULT FALSE,
847
- PRIMARY KEY (table_name, version)
848
- )
849
- `;
850
- await this.db.exec(sql);
851
- }
852
-
853
- /**
854
- * Generate physical table name with version suffix
855
- */
856
- private getPhysicalTableName(logicalName: string, version?: number): string {
857
- return version ? `${logicalName}_v${version}` : logicalName;
858
- }
859
-
860
- /**
861
- * Check if a versioned table exists
862
- */
863
- private async checkVersionedTableExists(
864
- logicalName: string,
865
- version: number,
866
- ): Promise<boolean> {
867
- const result = await this.db.exec(
868
- "SELECT COUNT(*) as count FROM __arc_table_versions WHERE table_name = $1 AND version = $2",
869
- [logicalName, version],
870
- );
871
- return result[0]?.count > 0;
872
- }
873
-
874
- /**
875
- * Register a new table version
876
- */
877
- private async registerTableVersion(
878
- logicalName: string,
879
- version: number,
880
- physicalName: string,
881
- ): Promise<void> {
882
- await this.db.exec(
883
- "INSERT INTO __arc_table_versions (table_name, version, physical_table_name, is_active) VALUES ($1, $2, $3, $4)",
884
- [logicalName, version, physicalName, true],
885
- );
886
- }
887
-
888
- /**
889
- * Check if a table has versioning enabled
890
- */
891
- public hasVersioning(tableName: string): boolean {
892
- // Check if the table has a __version column
893
- const table = this.tables.get(tableName);
894
- if (!table) return false;
895
- return table.columns.some((col) => col.name === "__version");
896
- }
897
-
898
- /**
899
- * Check if a table has soft delete enabled
900
- */
901
- public hasSoftDelete(tableName: string): boolean {
902
- // Check if the table has a deleted column
903
- const table = this.tables.get(tableName);
904
- if (!table) return false;
905
- return table.columns.some((col) => col.name === "deleted");
906
- }
907
-
908
- /**
909
- * Execute all pending reinitTable functions
910
- */
911
- public async executeReinitTables(dataStorage: any): Promise<void> {
912
- for (const { tableName, reinitFn } of this.pendingReinitTables) {
913
- try {
914
- await reinitFn(tableName, dataStorage);
915
- console.log(`Successfully reinitialized table: ${tableName}`);
916
- } catch (error) {
917
- console.error(`Failed to reinitialize table ${tableName}:`, error);
918
- throw error;
919
- }
920
- }
921
- // Clear the pending list after execution
922
- this.pendingReinitTables = [];
923
- }
924
-
925
- readWriteTransaction(stores?: string[]) {
926
- return new PostgreSQLReadWriteTransaction(this.db, this.tables, this);
927
- }
928
-
929
- readTransaction(stores?: string[]) {
930
- return new PostgreSQLReadTransaction(this.db, this.tables, this);
931
- }
932
- }
933
-
934
- export const createPostgreSQLAdapterFactory = (
935
- db: PostgreSQLDatabase,
936
- ): DBAdapterFactory => {
937
- return async (context: ArcContextAny): Promise<DatabaseAdapter> => {
938
- const adapter = new PostgreSQLAdapter(db, context);
939
- await adapter.initialize();
940
- return adapter;
941
- };
942
- };
943
-
944
- /**
945
- * Wrapper for the 'postgres' package (postgres.js) to implement PostgreSQLDatabase interface
946
- */
947
- class PostgresJsDatabase implements PostgreSQLDatabase {
948
- constructor(private sql: any) {}
949
-
950
- async exec(sql: string, params?: any[]): Promise<any> {
951
- try {
952
- if (params && params.length > 0) {
953
- const result = await this.sql.unsafe(sql, params);
954
- return Array.from(result);
955
- }
956
- const result = await this.sql.unsafe(sql);
957
- return Array.from(result);
958
- } catch (error) {
959
- console.error("PostgreSQL error:", error);
960
- console.error("Failed SQL:", sql);
961
- console.error("Failed params:", JSON.stringify(params, null, 2));
962
- throw error;
963
- }
964
- }
965
-
966
- async execBatch(
967
- queries: Array<{ sql: string; params?: any[] }>,
968
- ): Promise<any> {
969
- return this.sql.begin(async (tx: any) => {
970
- for (const query of queries) {
971
- if (query.params && query.params.length > 0) {
972
- await tx.unsafe(query.sql, query.params);
973
- } else {
974
- await tx.unsafe(query.sql);
975
- }
976
- }
977
- });
978
- }
979
- }
980
-
981
- import postgres from "postgres";
982
-
983
- /**
984
- * Create a PostgreSQL adapter factory from a connection string
985
- *
986
- * @param connectionString - PostgreSQL connection URL (e.g., "postgresql://user:pass@localhost:5432/db")
987
- *
988
- * @example
989
- * ```ts
990
- * const dbAdapterFactory = createPostgreSQLAdapterFactoryFromUrl("postgresql://user:pass@localhost:5432/db");
991
- * ```
992
- */
993
- export const createPostgreSQLAdapterFactoryFromUrl = (
994
- connectionString: string,
995
- ): DBAdapterFactory => {
996
- const sql = postgres(connectionString, {
997
- // Suppress NOTICE messages like "relation already exists, skipping"
998
- onnotice: () => {},
999
- });
1000
- const db = new PostgresJsDatabase(sql);
1001
- return createPostgreSQLAdapterFactory(db);
1002
- };