@xubylele/schema-forge 1.12.2 → 1.13.0

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/cli.js CHANGED
@@ -30,11 +30,190 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
30
30
  mod
31
31
  ));
32
32
 
33
+ // node_modules/@xubylele/schema-forge-core/dist/core/normalize.js
34
+ function normalizeIdent(input) {
35
+ return input.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, "");
36
+ }
37
+ function pkName(table) {
38
+ return `pk_${normalizeIdent(table)}`;
39
+ }
40
+ function uqName(table, column) {
41
+ return `uq_${normalizeIdent(table)}_${normalizeIdent(column)}`;
42
+ }
43
+ function legacyPkName(table) {
44
+ return `${normalizeIdent(table)}_pkey`;
45
+ }
46
+ function legacyUqName(table, column) {
47
+ return `${normalizeIdent(table)}_${normalizeIdent(column)}_key`;
48
+ }
49
+ function simpleHash(input) {
50
+ let hash = 2166136261;
51
+ for (let index = 0; index < input.length; index++) {
52
+ hash ^= input.charCodeAt(index);
53
+ hash = Math.imul(hash, 16777619);
54
+ }
55
+ return (hash >>> 0).toString(36);
56
+ }
57
+ function hashSqlContent(value) {
58
+ return simpleHash(value);
59
+ }
60
+ function normalizeSqlExpression(value) {
61
+ if (!value) {
62
+ return "";
63
+ }
64
+ return normalizeSpacesOutsideQuotes(value);
65
+ }
66
+ function deterministicIndexName(params) {
67
+ const tableName = normalizeIdent(params.table) || "table";
68
+ const columnList = params.columns ?? [];
69
+ if (columnList.length > 0) {
70
+ const columnToken = columnList.map((column) => normalizeIdent(column) || "col").join("_").slice(0, 40);
71
+ return `idx_${tableName}_${columnToken}`;
72
+ }
73
+ const normalizedExpression = normalizeSqlExpression(params.expression);
74
+ const identExpr = normalizeIdent(normalizedExpression).slice(0, 24) || "expr";
75
+ const hash = simpleHash(normalizedExpression || params.expression || "").slice(0, 8);
76
+ return `idx_${tableName}_${identExpr}_${hash}`;
77
+ }
78
+ function normalizeSpacesOutsideQuotes(value) {
79
+ let result = "";
80
+ let inSingleQuote = false;
81
+ let inDoubleQuote = false;
82
+ let pendingSpace = false;
83
+ for (const char of value) {
84
+ if (char === "'" && !inDoubleQuote) {
85
+ if (pendingSpace && result.length > 0 && result[result.length - 1] !== " ") {
86
+ result += " ";
87
+ }
88
+ pendingSpace = false;
89
+ inSingleQuote = !inSingleQuote;
90
+ result += char;
91
+ continue;
92
+ }
93
+ if (char === '"' && !inSingleQuote) {
94
+ if (pendingSpace && result.length > 0 && result[result.length - 1] !== " ") {
95
+ result += " ";
96
+ }
97
+ pendingSpace = false;
98
+ inDoubleQuote = !inDoubleQuote;
99
+ result += char;
100
+ continue;
101
+ }
102
+ if (!inSingleQuote && !inDoubleQuote && /\s/.test(char)) {
103
+ pendingSpace = true;
104
+ continue;
105
+ }
106
+ if (pendingSpace && result.length > 0 && result[result.length - 1] !== " ") {
107
+ result += " ";
108
+ }
109
+ pendingSpace = false;
110
+ result += char;
111
+ }
112
+ return result.trim();
113
+ }
114
+ function normalizeKnownFunctionsOutsideQuotes(value) {
115
+ let result = "";
116
+ let inSingleQuote = false;
117
+ let inDoubleQuote = false;
118
+ let buffer = "";
119
+ function flushBuffer() {
120
+ if (!buffer) {
121
+ return;
122
+ }
123
+ result += buffer.replace(/\bnow\s*\(\s*\)/gi, "now()").replace(/\bgen_random_uuid\s*\(\s*\)/gi, "gen_random_uuid()");
124
+ buffer = "";
125
+ }
126
+ for (const char of value) {
127
+ if (char === "'" && !inDoubleQuote) {
128
+ flushBuffer();
129
+ inSingleQuote = !inSingleQuote;
130
+ result += char;
131
+ continue;
132
+ }
133
+ if (char === '"' && !inSingleQuote) {
134
+ flushBuffer();
135
+ inDoubleQuote = !inDoubleQuote;
136
+ result += char;
137
+ continue;
138
+ }
139
+ if (inSingleQuote || inDoubleQuote) {
140
+ result += char;
141
+ continue;
142
+ }
143
+ buffer += char;
144
+ }
145
+ flushBuffer();
146
+ return result;
147
+ }
148
+ function normalizePunctuationOutsideQuotes(value) {
149
+ let result = "";
150
+ let inSingleQuote = false;
151
+ let inDoubleQuote = false;
152
+ for (let index = 0; index < value.length; index++) {
153
+ const char = value[index];
154
+ if (char === "'" && !inDoubleQuote) {
155
+ inSingleQuote = !inSingleQuote;
156
+ result += char;
157
+ continue;
158
+ }
159
+ if (char === '"' && !inSingleQuote) {
160
+ inDoubleQuote = !inDoubleQuote;
161
+ result += char;
162
+ continue;
163
+ }
164
+ if (!inSingleQuote && !inDoubleQuote && (char === "(" || char === ")")) {
165
+ while (result.endsWith(" ")) {
166
+ result = result.slice(0, -1);
167
+ }
168
+ result += char;
169
+ let lookahead = index + 1;
170
+ while (lookahead < value.length && value[lookahead] === " ") {
171
+ lookahead++;
172
+ }
173
+ index = lookahead - 1;
174
+ continue;
175
+ }
176
+ if (!inSingleQuote && !inDoubleQuote && char === ",") {
177
+ while (result.endsWith(" ")) {
178
+ result = result.slice(0, -1);
179
+ }
180
+ result += ", ";
181
+ let lookahead = index + 1;
182
+ while (lookahead < value.length && value[lookahead] === " ") {
183
+ lookahead++;
184
+ }
185
+ index = lookahead - 1;
186
+ continue;
187
+ }
188
+ result += char;
189
+ }
190
+ return result;
191
+ }
192
+ function normalizeDefault(expr) {
193
+ if (expr === void 0 || expr === null) {
194
+ return null;
195
+ }
196
+ const trimmed = expr.trim();
197
+ if (trimmed.length === 0) {
198
+ return null;
199
+ }
200
+ const normalizedSpacing = normalizeSpacesOutsideQuotes(trimmed);
201
+ const normalizedPunctuation = normalizePunctuationOutsideQuotes(normalizedSpacing);
202
+ return normalizeKnownFunctionsOutsideQuotes(normalizedPunctuation);
203
+ }
204
+ var init_normalize = __esm({
205
+ "node_modules/@xubylele/schema-forge-core/dist/core/normalize.js"() {
206
+ "use strict";
207
+ }
208
+ });
209
+
33
210
  // node_modules/@xubylele/schema-forge-core/dist/core/parser.js
