@kysera/dialects 0.8.2 → 0.8.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,19 +1,37 @@
1
1
  /**
2
2
  * PostgreSQL Dialect Adapter
3
+ *
4
+ * Supports PostgreSQL 12+ with full schema support for multi-tenant
5
+ * and modular database architectures.
3
6
  */
4
7
 
5
8
  import type { Kysely } from 'kysely'
6
9
  import { sql } from 'kysely'
7
10
  import { silentLogger, type KyseraLogger } from '@kysera/core'
8
- import type { DialectAdapter, DatabaseErrorLike } from '../types.js'
9
- import { assertValidIdentifier } from '../helpers.js'
11
+ import type { DialectAdapter, DialectAdapterOptions, SchemaOptions } from '../types.js'
12
+ import {
13
+ assertValidIdentifier,
14
+ resolveSchema as resolveSchemaUtil,
15
+ qualifyTableName,
16
+ errorMatchers
17
+ } from '../helpers.js'
18
+
19
+ /**
20
+ * PostgreSQL-specific adapter options
21
+ */
22
+ export interface PostgresAdapterOptions extends DialectAdapterOptions {
23
+ /** Logger instance for error reporting */
24
+ logger?: KyseraLogger
25
+ }
10
26
 
11
27
  export class PostgresAdapter implements DialectAdapter {
12
28
  readonly dialect = 'postgres' as const
29
+ readonly defaultSchema: string
13
30
  private logger: KyseraLogger
14
31
 
15
- constructor(logger: KyseraLogger = silentLogger) {
16
- this.logger = logger
32
+ constructor(options: PostgresAdapterOptions = {}) {
33
+ this.defaultSchema = options.defaultSchema ?? 'public'
34
+ this.logger = options.logger ?? silentLogger
17
35
  }
18
36
 
19
37
  getDefaultPort(): number {
@@ -33,34 +51,39 @@ export class PostgresAdapter implements DialectAdapter {
33
51
  }
34
52
 
35
53
  isUniqueConstraintError(error: unknown): boolean {
36
- const e = error as DatabaseErrorLike
37
- const message = e.message?.toLowerCase() || ''
38
- const code = e.code || ''
39
- return code === '23505' || message.includes('unique constraint')
54
+ return errorMatchers.postgres.uniqueConstraint(error)
40
55
  }
41
56
 
42
57
  isForeignKeyError(error: unknown): boolean {
43
- const e = error as DatabaseErrorLike
44
- const message = e.message?.toLowerCase() || ''
45
- const code = e.code || ''
46
- return code === '23503' || message.includes('foreign key constraint')
58
+ return errorMatchers.postgres.foreignKey(error)
47
59
  }
48
60
 
49
61
  isNotNullError(error: unknown): boolean {
50
- const e = error as DatabaseErrorLike
51
- const message = e.message?.toLowerCase() || ''
52
- const code = e.code || ''
53
- return code === '23502' || message.includes('not-null constraint')
62
+ return errorMatchers.postgres.notNull(error)
54
63
  }
55
64
 
56
- async tableExists(db: Kysely<any>, tableName: string): Promise<boolean> {
65
+ /**
66
+ * Resolve the schema to use for an operation.
67
+ * Uses the shared resolveSchema utility from helpers.ts.
68
+ */
69
+ private resolveSchema(options?: SchemaOptions): string {
70
+ return resolveSchemaUtil(this.defaultSchema, options)
71
+ }
72
+
73
+ async tableExists(
74
+ db: Kysely<any>,
75
+ tableName: string,
76
+ options?: SchemaOptions
77
+ ): Promise<boolean> {
57
78
  assertValidIdentifier(tableName, 'table name')
79
+ const schema = this.resolveSchema(options)
80
+
58
81
  try {
59
82
  const result = await db
60
83
  .selectFrom('information_schema.tables')
61
84
  .select('table_name')
62
85
  .where('table_name', '=', tableName)
63
- .where('table_schema', '=', 'public')
86
+ .where('table_schema', '=', schema)
64
87
  .executeTakeFirst()
65
88
  return !!result
66
89
  } catch {
@@ -68,14 +91,20 @@ export class PostgresAdapter implements DialectAdapter {
68
91
  }
69
92
  }
70
93
 
71
- async getTableColumns(db: Kysely<any>, tableName: string): Promise<string[]> {
94
+ async getTableColumns(
95
+ db: Kysely<any>,
96
+ tableName: string,
97
+ options?: SchemaOptions
98
+ ): Promise<string[]> {
72
99
  assertValidIdentifier(tableName, 'table name')
100
+ const schema = this.resolveSchema(options)
101
+
73
102
  try {
74
103
  const results = await db
75
104
  .selectFrom('information_schema.columns')
76
105
  .select('column_name')
77
106
  .where('table_name', '=', tableName)
78
- .where('table_schema', '=', 'public')
107
+ .where('table_schema', '=', schema)
79
108
  .execute()
80
109
  return results.map(r => r.column_name as string)
81
110
  } catch {
@@ -83,12 +112,14 @@ export class PostgresAdapter implements DialectAdapter {
83
112
  }
84
113
  }
85
114
 
86
- async getTables(db: Kysely<any>): Promise<string[]> {
115
+ async getTables(db: Kysely<any>, options?: SchemaOptions): Promise<string[]> {
116
+ const schema = this.resolveSchema(options)
117
+
87
118
  try {
88
119
  const results = await db
89
120
  .selectFrom('information_schema.tables')
90
121
  .select('table_name')
91
- .where('table_schema', '=', 'public')
122
+ .where('table_schema', '=', schema)
92
123
  .where('table_type', '=', 'BASE TABLE')
93
124
  .execute()
94
125
  return results.map(r => r.table_name as string)
@@ -111,11 +142,18 @@ export class PostgresAdapter implements DialectAdapter {
111
142
  }
112
143
  }
113
144
 
114
- async truncateTable(db: Kysely<any>, tableName: string): Promise<boolean> {
145
+ async truncateTable(
146
+ db: Kysely<any>,
147
+ tableName: string,
148
+ options?: SchemaOptions
149
+ ): Promise<boolean> {
115
150
  assertValidIdentifier(tableName, 'table name')
151
+ const schema = this.resolveSchema(options)
152
+
116
153
  try {
154
+ const qualifiedTable = qualifyTableName(schema, tableName, this.escapeIdentifier.bind(this))
117
155
  await sql
118
- .raw(`TRUNCATE TABLE ${this.escapeIdentifier(tableName)} RESTART IDENTITY CASCADE`)
156
+ .raw(`TRUNCATE TABLE ${qualifiedTable} RESTART IDENTITY CASCADE`)
119
157
  .execute(db)
120
158
  return true
121
159
  } catch (error) {
@@ -128,19 +166,572 @@ export class PostgresAdapter implements DialectAdapter {
128
166
  return false
129
167
  }
130
168
  // Log and rethrow unexpected errors
131
- this.logger.error(`Failed to truncate table "${tableName}":`, error)
169
+ this.logger.error(`Failed to truncate table "${schema}.${tableName}":`, error)
132
170
  throw error
133
171
  }
134
172
  }
135
173
 
136
- async truncateAllTables(db: Kysely<any>, exclude: string[] = []): Promise<void> {
137
- const tables = await this.getTables(db)
174
+ async truncateAllTables(
175
+ db: Kysely<any>,
176
+ exclude: string[] = [],
177
+ options?: SchemaOptions
178
+ ): Promise<void> {
179
+ const tables = await this.getTables(db, options)
138
180
  for (const table of tables) {
139
181
  if (!exclude.includes(table)) {
140
- await this.truncateTable(db, table)
182
+ await this.truncateTable(db, table, options)
183
+ }
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Check if a schema exists in the database
189
+ *
190
+ * @param db - Kysely database instance
191
+ * @param schemaName - Name of the schema to check
192
+ * @returns true if schema exists, false otherwise
193
+ *
194
+ * @example
195
+ * const exists = await adapter.schemaExists(db, 'auth')
196
+ */
197
+ async schemaExists(db: Kysely<any>, schemaName: string): Promise<boolean> {
198
+ assertValidIdentifier(schemaName, 'schema name')
199
+
200
+ try {
201
+ const result = await db
202
+ .selectFrom('information_schema.schemata')
203
+ .select('schema_name')
204
+ .where('schema_name', '=', schemaName)
205
+ .executeTakeFirst()
206
+ return !!result
207
+ } catch {
208
+ return false
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Get all schemas in the database (excluding system schemas)
214
+ *
215
+ * @param db - Kysely database instance
216
+ * @returns Array of schema names
217
+ *
218
+ * @example
219
+ * const schemas = await adapter.getSchemas(db)
220
+ * // ['public', 'auth', 'admin', 'tenant_1']
221
+ */
222
+ async getSchemas(db: Kysely<any>): Promise<string[]> {
223
+ try {
224
+ const results = await db
225
+ .selectFrom('information_schema.schemata')
226
+ .select('schema_name')
227
+ .where('schema_name', 'not like', 'pg_%')
228
+ .where('schema_name', '!=', 'information_schema')
229
+ .execute()
230
+ return results.map(r => r.schema_name as string)
231
+ } catch {
232
+ return []
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Create a new schema in the database
238
+ *
239
+ * @param db - Kysely database instance
240
+ * @param schemaName - Name of the schema to create
241
+ * @param options - Creation options
242
+ * @returns true if schema was created, false if it already exists
243
+ *
244
+ * @example
245
+ * await adapter.createSchema(db, 'tenant_123')
246
+ */
247
+ async createSchema(
248
+ db: Kysely<any>,
249
+ schemaName: string,
250
+ options: { ifNotExists?: boolean } = {}
251
+ ): Promise<boolean> {
252
+ assertValidIdentifier(schemaName, 'schema name')
253
+
254
+ try {
255
+ const ifNotExists = options.ifNotExists ? 'IF NOT EXISTS ' : ''
256
+ await sql
257
+ .raw(`CREATE SCHEMA ${ifNotExists}${this.escapeIdentifier(schemaName)}`)
258
+ .execute(db)
259
+ return true
260
+ } catch (error) {
261
+ const errorMessage = String(error)
262
+ if (errorMessage.includes('already exists')) {
263
+ return false
264
+ }
265
+ this.logger.error(`Failed to create schema "${schemaName}":`, error)
266
+ throw error
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Drop a schema from the database
272
+ *
273
+ * @param db - Kysely database instance
274
+ * @param schemaName - Name of the schema to drop
275
+ * @param options - Drop options
276
+ * @returns true if schema was dropped, false if it doesn't exist
277
+ *
278
+ * @example
279
+ * await adapter.dropSchema(db, 'tenant_123', { cascade: true })
280
+ */
281
+ async dropSchema(
282
+ db: Kysely<any>,
283
+ schemaName: string,
284
+ options: { ifExists?: boolean; cascade?: boolean } = {}
285
+ ): Promise<boolean> {
286
+ assertValidIdentifier(schemaName, 'schema name')
287
+
288
+ // Prevent dropping protected schemas
289
+ const protectedSchemas = ['public', 'pg_catalog', 'information_schema']
290
+ if (protectedSchemas.includes(schemaName)) {
291
+ throw new Error(`Cannot drop protected schema: ${schemaName}`)
292
+ }
293
+
294
+ try {
295
+ const ifExists = options.ifExists ? 'IF EXISTS ' : ''
296
+ const cascade = options.cascade ? ' CASCADE' : ''
297
+ await sql
298
+ .raw(`DROP SCHEMA ${ifExists}${this.escapeIdentifier(schemaName)}${cascade}`)
299
+ .execute(db)
300
+ return true
301
+ } catch (error) {
302
+ const errorMessage = String(error)
303
+ if (errorMessage.includes('does not exist')) {
304
+ return false
141
305
  }
306
+ this.logger.error(`Failed to drop schema "${schemaName}":`, error)
307
+ throw error
308
+ }
309
+ }
310
+
311
+ // ============================================================================
312
+ // Schema Information & Inspection
313
+ // ============================================================================
314
+
315
+ /**
316
+ * Get detailed information about a schema
317
+ *
318
+ * @param db - Kysely database instance
319
+ * @param schemaName - Name of the schema
320
+ * @returns Schema information including table count, size, and owner
321
+ *
322
+ * @example
323
+ * const info = await adapter.getSchemaInfo(db, 'tenant_123')
324
+ * // { name: 'tenant_123', tableCount: 15, owner: 'app_user', sizeBytes: 1048576 }
325
+ */
326
+ async getSchemaInfo(
327
+ db: Kysely<any>,
328
+ schemaName: string
329
+ ): Promise<{
330
+ name: string
331
+ tableCount: number
332
+ owner: string | null
333
+ sizeBytes: number
334
+ }> {
335
+ assertValidIdentifier(schemaName, 'schema name')
336
+
337
+ try {
338
+ // Get table count
339
+ const tablesResult = await db
340
+ .selectFrom('information_schema.tables')
341
+ .select(sql<number>`count(*)::int`.as('count'))
342
+ .where('table_schema', '=', schemaName)
343
+ .where('table_type', '=', 'BASE TABLE')
344
+ .executeTakeFirst()
345
+
346
+ // Get schema owner from pg_namespace
347
+ const ownerResult = await sql<{ owner: string }>`
348
+ SELECT pg_catalog.pg_get_userbyid(nspowner) as owner
349
+ FROM pg_catalog.pg_namespace
350
+ WHERE nspname = ${schemaName}
351
+ `.execute(db)
352
+
353
+ // Calculate schema size
354
+ const sizeResult = await sql<{ size: number }>`
355
+ SELECT COALESCE(
356
+ SUM(pg_total_relation_size(quote_ident(schemaname) || '.' || quote_ident(tablename)))::bigint,
357
+ 0
358
+ ) as size
359
+ FROM pg_tables
360
+ WHERE schemaname = ${schemaName}
361
+ `.execute(db)
362
+
363
+ const rawSize = sizeResult.rows?.[0]?.size
364
+ return {
365
+ name: schemaName,
366
+ tableCount: tablesResult?.count ?? 0,
367
+ owner: ownerResult.rows?.[0]?.owner ?? null,
368
+ sizeBytes: typeof rawSize === 'number' ? rawSize : (rawSize ? parseInt(String(rawSize), 10) : 0)
369
+ }
370
+ } catch (error) {
371
+ this.logger.error(`Failed to get schema info for "${schemaName}":`, error)
372
+ return {
373
+ name: schemaName,
374
+ tableCount: 0,
375
+ owner: null,
376
+ sizeBytes: 0
377
+ }
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Get index information for all tables in a schema
383
+ *
384
+ * @param db - Kysely database instance
385
+ * @param options - Optional schema configuration
386
+ * @returns Array of index information
387
+ *
388
+ * @example
389
+ * const indexes = await adapter.getSchemaIndexes(db, { schema: 'auth' })
390
+ */
391
+ async getSchemaIndexes(
392
+ db: Kysely<any>,
393
+ options?: SchemaOptions
394
+ ): Promise<{
395
+ tableName: string
396
+ indexName: string
397
+ indexType: string
398
+ isUnique: boolean
399
+ isPrimary: boolean
400
+ columns: string[]
401
+ }[]> {
402
+ const schema = this.resolveSchema(options)
403
+
404
+ try {
405
+ const result = await sql<{
406
+ table_name: string
407
+ index_name: string
408
+ index_type: string
409
+ is_unique: boolean
410
+ is_primary: boolean
411
+ column_names: string
412
+ }>`
413
+ SELECT
414
+ t.relname as table_name,
415
+ i.relname as index_name,
416
+ am.amname as index_type,
417
+ ix.indisunique as is_unique,
418
+ ix.indisprimary as is_primary,
419
+ string_agg(a.attname, ', ' ORDER BY array_position(ix.indkey, a.attnum)) as column_names
420
+ FROM pg_index ix
421
+ JOIN pg_class t ON t.oid = ix.indrelid
422
+ JOIN pg_class i ON i.oid = ix.indexrelid
423
+ JOIN pg_am am ON am.oid = i.relam
424
+ JOIN pg_namespace n ON n.oid = t.relnamespace
425
+ JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey)
426
+ WHERE n.nspname = ${schema}
427
+ AND t.relkind = 'r'
428
+ GROUP BY t.relname, i.relname, am.amname, ix.indisunique, ix.indisprimary
429
+ ORDER BY t.relname, i.relname
430
+ `.execute(db)
431
+
432
+ return (result.rows ?? []).map(row => ({
433
+ tableName: row.table_name,
434
+ indexName: row.index_name,
435
+ indexType: row.index_type,
436
+ isUnique: row.is_unique,
437
+ isPrimary: row.is_primary,
438
+ columns: row.column_names.split(', ')
439
+ }))
440
+ } catch (error) {
441
+ this.logger.error(`Failed to get indexes for schema "${schema}":`, error)
442
+ return []
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Get foreign key relationships in a schema
448
+ *
449
+ * @param db - Kysely database instance
450
+ * @param options - Optional schema configuration
451
+ * @returns Array of foreign key relationships
452
+ *
453
+ * @example
454
+ * const fks = await adapter.getSchemaForeignKeys(db, { schema: 'public' })
455
+ */
456
+ async getSchemaForeignKeys(
457
+ db: Kysely<any>,
458
+ options?: SchemaOptions
459
+ ): Promise<{
460
+ constraintName: string
461
+ tableName: string
462
+ columnName: string
463
+ referencedSchema: string
464
+ referencedTable: string
465
+ referencedColumn: string
466
+ onDelete: string
467
+ onUpdate: string
468
+ }[]> {
469
+ const schema = this.resolveSchema(options)
470
+
471
+ try {
472
+ const result = await sql<{
473
+ constraint_name: string
474
+ table_name: string
475
+ column_name: string
476
+ referenced_schema: string
477
+ referenced_table: string
478
+ referenced_column: string
479
+ on_delete: string
480
+ on_update: string
481
+ }>`
482
+ SELECT
483
+ tc.constraint_name,
484
+ tc.table_name,
485
+ kcu.column_name,
486
+ ccu.table_schema as referenced_schema,
487
+ ccu.table_name as referenced_table,
488
+ ccu.column_name as referenced_column,
489
+ rc.delete_rule as on_delete,
490
+ rc.update_rule as on_update
491
+ FROM information_schema.table_constraints tc
492
+ JOIN information_schema.key_column_usage kcu
493
+ ON tc.constraint_name = kcu.constraint_name
494
+ AND tc.table_schema = kcu.table_schema
495
+ JOIN information_schema.constraint_column_usage ccu
496
+ ON ccu.constraint_name = tc.constraint_name
497
+ AND ccu.table_schema = tc.table_schema
498
+ JOIN information_schema.referential_constraints rc
499
+ ON tc.constraint_name = rc.constraint_name
500
+ AND tc.table_schema = rc.constraint_schema
501
+ WHERE tc.constraint_type = 'FOREIGN KEY'
502
+ AND tc.table_schema = ${schema}
503
+ ORDER BY tc.table_name, kcu.column_name
504
+ `.execute(db)
505
+
506
+ return (result.rows ?? []).map(row => ({
507
+ constraintName: row.constraint_name,
508
+ tableName: row.table_name,
509
+ columnName: row.column_name,
510
+ referencedSchema: row.referenced_schema,
511
+ referencedTable: row.referenced_table,
512
+ referencedColumn: row.referenced_column,
513
+ onDelete: row.on_delete,
514
+ onUpdate: row.on_update
515
+ }))
516
+ } catch (error) {
517
+ this.logger.error(`Failed to get foreign keys for schema "${schema}":`, error)
518
+ return []
519
+ }
520
+ }
521
+
522
+ // ============================================================================
523
+ // Search Path Management
524
+ // ============================================================================
525
+
526
+ /**
527
+ * Get the current search_path setting
528
+ *
529
+ * @param db - Kysely database instance
530
+ * @returns Array of schema names in the search path
531
+ *
532
+ * @example
533
+ * const path = await adapter.getSearchPath(db)
534
+ * // ['public', 'tenant_123']
535
+ */
536
+ async getSearchPath(db: Kysely<any>): Promise<string[]> {
537
+ try {
538
+ const result = await sql<{ search_path: string }>`SHOW search_path`.execute(db)
539
+ const rawPath = result.rows?.[0]?.search_path ?? ''
540
+ return rawPath
541
+ .split(',')
542
+ .map(s => s.trim().replace(/^"(.*)"$/, '$1'))
543
+ .filter(s => s.length > 0)
544
+ } catch (error) {
545
+ this.logger.error('Failed to get search_path:', error)
546
+ return []
547
+ }
548
+ }
549
+
550
+ /**
551
+ * Set the search_path for the current session
552
+ *
553
+ * @param db - Kysely database instance
554
+ * @param schemas - Array of schema names to set in the search path
555
+ *
556
+ * @example
557
+ * // Set search path for multi-tenant query
558
+ * await adapter.setSearchPath(db, ['tenant_123', 'public'])
559
+ */
560
+ async setSearchPath(db: Kysely<any>, schemas: string[]): Promise<void> {
561
+ for (const schema of schemas) {
562
+ assertValidIdentifier(schema, 'schema name')
563
+ }
564
+
565
+ const escapedSchemas = schemas.map(s => this.escapeIdentifier(s)).join(', ')
566
+ await sql.raw(`SET search_path TO ${escapedSchemas}`).execute(db)
567
+ }
568
+
569
+ /**
570
+ * Execute a function with a temporary search_path, then restore the original
571
+ *
572
+ * @param db - Kysely database instance
573
+ * @param schemas - Temporary search path
574
+ * @param fn - Function to execute with the temporary search path
575
+ * @returns The result of the function
576
+ *
577
+ * @example
578
+ * const result = await adapter.withSearchPath(db, ['tenant_123'], async () => {
579
+ * // All queries in here will use tenant_123 schema by default
580
+ * return await db.selectFrom('users').selectAll().execute()
581
+ * })
582
+ */
583
+ async withSearchPath<T>(
584
+ db: Kysely<any>,
585
+ schemas: string[],
586
+ fn: () => Promise<T>
587
+ ): Promise<T> {
588
+ const originalPath = await this.getSearchPath(db)
589
+
590
+ try {
591
+ await this.setSearchPath(db, schemas)
592
+ return await fn()
593
+ } finally {
594
+ if (originalPath.length > 0) {
595
+ await this.setSearchPath(db, originalPath)
596
+ }
597
+ }
598
+ }
599
+
600
+ // ============================================================================
601
+ // Schema Cloning & Migration
602
+ // ============================================================================
603
+
604
+ /**
605
+ * Clone a schema's structure (tables, indexes, constraints) to a new schema
606
+ *
607
+ * @param db - Kysely database instance
608
+ * @param sourceSchema - Source schema to clone from
609
+ * @param targetSchema - Target schema name to create
610
+ * @param options - Clone options
611
+ * @returns true if successful
612
+ *
613
+ * @example
614
+ * // Clone 'template' schema to create new tenant schema
615
+ * await adapter.cloneSchema(db, 'template', 'tenant_456')
616
+ *
617
+ * @example
618
+ * // Clone with data included
619
+ * await adapter.cloneSchema(db, 'template', 'tenant_456', { includeData: true })
620
+ */
621
+ async cloneSchema(
622
+ db: Kysely<any>,
623
+ sourceSchema: string,
624
+ targetSchema: string,
625
+ options: {
626
+ includeData?: boolean
627
+ excludeTables?: string[]
628
+ } = {}
629
+ ): Promise<boolean> {
630
+ assertValidIdentifier(sourceSchema, 'source schema name')
631
+ assertValidIdentifier(targetSchema, 'target schema name')
632
+
633
+ const { includeData = false, excludeTables = [] } = options
634
+
635
+ try {
636
+ // Create target schema
637
+ await this.createSchema(db, targetSchema, { ifNotExists: true })
638
+
639
+ // Get all tables from source schema
640
+ const tables = await this.getTables(db, { schema: sourceSchema })
641
+ const tablesToClone = tables.filter(t => !excludeTables.includes(t))
642
+
643
+ // Clone each table
644
+ for (const tableName of tablesToClone) {
645
+ const sourceTable = qualifyTableName(sourceSchema, tableName, this.escapeIdentifier.bind(this))
646
+ const targetTable = qualifyTableName(targetSchema, tableName, this.escapeIdentifier.bind(this))
647
+
648
+ if (includeData) {
649
+ // Clone structure and data
650
+ await sql.raw(`CREATE TABLE ${targetTable} (LIKE ${sourceTable} INCLUDING ALL)`).execute(db)
651
+ await sql.raw(`INSERT INTO ${targetTable} SELECT * FROM ${sourceTable}`).execute(db)
652
+ } else {
653
+ // Clone structure only with all constraints, indexes, defaults
654
+ await sql.raw(`CREATE TABLE ${targetTable} (LIKE ${sourceTable} INCLUDING ALL)`).execute(db)
655
+ }
656
+ }
657
+
658
+ return true
659
+ } catch (error) {
660
+ this.logger.error(`Failed to clone schema "${sourceSchema}" to "${targetSchema}":`, error)
661
+ throw error
662
+ }
663
+ }
664
+
665
+ /**
666
+ * Compare two schemas and return the differences
667
+ *
668
+ * @param db - Kysely database instance
669
+ * @param schema1 - First schema name
670
+ * @param schema2 - Second schema name
671
+ * @returns Comparison result with tables unique to each schema
672
+ *
673
+ * @example
674
+ * const diff = await adapter.compareSchemas(db, 'template', 'tenant_123')
675
+ * // { onlyInFirst: ['archived_users'], onlyInSecond: ['custom_settings'], inBoth: ['users', 'posts'] }
676
+ */
677
+ async compareSchemas(
678
+ db: Kysely<any>,
679
+ schema1: string,
680
+ schema2: string
681
+ ): Promise<{
682
+ onlyInFirst: string[]
683
+ onlyInSecond: string[]
684
+ inBoth: string[]
685
+ }> {
686
+ assertValidIdentifier(schema1, 'schema name')
687
+ assertValidIdentifier(schema2, 'schema name')
688
+
689
+ const tables1 = new Set(await this.getTables(db, { schema: schema1 }))
690
+ const tables2 = new Set(await this.getTables(db, { schema: schema2 }))
691
+
692
+ const onlyInFirst: string[] = []
693
+ const inBoth: string[] = []
694
+
695
+ for (const table of tables1) {
696
+ if (tables2.has(table)) {
697
+ inBoth.push(table)
698
+ } else {
699
+ onlyInFirst.push(table)
700
+ }
701
+ }
702
+
703
+ const onlyInSecond = [...tables2].filter(t => !tables1.has(t))
704
+
705
+ return {
706
+ onlyInFirst: onlyInFirst.sort(),
707
+ onlyInSecond: onlyInSecond.sort(),
708
+ inBoth: inBoth.sort()
142
709
  }
143
710
  }
144
711
  }
145
712
 
713
+ /**
714
+ * Default PostgreSQL adapter instance with 'public' schema
715
+ */
146
716
  export const postgresAdapter = new PostgresAdapter()
717
+
718
+ /**
719
+ * Create a new PostgreSQL adapter with custom configuration
720
+ *
721
+ * @param options - Adapter configuration options
722
+ * @returns Configured PostgresAdapter instance
723
+ *
724
+ * @example
725
+ * // Create adapter with custom default schema
726
+ * const adapter = createPostgresAdapter({ defaultSchema: 'auth' })
727
+ *
728
+ * @example
729
+ * // Create adapter with logger
730
+ * const adapter = createPostgresAdapter({
731
+ * defaultSchema: 'app',
732
+ * logger: myLogger
733
+ * })
734
+ */
735
+ export function createPostgresAdapter(options?: PostgresAdapterOptions): PostgresAdapter {
736
+ return new PostgresAdapter(options)
737
+ }