@xubylele/schema-forge 1.10.1 → 1.12.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/README.md CHANGED
@@ -14,6 +14,7 @@ A modern CLI tool for database schema management with a clean DSL and automatic
14
14
  - **Postgres/Supabase** - Currently supports PostgreSQL and Supabase
15
15
  - **Constraint Diffing** - Detects UNIQUE and PRIMARY KEY changes with deterministic constraint names
16
16
  - **Live PostgreSQL Introspection** - Extract normalized schema directly from `information_schema`
17
+ - **Policy (RLS) support** - Define Row Level Security policies in the DSL; invalid policies produce clear CLI errors during validation
17
18
 
18
19
  ## Installation
19
20
 
@@ -210,12 +211,13 @@ Creates the necessary directory structure and configuration files.
210
211
  Generate SQL migration from schema changes.
211
212
 
212
213
  ```bash
213
- schema-forge generate [--name "migration description"] [--safe] [--force]
214
+ schema-forge generate [--name "migration description"] [--migration-format hyphen|underscore] [--safe] [--force]
214
215
  ```
215
216
 
216
217
  **Options:**
217
218
 
218
219
  - `--name` - Optional name for the migration (default: "migration")
220
+ - `--migration-format <format>` - Migration file name: `hyphen` (e.g. `20250315120000-add-users.sql`) or `underscore` (e.g. `20250315120000_add_users.sql`, Supabase CLI style). Overrides `migrationFileNameFormat` in config.
219
221
  - `--safe` - Block execution if destructive operations are detected (exits with error)
220
222
  - `--force` - Bypass safety checks and proceed with destructive operations (shows warning)
221
223
 
@@ -269,6 +271,20 @@ Behavior:
269
271
  - Ignores unsupported SQL safely and prints warnings
270
272
  - Writes a normalized SchemaForge DSL schema file
271
273
 
274
+ ### `schema-forge config`
275
+
276
+ Update `schemaforge/config.json` settings without editing the file.
277
+
278
+ ```bash
279
+ schema-forge config migration-format <format>
280
+ ```
281
+
282
+ **Subcommands:**
283
+
284
+ - **`migration-format <format>`** – Set migration file name format: `hyphen` (e.g. `20250315120000-add-users.sql`) or `underscore` (e.g. `20250315120000_add_users.sql`, Supabase CLI style). Writes `migrationFileNameFormat` to config.
285
+
286
+ Requires an initialized project. Other config keys are left unchanged.
287
+
272
288
  ### `schema-forge validate`
273
289
 
274
290
  Detect destructive or risky schema changes before generating/applying migrations.
@@ -309,6 +325,7 @@ Live `--json` output returns a structured `DriftReport`:
309
325
 
310
326
  Validation checks include:
311
327
 
328
+ - **Policy validation** – Each policy must reference an existing table, use a valid command (`select` / `insert` / `update` / `delete`), and have at least one of `using` or `with check`. Invalid policies cause validation to fail with a clear error (exit code 1).
312
329
  - Dropped tables (`DROP_TABLE`, error)
313
330
  - Dropped columns (`DROP_COLUMN`, error)
314
331
  - Column type changes (`ALTER_COLUMN_TYPE`, warning/error based on compatibility heuristics)