34
211
  function parseSchema(source) {
35
212
  const lines = source.split("\n");
36
213
  const tables = {};
37
214
  const policyList = [];
215
+ const indexList = [];
216
+ const viewList = [];
38
217
  let currentLine = 0;
39
218
  const validBaseColumnTypes = /* @__PURE__ */ new Set([
40
219
  "uuid",
@@ -338,6 +517,48 @@ function parseSchema(source) {
338
517
  };
339
518
  return { policy, nextLineIndex: lineIdx };
340
519
  }
520
+ const policyDeclRegex = /^policy\s+"([^"]*)"\s+on\s+\w+/;
521
+ function parseViewDeclaration(startLine, options) {
522
+ const firstLine = cleanLine(lines[startLine]);
523
+ const declMatch = firstLine.match(/^view\s+(\w+)\s+as(?:\s+([\s\S]+))?$/);
524
+ if (!declMatch) {
525
+ throw new Error(`Line ${startLine + 1}: Invalid view declaration. Expected: view <name> as <sql>`);
526
+ }
527
+ const viewName = declMatch[1];
528
+ validateIdentifier(viewName, startLine + 1, "view");
529
+ const queryLines = [];
530
+ const inlineQuery = declMatch[2]?.trim();
531
+ if (inlineQuery) {
532
+ queryLines.push(inlineQuery);
533
+ }
534
+ let lineIdx = startLine + 1;
535
+ while (lineIdx < lines.length) {
536
+ const rawLine = lines[lineIdx];
537
+ const cleaned = cleanLine(rawLine);
538
+ if (!cleaned) {
539
+ if (queryLines.length > 0) {
540
+ queryLines.push("");
541
+ }
542
+ lineIdx++;
543
+ continue;
544
+ }
545
+ if (cleaned.startsWith("table ") || policyDeclRegex.test(cleaned) || cleaned.startsWith("index ") || cleaned.startsWith("view ") || options?.stopAtTableClose === true && cleaned === "}") {
546
+ break;
547
+ }
548
+ queryLines.push(rawLine.trim());
549
+ lineIdx++;
550
+ }
551
+ const query = queryLines.join("\n").trim();
552
+ if (!query) {
553
+ throw new Error(`Line ${startLine + 1}: View '${viewName}' is missing SQL query body`);
554
+ }
555
+ const view = {
556
+ name: viewName,
557
+ query,
558
+ hash: hashSqlContent(query)
559
+ };
560
+ return { view, nextLineIndex: lineIdx };
561
+ }
341
562
  function parseTableBlock(startLine) {
342
563
  const firstLine = cleanLine(lines[startLine]);
343
564
  const match = firstLine.match(/^table\s+(\w+)\s*\{\s*$/);
@@ -362,6 +583,12 @@ function parseSchema(source) {
362
583
  foundClosingBrace = true;
363
584
  break;
364
585
  }
586
+ if (cleaned.startsWith("view ")) {
587
+ const { view, nextLineIndex } = parseViewDeclaration(lineIdx, { stopAtTableClose: true });
588
+ viewList.push({ view, startLine: lineIdx + 1 });
589
+ lineIdx = nextLineIndex;
590
+ continue;
591
+ }
365
592
  try {
366
593
  const column = parseColumn(cleaned, lineIdx + 1);
367
594
  columns.push(column);
@@ -381,7 +608,129 @@ function parseSchema(source) {
381
608
  };
382
609
  return lineIdx;
383
610
  }
384
- const policyDeclRegex = /^policy\s+"([^"]*)"\s+on\s+\w+/;
611
+ function parseIndexColumns(columnsRaw, lineNum) {
612
+ const parsed = columnsRaw.split(",").map((column) => column.trim()).filter(Boolean);
613
+ if (parsed.length === 0) {
614
+ throw new Error(`Line ${lineNum}: Index columns clause requires at least one column`);
615
+ }
616
+ for (const column of parsed) {
617
+ validateIdentifier(column, lineNum, "column");
618
+ }
619
+ return parsed;
620
+ }
621
+ function parseIndexDeclaration(startLine) {
622
+ const firstLine = cleanLine(lines[startLine]);
623
+ const inlineExprMatch = firstLine.match(/^index(?:\s+(\w+))?\s+on\s+(\w+)\s*\((.+)\)\s*$/);
624
+ if (inlineExprMatch) {
625
+ const name = inlineExprMatch[1];
626
+ const table2 = inlineExprMatch[2];
627
+ const expressionRaw = inlineExprMatch[3].trim();
628
+ if (name) {
629
+ validateIdentifier(name, startLine + 1, "column");
630
+ }
631
+ validateIdentifier(table2, startLine + 1, "table");
632
+ if (!expressionRaw) {
633
+ throw new Error(`Line ${startLine + 1}: Expression index requires a non-empty expression`);
634
+ }
635
+ const normalizedExpression = normalizeSqlExpression(expressionRaw);
636
+ const resolvedName2 = name ?? deterministicIndexName({ table: table2, expression: normalizedExpression });
637
+ return {
638
+ index: {
639
+ name: resolvedName2,
640
+ table: table2,
641
+ columns: [],
642
+ unique: false,
643
+ expression: normalizedExpression
644
+ },
645
+ nextLineIndex: startLine + 1
646
+ };
647
+ }
648
+ const blockMatch = firstLine.match(/^index(?:\s+(\w+))?\s+on\s+(\w+)\s*$/);
649
+ if (!blockMatch) {
650
+ throw new Error(`Line ${startLine + 1}: Invalid index declaration. Expected: index [name] on <table> or index [name] on <table>(<expression>)`);
651
+ }
652
+ const declaredName = blockMatch[1];
653
+ const table = blockMatch[2];
654
+ if (declaredName) {
655
+ validateIdentifier(declaredName, startLine + 1, "column");
656
+ }
657
+ validateIdentifier(table, startLine + 1, "table");
658
+ let lineIdx = startLine + 1;
659
+ let columns;
660
+ let unique = false;
661
+ let where;
662
+ let expression;
663
+ const seenClauses = /* @__PURE__ */ new Set();
664
+ while (lineIdx < lines.length) {
665
+ const cleaned = cleanLine(lines[lineIdx]);
666
+ if (!cleaned) {
667
+ lineIdx++;
668
+ continue;
669
+ }
670
+ if (cleaned.startsWith("table ") || cleaned.startsWith("policy ") || cleaned.startsWith("index ")) {
671
+ break;
672
+ }
673
+ if (cleaned === "unique") {
674
+ if (seenClauses.has("unique")) {
675
+ throw new Error(`Line ${lineIdx + 1}: Duplicate 'unique' in index declaration`);
676
+ }
677
+ seenClauses.add("unique");
678
+ unique = true;
679
+ lineIdx++;
680
+ continue;
681
+ }
682
+ if (cleaned.startsWith("columns ")) {
683
+ if (seenClauses.has("columns")) {
684
+ throw new Error(`Line ${lineIdx + 1}: Duplicate 'columns' in index declaration`);
685
+ }
686
+ seenClauses.add("columns");
687
+ columns = parseIndexColumns(cleaned.slice("columns ".length), lineIdx + 1);
688
+ lineIdx++;
689
+ continue;
690
+ }
691
+ if (cleaned.startsWith("where ")) {
692
+ if (seenClauses.has("where")) {
693
+ throw new Error(`Line ${lineIdx + 1}: Duplicate 'where' in index declaration`);
694
+ }
695
+ seenClauses.add("where");
696
+ where = normalizeSqlExpression(cleaned.slice("where ".length));
697
+ lineIdx++;
698
+ continue;
699
+ }
700
+ if (cleaned.startsWith("expression ")) {
701
+ if (seenClauses.has("expression")) {
702
+ throw new Error(`Line ${lineIdx + 1}: Duplicate 'expression' in index declaration`);
703
+ }
704
+ seenClauses.add("expression");
705
+ expression = normalizeSqlExpression(cleaned.slice("expression ".length));
706
+ lineIdx++;
707
+ continue;
708
+ }
709
+ throw new Error(`Line ${lineIdx + 1}: Unexpected index clause '${cleaned}'`);
710
+ }
711
+ if (columns && expression || !columns && !expression) {
712
+ throw new Error(`Line ${startLine + 1}: Index must define exactly one target kind: either 'columns' or 'expression'`);
713
+ }
714
+ if (expression !== void 0 && expression.trim().length === 0) {
715
+ throw new Error(`Line ${startLine + 1}: Expression index requires a non-empty expression`);
716
+ }
717
+ const resolvedName = declaredName ?? deterministicIndexName({
718
+ table,
719
+ columns,
720
+ expression
721
+ });
722
+ return {
723
+ index: {
724
+ name: resolvedName,
725
+ table,
726
+ columns: columns ?? [],
727
+ unique,
728
+ ...where !== void 0 && { where },
729
+ ...expression !== void 0 && { expression }
730
+ },
731
+ nextLineIndex: lineIdx
732
+ };
733
+ }
385
734
  while (currentLine < lines.length) {
386
735
  const cleaned = cleanLine(lines[currentLine]);
387
736
  if (!cleaned) {
@@ -395,10 +744,27 @@ function parseSchema(source) {
395
744
  const { policy, nextLineIndex } = parsePolicyBlock(currentLine);
396
745
  policyList.push({ policy, startLine: currentLine + 1 });
397
746
  currentLine = nextLineIndex;
747
+ } else if (cleaned.startsWith("index ")) {
748
+ const { index, nextLineIndex } = parseIndexDeclaration(currentLine);
749
+ indexList.push({ index, startLine: currentLine + 1 });
750
+ currentLine = nextLineIndex;
751
+ } else if (cleaned.startsWith("view ")) {
752
+ const { view, nextLineIndex } = parseViewDeclaration(currentLine);
753
+ viewList.push({ view, startLine: currentLine + 1 });
754
+ currentLine = nextLineIndex;
398
755
  } else {
399
- throw new Error(`Line ${currentLine + 1}: Unexpected content '${cleaned}'. Expected table or policy definition.`);
756
+ throw new Error(`Line ${currentLine + 1}: Unexpected content '${cleaned}'. Expected table, index, view, or policy definition.`);
400
757
  }
401
758
  }
759
+ for (const { index, startLine } of indexList) {
760
+ if (!tables[index.table]) {
761
+ throw new Error(`Line ${startLine}: Index "${index.name}" references undefined table "${index.table}"`);
762
+ }
763
+ if (!tables[index.table].indexes) {
764
+ tables[index.table].indexes = [];
765
+ }
766
+ tables[index.table].indexes.push(index);
767
+ }
402
768
  for (const { policy, startLine } of policyList) {
403
769
  if (!tables[policy.table]) {
404
770
  throw new Error(`Line ${startLine}: Policy "${policy.name}" references undefined table "${policy.table}"`);
@@ -408,164 +774,27 @@ function parseSchema(source) {
408
774
  }
409
775
  tables[policy.table].policies.push(policy);
410
776
  }
411
- return { tables };
777
+ const views = {};
778
+ for (const { view, startLine } of viewList) {
779
+ if (views[view.name]) {
780
+ throw new Error(`Line ${startLine}: Duplicate view definition '${view.name}'`);
781
+ }
782
+ views[view.name] = view;
783
+ }
784
+ return {
785
+ tables,
786
+ ...Object.keys(views).length > 0 && { views }
787
+ };
412
788
  }
413
789
  var POLICY_COMMANDS;
414
790
  var init_parser = __esm({
415
791
  "node_modules/@xubylele/schema-forge-core/dist/core/parser.js"() {
416
792
  "use strict";
793
+ init_normalize();
417
794
  POLICY_COMMANDS = ["select", "insert", "update", "delete", "all"];
418
795
  }
419
796
  });
420
797
 
421
- // node_modules/@xubylele/schema-forge-core/dist/core/normalize.js
422
- function normalizeIdent(input) {
423
- return input.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, "");
424
- }
425
- function pkName(table) {
426
- return `pk_${normalizeIdent(table)}`;
427
- }
428
- function uqName(table, column) {
429
- return `uq_${normalizeIdent(table)}_${normalizeIdent(column)}`;
430
- }
431
- function legacyPkName(table) {
432
- return `${normalizeIdent(table)}_pkey`;
433
- }
434
- function legacyUqName(table, column) {
435
- return `${normalizeIdent(table)}_${normalizeIdent(column)}_key`;
436
- }
437
- function normalizeSpacesOutsideQuotes(value) {
438
- let result = "";
439
- let inSingleQuote = false;
440
- let inDoubleQuote = false;
441
- let pendingSpace = false;
442
- for (const char of value) {
443
- if (char === "'" && !inDoubleQuote) {
444
- if (pendingSpace && result.length > 0 && result[result.length - 1] !== " ") {
445
- result += " ";
446
- }
447
- pendingSpace = false;
448
- inSingleQuote = !inSingleQuote;
449
- result += char;
450
- continue;
451
- }
452
- if (char === '"' && !inSingleQuote) {
453
- if (pendingSpace && result.length > 0 && result[result.length - 1] !== " ") {
454
- result += " ";
455
- }
456
- pendingSpace = false;
457
- inDoubleQuote = !inDoubleQuote;
458
- result += char;
459
- continue;
460
- }
461
- if (!inSingleQuote && !inDoubleQuote && /\s/.test(char)) {
462
- pendingSpace = true;
463
- continue;
464
- }
465
- if (pendingSpace && result.length > 0 && result[result.length - 1] !== " ") {
466
- result += " ";
467
- }
468
- pendingSpace = false;
469
- result += char;
470
- }
471
- return result.trim();
472
- }
473
- function normalizeKnownFunctionsOutsideQuotes(value) {
474
- let result = "";
475
- let inSingleQuote = false;
476
- let inDoubleQuote = false;
477
- let buffer = "";
478
- function flushBuffer() {
479
- if (!buffer) {
480
- return;
481
- }
482
- result += buffer.replace(/\bnow\s*\(\s*\)/gi, "now()").replace(/\bgen_random_uuid\s*\(\s*\)/gi, "gen_random_uuid()");
483
- buffer = "";
484
- }
485
- for (const char of value) {
486
- if (char === "'" && !inDoubleQuote) {
487
- flushBuffer();
488
- inSingleQuote = !inSingleQuote;
489
- result += char;
490
- continue;
491
- }
492
- if (char === '"' && !inSingleQuote) {
493
- flushBuffer();
494
- inDoubleQuote = !inDoubleQuote;
495
- result += char;
496
- continue;
497
- }
498
- if (inSingleQuote || inDoubleQuote) {
499
- result += char;
500
- continue;
501
- }
502
- buffer += char;
503
- }
504
- flushBuffer();
505
- return result;
506
- }
507
- function normalizePunctuationOutsideQuotes(value) {
508
- let result = "";
509
- let inSingleQuote = false;
510
- let inDoubleQuote = false;
511
- for (let index = 0; index < value.length; index++) {
512
- const char = value[index];
513
- if (char === "'" && !inDoubleQuote) {
514
- inSingleQuote = !inSingleQuote;
515
- result += char;
516
- continue;
517
- }
518
- if (char === '"' && !inSingleQuote) {
519
- inDoubleQuote = !inDoubleQuote;
520
- result += char;
521
- continue;
522
- }
523
- if (!inSingleQuote && !inDoubleQuote && (char === "(" || char === ")")) {
524
- while (result.endsWith(" ")) {
525
- result = result.slice(0, -1);
526
- }
527
- result += char;
528
- let lookahead = index + 1;
529
- while (lookahead < value.length && value[lookahead] === " ") {
530
- lookahead++;
531
- }
532
- index = lookahead - 1;
533
- continue;
534
- }
535
- if (!inSingleQuote && !inDoubleQuote && char === ",") {
536
- while (result.endsWith(" ")) {
537
- result = result.slice(0, -1);
538
- }
539
- result += ", ";
540
- let lookahead = index + 1;
541
- while (lookahead < value.length && value[lookahead] === " ") {
542
- lookahead++;
543
- }
544
- index = lookahead - 1;
545
- continue;
546
- }
547
- result += char;
548
- }
549
- return result;
550
- }
551
- function normalizeDefault(expr) {
552
- if (expr === void 0 || expr === null) {
553
- return null;
554
- }
555
- const trimmed = expr.trim();
556
- if (trimmed.length === 0) {
557
- return null;
558
- }
559
- const normalizedSpacing = normalizeSpacesOutsideQuotes(trimmed);
560
- const normalizedPunctuation = normalizePunctuationOutsideQuotes(normalizedSpacing);
561
- return normalizeKnownFunctionsOutsideQuotes(normalizedPunctuation);
562
- }
563
- var init_normalize = __esm({
564
- "node_modules/@xubylele/schema-forge-core/dist/core/normalize.js"() {
565
- "use strict";
566
- }
567
- });
568
-
569
798
  // node_modules/@xubylele/schema-forge-core/dist/core/diff.js
