relq 1.0.4 → 1.0.6
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.
- package/dist/cjs/cli/commands/add.cjs +252 -12
- package/dist/cjs/cli/commands/commit.cjs +12 -1
- package/dist/cjs/cli/commands/export.cjs +25 -19
- package/dist/cjs/cli/commands/import.cjs +219 -100
- package/dist/cjs/cli/commands/init.cjs +86 -14
- package/dist/cjs/cli/commands/pull.cjs +104 -23
- package/dist/cjs/cli/commands/push.cjs +38 -3
- package/dist/cjs/cli/index.cjs +9 -1
- package/dist/cjs/cli/utils/ast/codegen/builder.cjs +297 -0
- package/dist/cjs/cli/utils/ast/codegen/constraints.cjs +185 -0
- package/dist/cjs/cli/utils/ast/codegen/defaults.cjs +311 -0
- package/dist/cjs/cli/utils/ast/codegen/index.cjs +24 -0
- package/dist/cjs/cli/utils/ast/codegen/type-map.cjs +116 -0
- package/dist/cjs/cli/utils/ast/codegen/utils.cjs +69 -0
- package/dist/cjs/cli/utils/ast/index.cjs +19 -0
- package/dist/cjs/cli/utils/ast/transformer/helpers.cjs +154 -0
- package/dist/cjs/cli/utils/ast/transformer/index.cjs +25 -0
- package/dist/cjs/cli/utils/ast/types.cjs +2 -0
- package/dist/cjs/cli/utils/ast-codegen.cjs +949 -0
- package/dist/cjs/cli/utils/ast-transformer.cjs +916 -0
- package/dist/cjs/cli/utils/change-tracker.cjs +50 -1
- package/dist/cjs/cli/utils/cli-utils.cjs +151 -0
- package/dist/cjs/cli/utils/fast-introspect.cjs +149 -23
- package/dist/cjs/cli/utils/pg-parser.cjs +1 -0
- package/dist/cjs/cli/utils/repo-manager.cjs +121 -4
- package/dist/cjs/cli/utils/schema-comparator.cjs +98 -14
- package/dist/cjs/cli/utils/schema-introspect.cjs +56 -19
- package/dist/cjs/cli/utils/snapshot-manager.cjs +0 -1
- package/dist/cjs/cli/utils/sql-generator.cjs +353 -64
- package/dist/cjs/cli/utils/type-generator.cjs +114 -15
- package/dist/cjs/core/relq-client.cjs +22 -6
- package/dist/cjs/schema-definition/column-types.cjs +150 -13
- package/dist/cjs/schema-definition/defaults.cjs +72 -0
- package/dist/cjs/schema-definition/index.cjs +15 -1
- package/dist/cjs/schema-definition/introspection.cjs +7 -3
- package/dist/cjs/schema-definition/pg-relations.cjs +169 -0
- package/dist/cjs/schema-definition/pg-view.cjs +30 -0
- package/dist/cjs/schema-definition/table-definition.cjs +110 -4
- package/dist/cjs/types/config-types.cjs +13 -4
- package/dist/cjs/utils/aws-dsql.cjs +177 -0
- package/dist/config.d.ts +146 -1
- package/dist/esm/cli/commands/add.js +250 -13
- package/dist/esm/cli/commands/commit.js +12 -1
- package/dist/esm/cli/commands/export.js +25 -19
- package/dist/esm/cli/commands/import.js +221 -102
- package/dist/esm/cli/commands/init.js +86 -14
- package/dist/esm/cli/commands/pull.js +106 -25
- package/dist/esm/cli/commands/push.js +39 -4
- package/dist/esm/cli/index.js +9 -1
- package/dist/esm/cli/utils/ast/codegen/builder.js +291 -0
- package/dist/esm/cli/utils/ast/codegen/constraints.js +176 -0
- package/dist/esm/cli/utils/ast/codegen/defaults.js +305 -0
- package/dist/esm/cli/utils/ast/codegen/index.js +6 -0
- package/dist/esm/cli/utils/ast/codegen/type-map.js +111 -0
- package/dist/esm/cli/utils/ast/codegen/utils.js +60 -0
- package/dist/esm/cli/utils/ast/index.js +3 -0
- package/dist/esm/cli/utils/ast/transformer/helpers.js +141 -0
- package/dist/esm/cli/utils/ast/transformer/index.js +2 -0
- package/dist/esm/cli/utils/ast/types.js +1 -0
- package/dist/esm/cli/utils/ast-codegen.js +945 -0
- package/dist/esm/cli/utils/ast-transformer.js +907 -0
- package/dist/esm/cli/utils/change-tracker.js +50 -1
- package/dist/esm/cli/utils/cli-utils.js +147 -0
- package/dist/esm/cli/utils/fast-introspect.js +149 -23
- package/dist/esm/cli/utils/pg-parser.js +1 -0
- package/dist/esm/cli/utils/repo-manager.js +114 -4
- package/dist/esm/cli/utils/schema-comparator.js +98 -14
- package/dist/esm/cli/utils/schema-introspect.js +56 -19
- package/dist/esm/cli/utils/snapshot-manager.js +0 -1
- package/dist/esm/cli/utils/sql-generator.js +353 -64
- package/dist/esm/cli/utils/type-generator.js +114 -15
- package/dist/esm/core/relq-client.js +23 -7
- package/dist/esm/schema-definition/column-types.js +147 -12
- package/dist/esm/schema-definition/defaults.js +69 -0
- package/dist/esm/schema-definition/index.js +3 -0
- package/dist/esm/schema-definition/introspection.js +7 -3
- package/dist/esm/schema-definition/pg-relations.js +161 -0
- package/dist/esm/schema-definition/pg-view.js +24 -0
- package/dist/esm/schema-definition/table-definition.js +110 -4
- package/dist/esm/types/config-types.js +12 -4
- package/dist/esm/utils/aws-dsql.js +139 -0
- package/dist/index.d.ts +159 -1
- package/dist/schema-builder.d.ts +1314 -32
- package/package.json +1 -1
package/dist/config.d.ts
CHANGED
|
@@ -253,6 +253,14 @@ declare class ClientBase extends events.EventEmitter {
|
|
|
253
253
|
getTypeParser: typeof getTypeParser;
|
|
254
254
|
on<E extends "drain" | "error" | "notice" | "notification" | "end">(event: E, listener: E extends "drain" | "end" ? () => void : E extends "error" ? (err: Error) => void : E extends "notice" ? (notice: NoticeMessage) => void : (message: Notification) => void): this;
|
|
255
255
|
}
|
|
256
|
+
/**
|
|
257
|
+
* Type-safe PostgreSQL DEFAULT value helpers
|
|
258
|
+
* Covers all PostgreSQL default value types with 100% typed output
|
|
259
|
+
*/
|
|
260
|
+
export interface DefaultValue {
|
|
261
|
+
readonly $sql: string;
|
|
262
|
+
readonly $isDefault: true;
|
|
263
|
+
}
|
|
256
264
|
declare const EMPTY_OBJECT: unique symbol;
|
|
257
265
|
declare const EMPTY_ARRAY: unique symbol;
|
|
258
266
|
export interface ColumnConfig<T = unknown> {
|
|
@@ -260,7 +268,7 @@ export interface ColumnConfig<T = unknown> {
|
|
|
260
268
|
$sqlType?: string;
|
|
261
269
|
$tsType?: T;
|
|
262
270
|
$nullable?: boolean;
|
|
263
|
-
$default?: T | (() => T) | string | object | typeof EMPTY_OBJECT | typeof EMPTY_ARRAY;
|
|
271
|
+
$default?: T | (() => T) | string | object | typeof EMPTY_OBJECT | typeof EMPTY_ARRAY | DefaultValue;
|
|
264
272
|
$primaryKey?: boolean;
|
|
265
273
|
$unique?: boolean;
|
|
266
274
|
$references?: {
|
|
@@ -282,6 +290,7 @@ export interface ColumnConfig<T = unknown> {
|
|
|
282
290
|
$scale?: number;
|
|
283
291
|
$withTimezone?: boolean;
|
|
284
292
|
$columnName?: string;
|
|
293
|
+
$trackingId?: string;
|
|
285
294
|
}
|
|
286
295
|
/**
|
|
287
296
|
* Partition Types and Builders
|
|
@@ -332,6 +341,58 @@ export interface IndexDefinition {
|
|
|
332
341
|
include?: string[];
|
|
333
342
|
/** Expression for expression-based indexes */
|
|
334
343
|
expression?: string;
|
|
344
|
+
/**
|
|
345
|
+
* Generate CREATE INDEX IF NOT EXISTS instead of CREATE INDEX.
|
|
346
|
+
*
|
|
347
|
+
* When true, the generated SQL will be:
|
|
348
|
+
* `CREATE INDEX IF NOT EXISTS index_name ON table_name (...)`
|
|
349
|
+
*
|
|
350
|
+
* This makes index creation idempotent - if the index already exists,
|
|
351
|
+
* PostgreSQL will skip creation instead of throwing an error.
|
|
352
|
+
*
|
|
353
|
+
* Use cases:
|
|
354
|
+
* - Idempotent migrations that can be run multiple times safely
|
|
355
|
+
* - Manual schema management where you want to avoid errors on re-runs
|
|
356
|
+
* - Incremental schema updates in development environments
|
|
357
|
+
* - CI/CD pipelines where schema might already exist
|
|
358
|
+
*
|
|
359
|
+
* @default false
|
|
360
|
+
*/
|
|
361
|
+
ifNotExists?: boolean;
|
|
362
|
+
/**
|
|
363
|
+
* Use ON ONLY clause for partitioned tables.
|
|
364
|
+
*
|
|
365
|
+
* When true, the generated SQL will be:
|
|
366
|
+
* `CREATE INDEX index_name ON ONLY table_name (...)`
|
|
367
|
+
*
|
|
368
|
+
* This creates an index on the parent partitioned table only, without
|
|
369
|
+
* automatically creating matching indexes on child partitions. Each
|
|
370
|
+
* partition must have its own index created separately.
|
|
371
|
+
*
|
|
372
|
+
* Use cases:
|
|
373
|
+
* - When you want different index configurations per partition
|
|
374
|
+
* - When partitions have different access patterns
|
|
375
|
+
* - When you want to control index creation timing per partition
|
|
376
|
+
* - For declarative partitioning with custom index strategies
|
|
377
|
+
* - When some partitions don't need certain indexes (e.g., archive partitions)
|
|
378
|
+
*
|
|
379
|
+
* @default false
|
|
380
|
+
* @see https://www.postgresql.org/docs/current/ddl-partitioning.html#DDL-PARTITIONING-DECLARATIVE-MAINTENANCE
|
|
381
|
+
*/
|
|
382
|
+
tableOnly?: boolean;
|
|
383
|
+
/** Index comment/description */
|
|
384
|
+
comment?: string;
|
|
385
|
+
/** Tracking ID for rename detection */
|
|
386
|
+
trackingId?: string;
|
|
387
|
+
}
|
|
388
|
+
/** Constraint definition result */
|
|
389
|
+
export interface ConstraintDef {
|
|
390
|
+
readonly $type: "PRIMARY KEY" | "UNIQUE" | "EXCLUDE";
|
|
391
|
+
readonly $name: string;
|
|
392
|
+
readonly $columns: string[];
|
|
393
|
+
readonly $expression?: string;
|
|
394
|
+
/** Tracking ID for rename detection */
|
|
395
|
+
readonly $trackingId?: string;
|
|
335
396
|
}
|
|
336
397
|
export type IsSerialType<T extends string> = T extends "SERIAL" | "BIGSERIAL" | "SMALLSERIAL" | "SERIAL4" | "SERIAL2" | "SERIAL8" ? true : false;
|
|
337
398
|
export type HasDefault<C> = C extends {
|
|
@@ -417,6 +478,8 @@ export interface TableDefinition<T extends Record<string, ColumnConfig>> {
|
|
|
417
478
|
expression: string;
|
|
418
479
|
name?: string;
|
|
419
480
|
}>;
|
|
481
|
+
/** Table-level constraints (composite PKs, etc.) */
|
|
482
|
+
$constraints?: ConstraintDef[];
|
|
420
483
|
$foreignKeys?: Array<{
|
|
421
484
|
columns: string[];
|
|
422
485
|
references: {
|
|
@@ -426,17 +489,93 @@ export interface TableDefinition<T extends Record<string, ColumnConfig>> {
|
|
|
426
489
|
onDelete?: string;
|
|
427
490
|
onUpdate?: string;
|
|
428
491
|
name?: string;
|
|
492
|
+
/** Tracking ID for rename detection */
|
|
493
|
+
trackingId?: string;
|
|
429
494
|
}>;
|
|
430
495
|
$indexes?: IndexDefinition[];
|
|
431
496
|
$inherits?: string[];
|
|
432
497
|
$partitionBy?: PartitionStrategyDef;
|
|
433
498
|
$tablespace?: string;
|
|
434
499
|
$withOptions?: Record<string, unknown>;
|
|
500
|
+
/** Whether to use CREATE TABLE IF NOT EXISTS */
|
|
501
|
+
$ifNotExists?: boolean;
|
|
502
|
+
/** Tracking ID for rename detection */
|
|
503
|
+
$trackingId?: string;
|
|
435
504
|
$inferSelect: BuildSelectType<T>;
|
|
436
505
|
$inferInsert: BuildInsertType<T>;
|
|
437
506
|
toSQL(): string;
|
|
438
507
|
toCreateIndexSQL(): string[];
|
|
439
508
|
}
|
|
509
|
+
/**
|
|
510
|
+
* AWS regions with autocomplete support
|
|
511
|
+
* Includes all standard AWS regions that may support DSQL
|
|
512
|
+
*/
|
|
513
|
+
export type AwsRegion = "us-east-1" | "us-east-2" | "us-west-1" | "us-west-2" | "eu-west-1" | "eu-west-2" | "eu-west-3" | "eu-central-1" | "eu-central-2" | "eu-north-1" | "eu-south-1" | "eu-south-2" | "ap-east-1" | "ap-south-1" | "ap-south-2" | "ap-northeast-1" | "ap-northeast-2" | "ap-northeast-3" | "ap-southeast-1" | "ap-southeast-2" | "ap-southeast-3" | "ap-southeast-4" | "me-south-1" | "me-central-1" | "af-south-1" | "il-central-1" | "sa-east-1" | "ca-central-1" | "ca-west-1" | "us-gov-east-1" | "us-gov-west-1" | (string & {});
|
|
514
|
+
/**
|
|
515
|
+
* AWS DSQL (Aurora Serverless) configuration
|
|
516
|
+
* When provided, Relq automatically handles IAM token generation and caching
|
|
517
|
+
*
|
|
518
|
+
* @example
|
|
519
|
+
* ```typescript
|
|
520
|
+
* const db = new Relq(schema, {
|
|
521
|
+
* database: 'postgres',
|
|
522
|
+
* aws: {
|
|
523
|
+
* hostname: 'abc123.dsql.us-east-1.on.aws',
|
|
524
|
+
* region: 'us-east-1', // Autocomplete supported!
|
|
525
|
+
* accessKeyId: 'AKIA...',
|
|
526
|
+
* secretAccessKey: '...'
|
|
527
|
+
* }
|
|
528
|
+
* });
|
|
529
|
+
* ```
|
|
530
|
+
*/
|
|
531
|
+
export interface AwsDbConfig {
|
|
532
|
+
/**
|
|
533
|
+
* DSQL cluster hostname
|
|
534
|
+
* @example "7btnrhwkzis7lsxg24cqdyzsm4.dsql.us-east-1.on.aws"
|
|
535
|
+
*/
|
|
536
|
+
hostname: string;
|
|
537
|
+
/**
|
|
538
|
+
* AWS region with autocomplete support
|
|
539
|
+
* @example "us-east-1"
|
|
540
|
+
*/
|
|
541
|
+
region: AwsRegion;
|
|
542
|
+
/**
|
|
543
|
+
* AWS Access Key ID
|
|
544
|
+
* Required unless useDefaultCredentials is true
|
|
545
|
+
*/
|
|
546
|
+
accessKeyId?: string;
|
|
547
|
+
/**
|
|
548
|
+
* AWS Secret Access Key
|
|
549
|
+
* Required unless useDefaultCredentials is true
|
|
550
|
+
*/
|
|
551
|
+
secretAccessKey?: string;
|
|
552
|
+
/**
|
|
553
|
+
* Database port (inherited from root config if not specified)
|
|
554
|
+
* @default 5432
|
|
555
|
+
*/
|
|
556
|
+
port?: number;
|
|
557
|
+
/**
|
|
558
|
+
* Database user (inherited from root config if not specified)
|
|
559
|
+
* @default 'admin'
|
|
560
|
+
*/
|
|
561
|
+
user?: string;
|
|
562
|
+
/**
|
|
563
|
+
* Enable SSL/TLS for connection
|
|
564
|
+
* @default true (DSQL typically requires SSL)
|
|
565
|
+
*/
|
|
566
|
+
ssl?: boolean;
|
|
567
|
+
/**
|
|
568
|
+
* Use AWS default credential provider chain
|
|
569
|
+
* (env vars, IAM role, ~/.aws/credentials)
|
|
570
|
+
* @default false
|
|
571
|
+
*/
|
|
572
|
+
useDefaultCredentials?: boolean;
|
|
573
|
+
/**
|
|
574
|
+
* Token expiration time in seconds
|
|
575
|
+
* @default 604800 (7 days)
|
|
576
|
+
*/
|
|
577
|
+
tokenExpiresIn?: number;
|
|
578
|
+
}
|
|
440
579
|
export interface RelqMigrationConfig {
|
|
441
580
|
directory: string;
|
|
442
581
|
tableName?: string;
|
|
@@ -466,6 +605,12 @@ export interface RelqCacheConfig {
|
|
|
466
605
|
export interface RelqConnectionConfig extends Omit<PoolConfig, "ssl"> {
|
|
467
606
|
url?: string;
|
|
468
607
|
ssl?: boolean | "require" | "prefer" | "allow" | "disable" | PoolConfig["ssl"];
|
|
608
|
+
/**
|
|
609
|
+
* AWS DSQL configuration
|
|
610
|
+
* When provided, enables first-class AWS Aurora DSQL support
|
|
611
|
+
* with automatic IAM token generation and caching
|
|
612
|
+
*/
|
|
613
|
+
aws?: AwsDbConfig;
|
|
469
614
|
}
|
|
470
615
|
export interface RelqGenerateConfig {
|
|
471
616
|
outDir: string;
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
|
+
import stripComments from 'strip-comments';
|
|
2
3
|
import { colors, fatal, warning } from "../utils/spinner.js";
|
|
3
4
|
import { loadRelqignore, isIgnored, } from "../utils/relqignore.js";
|
|
4
5
|
import { loadConfig } from "../../config/config.js";
|
|
5
6
|
import * as path from 'path';
|
|
6
|
-
import { isInitialized, loadSnapshot, getUnstagedChanges, getStagedChanges, stageChanges, detectFileChanges, addUnstagedChanges, clearUnstagedChanges, } from "../utils/repo-manager.js";
|
|
7
|
+
import { isInitialized, loadSnapshot, getUnstagedChanges, getStagedChanges, stageChanges, detectFileChanges, addUnstagedChanges, clearUnstagedChanges, cleanupStagedChanges, } from "../utils/repo-manager.js";
|
|
7
8
|
import { getChangeDisplayName } from "../utils/change-tracker.js";
|
|
8
9
|
import { compareSchemas } from "../utils/schema-comparator.js";
|
|
9
10
|
function parseSchemaFileForComparison(schemaPath) {
|
|
10
11
|
if (!fs.existsSync(schemaPath)) {
|
|
11
12
|
return null;
|
|
12
13
|
}
|
|
13
|
-
const
|
|
14
|
+
const rawContent = fs.readFileSync(schemaPath, 'utf-8');
|
|
15
|
+
const content = stripComments(rawContent);
|
|
14
16
|
const tables = [];
|
|
15
17
|
const tableStartRegex = /defineTable\s*\(\s*['"]([^'"]+)['"],\s*\{/g;
|
|
16
18
|
let tableStartMatch;
|
|
@@ -50,15 +52,21 @@ function parseSchemaFileForComparison(schemaPath) {
|
|
|
50
52
|
const tsToDbNameMap = new Map();
|
|
51
53
|
const lines = columnsBlock.split('\n');
|
|
52
54
|
let currentColDef = '';
|
|
55
|
+
let pendingJsDocComment = null;
|
|
53
56
|
for (const line of lines) {
|
|
54
57
|
const trimmed = line.trim();
|
|
58
|
+
const jsDocMatch = trimmed.match(/^\/\*\*\s*(.*?)\s*\*\/$/);
|
|
59
|
+
if (jsDocMatch) {
|
|
60
|
+
pendingJsDocComment = jsDocMatch[1];
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
55
63
|
if (trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*'))
|
|
56
64
|
continue;
|
|
57
65
|
currentColDef += ' ' + trimmed;
|
|
58
66
|
if (trimmed.endsWith(',') || trimmed.endsWith(')')) {
|
|
59
67
|
const colDef = currentColDef.trim();
|
|
60
68
|
currentColDef = '';
|
|
61
|
-
const typePattern = 'varchar|text|uuid|integer|bigint|boolean|timestamp|date|jsonb|json|numeric|serial|bigserial|smallserial|tsvector|smallint|real|doublePrecision|char|inet|cidr|macaddr|macaddr8|interval|time|point|line|lseg|box|path|polygon|circle|bytea|bit|varbit|money|xml|oid';
|
|
69
|
+
const typePattern = 'varchar|text|uuid|integer|bigint|boolean|timestamptz|timestamp|date|jsonb|json|numeric|serial|bigserial|smallserial|tsvector|smallint|real|doublePrecision|char|inet|cidr|macaddr|macaddr8|interval|timetz|time|point|line|lseg|box|path|polygon|circle|bytea|bit|varbit|money|xml|oid|enumType|domainType';
|
|
62
70
|
const colMatch = colDef.match(new RegExp(`^(\\w+):\\s*(${typePattern})`));
|
|
63
71
|
if (!colMatch)
|
|
64
72
|
continue;
|
|
@@ -74,13 +82,17 @@ function parseSchemaFileForComparison(schemaPath) {
|
|
|
74
82
|
if (bigintMatch) {
|
|
75
83
|
defaultValue = bigintMatch[1];
|
|
76
84
|
}
|
|
77
|
-
const
|
|
85
|
+
const bigintLiteralMatch = !defaultValue && colDef.match(/\.default\(\s*(-?\d+)n\s*\)/);
|
|
86
|
+
if (bigintLiteralMatch) {
|
|
87
|
+
defaultValue = bigintLiteralMatch[1];
|
|
88
|
+
}
|
|
89
|
+
const funcDefaultMatch = !defaultValue && colDef.match(/\.default\(\s*(?:DEFAULT\.)?(genRandomUuid|now|currentDate|currentTimestamp|emptyArray|emptyObject|emptyJsonb)\s*\(\s*\)\s*\)/);
|
|
78
90
|
if (funcDefaultMatch) {
|
|
79
91
|
const funcName = funcDefaultMatch[1];
|
|
80
92
|
if (funcName === 'emptyArray') {
|
|
81
93
|
defaultValue = isJsonbColumn ? "'[]'::jsonb" : "'{}'::text[]";
|
|
82
94
|
}
|
|
83
|
-
else if (funcName === 'emptyObject') {
|
|
95
|
+
else if (funcName === 'emptyObject' || funcName === 'emptyJsonb') {
|
|
84
96
|
defaultValue = "'{}'::jsonb";
|
|
85
97
|
}
|
|
86
98
|
else {
|
|
@@ -145,10 +157,22 @@ function parseSchemaFileForComparison(schemaPath) {
|
|
|
145
157
|
if (commentMatch) {
|
|
146
158
|
comment = commentMatch[2];
|
|
147
159
|
}
|
|
160
|
+
else if (pendingJsDocComment) {
|
|
161
|
+
comment = pendingJsDocComment;
|
|
162
|
+
}
|
|
163
|
+
pendingJsDocComment = null;
|
|
148
164
|
if (colDef.includes('.identity()')) {
|
|
149
165
|
defaultValue = defaultValue || 'GENERATED BY DEFAULT AS IDENTITY';
|
|
150
166
|
}
|
|
151
|
-
|
|
167
|
+
let trackingId = undefined;
|
|
168
|
+
const trackingIdMatch = colDef.match(/\.\$id\(\s*['"]([^'"]+)['"]\s*\)/);
|
|
169
|
+
if (trackingIdMatch) {
|
|
170
|
+
trackingId = trackingIdMatch[1];
|
|
171
|
+
}
|
|
172
|
+
const hasArrayModifier = colDef.includes('.array()');
|
|
173
|
+
const hasEmptyArrayDefault = colDef.includes('emptyArray()');
|
|
174
|
+
const isJsonbType = type === 'jsonb' || type === 'json';
|
|
175
|
+
const isArray = hasArrayModifier || (hasEmptyArrayDefault && !isJsonbType);
|
|
152
176
|
columns.push({
|
|
153
177
|
name: dbColName,
|
|
154
178
|
dataType: isArray ? `${type}[]` : type,
|
|
@@ -161,6 +185,7 @@ function parseSchemaFileForComparison(schemaPath) {
|
|
|
161
185
|
scale: null,
|
|
162
186
|
references: null,
|
|
163
187
|
comment,
|
|
188
|
+
trackingId,
|
|
164
189
|
});
|
|
165
190
|
}
|
|
166
191
|
}
|
|
@@ -173,8 +198,13 @@ function parseSchemaFileForComparison(schemaPath) {
|
|
|
173
198
|
const tsColName = c.trim().replace(/table\.\s*/, '');
|
|
174
199
|
return tsToDbNameMap.get(tsColName) || tsColName;
|
|
175
200
|
});
|
|
176
|
-
const
|
|
177
|
-
|
|
201
|
+
const indexStart = optionsBlock.indexOf(`index('${indexName}')`);
|
|
202
|
+
const indexLine = indexStart >= 0 ? optionsBlock.substring(indexStart).split('\n')[0] : '';
|
|
203
|
+
const isUnique = indexLine.includes('.unique()');
|
|
204
|
+
const commentMatch = indexLine.match(/\.comment\(\s*(['"])([^'"]*)\1\s*\)/);
|
|
205
|
+
const comment = commentMatch ? commentMatch[2] : null;
|
|
206
|
+
const trackingIdMatch = indexLine.match(/\.\$id\(\s*(['"])([^'"]+)\1\s*\)/);
|
|
207
|
+
const trackingId = trackingIdMatch ? trackingIdMatch[2] : undefined;
|
|
178
208
|
indexes.push({
|
|
179
209
|
name: indexName,
|
|
180
210
|
columns: indexCols,
|
|
@@ -184,6 +214,8 @@ function parseSchemaFileForComparison(schemaPath) {
|
|
|
184
214
|
definition: '',
|
|
185
215
|
whereClause: null,
|
|
186
216
|
expression: null,
|
|
217
|
+
comment,
|
|
218
|
+
trackingId,
|
|
187
219
|
});
|
|
188
220
|
}
|
|
189
221
|
const checkRegexLegacy = /check\s*\(\s*['"]([^'"]+)['"]\s*,\s*sql`([^`]+)`\s*\)/g;
|
|
@@ -234,6 +266,83 @@ function parseSchemaFileForComparison(schemaPath) {
|
|
|
234
266
|
const dbPartitionKey = tsToDbNameMap.get(tsPartitionKey) || tsPartitionKey;
|
|
235
267
|
partitionKey = [dbPartitionKey];
|
|
236
268
|
}
|
|
269
|
+
const pkColumns = columns.filter(c => c.isPrimaryKey).map(c => c.name);
|
|
270
|
+
if (pkColumns.length > 0) {
|
|
271
|
+
const pkName = `${tableName}_pkey`;
|
|
272
|
+
if (!constraints.some(c => c.type === 'PRIMARY KEY')) {
|
|
273
|
+
constraints.push({
|
|
274
|
+
name: pkName,
|
|
275
|
+
type: 'PRIMARY KEY',
|
|
276
|
+
columns: pkColumns,
|
|
277
|
+
definition: '',
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
for (const col of columns) {
|
|
282
|
+
if (col.isUnique && !col.isPrimaryKey) {
|
|
283
|
+
const uniqueName = `${tableName}_${col.name}_key`;
|
|
284
|
+
col.isUnique = false;
|
|
285
|
+
if (!constraints.some(c => c.name === uniqueName)) {
|
|
286
|
+
constraints.push({
|
|
287
|
+
name: uniqueName,
|
|
288
|
+
type: 'UNIQUE',
|
|
289
|
+
columns: [col.name],
|
|
290
|
+
definition: '',
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const uniqueConstraintRegex = /constraint\.unique\s*\(\s*\{\s*name:\s*['"]([^'"]+)['"]\s*,\s*columns:\s*\[([^\]]+)\]/g;
|
|
296
|
+
let uniqueMatch;
|
|
297
|
+
while ((uniqueMatch = uniqueConstraintRegex.exec(optionsBlock)) !== null) {
|
|
298
|
+
const constraintName = uniqueMatch[1];
|
|
299
|
+
const colsStr = uniqueMatch[2];
|
|
300
|
+
const uniqueCols = colsStr.split(',').map(c => {
|
|
301
|
+
const tsColName = c.trim().replace(/table\.\s*/, '');
|
|
302
|
+
return tsToDbNameMap.get(tsColName) || tsColName;
|
|
303
|
+
});
|
|
304
|
+
if (!constraints.some(c => c.name === constraintName)) {
|
|
305
|
+
constraints.push({
|
|
306
|
+
name: constraintName,
|
|
307
|
+
type: 'UNIQUE',
|
|
308
|
+
columns: uniqueCols,
|
|
309
|
+
definition: '',
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const pkConstraintRegex = /constraint\.primaryKey\s*\(\s*\{\s*name:\s*['"]([^'"]+)['"]\s*,\s*columns:\s*\[([^\]]+)\]/g;
|
|
314
|
+
let pkMatch;
|
|
315
|
+
while ((pkMatch = pkConstraintRegex.exec(optionsBlock)) !== null) {
|
|
316
|
+
const constraintName = pkMatch[1];
|
|
317
|
+
const colsStr = pkMatch[2];
|
|
318
|
+
const pkCols = colsStr.split(',').map(c => {
|
|
319
|
+
const tsColName = c.trim().replace(/table\.\s*/, '');
|
|
320
|
+
return tsToDbNameMap.get(tsColName) || tsColName;
|
|
321
|
+
});
|
|
322
|
+
const existingPkIdx = constraints.findIndex(c => c.type === 'PRIMARY KEY');
|
|
323
|
+
if (existingPkIdx >= 0) {
|
|
324
|
+
constraints[existingPkIdx] = {
|
|
325
|
+
name: constraintName,
|
|
326
|
+
type: 'PRIMARY KEY',
|
|
327
|
+
columns: pkCols,
|
|
328
|
+
definition: '',
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
constraints.push({
|
|
333
|
+
name: constraintName,
|
|
334
|
+
type: 'PRIMARY KEY',
|
|
335
|
+
columns: pkCols,
|
|
336
|
+
definition: '',
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
for (const dbColName of pkCols) {
|
|
340
|
+
const col = columns.find(c => c.name === dbColName);
|
|
341
|
+
if (col) {
|
|
342
|
+
col.isPrimaryKey = true;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
237
346
|
tables.push({
|
|
238
347
|
name: tableName,
|
|
239
348
|
schema: 'public',
|
|
@@ -269,6 +378,80 @@ function parseSchemaFileForComparison(schemaPath) {
|
|
|
269
378
|
}
|
|
270
379
|
}
|
|
271
380
|
}
|
|
381
|
+
const toSnakeCase = (str) => {
|
|
382
|
+
return str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
|
|
383
|
+
};
|
|
384
|
+
const pgRelationsStart = content.indexOf('pgRelations(');
|
|
385
|
+
let relationsBlock = '';
|
|
386
|
+
if (pgRelationsStart !== -1) {
|
|
387
|
+
const arrowStart = content.indexOf('=> ({', pgRelationsStart);
|
|
388
|
+
if (arrowStart !== -1) {
|
|
389
|
+
const blockStart = arrowStart + 5;
|
|
390
|
+
let depth = 1;
|
|
391
|
+
let i = blockStart;
|
|
392
|
+
while (i < content.length && depth > 0) {
|
|
393
|
+
if (content[i] === '{')
|
|
394
|
+
depth++;
|
|
395
|
+
else if (content[i] === '}')
|
|
396
|
+
depth--;
|
|
397
|
+
i++;
|
|
398
|
+
}
|
|
399
|
+
relationsBlock = content.slice(blockStart, i - 1);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (relationsBlock) {
|
|
403
|
+
const tableEntryRegex = /(\w+):\s*tables\.\1\s*\(\s*\([^)]*\)\s*=>\s*\(\{/g;
|
|
404
|
+
let tableMatch;
|
|
405
|
+
while ((tableMatch = tableEntryRegex.exec(relationsBlock)) !== null) {
|
|
406
|
+
const tableTsName = tableMatch[1];
|
|
407
|
+
const tableDbName = toSnakeCase(tableTsName);
|
|
408
|
+
const entryStart = tableMatch.index + tableMatch[0].length;
|
|
409
|
+
let depth = 1;
|
|
410
|
+
let j = entryStart;
|
|
411
|
+
while (j < relationsBlock.length && depth > 0) {
|
|
412
|
+
if (relationsBlock[j] === '{')
|
|
413
|
+
depth++;
|
|
414
|
+
else if (relationsBlock[j] === '}')
|
|
415
|
+
depth--;
|
|
416
|
+
j++;
|
|
417
|
+
}
|
|
418
|
+
const tableRelationsContent = relationsBlock.slice(entryStart, j - 1);
|
|
419
|
+
const table = tables.find(t => t.name === tableDbName);
|
|
420
|
+
if (!table)
|
|
421
|
+
continue;
|
|
422
|
+
const fkRegex = /(\w+):\s*r\.referenceTo\.(\w+)\s*\(\s*\w*\s*=>\s*\(\{([^}]*)\}\)/g;
|
|
423
|
+
let fkMatch;
|
|
424
|
+
while ((fkMatch = fkRegex.exec(tableRelationsContent)) !== null) {
|
|
425
|
+
const colTsName = fkMatch[1];
|
|
426
|
+
const refTableTsName = fkMatch[2];
|
|
427
|
+
const fkOptionsStr = fkMatch[3];
|
|
428
|
+
const colDbName = toSnakeCase(colTsName);
|
|
429
|
+
const refTableDbName = toSnakeCase(refTableTsName);
|
|
430
|
+
let onDelete;
|
|
431
|
+
let onUpdate;
|
|
432
|
+
const nameMatch = fkOptionsStr.match(/name:\s*['"]([^'"]+)['"]/);
|
|
433
|
+
const onDeleteMatch = fkOptionsStr.match(/onDelete:\s*['"]([^'"]+)['"]/);
|
|
434
|
+
const onUpdateMatch = fkOptionsStr.match(/onUpdate:\s*['"]([^'"]+)['"]/);
|
|
435
|
+
if (onDeleteMatch)
|
|
436
|
+
onDelete = onDeleteMatch[1];
|
|
437
|
+
if (onUpdateMatch)
|
|
438
|
+
onUpdate = onUpdateMatch[1];
|
|
439
|
+
const fkName = nameMatch ? nameMatch[1] : `${tableDbName}_${colDbName}_fkey`;
|
|
440
|
+
if (!table.constraints.some(c => c.name === fkName || (c.type === 'FOREIGN KEY' && c.columns?.includes(colDbName)))) {
|
|
441
|
+
table.constraints.push({
|
|
442
|
+
name: fkName,
|
|
443
|
+
type: 'FOREIGN KEY',
|
|
444
|
+
columns: [colDbName],
|
|
445
|
+
definition: '',
|
|
446
|
+
referencedTable: refTableDbName,
|
|
447
|
+
referencedColumns: ['id'],
|
|
448
|
+
onDelete,
|
|
449
|
+
onUpdate,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
272
455
|
return {
|
|
273
456
|
tables,
|
|
274
457
|
enums,
|
|
@@ -301,6 +484,7 @@ function snapshotToDatabaseSchema(snapshot) {
|
|
|
301
484
|
scale: null,
|
|
302
485
|
references: null,
|
|
303
486
|
comment: c.comment || null,
|
|
487
|
+
trackingId: c.trackingId,
|
|
304
488
|
})),
|
|
305
489
|
indexes: (t.indexes || []).map(i => ({
|
|
306
490
|
name: i.name,
|
|
@@ -311,6 +495,7 @@ function snapshotToDatabaseSchema(snapshot) {
|
|
|
311
495
|
definition: i.definition || '',
|
|
312
496
|
whereClause: i.whereClause || null,
|
|
313
497
|
expression: null,
|
|
498
|
+
trackingId: i.trackingId,
|
|
314
499
|
})),
|
|
315
500
|
constraints: (t.constraints || []).map(c => ({
|
|
316
501
|
name: c.name,
|
|
@@ -339,6 +524,57 @@ function snapshotToDatabaseSchema(snapshot) {
|
|
|
339
524
|
extensions: (snapshot.extensions || []).map(e => typeof e === 'string' ? e : e.name),
|
|
340
525
|
};
|
|
341
526
|
}
|
|
527
|
+
let trackingIdCounter = 0;
|
|
528
|
+
function generateTrackingId(prefix) {
|
|
529
|
+
trackingIdCounter++;
|
|
530
|
+
const base = (Date.now().toString(36) + trackingIdCounter.toString(36)).slice(-5);
|
|
531
|
+
return prefix + base.padStart(5, '0');
|
|
532
|
+
}
|
|
533
|
+
function injectTrackingIds(schemaPath) {
|
|
534
|
+
if (!fs.existsSync(schemaPath)) {
|
|
535
|
+
return 0;
|
|
536
|
+
}
|
|
537
|
+
let content = fs.readFileSync(schemaPath, 'utf-8');
|
|
538
|
+
let injectedCount = 0;
|
|
539
|
+
const columnLineRegex = /^(\s*)(\w+):\s*(varchar|text|uuid|integer|bigint|boolean|timestamptz|timestamp|date|jsonb|json|numeric|serial|bigserial|smallserial|tsvector|smallint|real|doublePrecision|char|inet|cidr|macaddr|macaddr8|interval|timetz|time|point|line|lseg|box|path|polygon|circle|bytea|bit|varbit|money|xml|oid|enumType|domainType)\s*\([^)]*\)([^,\n]*)(,?)$/gm;
|
|
540
|
+
content = content.replace(columnLineRegex, (match, indent, colName, type, modifiers, comma) => {
|
|
541
|
+
if (modifiers.includes('.$id(')) {
|
|
542
|
+
return match;
|
|
543
|
+
}
|
|
544
|
+
const commentMatch = modifiers.match(/^(.*)(\.\s*comment\s*\([^)]+\))$/);
|
|
545
|
+
if (commentMatch) {
|
|
546
|
+
const beforeComment = commentMatch[1];
|
|
547
|
+
const comment = commentMatch[2];
|
|
548
|
+
const trackingId = generateTrackingId('c');
|
|
549
|
+
injectedCount++;
|
|
550
|
+
return `${indent}${colName}: ${type}(${match.split('(')[1].split(')')[0]})${beforeComment}.$id('${trackingId}')${comment}${comma}`;
|
|
551
|
+
}
|
|
552
|
+
const trackingId = generateTrackingId('c');
|
|
553
|
+
injectedCount++;
|
|
554
|
+
return `${indent}${colName}: ${type}(${match.split('(')[1].split(')')[0]})${modifiers}.$id('${trackingId}')${comma}`;
|
|
555
|
+
});
|
|
556
|
+
const indexLineRegex = /^(\s*)(index\s*\(\s*['"][^'"]+['"]\s*\)\s*\.on\s*\([^)]+\)[^,\n]*)(,?)$/gm;
|
|
557
|
+
content = content.replace(indexLineRegex, (match, indent, indexDef, comma) => {
|
|
558
|
+
if (indexDef.includes('.$id(')) {
|
|
559
|
+
return match;
|
|
560
|
+
}
|
|
561
|
+
const commentMatch = indexDef.match(/^(.*)(\.\s*comment\s*\([^)]+\))$/);
|
|
562
|
+
if (commentMatch) {
|
|
563
|
+
const beforeComment = commentMatch[1];
|
|
564
|
+
const comment = commentMatch[2];
|
|
565
|
+
const trackingId = generateTrackingId('i');
|
|
566
|
+
injectedCount++;
|
|
567
|
+
return `${indent}${beforeComment}.$id('${trackingId}')${comment}${comma}`;
|
|
568
|
+
}
|
|
569
|
+
const trackingId = generateTrackingId('i');
|
|
570
|
+
injectedCount++;
|
|
571
|
+
return `${indent}${indexDef}.$id('${trackingId}')${comma}`;
|
|
572
|
+
});
|
|
573
|
+
if (injectedCount > 0) {
|
|
574
|
+
fs.writeFileSync(schemaPath, content, 'utf-8');
|
|
575
|
+
}
|
|
576
|
+
return injectedCount;
|
|
577
|
+
}
|
|
342
578
|
export async function addCommand(context) {
|
|
343
579
|
const { args, projectRoot } = context;
|
|
344
580
|
console.log('');
|
|
@@ -349,6 +585,10 @@ export async function addCommand(context) {
|
|
|
349
585
|
const config = await loadConfig();
|
|
350
586
|
const schemaPathRaw = typeof config.schema === 'string' ? config.schema : './db/schema.ts';
|
|
351
587
|
const schemaPath = path.resolve(projectRoot, schemaPathRaw);
|
|
588
|
+
const injectedCount = injectTrackingIds(schemaPath);
|
|
589
|
+
if (injectedCount > 0) {
|
|
590
|
+
console.log(colors.muted(`Injected ${injectedCount} tracking ID(s) into schema.ts`));
|
|
591
|
+
}
|
|
352
592
|
const fileChange = detectFileChanges(schemaPath, projectRoot);
|
|
353
593
|
if (fileChange) {
|
|
354
594
|
const currentSchema = parseSchemaFileForComparison(schemaPath);
|
|
@@ -356,16 +596,13 @@ export async function addCommand(context) {
|
|
|
356
596
|
if (currentSchema && snapshot) {
|
|
357
597
|
const snapshotAsDbSchema = snapshotToDatabaseSchema(snapshot);
|
|
358
598
|
const schemaChanges = compareSchemas(snapshotAsDbSchema, currentSchema);
|
|
599
|
+
cleanupStagedChanges(schemaChanges, projectRoot);
|
|
359
600
|
if (schemaChanges.length > 0) {
|
|
360
601
|
clearUnstagedChanges(projectRoot);
|
|
361
602
|
addUnstagedChanges(schemaChanges, projectRoot);
|
|
362
603
|
}
|
|
363
604
|
else {
|
|
364
|
-
|
|
365
|
-
const hasFileChange = existingUnstaged.some(c => c.objectType === 'SCHEMA_FILE');
|
|
366
|
-
if (!hasFileChange) {
|
|
367
|
-
addUnstagedChanges([fileChange], projectRoot);
|
|
368
|
-
}
|
|
605
|
+
clearUnstagedChanges(projectRoot);
|
|
369
606
|
}
|
|
370
607
|
}
|
|
371
608
|
}
|
|
@@ -39,6 +39,7 @@ export async function commitCommand(context) {
|
|
|
39
39
|
const creates = staged.filter(c => c.type === 'CREATE').length;
|
|
40
40
|
const alters = staged.filter(c => c.type === 'ALTER').length;
|
|
41
41
|
const drops = staged.filter(c => c.type === 'DROP').length;
|
|
42
|
+
const renames = staged.filter(c => c.type === 'RENAME').length;
|
|
42
43
|
const parentHash = getHead(projectRoot);
|
|
43
44
|
const hashInput = JSON.stringify({
|
|
44
45
|
changes: staged.map(c => c.id),
|
|
@@ -60,6 +61,7 @@ export async function commitCommand(context) {
|
|
|
60
61
|
creates,
|
|
61
62
|
alters,
|
|
62
63
|
drops,
|
|
64
|
+
renames,
|
|
63
65
|
total: staged.length,
|
|
64
66
|
},
|
|
65
67
|
};
|
|
@@ -92,7 +94,16 @@ export async function commitCommand(context) {
|
|
|
92
94
|
saveFileHash(currentHash, projectRoot);
|
|
93
95
|
}
|
|
94
96
|
console.log(`[${shortHash(hash)}] ${message}`);
|
|
95
|
-
|
|
97
|
+
const statsParts = [];
|
|
98
|
+
if (creates > 0)
|
|
99
|
+
statsParts.push(`${creates} create(s)`);
|
|
100
|
+
if (alters > 0)
|
|
101
|
+
statsParts.push(`${alters} alter(s)`);
|
|
102
|
+
if (drops > 0)
|
|
103
|
+
statsParts.push(`${drops} drop(s)`);
|
|
104
|
+
if (renames > 0)
|
|
105
|
+
statsParts.push(`${renames} rename(s)`);
|
|
106
|
+
console.log(` ${statsParts.length > 0 ? statsParts.join(', ') : 'no changes'}`);
|
|
96
107
|
console.log('');
|
|
97
108
|
hint("run 'relq push' to apply changes to database");
|
|
98
109
|
hint("run 'relq export' to export as SQL file");
|