@@ -551,6 +568,26 @@ table table_name {
551
568
  - `default <value>` - Default value (e.g., `default now()`, `default false`, `default 0`)
552
569
  - `fk <table>.<column>` - Foreign key reference (e.g., `fk users.id`)
553
570
 
571
+ ### Policies (RLS)
572
+
573
+ Row Level Security policies are declared at the top level (the table must be defined first). Each policy must have a `for` command and at least one of `using` or `with check`.
574
+
575
+ ```sql
576
+ table users {
577
+ id uuid pk
578
+ email text unique
579
+ }
580
+
581
+ policy "Users can read themselves" on users
582
+ for select
583
+ using auth.uid() = id
584
+ ```
585
+
586
+ - **First line:** `policy "<name>" on <table>`
587
+ - **Continuation:** `for <command>` (required; one of `select`, `insert`, `update`, `delete`), optional `using <expr>`, optional `with check <expr>`
588
+
589
+ Invalid policies (missing table, invalid command, or no expressions) fail `schema-forge validate` with a clear error message.
590
+
554
591
  ### Examples
555
592
 
556
593
  #### Simple table
@@ -626,7 +663,8 @@ The `schemaforge/config.json` file contains project configuration. The `provider
626
663
  ```
627
664
 
628
665
  - **postgres**: `provider: "postgres"`, `outputDir: "migrations"`.
629
- - **supabase**: `provider: "supabase"`, `outputDir: "supabase/migrations"`.
666
+ - **supabase**: `provider: "supabase"`, `outputDir: "supabase/migrations"`. Init also sets `migrationFileNameFormat: "hyphen"` so migrations are named `timestamp-name.sql` (Supabase CLI uses `timestamp_name.sql` by default; you can set `migrationFileNameFormat: "underscore"` in config or use `--migration-format underscore` to match).
667
+ - **migrationFileNameFormat** (optional): `"hyphen"` (default) or `"underscore"`. Controls generated migration file names: `YYYYMMDDHHmmss-name.sql` vs `YYYYMMDDHHmmss_name.sql`. You can set it via **`schema-forge config migration-format <hyphen|underscore>`** instead of editing the file.
630
668
 
631
669
  ## Supported Databases
632
670
 
package/dist/api.d.ts CHANGED
@@ -11,10 +11,13 @@ interface DoctorOptions {
11
11
  schema?: string;
12
12
  }
13
13
 
14
+ type MigrationFileNameFormat = 'hyphen' | 'underscore';
15
+
14
16
  interface GenerateOptions {
15
17
  name?: string;
16
18
  safe?: boolean;
17
19
  force?: boolean;
20
+ migrationFormat?: MigrationFileNameFormat;
18
21
  }
19
22
 
20
23
  interface ImportOptions {
@@ -39,69 +42,22 @@ interface ValidateOptions {
39
42
  force?: boolean;
40
43
  }
41
44
 
42
- /**
43
- * Exit codes used throughout the CLI for deterministic behavior
44
- *
45
- * @see SF-106 Standardize Exit Codes
46
- */
47
45
  declare const EXIT_CODES: {
48
- /** Successful operation */
49
46
  readonly SUCCESS: 0;
50
- /** Validation error (invalid DSL syntax, config errors, missing files, etc.) */
51
47
  readonly VALIDATION_ERROR: 1;
52
- /** Drift detected - Reserved for future use when comparing actual DB state vs schema */
53
48
  readonly DRIFT_DETECTED: 2;
54
- /** Destructive operation detected in CI environment without --force */
55
49
  readonly CI_DESTRUCTIVE: 3;
56
50
  };
57
51
 
58
- /**
59
- * Programmatic API for Schema Forge.
60
- * Use this entrypoint when integrating from Node (e.g. scripts, GitHub Actions)
61
- * instead of invoking the CLI via shell.
62
- *
63
- * @example
64
- * const { generate, EXIT_CODES } = require('@xubylele/schema-forge/api');
65
- * const result = await generate({ name: 'MyMigration' });
66
- * if (result.exitCode !== EXIT_CODES.SUCCESS) process.exit(result.exitCode);
67
- */
68
-
69
- /**
70
- * Result of a programmatic command run. Exit codes match the CLI contract.
71
- * @see docs/exit-codes.json
72
- */
73
52
  interface RunResult {
74
53
  exitCode: number;
75
54
  }
76
- /**
77
- * Initialize a new schema project in the current directory.
78
- * @param options.provider - Database provider: 'postgres' (default) or 'supabase'. Supabase uses supabase/migrations for output.
79
- */
80
55
  declare function init(options?: InitOptions): Promise<RunResult>;
81
- /**
82
- * Generate SQL migration from schema files.
83
- */
84
56
  declare function generate(options?: GenerateOptions): Promise<RunResult>;
85
- /**
86
- * Compare two schema versions and generate migration SQL (optionally against live DB).
87
- */
88
57
  declare function diff(options?: DiffOptions): Promise<RunResult>;
89
- /**
90
- * Check live database drift against state.
91
- */
92
58
  declare function doctor(options?: DoctorOptions): Promise<RunResult>;
93
- /**
94
- * Validate schema and optionally check for destructive changes or live drift.
95
- */
96
59
  declare function validate(options?: ValidateOptions): Promise<RunResult>;
97
- /**
98
- * Extract normalized live schema from PostgreSQL.
99
- */
100
60
  declare function introspect(options?: IntrospectOptions): Promise<RunResult>;
101
- /**
102
- * Import schema from SQL migrations.
103
- * @param inputPath - Path to .sql file or migrations directory
104
- */
105
61
  declare function importSchema(inputPath: string, options?: ImportOptions): Promise<RunResult>;
106
62
 
107
63
  export { type DiffOptions, type DoctorOptions, EXIT_CODES, type GenerateOptions, type ImportOptions, type InitOptions, type IntrospectOptions, type RunResult, type ValidateOptions, diff, doctor, generate, importSchema, init, introspect, validate };
package/dist/api.js CHANGED
@@ -34,6 +34,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
34
34
  function parseSchema(source) {
35
35
  const lines = source.split("\n");
36
36
  const tables = {};
37
+ const policyList = [];
37
38
  let currentLine = 0;
38
39
  const validBaseColumnTypes = /* @__PURE__ */ new Set([
39
40
  "uuid",
@@ -258,6 +259,67 @@ function parseSchema(source) {
258
259
  }
259
260
  return column;
260
261
  }
262
+ function parsePolicyBlock(startLine) {
263
+ const firstLine = cleanLine(lines[startLine]);
264
+ const declMatch = firstLine.match(/^policy\s+"([^"]*)"\s+on\s+(\w+)\s*$/);
265
+ if (!declMatch) {
266
+ throw new Error(`Line ${startLine + 1}: Invalid policy declaration. Expected: policy "<name>" on <table>`);
267
+ }
268
+ const policyName = declMatch[1];
269
+ const tableIdent = declMatch[2];
270
+ validateIdentifier(tableIdent, startLine + 1, "table");
271
+ let command;
272
+ let using;
273
+ let withCheck;
274
+ let lineIdx = startLine + 1;
275
+ while (lineIdx < lines.length) {
276
+ const cleaned = cleanLine(lines[lineIdx]);
277
+ if (!cleaned) {
278
+ lineIdx++;
279
+ continue;
280
+ }
281
+ if (cleaned.startsWith("for ")) {
282
+ const cmd = cleaned.slice(4).trim().toLowerCase();
283
+ if (!POLICY_COMMANDS.includes(cmd)) {
284
+ throw new Error(`Line ${lineIdx + 1}: Invalid policy command '${cmd}'. Expected: select, insert, update, or delete`);
285
+ }
286
+ if (command !== void 0) {
287
+ throw new Error(`Line ${lineIdx + 1}: Duplicate 'for' in policy`);
288
+ }
289
+ command = cmd;
290
+ lineIdx++;
291
+ continue;
292
+ }
293
+ if (cleaned.startsWith("using ")) {
294
+ if (using !== void 0) {
295
+ throw new Error(`Line ${lineIdx + 1}: Duplicate 'using' in policy`);
296
+ }
297
+ using = cleaned.slice(6).trim();
298
+ lineIdx++;
299
+ continue;
300
+ }
301
+ if (cleaned.startsWith("with check ")) {
302
+ if (withCheck !== void 0) {
303
+ throw new Error(`Line ${lineIdx + 1}: Duplicate 'with check' in policy`);
304
+ }
305
+ withCheck = cleaned.slice(11).trim();
306
+ lineIdx++;
307
+ continue;
308
+ }
309
+ break;
310
+ }
311
+ if (command === void 0) {
312
+ throw new Error(`Line ${startLine + 1}: Policy is missing 'for <command>'`);
313
+ }
314
+ const policy = {
315
+ name: policyName,
316
+ table: tableIdent,
317
+ command,
318
+ ...using !== void 0 && { using },
319
+ ...withCheck !== void 0 && { withCheck }
320
+ };
321
+ return { policy, nextLineIndex: lineIdx };
322
+ }
261
323
  function parseTableBlock(startLine) {
262
324
  const firstLine = cleanLine(lines[startLine]);
263
325
  const match = firstLine.match(/^table\s+(\w+)\s*\{\s*$/);
@@ -301,6 +363,7 @@ function parseSchema(source) {
301
363
  };
302
364
  return lineIdx;
303
365
  }
366
+ const policyDeclRegex = /^policy\s+"([^"]*)"\s+on\s+\w+/;
304
367
  while (currentLine < lines.length) {
305
368
  const cleaned = cleanLine(lines[currentLine]);
306
369
  if (!cleaned) {
@@ -309,16 +372,31 @@ function parseSchema(source) {
309
372
  }
310
373
  if (cleaned.startsWith("table ")) {
311
374
  currentLine = parseTableBlock(currentLine);
375
+ currentLine++;
376
+ } else if (policyDeclRegex.test(cleaned)) {
377
+ const { policy, nextLineIndex } = parsePolicyBlock(currentLine);
378
+ policyList.push({ policy, startLine: currentLine + 1 });
379
+ currentLine = nextLineIndex;
312
380
  } else {
313
- throw new Error(`Line ${currentLine + 1}: Unexpected content '${cleaned}'. Expected table definition.`);
381
+ throw new Error(`Line ${currentLine + 1}: Unexpected content '${cleaned}'. Expected table or policy definition.`);
314
382
  }
315
- currentLine++;
383
+ }
384
+ for (const { policy, startLine } of policyList) {
385
+ if (!tables[policy.table]) {
386
+ throw new Error(`Line ${startLine}: Policy "${policy.name}" references undefined table "${policy.table}"`);
387
+ }
388
+ if (!tables[policy.table].policies) {
389
+ tables[policy.table].policies = [];
390
+ }
391
+ tables[policy.table].policies.push(policy);
316
392
  }
317
393
  return { tables };
318
394
  }
395
+ var POLICY_COMMANDS;
319
396
  var init_parser = __esm({
320
397
  "node_modules/@xubylele/schema-forge-core/dist/core/parser.js"() {
321
398
  "use strict";
399
+ POLICY_COMMANDS = ["select", "insert", "update", "delete"];
322
400
  }
323
401
  });
324
402
 
@@ -498,6 +576,18 @@ function resolveSchemaPrimaryKey(table) {
498
576
  function normalizeNullable(nullable) {
499
577
  return nullable ?? true;
500
578
  }
579
+ function normalizePolicyExpression(s) {
580
+ return (s ?? "").trim().replace(/\s+/g, " ");
581
+ }
582
+ function policyEquals(oldP, newP) {
583
+ if (oldP.command !== newP.command)
584
+ return false;
585
+ if (normalizePolicyExpression(oldP.using) !== normalizePolicyExpression(newP.using))
586
+ return false;
587
+ if (normalizePolicyExpression(oldP.withCheck) !== normalizePolicyExpression(newP.withCheck))
588
+ return false;
589
+ return true;
590
+ }
501
591
  function diffSchemas(oldState, newSchema) {
502
592
  const operations = [];
503
593
  const oldTableNames = getTableNamesFromState(oldState);
@@ -674,6 +764,37 @@ function diffSchemas(oldState, newSchema) {
674
764
  }
675
765
  }
676
766
  }
767
+ const oldPolicyNamesByTable = (t) => new Set(Object.keys(t.policies ?? {}));
768
+ const newPolicyListByTable = (t) => t.policies ?? [];
769
+ for (const tableName of commonTableNames) {
770
+ const newTable = newSchema.tables[tableName];
771
+ const oldTable = oldState.tables[tableName];
772
+ if (!newTable || !oldTable)
773
+ continue;
774
+ const oldPolicyNames = oldPolicyNamesByTable(oldTable);
775
+ const newPolicies = newPolicyListByTable(newTable);
776
+ const newPolicyNames = new Set(newPolicies.map((p) => p.name));
777
+ const sortedNewPolicyNames = Array.from(newPolicyNames).sort((a, b) => a.localeCompare(b));
778
+ const sortedOldPolicyNames = Array.from(oldPolicyNames).sort((a, b) => a.localeCompare(b));
779
+ for (const policyName of sortedNewPolicyNames) {
780
+ const policy = newPolicies.find((p) => p.name === policyName);
781
+ if (!policy)
782
+ continue;
783
+ if (!oldPolicyNames.has(policyName)) {
784
+ operations.push({ kind: "create_policy", tableName, policy });
785
+ } else {
786
+ const oldP = oldTable.policies?.[policyName];
787
+ if (oldP && !policyEquals(oldP, policy)) {
788
+ operations.push({ kind: "modify_policy", tableName, policyName, policy });
789
+ }
790
+ }
791
+ }
792
+ for (const policyName of sortedOldPolicyNames) {
793
+ if (!newPolicyNames.has(policyName)) {
794
+ operations.push({ kind: "drop_policy", tableName, policyName });
795
+ }
796
+ }
797
+ }
677
798
  for (const tableName of sortedOldTableNames) {
678
799
  if (!newTableNames.has(tableName)) {
679
800
  operations.push({
@@ -774,6 +895,7 @@ function validateSchema(schema) {
774
895
  const table = schema.tables[tableName];
775
896
  validateTableColumns(tableName, table, schema.tables);
776
897
  }
898
+ validatePolicies(schema);
777
899
  }
778
900
  function validateDuplicateTables(schema) {
779
901
  const tableNames = Object.keys(schema.tables);
@@ -829,10 +951,36 @@ function validateTableColumns(tableName, table, allTables) {
829
951
  }
830
952
  }
831
953
  }
832
- var VALID_BASE_COLUMN_TYPES;
954
+ function validatePolicies(schema) {
955
+ for (const tableName in schema.tables) {
956
+ const table = schema.tables[tableName];
957
+ if (!table.policies?.length)
958
+ continue;
959
+ for (const policy of table.policies) {
960
+ if (!schema.tables[policy.table]) {
961
+ throw new Error(`Policy "${policy.name}" on table "${tableName}": referenced table "${policy.table}" does not exist`);
962
+ }
963
+ if (!VALID_POLICY_COMMANDS.includes(policy.command)) {
964
+ throw new Error(`Policy "${policy.name}" on table "${tableName}": invalid command "${policy.command}". Expected: select, insert, update, or delete`);
965
+ }
966
+ const hasUsing = policy.using !== void 0 && String(policy.using).trim() !== "";
967
+ const hasWithCheck = policy.withCheck !== void 0 && String(policy.withCheck).trim() !== "";
968
+ if (!hasUsing && !hasWithCheck) {
969
+ throw new Error(`Policy "${policy.name}" on table "${tableName}": must have at least one of "using" or "with check"`);
970
+ }
971
+ }
972
+ }
973
+ }
974
+ var VALID_POLICY_COMMANDS, VALID_BASE_COLUMN_TYPES;
833
975
  var init_validator = __esm({
834
976
  "node_modules/@xubylele/schema-forge-core/dist/core/validator.js"() {
835
977
  "use strict";
978
+ VALID_POLICY_COMMANDS = [
979
+ "select",
980
+ "insert",
981
+ "update",
982
+ "delete"
983
+ ];
836
984
  VALID_BASE_COLUMN_TYPES = [
837
985
  "uuid",
838
986
  "varchar",
@@ -926,6 +1074,12 @@ function classifyOperation(operation) {
926
1074
  return "DESTRUCTIVE";
927
1075
  case "add_primary_key_constraint":
928
1076
  return "SAFE";
1077
+ case "create_policy":
1078
+ return "SAFE";
1079
+ case "drop_policy":
1080
+ return "DESTRUCTIVE";
1081
+ case "modify_policy":
1082
+ return "WARNING";
929
1083
  default:
930
1084
  const _exhaustive = operation;
931
1085
  return _exhaustive;
@@ -1048,6 +1202,22 @@ function checkOperationSafety(operation) {
1048
1202
  message: "Primary key constraint removed",
1049
1203
  operationKind: operation.kind
1050
1204
  };
1205
+ case "drop_policy":
1206
+ return {
1207
+ safetyLevel,
1208
+ code: "DROP_POLICY",
1209
+ table: operation.tableName,
1210
+ message: "Policy removed",
1211
+ operationKind: operation.kind
1212
+ };
1213
+ case "modify_policy":
1214
+ return {
1215
+ safetyLevel,
1216
+ code: "MODIFY_POLICY",
1217
+ table: operation.tableName,
1218
+ message: "Policy expression changed",
1219
+ operationKind: operation.kind
1220
+ };
1051
1221
  default:
1052
1222
  return null;
1053
1223
  }
@@ -1222,9 +1392,21 @@ async function schemaToState(schema) {
1222
1392
  ...column.foreignKey !== void 0 && { foreignKey: column.foreignKey }
1223
1393
  };
1224
1394
  }
1395
+ let policies;
1396
+ if (table.policies?.length) {
1397
+ policies = {};
1398
+ for (const p of table.policies) {
1399
+ policies[p.name] = {
1400
+ command: p.command,
1401
+ ...p.using !== void 0 && { using: p.using },
1402
+ ...p.withCheck !== void 0 && { withCheck: p.withCheck }
1403
+ };
1404
+ }
1405
+ }
1225
1406
  tables[tableName] = {
1226
1407
  columns,
1227
- ...primaryKeyColumn !== null && { primaryKey: primaryKeyColumn }
1408
+ ...primaryKeyColumn !== null && { primaryKey: primaryKeyColumn },
1409
+ ...policies !== void 0 && { policies }
1228
1410
  };
1229
1411
  }
1230
1412
  return {
@@ -1259,9 +1441,20 @@ var init_state_manager = __esm({
1259
1441
  });
1260
1442
 
1261
1443
  // node_modules/@xubylele/schema-forge-core/dist/generator/sql-generator.js
1444
+ function generateEnableRls(tableName) {
1445
+ return `ALTER TABLE ${tableName} ENABLE ROW LEVEL SECURITY;`;
1446
+ }
1262
1447
  function generateSql(diff2, provider, sqlConfig) {
1263
1448
  const statements = [];
1449
+ const enabledRlsTables = /* @__PURE__ */ new Set();
1264
1450
  for (const operation of diff2.operations) {
1451
+ if (operation.kind === "create_policy" || operation.kind === "modify_policy") {
1452
+ const tableName = operation.tableName;
1453
+ if (!enabledRlsTables.has(tableName)) {
1454
+ statements.push(generateEnableRls(tableName));
1455
+ enabledRlsTables.add(tableName);
1456
+ }
1457
+ }
1265
1458
  const sql = generateOperation(operation, provider, sqlConfig);
1266
1459
  if (sql) {
1267
1460
  statements.push(sql);
@@ -1291,6 +1484,12 @@ function generateOperation(operation, provider, sqlConfig) {
1291
1484
  return generateDropPrimaryKeyConstraint(operation.tableName);
1292
1485
  case "add_primary_key_constraint":
1293
1486
  return generateAddPrimaryKeyConstraint(operation.tableName, operation.columnName);
1487
+ case "create_policy":
1488
+ return generateCreatePolicy(operation.tableName, operation.policy);
1489
+ case "drop_policy":
1490
+ return generateDropPolicy(operation.tableName, operation.policyName);
1491
+ case "modify_policy":
1492
+ return generateModifyPolicy(operation.tableName, operation.policyName, operation.policy);
1294
1493
  }
1295
1494
  }
1296
1495
  function generateCreateTable(table, provider, sqlConfig) {
@@ -1371,6 +1570,25 @@ function generateAlterColumnNullability(tableName, columnName, toNullable) {
1371
1570
  }
1372
1571
  return `ALTER TABLE ${tableName} ALTER COLUMN ${columnName} SET NOT NULL;`;
1373
1572
  }
1573
+ function generateCreatePolicy(tableName, policy) {
1574
+ const command = policy.command.toUpperCase();
1575
+ const parts = [`CREATE POLICY "${policy.name}" ON ${tableName} FOR ${command}`];
1576
+ if (policy.using !== void 0 && policy.using !== "") {
1577
+ parts.push(`USING (${policy.using})`);
1578
+ }
1579
+ if (policy.withCheck !== void 0 && policy.withCheck !== "") {
1580
+ parts.push(`WITH CHECK (${policy.withCheck})`);
1581
+ }
1582
+ return parts.join(" ") + ";";
1583
+ }
1584
+ function generateDropPolicy(tableName, policyName) {
1585
+ return `DROP POLICY "${policyName}" ON ${tableName};`;
1586
+ }
1587
+ function generateModifyPolicy(tableName, policyName, policy) {
1588
+ const drop = generateDropPolicy(tableName, policyName);
1589
+ const create = generateCreatePolicy(tableName, policy);
1590
+ return drop + "\n\n" + create;
1591
+ }
1374
1592
  var init_sql_generator = __esm({
1375
1593
  "node_modules/@xubylele/schema-forge-core/dist/generator/sql-generator.js"() {
1376
1594
  "use strict";
@@ -2941,13 +3159,9 @@ async function withPostgresQueryExecutor(connectionString, run) {
2941
3159
 
2942
3160
  // src/utils/exitCodes.ts
2943
3161
  var EXIT_CODES = {
2944
- /** Successful operation */
2945
3162
  SUCCESS: 0,
2946
- /** Validation error (invalid DSL syntax, config errors, missing files, etc.) */
2947
3163
  VALIDATION_ERROR: 1,
2948
- /** Drift detected - Reserved for future use when comparing actual DB state vs schema */
2949
3164
  DRIFT_DETECTED: 2,
2950
- /** Destructive operation detected in CI environment without --force */
2951
3165
  CI_DESTRUCTIVE: 3
2952
3166
  };
2953
3167
  function shouldFailCIDestructive(isCIEnvironment, hasDestructiveFindings2, isForceEnabled) {
@@ -3241,6 +3455,10 @@ function nowTimestamp2() {
3241
3455
  function slugifyName2(name) {
3242
3456
  return name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "migration";
3243
3457
  }
3458
+ function migrationFileName(timestamp, slug, format = "hyphen") {
3459
+ const sep = format === "underscore" ? "_" : "-";
3460
+ return `${timestamp}${sep}${slug}.sql`;
3461
+ }
3244
3462
 
3245
3463
  // src/commands/generate.ts
3246
3464
  var REQUIRED_CONFIG_FIELDS = [
@@ -3248,6 +3466,19 @@ var REQUIRED_CONFIG_FIELDS = [
3248
3466
  "stateFile",
3249
3467
  "outputDir"
3250
3468
  ];
3469
+ var MIGRATION_FORMATS = ["hyphen", "underscore"];
3470
+ function resolveMigrationFormat(cliFormat, configFormat) {
3471
+ if (cliFormat !== void 0 && cliFormat !== "") {
3472
+ const normalized = cliFormat.trim().toLowerCase();
3473
+ if (MIGRATION_FORMATS.includes(normalized)) {
3474
+ return normalized;
3475
+ }
3476
+ throw new Error(
3477
+ `Invalid --migration-format "${cliFormat}". Allowed: ${MIGRATION_FORMATS.join(", ")}.`
3478
+ );
3479
+ }
3480
+ return configFormat ?? "hyphen";
3481
+ }
3251
3482
  function resolveConfigPath3(root, targetPath) {
3252
3483
  return import_path9.default.isAbsolute(targetPath) ? targetPath : import_path9.default.join(root, targetPath);
3253
3484
  }
@@ -3328,7 +3559,11 @@ Remove --safe flag or modify schema to avoid destructive changes.`
3328
3559
  const sql = await generateSql2(diff2, provider, config.sql);
3329
3560
  const timestamp = nowTimestamp2();
3330
3561
  const slug = slugifyName2(options.name ?? "migration");
3331
- const fileName = `${timestamp}-${slug}.sql`;
3562
+ const format = resolveMigrationFormat(
3563
+ options.migrationFormat,
3564
+ config.migrationFileNameFormat
3565
+ );
3566
+ const fileName = migrationFileName(timestamp, slug, format);
3332
3567
  await ensureDir(outputDir);
3333
3568
  const migrationPath = import_path9.default.join(outputDir, fileName);
3334
3569
  await writeTextFile(migrationPath, sql + "\n");
@@ -3464,6 +3699,9 @@ table users {
3464
3699
  timestampDefault: "now()"
3465
3700
  }
3466
3701
  };
3702
+ if (provider === "supabase") {
3703
+ config.migrationFileNameFormat = "hyphen";
3704
+ }
3467
3705
  await writeJsonFile(configPath, config);
3468
3706
  success(`Created ${configPath}`);
3469
3707
  const state = {
package/dist/cli.js CHANGED
@@ -34,6 +34,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
34
34
  function parseSchema(source) {
35
35
  const lines = source.split("\n");
36
36
  const tables = {};
37
+ const policyList = [];
37
38
  let currentLine = 0;
38
39
  const validBaseColumnTypes = /* @__PURE__ */ new Set([
39
40
  "uuid",
@@ -258,6 +259,67 @@ function parseSchema(source) {
258
259
  }
259
260
  return column;
260
261
  }
262
+ function parsePolicyBlock(startLine) {
263
+ const firstLine = cleanLine(lines[startLine]);
264
+ const declMatch = firstLine.match(/^policy\s+"([^"]*)"\s+on\s+(\w+)\s*$/);
265
+ if (!declMatch) {
266
+ throw new Error(`Line ${startLine + 1}: Invalid policy declaration. Expected: policy "<name>" on <table>`);
267
+ }
268
+ const policyName = declMatch[1];
269
+ const tableIdent = declMatch[2];
270
+ validateIdentifier(tableIdent, startLine + 1, "table");
271
+ let command;
272
+ let using;
273
+ let withCheck;
274
+ let lineIdx = startLine + 1;
275
+ while (lineIdx < lines.length) {
276
+ const cleaned = cleanLine(lines[lineIdx]);
277
+ if (!cleaned) {
278
+ lineIdx++;
279
+ continue;
280
+ }
281
+ if (cleaned.startsWith("for ")) {
282
+ const cmd = cleaned.slice(4).trim().toLowerCase();
283
+ if (!POLICY_COMMANDS.includes(cmd)) {
284
+ throw new Error(`Line ${lineIdx + 1}: Invalid policy command '${cmd}'. Expected: select, insert, update, or delete`);
285
+ }
286
+ if (command !== void 0) {
287
+ throw new Error(`Line ${lineIdx + 1}: Duplicate 'for' in policy`);
288
+ }
289
+ command = cmd;
290
+ lineIdx++;
291
+ continue;
292
+ }
293
+ if (cleaned.startsWith("using ")) {
294
+ if (using !== void 0) {
295
+ throw new Error(`Line ${lineIdx + 1}: Duplicate 'using' in policy`);
296
+ }
297
+ using = cleaned.slice(6).trim();
298
+ lineIdx++;
299
+ continue;
300
+ }
301
+ if (cleaned.startsWith("with check ")) {
302
+ if (withCheck !== void 0) {
303
+ throw new Error(`Line ${lineIdx + 1}: Duplicate 'with check' in policy`);
304
+ }
305
+ withCheck = cleaned.slice(11).trim();
306
+ lineIdx++;
307
+ continue;
308
+ }
309
+ break;
310
+ }
311
+ if (command === void 0) {
312
+ throw new Error(`Line ${startLine + 1}: Policy is missing 'for <command>'`);
313
+ }
314
+ const policy = {
315
+ name: policyName,
316
+ table: tableIdent,
317
+ command,
318
+ ...using !== void 0 && { using },
319
+ ...withCheck !== void 0 && { withCheck }
320
+ };
321
+ return { policy, nextLineIndex: lineIdx };
322
+ }
261
323
  function parseTableBlock(startLine) {
262
324
  const firstLine = cleanLine(lines[startLine]);
263
325
  const match = firstLine.match(/^table\s+(\w+)\s*\{\s*$/);
@@ -301,6 +363,7 @@ function parseSchema(source) {
301
363
  };
302
364
  return lineIdx;
303
365
  }
366
+ const policyDeclRegex = /^policy\s+"([^"]*)"\s+on\s+\w+/;
304
367
  while (currentLine < lines.length) {
305
368
  const cleaned = cleanLine(lines[currentLine]);
306
369
  if (!cleaned) {
@@ -309,16 +372,31 @@ function parseSchema(source) {
309
372
  }
310
373
  if (cleaned.startsWith("table ")) {
311
374
  currentLine = parseTableBlock(currentLine);
375
+ currentLine++;
376
+ } else if (policyDeclRegex.test(cleaned)) {
377
+ const { policy, nextLineIndex } = parsePolicyBlock(currentLine);
378
+ policyList.push({ policy, startLine: currentLine + 1 });
379
+ currentLine = nextLineIndex;
312
380
  } else {
313
- throw new Error(`Line ${currentLine + 1}: Unexpected content '${cleaned}'. Expected table definition.`);
381
+ throw new Error(`Line ${currentLine + 1}: Unexpected content '${cleaned}'. Expected table or policy definition.`);
382
+ }
383
+ }
384
+ for (const { policy, startLine } of policyList) {
385
+ if (!tables[policy.table]) {
386
+ throw new Error(`Line ${startLine}: Policy "${policy.name}" references undefined table "${policy.table}"`);
387
+ }
388
+ if (!tables[policy.table].policies) {
389
+ tables[policy.table].policies = [];
314
390
  }
315
- currentLine++;
391
+ tables[policy.table].policies.push(policy);
316
392
  }
317
393
  return { tables };
318
394
  }
395
+ var POLICY_COMMANDS;
319
396
  var init_parser = __esm({
320
397
  "node_modules/@xubylele/schema-forge-core/dist/core/parser.js"() {
321
398
  "use strict";
399
+ POLICY_COMMANDS = ["select", "insert", "update", "delete"];
322
400
  }
323
401
  });
324
402
 
@@ -498,6 +576,18 @@ function resolveSchemaPrimaryKey(table) {
498
576
  function normalizeNullable(nullable) {
499
577
  return nullable ?? true;
500
578
  }
579
+ function normalizePolicyExpression(s) {
580
+ return (s ?? "").trim().replace(/\s+/g, " ");
581
+ }
582
+ function policyEquals(oldP, newP) {
583
+ if (oldP.command !== newP.command)
584
+ return false;
585
+ if (normalizePolicyExpression(oldP.using) !== normalizePolicyExpression(newP.using))
586
+ return false;
587
+ if (normalizePolicyExpression(oldP.withCheck) !== normalizePolicyExpression(newP.withCheck))
588
+ return false;
589
+ return true;
590
+ }
501
591
  function diffSchemas(oldState, newSchema) {
502
592
  const operations = [];
503
593
  const oldTableNames = getTableNamesFromState(oldState);
@@ -674,6 +764,37 @@ function diffSchemas(oldState, newSchema) {
674
764
  }
675
765
  }
676
766
  }
767
+ const oldPolicyNamesByTable = (t) => new Set(Object.keys(t.policies ?? {}));
768
+ const newPolicyListByTable = (t) => t.policies ?? [];
769
+ for (const tableName of commonTableNames) {
770
+ const newTable = newSchema.tables[tableName];
771
+ const oldTable = oldState.tables[tableName];
772
+ if (!newTable || !oldTable)
773
+ continue;
774
+ const oldPolicyNames = oldPolicyNamesByTable(oldTable);
775
+ const newPolicies = newPolicyListByTable(newTable);
776
+ const newPolicyNames = new Set(newPolicies.map((p) => p.name));
777
+ const sortedNewPolicyNames = Array.from(newPolicyNames).sort((a, b) => a.localeCompare(b));
778
+ const sortedOldPolicyNames = Array.from(oldPolicyNames).sort((a, b) => a.localeCompare(b));
779
+ for (const policyName of sortedNewPolicyNames) {
780
+ const policy = newPolicies.find((p) => p.name === policyName);
781
+ if (!policy)
782
+ continue;
783
+ if (!oldPolicyNames.has(policyName)) {
784
+ operations.push({ kind: "create_policy", tableName, policy });
785
+ } else {
786
+ const oldP = oldTable.policies?.[policyName];
787
+ if (oldP && !policyEquals(oldP, policy)) {
788
+ operations.push({ kind: "modify_policy", tableName, policyName, policy });
789
+ }
790
+ }
791
+ }
792
+ for (const policyName of sortedOldPolicyNames) {
793
+ if (!newPolicyNames.has(policyName)) {
794
+ operations.push({ kind: "drop_policy", tableName, policyName });
795
+ }
796
+ }
797
+ }
677
798
  for (const tableName of sortedOldTableNames) {
678
799
  if (!newTableNames.has(tableName)) {
679
800
  operations.push({
@@ -774,6 +895,7 @@ function validateSchema(schema) {
774
895
  const table = schema.tables[tableName];
775
896
  validateTableColumns(tableName, table, schema.tables);
776
897
  }
898
+ validatePolicies(schema);
777
899
  }
778
900
  function validateDuplicateTables(schema) {
779
901
  const tableNames = Object.keys(schema.tables);
@@ -829,10 +951,36 @@ function validateTableColumns(tableName, table, allTables) {
829
951
  }
830
952
  }
831
953
  }
832
- var VALID_BASE_COLUMN_TYPES;
954
+ function validatePolicies(schema) {
955
+ for (const tableName in schema.tables) {
956
+ const table = schema.tables[tableName];
957
+ if (!table.policies?.length)
958
+ continue;
959
+ for (const policy of table.policies) {
960
+ if (!schema.tables[policy.table]) {
961
+ throw new Error(`Policy "${policy.name}" on table "${tableName}": referenced table "${policy.table}" does not exist`);
962
+ }
963
+ if (!VALID_POLICY_COMMANDS.includes(policy.command)) {
964
+ throw new Error(`Policy "${policy.name}" on table "${tableName}": invalid command "${policy.command}". Expected: select, insert, update, or delete`);
965
+ }
966
+ const hasUsing = policy.using !== void 0 && String(policy.using).trim() !== "";
967
+ const hasWithCheck = policy.withCheck !== void 0 && String(policy.withCheck).trim() !== "";
968
+ if (!hasUsing && !hasWithCheck) {
969
+ throw new Error(`Policy "${policy.name}" on table "${tableName}": must have at least one of "using" or "with check"`);
970
+ }
971
+ }
972
+ }
973
+ }
974
+ var VALID_POLICY_COMMANDS, VALID_BASE_COLUMN_TYPES;
833
975
  var init_validator = __esm({
834
976
  "node_modules/@xubylele/schema-forge-core/dist/core/validator.js"() {
835
977
  "use strict";
978
+ VALID_POLICY_COMMANDS = [
979
+ "select",
980
+ "insert",
981
+ "update",
982
+ "delete"
983
+ ];
836
984
  VALID_BASE_COLUMN_TYPES = [
837
985
  "uuid",
838
986
  "varchar",
@@ -926,6 +1074,12 @@ function classifyOperation(operation) {
926
1074
  return "DESTRUCTIVE";
927
1075
  case "add_primary_key_constraint":
928
1076
  return "SAFE";
1077
+ case "create_policy":
1078
+ return "SAFE";
1079
+ case "drop_policy":
1080
+ return "DESTRUCTIVE";
1081
+ case "modify_policy":
1082
+ return "WARNING";
929
1083
  default:
930
1084
  const _exhaustive = operation;
931
1085
  return _exhaustive;
@@ -1048,6 +1202,22 @@ function checkOperationSafety(operation) {
1048
1202
  message: "Primary key constraint removed",
1049
1203
  operationKind: operation.kind
1050
1204
  };
1205
+ case "drop_policy":
1206
+ return {
1207
+ safetyLevel,
1208
+ code: "DROP_POLICY",
1209
+ table: operation.tableName,
1210
+ message: "Policy removed",
1211
+ operationKind: operation.kind
1212
+ };
1213
+ case "modify_policy":
1214
+ return {
1215
+ safetyLevel,
1216
+ code: "MODIFY_POLICY",
1217
+ table: operation.tableName,
1218
+ message: "Policy expression changed",
1219
+ operationKind: operation.kind
1220
+ };
1051
1221
  default:
1052
1222
  return null;
1053
1223
  }
@@ -1130,14 +1300,14 @@ var init_validate = __esm({
1130
1300
  // node_modules/@xubylele/schema-forge-core/dist/core/fs.js
1131
1301
  async function ensureDir2(dirPath) {
1132
1302
  try {
1133
- await import_fs2.promises.mkdir(dirPath, { recursive: true });
1303
+ await import_fs3.promises.mkdir(dirPath, { recursive: true });
1134
1304
  } catch (error2) {
1135
1305
  throw new Error(`Failed to create directory ${dirPath}: ${error2}`);
1136
1306
  }
1137
1307
  }
1138
1308
  async function fileExists2(filePath) {
1139
1309
  try {
1140
- await import_fs2.promises.access(filePath);
1310
+ await import_fs3.promises.access(filePath);
1141
1311
  return true;
1142
1312
  } catch {
1143
1313
  return false;
@@ -1145,7 +1315,7 @@ async function fileExists2(filePath) {
1145
1315
  }
1146
1316
  async function readTextFile2(filePath) {
1147
1317
  try {
1148
- return await import_fs2.promises.readFile(filePath, "utf-8");
1318
+ return await import_fs3.promises.readFile(filePath, "utf-8");
1149
1319
  } catch (error2) {
1150
1320
  throw new Error(`Failed to read file ${filePath}: ${error2}`);
1151
1321
  }
@@ -1154,7 +1324,7 @@ async function writeTextFile2(filePath, content) {
1154
1324
  try {
1155
1325
  const dir = import_path3.default.dirname(filePath);
1156
1326
  await ensureDir2(dir);
1157
- await import_fs2.promises.writeFile(filePath, content, "utf-8");
1327
+ await import_fs3.promises.writeFile(filePath, content, "utf-8");
1158
1328
  } catch (error2) {
1159
1329
  throw new Error(`Failed to write file ${filePath}: ${error2}`);
1160
1330
  }
@@ -1182,7 +1352,7 @@ async function writeJsonFile2(filePath, data) {
1182
1352
  async function findFiles(dirPath, pattern) {
1183
1353
  const results = [];
1184
1354
  try {
1185
- const items = await import_fs2.promises.readdir(dirPath, { withFileTypes: true });
1355
+ const items = await import_fs3.promises.readdir(dirPath, { withFileTypes: true });
1186
1356
  for (const item of items) {
1187
1357
  const fullPath = import_path3.default.join(dirPath, item.name);
1188
1358
  if (item.isDirectory()) {
@@ -1197,11 +1367,11 @@ async function findFiles(dirPath, pattern) {
1197
1367
  }
1198
1368
  return results;
1199
1369
  }
1200
- var import_fs2, import_path3;
1370
+ var import_fs3, import_path3;
1201
1371
  var init_fs = __esm({
1202
1372
  "node_modules/@xubylele/schema-forge-core/dist/core/fs.js"() {
1203
1373
  "use strict";
1204
- import_fs2 = require("fs");
1374
+ import_fs3 = require("fs");
1205
1375
  import_path3 = __toESM(require("path"), 1);
1206
1376
  }
1207
1377
  });
@@ -1222,9 +1392,21 @@ async function schemaToState(schema) {
1222
1392
  ...column.foreignKey !== void 0 && { foreignKey: column.foreignKey }
1223
1393
  };
1224
1394
  }
1395
+ let policies;
1396
+ if (table.policies?.length) {
1397
+ policies = {};
1398
+ for (const p of table.policies) {
1399
+ policies[p.name] = {
1400
+ command: p.command,
1401
+ ...p.using !== void 0 && { using: p.using },
1402
+ ...p.withCheck !== void 0 && { withCheck: p.withCheck }
1403
+ };
1404
+ }
1405
+ }
1225
1406
  tables[tableName] = {
1226
1407
  columns,
1227
- ...primaryKeyColumn !== null && { primaryKey: primaryKeyColumn }
1408
+ ...primaryKeyColumn !== null && { primaryKey: primaryKeyColumn },
1409
+ ...policies !== void 0 && { policies }
1228
1410
  };
1229
1411
  }
1230
1412
  return {
@@ -1259,9 +1441,20 @@ var init_state_manager = __esm({
1259
1441
  });
1260
1442
 
1261
1443
  // node_modules/@xubylele/schema-forge-core/dist/generator/sql-generator.js
1444
+ function generateEnableRls(tableName) {
1445
+ return `ALTER TABLE ${tableName} ENABLE ROW LEVEL SECURITY;`;
1446
+ }
1262
1447
  function generateSql(diff, provider, sqlConfig) {
1263
1448
  const statements = [];
1449
+ const enabledRlsTables = /* @__PURE__ */ new Set();
1264
1450
  for (const operation of diff.operations) {
1451
+ if (operation.kind === "create_policy" || operation.kind === "modify_policy") {
1452
+ const tableName = operation.tableName;
1453
+ if (!enabledRlsTables.has(tableName)) {
1454
+ statements.push(generateEnableRls(tableName));
1455
+ enabledRlsTables.add(tableName);
1456
+ }
1457
+ }
1265
1458
  const sql = generateOperation(operation, provider, sqlConfig);
1266
1459
  if (sql) {
1267
1460
  statements.push(sql);
@@ -1291,6 +1484,12 @@ function generateOperation(operation, provider, sqlConfig) {
1291
1484
  return generateDropPrimaryKeyConstraint(operation.tableName);
1292
1485
  case "add_primary_key_constraint":
1293
1486
  return generateAddPrimaryKeyConstraint(operation.tableName, operation.columnName);
1487
+ case "create_policy":
1488
+ return generateCreatePolicy(operation.tableName, operation.policy);
1489
+ case "drop_policy":
1490
+ return generateDropPolicy(operation.tableName, operation.policyName);
1491
+ case "modify_policy":
1492
+ return generateModifyPolicy(operation.tableName, operation.policyName, operation.policy);
1294
1493
  }
1295
1494
  }
1296
1495
  function generateCreateTable(table, provider, sqlConfig) {
@@ -1371,6 +1570,25 @@ function generateAlterColumnNullability(tableName, columnName, toNullable) {
1371
1570
  }
1372
1571
  return `ALTER TABLE ${tableName} ALTER COLUMN ${columnName} SET NOT NULL;`;
1373
1572
  }
1573
+ function generateCreatePolicy(tableName, policy) {
1574
+ const command = policy.command.toUpperCase();
1575
+ const parts = [`CREATE POLICY "${policy.name}" ON ${tableName} FOR ${command}`];
1576
+ if (policy.using !== void 0 && policy.using !== "") {
1577
+ parts.push(`USING (${policy.using})`);
1578
+ }
1579
+ if (policy.withCheck !== void 0 && policy.withCheck !== "") {
1580
+ parts.push(`WITH CHECK (${policy.withCheck})`);
1581
+ }
1582
+ return parts.join(" ") + ";";
1583
+ }
1584
+ function generateDropPolicy(tableName, policyName) {
1585
+ return `DROP POLICY "${policyName}" ON ${tableName};`;
1586
+ }
1587
+ function generateModifyPolicy(tableName, policyName, policy) {
1588
+ const drop = generateDropPolicy(tableName, policyName);
1589
+ const create = generateCreatePolicy(tableName, policy);
1590
+ return drop + "\n\n" + create;
1591
+ }
1374
1592
  var init_sql_generator = __esm({
1375
1593
  "node_modules/@xubylele/schema-forge-core/dist/generator/sql-generator.js"() {
1376
1594
  "use strict";
@@ -2204,7 +2422,7 @@ var init_schema_to_dsl = __esm({
2204
2422
 
2205
2423
  // node_modules/@xubylele/schema-forge-core/dist/core/sql/load-migrations.js
2206
2424
  async function loadMigrationSqlInput(inputPath) {
2207
- const stats = await import_fs4.promises.stat(inputPath);
2425
+ const stats = await import_fs5.promises.stat(inputPath);
2208
2426
  if (stats.isFile()) {
2209
2427
  if (!inputPath.toLowerCase().endsWith(".sql")) {
2210
2428
  throw new Error(`Input file must be a .sql file: ${inputPath}`);
@@ -2225,11 +2443,11 @@ async function loadMigrationSqlInput(inputPath) {
2225
2443
  }
2226
2444
  return result;
2227
2445
  }
2228
- var import_fs4, import_path5;
2446
+ var import_fs5, import_path5;
2229
2447
  var init_load_migrations = __esm({
2230
2448
  "node_modules/@xubylele/schema-forge-core/dist/core/sql/load-migrations.js"() {
2231
2449
  "use strict";
2232
- import_fs4 = require("fs");
2450
+ import_fs5 = require("fs");
2233
2451
  import_path5 = __toESM(require("path"), 1);
2234
2452
  init_fs();
2235
2453
  }
@@ -2733,7 +2951,7 @@ var import_commander8 = require("commander");
2733
2951
  // package.json
2734
2952
  var package_default = {
2735
2953
  name: "@xubylele/schema-forge",
2736
- version: "1.10.1",
2954
+ version: "1.12.0",
2737
2955
  description: "Universal migration generator from schema DSL",
2738
2956
  main: "dist/cli.js",
2739
2957
  type: "commonjs",
@@ -2787,13 +3005,14 @@ var package_default = {
2787
3005
  boxen: "^8.0.1",
2788
3006
  chalk: "^5.6.2",
2789
3007
  commander: "^14.0.3",
2790
- pg: "^8.19.0"
3008
+ pg: "^8.19.0",
3009
+ "update-notifier": "^7.3.1"
2791
3010
  },
2792
3011
  devDependencies: {
2793
3012
  "@changesets/cli": "^2.30.0",
2794
3013
  "@types/node": "^25.2.3",
2795
3014
  "@types/pg": "^8.18.0",
2796
- "@xubylele/schema-forge-core": "^1.3.1",
3015
+ "@xubylele/schema-forge-core": "^1.4.0",
2797
3016
  testcontainers: "^11.8.1",
2798
3017
  "ts-node": "^10.9.2",
2799
3018
  tsup: "^8.5.1",
@@ -2802,10 +3021,6 @@ var package_default = {
2802
3021
  }
2803
3022
  };
2804
3023
 
2805
- // src/commands/diff.ts
2806
- var import_commander = require("commander");
2807
- var import_path7 = __toESM(require("path"));
2808
-
2809
3024
  // src/core/fs.ts
2810
3025
  var import_fs = require("fs");
2811
3026
  var import_path = __toESM(require("path"));
@@ -2884,6 +3099,91 @@ function getStatePath(root, config) {
2884
3099
  return import_path2.default.join(schemaForgeDir, fileName);
2885
3100
  }
2886
3101
 
3102
+ // src/utils/exitCodes.ts
3103
+ var EXIT_CODES = {
3104
+ SUCCESS: 0,
3105
+ VALIDATION_ERROR: 1,
3106
+ DRIFT_DETECTED: 2,
3107
+ CI_DESTRUCTIVE: 3
3108
+ };
3109
+ function shouldFailCIDestructive(isCIEnvironment, hasDestructiveFindings2, isForceEnabled) {
3110
+ return isCIEnvironment && hasDestructiveFindings2 && !isForceEnabled;
3111
+ }
3112
+
3113
+ // src/utils/output.ts
3114
+ var import_boxen = __toESM(require("boxen"));
3115
+ var import_chalk = require("chalk");
3116
+ var isInteractive = Boolean(process.stdout?.isTTY);
3117
+ var colorsEnabled = isInteractive && process.env.FORCE_COLOR !== "0" && !("NO_COLOR" in process.env);
3118
+ var color = new import_chalk.Chalk({ level: colorsEnabled ? 3 : 0 });
3119
+ var theme = {
3120
+ primary: color.cyanBright,
3121
+ success: color.hex("#00FF88"),
3122
+ warning: color.hex("#FFD166"),
3123
+ error: color.hex("#EF476F"),
3124
+ accent: color.magentaBright
3125
+ };
3126
+ function success(message) {
3127
+ const text = theme.success(`[OK] ${message}`);
3128
+ if (!isInteractive) {
3129
+ console.log(text);
3130
+ return;
3131
+ }
3132
+ try {
3133
+ console.log(
3134
+ (0, import_boxen.default)(text, {
3135
+ padding: 1,
3136
+ borderColor: "cyan",
3137
+ borderStyle: "round"
3138
+ })
3139
+ );
3140
+ } catch {
3141
+ console.log(text);
3142
+ }
3143
+ }
3144
+ function info(message) {
3145
+ console.log(theme.primary(message));
3146
+ }
3147
+ function warning(message) {
3148
+ console.warn(theme.warning(`[WARN] ${message}`));
3149
+ }
3150
+ function error(message) {
3151
+ console.error(theme.error(`[ERROR] ${message}`));
3152
+ }
3153
+ function forceWarning(message) {
3154
+ console.error(theme.warning(`[FORCE] ${message}`));
3155
+ }
3156
+
3157
+ // src/commands/config.ts
3158
+ var MIGRATION_FORMATS = ["hyphen", "underscore"];
3159
+ async function runConfig(options) {
3160
+ const root = getProjectRoot();
3161
+ const configPath = getConfigPath(root);
3162
+ if (!await fileExists(configPath)) {
3163
+ throw new Error('SchemaForge project not initialized. Run "schema-forge init" first.');
3164
+ }
3165
+ const config = await readJsonFile(configPath, {});
3166
+ if (options.migrationFormat !== void 0) {
3167
+ const format = options.migrationFormat.trim().toLowerCase();
3168
+ if (!MIGRATION_FORMATS.includes(format)) {
3169
+ throw new Error(
3170
+ `Invalid migration format "${options.migrationFormat}". Allowed: ${MIGRATION_FORMATS.join(", ")}.`
3171
+ );
3172
+ }
3173
+ config.migrationFileNameFormat = format;
3174
+ }
3175
+ await writeJsonFile(configPath, config);
3176
+ success(`Updated ${configPath}`);
3177
+ if (options.migrationFormat !== void 0) {
3178
+ success(`migrationFileNameFormat set to "${config.migrationFileNameFormat}" (${config.migrationFileNameFormat === "hyphen" ? "timestamp-name.sql" : "timestamp_name.sql"})`);
3179
+ }
3180
+ process.exitCode = EXIT_CODES.SUCCESS;
3181
+ }
3182
+
3183
+ // src/commands/diff.ts
3184
+ var import_commander = require("commander");
3185
+ var import_path7 = __toESM(require("path"));
3186
+
2887
3187
  // src/core/provider.ts
2888
3188
  var DEFAULT_PROVIDER = "postgres";
2889
3189
  function resolveProvider(provider) {
@@ -3004,65 +3304,6 @@ async function withPostgresQueryExecutor(connectionString, run) {
3004
3304
  }
3005
3305
  }
3006
3306
 
3007
- // src/utils/exitCodes.ts
3008
- var EXIT_CODES = {
3009
- /** Successful operation */
3010
- SUCCESS: 0,
3011
- /** Validation error (invalid DSL syntax, config errors, missing files, etc.) */
3012
- VALIDATION_ERROR: 1,
3013
- /** Drift detected - Reserved for future use when comparing actual DB state vs schema */
3014
- DRIFT_DETECTED: 2,
3015
- /** Destructive operation detected in CI environment without --force */
3016
- CI_DESTRUCTIVE: 3
3017
- };
3018
- function shouldFailCIDestructive(isCIEnvironment, hasDestructiveFindings2, isForceEnabled) {
3019
- return isCIEnvironment && hasDestructiveFindings2 && !isForceEnabled;
3020
- }
3021
-
3022
- // src/utils/output.ts
3023
- var import_boxen = __toESM(require("boxen"));
3024
- var import_chalk = require("chalk");
3025
- var isInteractive = Boolean(process.stdout?.isTTY);
3026
- var colorsEnabled = isInteractive && process.env.FORCE_COLOR !== "0" && !("NO_COLOR" in process.env);
3027
- var color = new import_chalk.Chalk({ level: colorsEnabled ? 3 : 0 });
3028
- var theme = {
3029
- primary: color.cyanBright,
3030
- success: color.hex("#00FF88"),
3031
- warning: color.hex("#FFD166"),
3032
- error: color.hex("#EF476F"),
3033
- accent: color.magentaBright
3034
- };
3035
- function success(message) {
3036
- const text = theme.success(`[OK] ${message}`);
3037
- if (!isInteractive) {
3038
- console.log(text);
3039
- return;
3040
- }
3041
- try {
3042
- console.log(
3043
- (0, import_boxen.default)(text, {
3044
- padding: 1,
3045
- borderColor: "cyan",
3046
- borderStyle: "round"
3047
- })
3048
- );
3049
- } catch {
3050
- console.log(text);
3051
- }
3052
- }
3053
- function info(message) {
3054
- console.log(theme.primary(message));
3055
- }
3056
- function warning(message) {
3057
- console.warn(theme.warning(`[WARN] ${message}`));
3058
- }
3059
- function error(message) {
3060
- console.error(theme.error(`[ERROR] ${message}`));
3061
- }
3062
- function forceWarning(message) {
3063
- console.error(theme.warning(`[FORCE] ${message}`));
3064
- }
3065
-
3066
3307
  // src/utils/prompt.ts
3067
3308
  var import_node_readline = __toESM(require("readline"));
3068
3309
  function isCI() {
@@ -3306,6 +3547,10 @@ function nowTimestamp2() {
3306
3547
  function slugifyName2(name) {
3307
3548
  return name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "migration";
3308
3549
  }
3550
+ function migrationFileName(timestamp, slug, format = "hyphen") {
3551
+ const sep = format === "underscore" ? "_" : "-";
3552
+ return `${timestamp}${sep}${slug}.sql`;
3553
+ }
3309
3554
 
3310
3555
  // src/commands/generate.ts
3311
3556
  var REQUIRED_CONFIG_FIELDS = [
@@ -3313,6 +3558,19 @@ var REQUIRED_CONFIG_FIELDS = [
3313
3558
  "stateFile",
3314
3559
  "outputDir"
3315
3560
  ];
3561
+ var MIGRATION_FORMATS2 = ["hyphen", "underscore"];
3562
+ function resolveMigrationFormat(cliFormat, configFormat) {
3563
+ if (cliFormat !== void 0 && cliFormat !== "") {
3564
+ const normalized = cliFormat.trim().toLowerCase();
3565
+ if (MIGRATION_FORMATS2.includes(normalized)) {
3566
+ return normalized;
3567
+ }
3568
+ throw new Error(
3569
+ `Invalid --migration-format "${cliFormat}". Allowed: ${MIGRATION_FORMATS2.join(", ")}.`
3570
+ );
3571
+ }
3572
+ return configFormat ?? "hyphen";
3573
+ }
3316
3574
  function resolveConfigPath3(root, targetPath) {
3317
3575
  return import_path9.default.isAbsolute(targetPath) ? targetPath : import_path9.default.join(root, targetPath);
3318
3576
  }
@@ -3393,7 +3651,11 @@ Remove --safe flag or modify schema to avoid destructive changes.`
3393
3651
  const sql = await generateSql2(diff, provider, config.sql);
3394
3652
  const timestamp = nowTimestamp2();
3395
3653
  const slug = slugifyName2(options.name ?? "migration");
3396
- const fileName = `${timestamp}-${slug}.sql`;
3654
+ const format = resolveMigrationFormat(
3655
+ options.migrationFormat,
3656
+ config.migrationFileNameFormat
3657
+ );
3658
+ const fileName = migrationFileName(timestamp, slug, format);
3397
3659
  await ensureDir(outputDir);
3398
3660
  const migrationPath = import_path9.default.join(outputDir, fileName);
3399
3661
  await writeTextFile(migrationPath, sql + "\n");
@@ -3529,6 +3791,9 @@ table users {
3529
3791
  timestampDefault: "now()"
3530
3792
  }
3531
3793
  };
3794
+ if (provider === "supabase") {
3795
+ config.migrationFileNameFormat = "hyphen";
3796
+ }
3532
3797
  await writeJsonFile(configPath, config);
3533
3798
  success(`Created ${configPath}`);
3534
3799
  const state = {
@@ -3689,6 +3954,35 @@ function getCliMetaPath() {
3689
3954
  function getReleaseUrl(version) {
3690
3955
  return `https://github.com/xubylele/schema-forge/releases/tag/v${version}`;
3691
3956
  }
3957
+ function extractChangelogSection(changelogText, version) {
3958
+ const heading = `## ${version}`;
3959
+ const idx = changelogText.indexOf(heading);
3960
+ if (idx === -1) {
3961
+ return null;
3962
+ }
3963
+ const start = idx + heading.length;
3964
+ const rest = changelogText.slice(start);
3965
+ const nextHeading = rest.indexOf("\n## ");
3966
+ const section = nextHeading === -1 ? rest : rest.slice(0, nextHeading);
3967
+ return section.trim() || null;
3968
+ }
3969
+ async function fetchChangelogForVersion(version) {
3970
+ const urls = [
3971
+ `https://raw.githubusercontent.com/xubylele/schema-forge/v${version}/CHANGELOG.md`,
3972
+ "https://raw.githubusercontent.com/xubylele/schema-forge/main/CHANGELOG.md"
3973
+ ];
3974
+ for (const url of urls) {
3975
+ try {
3976
+ const res = await fetch(url, { signal: AbortSignal.timeout(5e3) });
3977
+ if (!res.ok) continue;
3978
+ const text = await res.text();
3979
+ const section = extractChangelogSection(text, version);
3980
+ if (section) return section;
3981
+ } catch {
3982
+ }
3983
+ }
3984
+ return null;
3985
+ }
3692
3986
  function shouldShowWhatsNew(argv) {
3693
3987
  if (argv.length === 0) {
3694
3988
  return false;
@@ -3708,7 +4002,14 @@ async function showWhatsNewIfUpdated(currentVersion, argv) {
3708
4002
  if (meta.lastSeenVersion === currentVersion) {
3709
4003
  return;
3710
4004
  }
3711
- info(`What's new in schema-forge v${currentVersion}: ${getReleaseUrl(currentVersion)}`);
4005
+ const section = await fetchChangelogForVersion(currentVersion);
4006
+ if (section) {
4007
+ info(`What's new in schema-forge v${currentVersion}:`);
4008
+ info(section);
4009
+ info(`Release: ${getReleaseUrl(currentVersion)}`);
4010
+ } else {
4011
+ info(`What's new in schema-forge v${currentVersion}: ${getReleaseUrl(currentVersion)}`);
4012
+ }
3712
4013
  await writeJsonFile(metaPath, { lastSeenVersion: currentVersion });
3713
4014
  } catch {
3714
4015
  }
@@ -3752,7 +4053,7 @@ program.command("init").description(
3752
4053
  await handleError(error2);
3753
4054
  }
3754
4055
  });
3755
- program.command("generate").description("Generate SQL from schema files. In CI environments (CI=true), exits with code 3 if destructive operations are detected unless --force is used.").option("--name <string>", "Schema name to generate").action(async (options) => {
4056
+ program.command("generate").description("Generate SQL from schema files. In CI environments (CI=true), exits with code 3 if destructive operations are detected unless --force is used.").option("--name <string>", "Schema name to generate").option("--migration-format <format>", "Migration file name: hyphen (timestamp-name.sql) or underscore (timestamp_name.sql, Supabase CLI style)").action(async (options) => {
3756
4057
  try {
3757
4058
  const globalOptions = program.opts();
3758
4059
  validateFlagExclusivity(globalOptions);
@@ -3791,6 +4092,13 @@ program.command("import").description("Import schema from SQL migrations").argum
3791
4092
  await handleError(error2);
3792
4093
  }
3793
4094
  });
4095
+ program.command("config").description("Update schemaforge/config.json settings").command("migration-format <format>").description("Set migration file name format: hyphen (timestamp-name.sql) or underscore (timestamp_name.sql)").action(async (format) => {
4096
+ try {
4097
+ await runConfig({ migrationFormat: format });
4098
+ } catch (error2) {
4099
+ await handleError(error2);
4100
+ }
4101
+ });
3794
4102
  program.command("validate").description("Detect destructive or risky schema changes. In CI environments (CI=true), exits with code 3 if destructive operations are detected unless --force is used.").option("--json", "Output structured JSON").option("--url <string>", "PostgreSQL connection URL for live drift validation (defaults to DATABASE_URL)").option("--schema <list>", "Comma-separated schema names to introspect (default: public)").action(async (options) => {
3795
4103
  try {
3796
4104
  const globalOptions = program.opts();
@@ -3800,10 +4108,21 @@ program.command("validate").description("Detect destructive or risky schema chan
3800
4108
  await handleError(error2);
3801
4109
  }
3802
4110
  });
4111
+ function shouldCheckForUpdate(argv) {
4112
+ if (process.env.CI === "true") {
4113
+ return false;
4114
+ }
4115
+ const onlyHelpOrVersion = argv.length === 0 || argv.length === 1 && ["--help", "-h", "--version", "-V"].includes(argv[0]);
4116
+ return !onlyHelpOrVersion;
4117
+ }
3803
4118
  async function main() {
3804
4119
  const argv = process.argv.slice(2);
3805
4120
  await seedLastSeenVersion(package_default.version);
3806
4121
  await showWhatsNewIfUpdated(package_default.version, argv);
4122
+ if (shouldCheckForUpdate(argv)) {
4123
+ import("update-notifier").then((m) => m.default({ pkg: package_default, shouldNotifyInNpmScript: false }).notify()).catch(() => {
4124
+ });
4125
+ }
3807
4126
  program.parse(process.argv);
3808
4127
  if (!argv.length) {
3809
4128
  program.outputHelp();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xubylele/schema-forge",
3
- "version": "1.10.1",
3
+ "version": "1.12.0",
4
4
  "description": "Universal migration generator from schema DSL",
5
5
  "main": "dist/cli.js",
6
6
  "type": "commonjs",
@@ -54,13 +54,14 @@
54
54
  "boxen": "^8.0.1",
55
55
  "chalk": "^5.6.2",
56
56
  "commander": "^14.0.3",
57
- "pg": "^8.19.0"
57
+ "pg": "^8.19.0",
58
+ "update-notifier": "^7.3.1"
58
59
  },
59
60
  "devDependencies": {
60
61
  "@changesets/cli": "^2.30.0",
61
62
  "@types/node": "^25.2.3",
62
63
  "@types/pg": "^8.18.0",
63
- "@xubylele/schema-forge-core": "^1.3.1",
64
+ "@xubylele/schema-forge-core": "^1.4.0",
64
65
  "testcontainers": "^11.8.1",
65
66
  "ts-node": "^10.9.2",
66
67
  "tsup": "^8.5.1",