570
799
  function getTableNamesFromState(state) {
571
800
  return new Set(Object.keys(state.tables));
@@ -610,6 +839,62 @@ function policyEquals(oldP, newP) {
610
839
  return false;
611
840
  return true;
612
841
  }
842
+ function resolveSchemaIndexName(index) {
843
+ if (index.name && index.name.trim().length > 0) {
844
+ return index.name.trim();
845
+ }
846
+ return deterministicIndexName({
847
+ table: index.table,
848
+ columns: index.columns,
849
+ expression: index.expression
850
+ });
851
+ }
852
+ function resolveStateIndexName(index) {
853
+ if (index.name && index.name.trim().length > 0) {
854
+ return index.name.trim();
855
+ }
856
+ return deterministicIndexName({
857
+ table: index.table,
858
+ columns: index.columns,
859
+ expression: index.expression
860
+ });
861
+ }
862
+ function normalizeIndexWhere(value) {
863
+ return normalizeSqlExpression(value);
864
+ }
865
+ function normalizeIndexExpression(value) {
866
+ return normalizeSqlExpression(value);
867
+ }
868
+ function indexesEqual(previous, current) {
869
+ if (previous.table !== current.table)
870
+ return false;
871
+ if ((previous.unique ?? false) !== (current.unique ?? false))
872
+ return false;
873
+ if (previous.columns.length !== current.columns.length)
874
+ return false;
875
+ for (let index = 0; index < previous.columns.length; index++) {
876
+ if (previous.columns[index] !== current.columns[index]) {
877
+ return false;
878
+ }
879
+ }
880
+ if (normalizeIndexWhere(previous.where) !== normalizeIndexWhere(current.where))
881
+ return false;
882
+ if (normalizeIndexExpression(previous.expression) !== normalizeIndexExpression(current.expression))
883
+ return false;
884
+ return true;
885
+ }
886
+ function resolveStateViewHash(view) {
887
+ if (view.hash && view.hash.trim().length > 0) {
888
+ return view.hash;
889
+ }
890
+ return hashSqlContent(view.query);
891
+ }
892
+ function resolveSchemaViewHash(view) {
893
+ if (view.hash && view.hash.trim().length > 0) {
894
+ return view.hash;
895
+ }
896
+ return hashSqlContent(view.query);
897
+ }
613
898
  function diffSchemas(oldState, newSchema) {
614
899
  const operations = [];
615
900
  const oldTableNames = getTableNamesFromState(oldState);
@@ -622,6 +907,15 @@ function diffSchemas(oldState, newSchema) {
622
907
  kind: "create_table",
623
908
  table: newSchema.tables[tableName]
624
909
  });
910
+ const createdTable = newSchema.tables[tableName];
911
+ const createdIndexes = (createdTable.indexes ?? []).map((index) => ({ ...index, name: resolveSchemaIndexName(index) })).sort((left, right) => left.name.localeCompare(right.name));
912
+ for (const index of createdIndexes) {
913
+ operations.push({
914
+ kind: "create_index",
915
+ tableName,
916
+ index
917
+ });
918
+ }
625
919
  }
