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.
Files changed (84) hide show
  1. package/dist/cjs/cli/commands/add.cjs +252 -12
  2. package/dist/cjs/cli/commands/commit.cjs +12 -1
  3. package/dist/cjs/cli/commands/export.cjs +25 -19
  4. package/dist/cjs/cli/commands/import.cjs +219 -100
  5. package/dist/cjs/cli/commands/init.cjs +86 -14
  6. package/dist/cjs/cli/commands/pull.cjs +104 -23
  7. package/dist/cjs/cli/commands/push.cjs +38 -3
  8. package/dist/cjs/cli/index.cjs +9 -1
  9. package/dist/cjs/cli/utils/ast/codegen/builder.cjs +297 -0
  10. package/dist/cjs/cli/utils/ast/codegen/constraints.cjs +185 -0
  11. package/dist/cjs/cli/utils/ast/codegen/defaults.cjs +311 -0
  12. package/dist/cjs/cli/utils/ast/codegen/index.cjs +24 -0
  13. package/dist/cjs/cli/utils/ast/codegen/type-map.cjs +116 -0
  14. package/dist/cjs/cli/utils/ast/codegen/utils.cjs +69 -0
  15. package/dist/cjs/cli/utils/ast/index.cjs +19 -0
  16. package/dist/cjs/cli/utils/ast/transformer/helpers.cjs +154 -0
  17. package/dist/cjs/cli/utils/ast/transformer/index.cjs +25 -0
  18. package/dist/cjs/cli/utils/ast/types.cjs +2 -0
  19. package/dist/cjs/cli/utils/ast-codegen.cjs +949 -0
  20. package/dist/cjs/cli/utils/ast-transformer.cjs +916 -0
  21. package/dist/cjs/cli/utils/change-tracker.cjs +50 -1
  22. package/dist/cjs/cli/utils/cli-utils.cjs +151 -0
  23. package/dist/cjs/cli/utils/fast-introspect.cjs +149 -23
  24. package/dist/cjs/cli/utils/pg-parser.cjs +1 -0
  25. package/dist/cjs/cli/utils/repo-manager.cjs +121 -4
  26. package/dist/cjs/cli/utils/schema-comparator.cjs +98 -14
  27. package/dist/cjs/cli/utils/schema-introspect.cjs +56 -19
  28. package/dist/cjs/cli/utils/snapshot-manager.cjs +0 -1
  29. package/dist/cjs/cli/utils/sql-generator.cjs +353 -64
  30. package/dist/cjs/cli/utils/type-generator.cjs +114 -15
  31. package/dist/cjs/core/relq-client.cjs +22 -6
  32. package/dist/cjs/schema-definition/column-types.cjs +150 -13
  33. package/dist/cjs/schema-definition/defaults.cjs +72 -0
  34. package/dist/cjs/schema-definition/index.cjs +15 -1
  35. package/dist/cjs/schema-definition/introspection.cjs +7 -3
  36. package/dist/cjs/schema-definition/pg-relations.cjs +169 -0
  37. package/dist/cjs/schema-definition/pg-view.cjs +30 -0
  38. package/dist/cjs/schema-definition/table-definition.cjs +110 -4
  39. package/dist/cjs/types/config-types.cjs +13 -4
  40. package/dist/cjs/utils/aws-dsql.cjs +177 -0
  41. package/dist/config.d.ts +146 -1
  42. package/dist/esm/cli/commands/add.js +250 -13
  43. package/dist/esm/cli/commands/commit.js +12 -1
  44. package/dist/esm/cli/commands/export.js +25 -19
  45. package/dist/esm/cli/commands/import.js +221 -102
  46. package/dist/esm/cli/commands/init.js +86 -14
  47. package/dist/esm/cli/commands/pull.js +106 -25
  48. package/dist/esm/cli/commands/push.js +39 -4
  49. package/dist/esm/cli/index.js +9 -1
  50. package/dist/esm/cli/utils/ast/codegen/builder.js +291 -0
  51. package/dist/esm/cli/utils/ast/codegen/constraints.js +176 -0
  52. package/dist/esm/cli/utils/ast/codegen/defaults.js +305 -0
  53. package/dist/esm/cli/utils/ast/codegen/index.js +6 -0
  54. package/dist/esm/cli/utils/ast/codegen/type-map.js +111 -0
  55. package/dist/esm/cli/utils/ast/codegen/utils.js +60 -0
  56. package/dist/esm/cli/utils/ast/index.js +3 -0
  57. package/dist/esm/cli/utils/ast/transformer/helpers.js +141 -0
  58. package/dist/esm/cli/utils/ast/transformer/index.js +2 -0
  59. package/dist/esm/cli/utils/ast/types.js +1 -0
  60. package/dist/esm/cli/utils/ast-codegen.js +945 -0
  61. package/dist/esm/cli/utils/ast-transformer.js +907 -0
  62. package/dist/esm/cli/utils/change-tracker.js +50 -1
  63. package/dist/esm/cli/utils/cli-utils.js +147 -0
  64. package/dist/esm/cli/utils/fast-introspect.js +149 -23
  65. package/dist/esm/cli/utils/pg-parser.js +1 -0
  66. package/dist/esm/cli/utils/repo-manager.js +114 -4
  67. package/dist/esm/cli/utils/schema-comparator.js +98 -14
  68. package/dist/esm/cli/utils/schema-introspect.js +56 -19
  69. package/dist/esm/cli/utils/snapshot-manager.js +0 -1
  70. package/dist/esm/cli/utils/sql-generator.js +353 -64
  71. package/dist/esm/cli/utils/type-generator.js +114 -15
  72. package/dist/esm/core/relq-client.js +23 -7
  73. package/dist/esm/schema-definition/column-types.js +147 -12
  74. package/dist/esm/schema-definition/defaults.js +69 -0
  75. package/dist/esm/schema-definition/index.js +3 -0
  76. package/dist/esm/schema-definition/introspection.js +7 -3
  77. package/dist/esm/schema-definition/pg-relations.js +161 -0
  78. package/dist/esm/schema-definition/pg-view.js +24 -0
  79. package/dist/esm/schema-definition/table-definition.js +110 -4
  80. package/dist/esm/types/config-types.js +12 -4
  81. package/dist/esm/utils/aws-dsql.js +139 -0
  82. package/dist/index.d.ts +159 -1
  83. package/dist/schema-builder.d.ts +1314 -32
  84. 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 content = fs.readFileSync(schemaPath, 'utf-8');
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 funcDefaultMatch = !defaultValue && colDef.match(/\.default\(\s*(genRandomUuid|now|currentDate|currentTimestamp|emptyArray|emptyObject)\s*\(\s*\)\s*\)/);
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
- const isArray = colDef.includes('.array()');
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 isUnique = optionsBlock.includes(`index('${indexName}')`) &&
177
- optionsBlock.substring(optionsBlock.indexOf(`index('${indexName}')`)).split('\n')[0].includes('.unique()');
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
- const existingUnstaged = getUnstagedChanges(projectRoot);
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
- console.log(` ${creates} create(s), ${alters} alter(s), ${drops} drop(s)`);
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");