626
920
  }
627
921
  const commonTableNames = sortedNewTableNames.filter((tableName) => oldTableNames.has(tableName));
@@ -765,7 +1059,66 @@ function diffSchemas(oldState, newSchema) {
765
1059
  operations.push({
766
1060
  kind: "add_primary_key_constraint",
767
1061
  tableName,
768
- columnName: currentPrimaryKey
1062
+ columnName: currentPrimaryKey
1063
+ });
1064
+ }
1065
+ }
1066
+ for (const tableName of commonTableNames) {
1067
+ const newTable = newSchema.tables[tableName];
1068
+ const oldTable = oldState.tables[tableName];
1069
+ if (!newTable || !oldTable) {
1070
+ continue;
1071
+ }
1072
+ const oldIndexes = oldTable.indexes ?? {};
1073
+ const newIndexesByName = /* @__PURE__ */ new Map();
1074
+ for (const index of newTable.indexes ?? []) {
1075
+ const resolved = { ...index, name: resolveSchemaIndexName(index) };
1076
+ newIndexesByName.set(resolved.name, resolved);
1077
+ }
1078
+ const oldIndexesByName = /* @__PURE__ */ new Map();
1079
+ for (const oldIndex of Object.values(oldIndexes)) {
1080
+ oldIndexesByName.set(resolveStateIndexName(oldIndex), oldIndex);
1081
+ }
1082
+ const oldIndexNames = Array.from(oldIndexesByName.keys()).sort((a, b) => a.localeCompare(b));
1083
+ const newIndexNames = Array.from(newIndexesByName.keys()).sort((a, b) => a.localeCompare(b));
1084
+ const indexDrops = [];
1085
+ const indexCreates = [];
1086
+ for (const oldIndexName of oldIndexNames) {
1087
+ const oldIndex = oldIndexesByName.get(oldIndexName);
1088
+ if (!oldIndex) {
1089
+ continue;
1090
+ }
1091
+ const nextIndex = newIndexesByName.get(oldIndexName);
1092
+ if (!nextIndex) {
1093
+ indexDrops.push({ ...oldIndex, name: oldIndexName });
1094
+ continue;
1095
+ }
1096
+ if (!indexesEqual(oldIndex, nextIndex)) {
1097
+ indexDrops.push({ ...oldIndex, name: oldIndexName });
1098
+ }
1099
+ }
1100
+ for (const newIndexName of newIndexNames) {
1101
+ const newIndex = newIndexesByName.get(newIndexName);
1102
+ if (!newIndex) {
1103
+ continue;
1104
+ }
1105
+ const oldIndex = oldIndexesByName.get(newIndexName);
1106
+ if (!oldIndex || !indexesEqual(oldIndex, newIndex)) {
1107
+ indexCreates.push(newIndex);
1108
+ }
1109
+ }
1110
+ for (const index of indexDrops) {
1111
+ operations.push({
1112
+ kind: "drop_index",
1113
+ tableName,
1114
+ index
1115
+ });
1116
+ }
1117
+ for (const index of indexCreates) {
1118
+ operations.push({
1119
+ kind: "create_index",
1120
+ tableName,
1121
+ index
769
1122
  });
770
1123
  }
771
1124
  }
@@ -817,6 +1170,36 @@ function diffSchemas(oldState, newSchema) {
817
1170
  }
818
1171
  }
819
1172
  }
1173
+ const oldViews = oldState.views ?? {};
1174
+ const newViews = newSchema.views ?? {};
1175
+ const oldViewNames = Object.keys(oldViews).sort((a, b) => a.localeCompare(b));
1176
+ const newViewNames = Object.keys(newViews).sort((a, b) => a.localeCompare(b));
1177
+ const oldViewNameSet = new Set(oldViewNames);
1178
+ const newViewNameSet = new Set(newViewNames);
1179
+ for (const viewName of newViewNames) {
1180
+ const nextView = newViews[viewName];
1181
+ if (!nextView) {
1182
+ continue;
1183
+ }
1184
+ if (!oldViewNameSet.has(viewName)) {
1185
+ operations.push({ kind: "create_view", view: nextView });
1186
+ continue;
1187
+ }
1188
+ const prevView = oldViews[viewName];
1189
+ if (!prevView) {
1190
+ continue;
1191
+ }
1192
+ const oldHash = resolveStateViewHash(prevView);
1193
+ const newHash = resolveSchemaViewHash(nextView);
1194
+ if (oldHash !== newHash) {
1195
+ operations.push({ kind: "replace_view", view: nextView });
1196
+ }
1197
+ }
1198
+ for (const viewName of oldViewNames) {
1199
+ if (!newViewNameSet.has(viewName)) {
1200
+ operations.push({ kind: "drop_view", viewName });
1201
+ }
1202
+ }
820
1203
  for (const tableName of sortedOldTableNames) {
821
1204
  if (!newTableNames.has(tableName)) {
822
1205
  operations.push({
@@ -916,9 +1299,52 @@ function validateSchema(schema) {
916
1299
  for (const tableName in schema.tables) {
917
1300
  const table = schema.tables[tableName];
918
1301
  validateTableColumns(tableName, table, schema.tables);
1302
+ validateTableIndexes(tableName, table, schema.tables);
919
1303
  }
920
1304
  validatePolicies(schema);
921
1305
  }
1306
+ function validateTableIndexes(tableName, table, allTables) {
1307
+ if (!table.indexes?.length) {
1308
+ return;
1309
+ }
1310
+ const normalizedNames = /* @__PURE__ */ new Set();
1311
+ for (const index of table.indexes) {
1312
+ validateSingleIndex(tableName, table, index, allTables);
1313
+ const effectiveName = index.name?.trim() || deterministicIndexName({
1314
+ table: index.table,
1315
+ columns: index.columns,
1316
+ expression: index.expression
1317
+ });
1318
+ if (normalizedNames.has(effectiveName)) {
1319
+ throw new Error(`Table '${tableName}': duplicate index name '${effectiveName}'`);
1320
+ }
1321
+ normalizedNames.add(effectiveName);
1322
+ }
1323
+ }
1324
+ function validateSingleIndex(tableName, table, index, allTables) {
1325
+ if (!allTables[index.table]) {
1326
+ throw new Error(`Table '${tableName}': index '${index.name}' references table '${index.table}' which does not exist`);
1327
+ }
1328
+ if (index.table !== tableName) {
1329
+ throw new Error(`Table '${tableName}': index '${index.name}' must target table '${tableName}'`);
1330
+ }
1331
+ const hasColumns = index.columns.length > 0;
1332
+ const hasExpression = index.expression !== void 0;
1333
+ if (hasColumns && hasExpression || !hasColumns && !hasExpression) {
1334
+ throw new Error(`Table '${tableName}': index '${index.name}' must define exactly one of columns or expression`);
1335
+ }
1336
+ if (hasExpression && index.expression.trim().length === 0) {
1337
+ throw new Error(`Table '${tableName}': index '${index.name}' expression cannot be empty`);
1338
+ }
1339
+ if (hasColumns) {
1340
+ const tableColumns = new Set(table.columns.map((column) => column.name));
1341
+ for (const columnName of index.columns) {
1342
+ if (!tableColumns.has(columnName)) {
1343
+ throw new Error(`Table '${tableName}': index '${index.name}' references unknown column '${columnName}'`);
1344
+ }
1345
+ }
1346
+ }
1347
+ }
922
1348
  function validateDuplicateTables(schema) {
923
1349
  const tableNames = Object.keys(schema.tables);
924
1350
  const seen = /* @__PURE__ */ new Set();
@@ -997,6 +1423,7 @@ var VALID_POLICY_COMMANDS, VALID_BASE_COLUMN_TYPES;
997
1423
  var init_validator = __esm({
998
1424
  "node_modules/@xubylele/schema-forge-core/dist/core/validator.js"() {
999
1425
  "use strict";
1426
+ init_normalize();
1000
1427
  VALID_POLICY_COMMANDS = [
1001
1428
  "select",
1002
1429
  "insert",
@@ -1097,12 +1524,22 @@ function classifyOperation(operation) {
1097
1524
  return "DESTRUCTIVE";
1098
1525
  case "add_primary_key_constraint":
1099
1526
  return "SAFE";
1527
+ case "create_index":
1528
+ return "SAFE";
1529
+ case "drop_index":
1530
+ return "WARNING";
1100
1531
  case "create_policy":
1101
1532
  return "SAFE";
1102
1533
  case "drop_policy":
1103
1534
  return "DESTRUCTIVE";
1104
1535
  case "modify_policy":
1105
1536
  return "WARNING";
1537
+ case "create_view":
1538
+ return "SAFE";
1539
+ case "drop_view":
1540
+ return "DESTRUCTIVE";
1541
+ case "replace_view":
1542
+ return "WARNING";
1106
1543
  default:
1107
1544
  const _exhaustive = operation;
1108
1545
  return _exhaustive;
@@ -1225,6 +1662,14 @@ function checkOperationSafety(operation) {
1225
1662
  message: "Primary key constraint removed",
1226
1663
  operationKind: operation.kind
1227
1664
  };
1665
+ case "drop_index":
1666
+ return {
1667
+ safetyLevel,
1668
+ code: "DROP_INDEX",
1669
+ table: operation.tableName,
1670
+ message: `Index '${operation.index.name}' removed`,
1671
+ operationKind: operation.kind
1672
+ };
1228
1673
  case "drop_policy":
1229
1674
  return {
1230
1675
  safetyLevel,
@@ -1241,6 +1686,22 @@ function checkOperationSafety(operation) {
1241
1686
  message: "Policy expression changed",
1242
1687
  operationKind: operation.kind
1243
1688
  };
1689
+ case "drop_view":
1690
+ return {
1691
+ safetyLevel,
1692
+ code: "DROP_VIEW",
1693
+ table: operation.viewName,
1694
+ message: "View removed",
1695
+ operationKind: operation.kind
1696
+ };
1697
+ case "replace_view":
1698
+ return {
1699
+ safetyLevel,
1700
+ code: "REPLACE_VIEW",
1701
+ table: operation.view.name,
1702
+ message: "View definition changed",
1703
+ operationKind: operation.kind
1704
+ };
1244
1705
  default:
1245
1706
  return null;
1246
1707
  }
@@ -1402,6 +1863,7 @@ var init_fs = __esm({
1402
1863
  // node_modules/@xubylele/schema-forge-core/dist/core/state-transform.js
1403
1864
  async function schemaToState(schema) {
1404
1865
  const tables = {};
1866
+ let views;
1405
1867
  for (const [tableName, table] of Object.entries(schema.tables)) {
1406
1868
  const columns = {};
1407
1869
  const primaryKeyColumn = table.primaryKey ?? table.columns.find((column) => column.primaryKey)?.name ?? null;
@@ -1426,15 +1888,40 @@ async function schemaToState(schema) {
1426
1888
  };
1427
1889
  }
1428
1890
  }
1891
+ let indexes;
1892
+ if (table.indexes?.length) {
1893
+ indexes = {};
1894
+ for (const index of table.indexes) {
1895
+ indexes[index.name] = {
1896
+ name: index.name,
1897
+ table: index.table,
1898
+ columns: [...index.columns],
1899
+ unique: index.unique,
1900
+ ...index.where !== void 0 && { where: index.where },
1901
+ ...index.expression !== void 0 && { expression: index.expression }
1902
+ };
1903
+ }
1904
+ }
1429
1905
  tables[tableName] = {
1430
1906
  columns,
1431
1907
  ...primaryKeyColumn !== null && { primaryKey: primaryKeyColumn },
1908
+ ...indexes !== void 0 && { indexes },
1432
1909
  ...policies !== void 0 && { policies }
1433
1910
  };
1434
1911
  }
1912
+ if (schema.views && Object.keys(schema.views).length > 0) {
1913
+ views = {};
1914
+ for (const [viewName, view] of Object.entries(schema.views)) {
1915
+ views[viewName] = {
1916
+ query: view.query,
1917
+ hash: view.hash
1918
+ };
1919
+ }
1920
+ }
1435
1921
  return {
1436
1922
  version: 1,
1437
- tables
1923
+ tables,
1924
+ ...views !== void 0 && { views }
1438
1925
  };
1439
1926
  }
1440
1927
  var init_state_transform = __esm({
@@ -1463,6 +1950,242 @@ var init_state_manager = __esm({
1463
1950
  }
1464
1951
  });
1465
1952
 
1953
+ // node_modules/@xubylele/schema-forge-core/dist/core/plan-builder.js
1954
+ function toAction(operation) {
1955
+ switch (operation.kind) {
1956
+ case "create_table":
1957
+ case "add_column":
1958
+ case "add_primary_key_constraint":
1959
+ case "create_index":
1960
+ case "create_policy":
1961
+ case "create_view":
1962
+ return "create";
1963
+ case "drop_table":
1964
+ case "drop_column":
1965
+ case "drop_primary_key_constraint":
1966
+ case "drop_index":
1967
+ case "drop_policy":
1968
+ case "drop_view":
1969
+ return "delete";
1970
+ case "column_type_changed":
1971
+ case "column_nullability_changed":
1972
+ case "column_default_changed":
1973
+ case "column_unique_changed":
1974
+ case "modify_policy":
1975
+ case "replace_view":
1976
+ return "modify";
1977
+ default: {
1978
+ const exhaustiveCheck = operation;
1979
+ return exhaustiveCheck;
1980
+ }
1981
+ }
1982
+ }
1983
+ function actionToSymbol(action) {
1984
+ if (action === "create") {
1985
+ return "+";
1986
+ }
1987
+ if (action === "modify") {
1988
+ return "~";
1989
+ }
1990
+ return "-";
1991
+ }
1992
+ function formatNullable(value) {
1993
+ return value ? "nullable" : "not null";
1994
+ }
1995
+ function formatUnique(value) {
1996
+ return value ? "unique" : "not unique";
1997
+ }
1998
+ function formatDefault(value) {
1999
+ if (value === null) {
2000
+ return "null";
2001
+ }
2002
+ return value;
2003
+ }
2004
+ function toEntry(operation) {
2005
+ const action = toAction(operation);
2006
+ const symbol = actionToSymbol(action);
2007
+ switch (operation.kind) {
2008
+ case "create_table":
2009
+ return {
2010
+ action,
2011
+ symbol,
2012
+ operationKind: operation.kind,
2013
+ summary: `create table ${operation.table.name}`,
2014
+ tableName: operation.table.name
2015
+ };
2016
+ case "drop_table":
2017
+ return {
2018
+ action,
2019
+ symbol,
2020
+ operationKind: operation.kind,
2021
+ summary: `drop table ${operation.tableName}`,
2022
+ tableName: operation.tableName
2023
+ };
2024
+ case "add_column":
2025
+ return {
2026
+ action,
2027
+ symbol,
2028
+ operationKind: operation.kind,
2029
+ summary: `add column ${operation.column.name} to ${operation.tableName}`,
2030
+ tableName: operation.tableName,
2031
+ columnName: operation.column.name
2032
+ };
2033
+ case "drop_column":
2034
+ return {
2035
+ action,
2036
+ symbol,
2037
+ operationKind: operation.kind,
2038
+ summary: `drop column ${operation.columnName} from ${operation.tableName}`,
2039
+ tableName: operation.tableName,
2040
+ columnName: operation.columnName
2041
+ };
2042
+ case "column_type_changed":
2043
+ return {
2044
+ action,
2045
+ symbol,
2046
+ operationKind: operation.kind,
2047
+ summary: `modify column ${operation.columnName} type on ${operation.tableName} (${operation.fromType} -> ${operation.toType})`,
2048
+ tableName: operation.tableName,
2049
+ columnName: operation.columnName,
2050
+ from: operation.fromType,
2051
+ to: operation.toType
2052
+ };
2053
+ case "column_nullability_changed":
2054
+ return {
2055
+ action,
2056
+ symbol,
2057
+ operationKind: operation.kind,
2058
+ summary: `modify column ${operation.columnName} nullability on ${operation.tableName} (${formatNullable(operation.from)} -> ${formatNullable(operation.to)})`,
2059
+ tableName: operation.tableName,
2060
+ columnName: operation.columnName,
2061
+ from: operation.from,
2062
+ to: operation.to
2063
+ };
2064
+ case "column_default_changed":
2065
+ return {
2066
+ action,
2067
+ symbol,
2068
+ operationKind: operation.kind,
2069
+ summary: `modify column ${operation.columnName} default on ${operation.tableName} (${formatDefault(operation.fromDefault)} -> ${formatDefault(operation.toDefault)})`,
2070
+ tableName: operation.tableName,
2071
+ columnName: operation.columnName,
2072
+ from: operation.fromDefault,
2073
+ to: operation.toDefault
2074
+ };
2075
+ case "column_unique_changed":
2076
+ return {
2077
+ action,
2078
+ symbol,
2079
+ operationKind: operation.kind,
2080
+ summary: `modify column ${operation.columnName} unique constraint on ${operation.tableName} (${formatUnique(operation.from)} -> ${formatUnique(operation.to)})`,
2081
+ tableName: operation.tableName,
2082
+ columnName: operation.columnName,
2083
+ from: operation.from,
2084
+ to: operation.to
2085
+ };
2086
+ case "drop_primary_key_constraint":
2087
+ return {
2088
+ action,
2089
+ symbol,
2090
+ operationKind: operation.kind,
2091
+ summary: `drop primary key constraint on ${operation.tableName}`,
2092
+ tableName: operation.tableName
2093
+ };
2094
+ case "add_primary_key_constraint":
2095
+ return {
2096
+ action,
2097
+ symbol,
2098
+ operationKind: operation.kind,
2099
+ summary: `add primary key constraint on ${operation.tableName} (${operation.columnName})`,
2100
+ tableName: operation.tableName,
2101
+ columnName: operation.columnName
2102
+ };
2103
+ case "create_index":
2104
+ return {
2105
+ action,
2106
+ symbol,
2107
+ operationKind: operation.kind,
2108
+ summary: `create index ${operation.index.name} on ${operation.tableName}`,
2109
+ tableName: operation.tableName
2110
+ };
2111
+ case "drop_index":
2112
+ return {
2113
+ action,
2114
+ symbol,
2115
+ operationKind: operation.kind,
2116
+ summary: `drop index ${operation.index.name} on ${operation.tableName}`,
2117
+ tableName: operation.tableName
2118
+ };
2119
+ case "create_policy":
2120
+ return {
2121
+ action,
2122
+ symbol,
2123
+ operationKind: operation.kind,
2124
+ summary: `create policy ${operation.policy.name} on ${operation.tableName}`,
2125
+ tableName: operation.tableName
2126
+ };
2127
+ case "drop_policy":
2128
+ return {
2129
+ action,
2130
+ symbol,
2131
+ operationKind: operation.kind,
2132
+ summary: `drop policy ${operation.policyName} on ${operation.tableName}`,
2133
+ tableName: operation.tableName
2134
+ };
2135
+ case "modify_policy":
2136
+ return {
2137
+ action,
2138
+ symbol,
2139
+ operationKind: operation.kind,
2140
+ summary: `modify policy ${operation.policyName} on ${operation.tableName}`,
2141
+ tableName: operation.tableName
2142
+ };
2143
+ case "create_view":
2144
+ return {
2145
+ action,
2146
+ symbol,
2147
+ operationKind: operation.kind,
2148
+ summary: `create view ${operation.view.name}`
2149
+ };
2150
+ case "drop_view":
2151
+ return {
2152
+ action,
2153
+ symbol,
2154
+ operationKind: operation.kind,
2155
+ summary: `drop view ${operation.viewName}`
2156
+ };
2157
+ case "replace_view":
2158
+ return {
2159
+ action,
2160
+ symbol,
2161
+ operationKind: operation.kind,
2162
+ summary: `replace view ${operation.view.name}`
2163
+ };
2164
+ default: {
2165
+ const exhaustiveCheck = operation;
2166
+ return exhaustiveCheck;
2167
+ }
2168
+ }
2169
+ }
2170
+ function formatMigrationPlanLine(entry) {
2171
+ return `${entry.symbol} ${entry.summary}`;
2172
+ }
2173
+ function formatMigrationPlanLines(entries) {
2174
+ return entries.map(formatMigrationPlanLine);
2175
+ }
2176
+ function buildMigrationPlan(diff) {
2177
+ const entries = diff.operations.map(toEntry);
2178
+ return {
2179
+ entries,
2180
+ lines: formatMigrationPlanLines(entries)
2181
+ };
2182
+ }
2183
+ var init_plan_builder = __esm({
2184
+ "node_modules/@xubylele/schema-forge-core/dist/core/plan-builder.js"() {
2185
+ "use strict";
2186
+ }
2187
+ });
2188
+
1466
2189
  // node_modules/@xubylele/schema-forge-core/dist/generator/sql-generator.js
1467
2190
  function generateEnableRls(tableName) {
1468
2191
  return `ALTER TABLE ${tableName} ENABLE ROW LEVEL SECURITY;`;
@@ -1507,14 +2230,46 @@ function generateOperation(operation, provider, sqlConfig) {
1507
2230
  return generateDropPrimaryKeyConstraint(operation.tableName);
1508
2231
  case "add_primary_key_constraint":
1509
2232
  return generateAddPrimaryKeyConstraint(operation.tableName, operation.columnName);
2233
+ case "create_index":
2234
+ return generateCreateIndex(operation.tableName, operation.index);
2235
+ case "drop_index":
2236
+ return generateDropIndex(operation.tableName, operation.index);
1510
2237
  case "create_policy":
1511
2238
  return generateCreatePolicy(operation.tableName, operation.policy);
1512
2239
  case "drop_policy":
1513
2240
  return generateDropPolicy(operation.tableName, operation.policyName);
2241
+ case "create_view":
2242
+ return generateCreateOrReplaceView(operation.view);
2243
+ case "drop_view":
2244
+ return generateDropView(operation.viewName);
2245
+ case "replace_view":
2246
+ return generateCreateOrReplaceView(operation.view);
1514
2247
  case "modify_policy":
1515
2248
  return generateModifyPolicy(operation.tableName, operation.policyName, operation.policy);
1516
2249
  }
1517
2250
  }
2251
+ function generateCreateOrReplaceView(view) {
2252
+ return `CREATE OR REPLACE VIEW ${view.name} AS
2253
+ ${view.query};`;
2254
+ }
2255
+ function generateDropView(viewName) {
2256
+ return `DROP VIEW IF EXISTS ${viewName};`;
2257
+ }
2258
+ function generateCreateIndex(tableName, index) {
2259
+ const uniqueToken = index.unique ? "UNIQUE " : "";
2260
+ const payload = index.expression ? `(${index.expression})` : `(${index.columns.join(", ")})`;
2261
+ const whereClause = index.where ? ` WHERE ${index.where}` : "";
2262
+ return `CREATE ${uniqueToken}INDEX ${index.name} ON ${tableName} ${payload}${whereClause};`;
2263
+ }
2264
+ function generateDropIndex(tableName, index) {
2265
+ const fallbackName = deterministicIndexName({
2266
+ table: tableName,
2267
+ columns: index.columns,
2268
+ expression: index.expression
2269
+ });
2270
+ const indexNames = Array.from(/* @__PURE__ */ new Set([index.name, fallbackName]));
2271
+ return indexNames.map((indexName) => `DROP INDEX IF EXISTS ${indexName};`).join("\n");
2272
+ }
1518
2273
  function generateCreateTable(table, provider, sqlConfig) {
1519
2274
  const columnDefs = table.columns.map((col) => generateColumnDefinition(col, provider, sqlConfig));
1520
2275
  const lines = ["CREATE TABLE " + table.name + " ("];
@@ -2558,6 +3313,26 @@ function renderPolicy(policy) {
2558
3313
  }
2559
3314
  return lines.join("\n");
2560
3315
  }
3316
+ function renderIndex(index) {
3317
+ const resolvedName = index.name || deterministicIndexName({
3318
+ table: index.table,
3319
+ columns: index.columns,
3320
+ expression: index.expression
3321
+ });
3322
+ const lines = [`index ${resolvedName} on ${index.table}`];
3323
+ if (index.expression) {
3324
+ lines.push(`expression ${index.expression}`);
3325
+ } else {
3326
+ lines.push(`columns ${index.columns.join(", ")}`);
3327
+ }
3328
+ if (index.unique) {
3329
+ lines.push("unique");
3330
+ }
3331
+ if (index.where) {
3332
+ lines.push(`where ${index.where}`);
3333
+ }
3334
+ return lines.join("\n");
3335
+ }
2561
3336
  function schemaToDsl(schema) {
2562
3337
  const tableNames = Object.keys(schema.tables).sort((left, right) => left.localeCompare(right));
2563
3338
  const blocks = [];
@@ -2569,6 +3344,17 @@ function schemaToDsl(schema) {
2569
3344
  }
2570
3345
  tableLines.push("}");
2571
3346
  blocks.push(tableLines.join("\n"));
3347
+ const sortedIndexes = (table.indexes ?? []).map((index) => ({
3348
+ ...index,
3349
+ name: index.name || deterministicIndexName({
3350
+ table: index.table,
3351
+ columns: index.columns,
3352
+ expression: index.expression
3353
+ })
3354
+ })).sort((left, right) => left.name.localeCompare(right.name));
3355
+ for (const index of sortedIndexes) {
3356
+ blocks.push(renderIndex(index));
3357
+ }
2572
3358
  if (table.policies?.length) {
2573
3359
  for (const policy of table.policies) {
2574
3360
  blocks.push(renderPolicy(policy));
@@ -2586,6 +3372,7 @@ ${blocks.join("\n\n")}
2586
3372
  var init_schema_to_dsl = __esm({
2587
3373
  "node_modules/@xubylele/schema-forge-core/dist/core/sql/schema-to-dsl.js"() {
2588
3374
  "use strict";
3375
+ init_normalize();
2589
3376
  }
2590
3377
  });
2591
3378
 
@@ -3039,13 +3826,17 @@ __export(dist_exports, {
3039
3826
  SchemaValidationError: () => SchemaValidationError,
3040
3827
  analyzeSchemaDrift: () => analyzeSchemaDrift,
3041
3828
  applySqlOps: () => applySqlOps,
3829
+ buildMigrationPlan: () => buildMigrationPlan,
3042
3830
  checkOperationSafety: () => checkOperationSafety,
3043
3831
  checkSchemaSafety: () => checkSchemaSafety,
3044
3832
  classifyOperation: () => classifyOperation,
3833
+ deterministicIndexName: () => deterministicIndexName,
3045
3834
  diffSchemas: () => diffSchemas,
3046
3835
  ensureDir: () => ensureDir2,
3047
3836
  fileExists: () => fileExists2,
3048
3837
  findFiles: () => findFiles,
3838
+ formatMigrationPlanLine: () => formatMigrationPlanLine,
3839
+ formatMigrationPlanLines: () => formatMigrationPlanLines,
3049
3840
  generateSql: () => generateSql,
3050
3841
  getColumnNamesFromSchema: () => getColumnNamesFromSchema,
3051
3842
  getColumnNamesFromState: () => getColumnNamesFromState,
@@ -3056,6 +3847,7 @@ __export(dist_exports, {
3056
3847
  getStatePath: () => getStatePath2,
3057
3848
  getTableNamesFromSchema: () => getTableNamesFromSchema,
3058
3849
  getTableNamesFromState: () => getTableNamesFromState,
3850
+ hashSqlContent: () => hashSqlContent,
3059
3851
  introspectPostgresSchema: () => introspectPostgresSchema,
3060
3852
  legacyPkName: () => legacyPkName,
3061
3853
  legacyUqName: () => legacyUqName,
@@ -3063,6 +3855,7 @@ __export(dist_exports, {
3063
3855
  loadState: () => loadState,
3064
3856
  normalizeDefault: () => normalizeDefault,
3065
3857
  normalizeIdent: () => normalizeIdent,
3858
+ normalizeSqlExpression: () => normalizeSqlExpression,
3066
3859
  nowTimestamp: () => nowTimestamp,
3067
3860
  parseAddDropConstraint: () => parseAddDropConstraint,
3068
3861
  parseAlterColumnType: () => parseAlterColumnType,
@@ -3101,6 +3894,7 @@ var init_dist = __esm({
3101
3894
  init_validate();
3102
3895
  init_safety();
3103
3896
  init_state_manager();
3897
+ init_plan_builder();
3104
3898
  init_sql_generator();
3105
3899
  init_parse_migration();
3106
3900
  init_apply_ops();
@@ -3117,12 +3911,12 @@ var init_dist = __esm({
3117
3911
  });
3118
3912
 
3119
3913
  // src/cli.ts
3120
- var import_commander8 = require("commander");
3914
+ var import_commander10 = require("commander");
3121
3915
 
3122
3916
  // package.json
3123
3917
  var package_default = {
3124
3918
  name: "@xubylele/schema-forge",
3125
- version: "1.12.2",
3919
+ version: "1.13.0",
3126
3920
  description: "Universal migration generator from schema DSL",
3127
3921
  main: "dist/cli.js",
3128
3922
  type: "commonjs",
@@ -3176,19 +3970,19 @@ var package_default = {
3176
3970
  boxen: "^8.0.1",
3177
3971
  chalk: "^5.6.2",
3178
3972
  commander: "^14.0.3",
3179
- pg: "^8.19.0",
3973
+ pg: "^8.20.0",
3180
3974
  "update-notifier": "^7.3.1"
3181
3975
  },
3182
3976
  devDependencies: {
3183
3977
  "@changesets/cli": "^2.30.0",
3184
- "@types/node": "^25.2.3",
3185
- "@types/pg": "^8.18.0",
3186
- "@xubylele/schema-forge-core": "^1.5.0",
3187
- testcontainers: "^11.8.1",
3978
+ "@types/node": "^25.5.2",
3979
+ "@types/pg": "^8.20.0",
3980
+ "@xubylele/schema-forge-core": "^1.6.0",
3981
+ testcontainers: "^11.13.0",
3188
3982
  "ts-node": "^10.9.2",
3189
3983
  tsup: "^8.5.1",
3190
- typescript: "^5.9.3",
3191
- vitest: "^4.0.18"
3984
+ typescript: "^6.0.2",
3985
+ vitest: "^4.1.2"
3192
3986
  }
3193
3987
  };
3194
3988
 
@@ -3388,6 +4182,10 @@ async function generateSql2(diff, provider, config) {
3388
4182
  const core = await loadCore();
3389
4183
  return core.generateSql(diff, provider, config);
3390
4184
  }
4185
+ async function buildMigrationPlan2(diff) {
4186
+ const core = await loadCore();
4187
+ return core.buildMigrationPlan(diff);
4188
+ }
3391
4189
  async function schemaToState2(schema) {
3392
4190
  const core = await loadCore();
3393
4191
  return core.schemaToState(schema);
@@ -4009,12 +4807,109 @@ async function runIntrospect(options = {}) {
4009
4807
  }
4010
4808
  }
4011
4809
 
4012
- // src/commands/validate.ts
4810
+ // src/commands/plan.ts
4013
4811
  var import_commander7 = require("commander");
4014
4812
  var import_path13 = __toESM(require("path"));
4015
4813
  function resolveConfigPath5(root, targetPath) {
4016
4814
  return import_path13.default.isAbsolute(targetPath) ? targetPath : import_path13.default.join(root, targetPath);
4017
4815
  }
4816
+ async function runPlan(options = {}) {
4817
+ if (options.safe && options.force) {
4818
+ throw new Error("Cannot use --safe and --force flags together. Choose one:\n --safe: Block destructive operations\n --force: Bypass safety checks");
4819
+ }
4820
+ const root = getProjectRoot();
4821
+ const configPath = getConfigPath(root);
4822
+ if (!await fileExists(configPath)) {
4823
+ throw new Error('SchemaForge project not initialized. Run "schema-forge init" first.');
4824
+ }
4825
+ const config = await readJsonFile(configPath, {});
4826
+ const useLiveDatabase = Boolean(options.url || process.env.DATABASE_URL);
4827
+ const requiredFields = useLiveDatabase ? ["schemaFile"] : ["schemaFile", "stateFile"];
4828
+ for (const field of requiredFields) {
4829
+ const value = config[field];
4830
+ if (!value || typeof value !== "string") {
4831
+ throw new Error(`Invalid config: '${field}' is required`);
4832
+ }
4833
+ }
4834
+ const schemaPath = resolveConfigPath5(root, config.schemaFile);
4835
+ const statePath = config.stateFile ? resolveConfigPath5(root, config.stateFile) : null;
4836
+ const schemaSource = await readTextFile(schemaPath);
4837
+ const schema = await parseSchema2(schemaSource);
4838
+ try {
4839
+ await validateSchema2(schema);
4840
+ } catch (error2) {
4841
+ if (error2 instanceof Error) {
4842
+ throw await createSchemaValidationError(error2.message);
4843
+ }
4844
+ throw error2;
4845
+ }
4846
+ const previousState = useLiveDatabase ? await withPostgresQueryExecutor(
4847
+ resolvePostgresConnectionString({ url: options.url }),
4848
+ async (query) => {
4849
+ const schemaFilters = parseSchemaList(options.schema);
4850
+ const liveSchema = await introspectPostgresSchema2({
4851
+ query,
4852
+ ...schemaFilters ? { schemas: schemaFilters } : {}
4853
+ });
4854
+ return schemaToState2(liveSchema);
4855
+ }
4856
+ ) : await loadState2(statePath ?? "");
4857
+ const diff = await diffSchemas2(previousState, schema);
4858
+ if (options.force) {
4859
+ forceWarning("Are you sure to use --force? This option will bypass safety checks for destructive operations.");
4860
+ }
4861
+ if (options.safe && !options.force && diff.operations.length > 0) {
4862
+ const findings = await validateSchemaChanges2(previousState, schema);
4863
+ const destructiveFindings = findings.filter((f) => f.severity === "error");
4864
+ if (destructiveFindings.length > 0) {
4865
+ const errorMessages = destructiveFindings.map((f) => {
4866
+ const target = f.column ? `${f.table}.${f.column}` : f.table;
4867
+ const typeRange = f.from && f.to ? ` (${f.from} -> ${f.to})` : "";
4868
+ return ` - ${f.code}: ${target}${typeRange}`;
4869
+ }).join("\n");
4870
+ throw await createSchemaValidationError(
4871
+ `Cannot proceed with --safe flag: Found ${destructiveFindings.length} destructive operation(s):
4872
+ ${errorMessages}
4873
+
4874
+ Remove --safe flag or modify schema to avoid destructive changes.`
4875
+ );
4876
+ }
4877
+ }
4878
+ if (!options.safe && !options.force && diff.operations.length > 0) {
4879
+ const findings = await validateSchemaChanges2(previousState, schema);
4880
+ const riskyFindings = findings.filter((f) => f.severity === "error" || f.severity === "warning");
4881
+ if (riskyFindings.length > 0) {
4882
+ const confirmed = await confirmDestructiveOps(findings);
4883
+ if (!confirmed) {
4884
+ if (process.exitCode !== EXIT_CODES.CI_DESTRUCTIVE) {
4885
+ process.exitCode = EXIT_CODES.VALIDATION_ERROR;
4886
+ }
4887
+ return;
4888
+ }
4889
+ }
4890
+ }
4891
+ if (diff.operations.length === 0) {
4892
+ success("No changes detected");
4893
+ process.exitCode = EXIT_CODES.SUCCESS;
4894
+ return;
4895
+ }
4896
+ const plan = await buildMigrationPlan2(diff);
4897
+ console.log(plan.lines.join("\n"));
4898
+ process.exitCode = EXIT_CODES.SUCCESS;
4899
+ }
4900
+
4901
+ // src/commands/preview.ts
4902
+ var import_commander8 = require("commander");
4903
+ async function runPreview(options = {}) {
4904
+ await runPlan(options);
4905
+ }
4906
+
4907
+ // src/commands/validate.ts
4908
+ var import_commander9 = require("commander");
4909
+ var import_path14 = __toESM(require("path"));
4910
+ function resolveConfigPath6(root, targetPath) {
4911
+ return import_path14.default.isAbsolute(targetPath) ? targetPath : import_path14.default.join(root, targetPath);
4912
+ }
4018
4913
  async function runValidate(options = {}) {
4019
4914
  const root = getProjectRoot();
4020
4915
  const configPath = getConfigPath(root);
@@ -4030,11 +4925,11 @@ async function runValidate(options = {}) {
4030
4925
  throw new Error(`Invalid config: '${field}' is required`);
4031
4926
  }
4032
4927
  }
4033
- const schemaPath = resolveConfigPath5(root, config.schemaFile);
4928
+ const schemaPath = resolveConfigPath6(root, config.schemaFile);
4034
4929
  if (!config.stateFile) {
4035
4930
  throw new Error("Invalid config: 'stateFile' is required");
4036
4931
  }
4037
- const statePath = resolveConfigPath5(root, config.stateFile);
4932
+ const statePath = resolveConfigPath6(root, config.stateFile);
4038
4933
  const schemaSource = await readTextFile(schemaPath);
4039
4934
  const schema = await parseSchema2(schemaSource);
4040
4935
  try {
@@ -4194,7 +5089,7 @@ async function seedLastSeenVersion(version) {
4194
5089
  }
4195
5090
 
4196
5091
  // src/cli.ts
4197
- var program = new import_commander8.Command();
5092
+ var program = new import_commander10.Command();
4198
5093
  program.name("schema-forge").description("CLI tool for schema management and SQL generation").version(package_default.version).option("--safe", "Prevent execution of destructive operations").option("--force", "Force execution by bypassing safety checks and CI detection");
4199
5094
  function validateFlagExclusivity(options) {
4200
5095
  if (options.safe && options.force) {
@@ -4242,6 +5137,24 @@ program.command("diff").description("Compare two schema versions and generate mi
4242
5137
  await handleError(error2);
4243
5138
  }
4244
5139
  });
5140
+ program.command("plan").description("Preview migration operations as a human-readable plan. In CI environments (CI=true), exits with code 3 if destructive operations are detected unless --force is used.").option("--url <string>", "PostgreSQL connection URL for live plan (defaults to DATABASE_URL)").option("--schema <list>", "Comma-separated schema names to introspect (default: public)").action(async (options) => {
5141
+ try {
5142
+ const globalOptions = program.opts();
5143
+ validateFlagExclusivity(globalOptions);
5144
+ await runPlan({ ...options, ...globalOptions });
5145
+ } catch (error2) {
5146
+ await handleError(error2);
5147
+ }
5148
+ });
5149
+ program.command("preview").description("Preview migration operations (alias of plan). In CI environments (CI=true), exits with code 3 if destructive operations are detected unless --force is used.").option("--url <string>", "PostgreSQL connection URL for live preview (defaults to DATABASE_URL)").option("--schema <list>", "Comma-separated schema names to introspect (default: public)").action(async (options) => {
5150
+ try {
5151
+ const globalOptions = program.opts();
5152
+ validateFlagExclusivity(globalOptions);
5153
+ await runPreview({ ...options, ...globalOptions });
5154
+ } catch (error2) {
5155
+ await handleError(error2);
5156
+ }
5157
+ });
4245
5158
  program.command("doctor").description("Check live database drift against state. Exits with code 2 when drift is detected.").option("--json", "Output structured JSON").option("--url <string>", "PostgreSQL connection URL (defaults to DATABASE_URL)").option("--schema <list>", "Comma-separated schema names to introspect (default: public)").action(async (options) => {
4246
5159
  try {
4247
5160
  await runDoctor(options);