@xubylele/schema-forge 1.11.0 → 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
 
@@ -324,6 +325,7 @@ Live `--json` output returns a structured `DriftReport`:
324
325
 
325
326
  Validation checks include:
326
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).
327
329
  - Dropped tables (`DROP_TABLE`, error)
328
330
  - Dropped columns (`DROP_COLUMN`, error)
329
331
  - Column type changes (`ALTER_COLUMN_TYPE`, warning/error based on compatibility heuristics)
@@ -566,6 +568,26 @@ table table_name {
566
568
  - `default <value>` - Default value (e.g., `default now()`, `default false`, `default 0`)
567
569
  - `fk <table>.<column>` - Foreign key reference (e.g., `fk users.id`)
568
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
+
569
591
  ### Examples
570
592
 
571
593
  #### Simple table
package/dist/api.d.ts CHANGED
@@ -11,14 +11,12 @@ interface DoctorOptions {
11
11
  schema?: string;
12
12
  }
13
13
 
14
- /** Migration file name format: hyphen (timestamp-name.sql) or underscore (timestamp_name.sql, Supabase CLI style). */
15
14
  type MigrationFileNameFormat = 'hyphen' | 'underscore';
16
15
 
17
16
  interface GenerateOptions {
18
17
  name?: string;
19
18
  safe?: boolean;
20
19
  force?: boolean;
21
- /** Override migration file name format: hyphen (timestamp-name.sql) or underscore (timestamp_name.sql). */
22
20
  migrationFormat?: MigrationFileNameFormat;
23
21
  }
24
22
 
@@ -44,69 +42,22 @@ interface ValidateOptions {
44
42
  force?: boolean;
45
43
  }
46
44
 
47
- /**
48
- * Exit codes used throughout the CLI for deterministic behavior
49
- *
50
- * @see SF-106 Standardize Exit Codes
51
- */
52
45
  declare const EXIT_CODES: {
53
- /** Successful operation */
54
46
  readonly SUCCESS: 0;
55
- /** Validation error (invalid DSL syntax, config errors, missing files, etc.) */
56
47
  readonly VALIDATION_ERROR: 1;
57
- /** Drift detected - Reserved for future use when comparing actual DB state vs schema */
58
48
  readonly DRIFT_DETECTED: 2;
59
- /** Destructive operation detected in CI environment without --force */
60
49
  readonly CI_DESTRUCTIVE: 3;
61
50
  };
62
51
 
63
- /**
64
- * Programmatic API for Schema Forge.
65
- * Use this entrypoint when integrating from Node (e.g. scripts, GitHub Actions)
66
- * instead of invoking the CLI via shell.
67
- *
68
- * @example
69
- * const { generate, EXIT_CODES } = require('@xubylele/schema-forge/api');
70
- * const result = await generate({ name: 'MyMigration' });
71
- * if (result.exitCode !== EXIT_CODES.SUCCESS) process.exit(result.exitCode);
72
- */
73
-
74
- /**
75
- * Result of a programmatic command run. Exit codes match the CLI contract.
76
- * @see docs/exit-codes.json
77
- */
78
52
  interface RunResult {
79
53
  exitCode: number;
80
54
  }
81
- /**
82
- * Initialize a new schema project in the current directory.
83
- * @param options.provider - Database provider: 'postgres' (default) or 'supabase'. Supabase uses supabase/migrations for output.
84
- */
85
55
  declare function init(options?: InitOptions): Promise<RunResult>;
86
- /**
87
- * Generate SQL migration from schema files.
88
- */
89
56
  declare function generate(options?: GenerateOptions): Promise<RunResult>;
90
- /**
91
- * Compare two schema versions and generate migration SQL (optionally against live DB).
92
- */
93
57
  declare function diff(options?: DiffOptions): Promise<RunResult>;
94
- /**
95
- * Check live database drift against state.
96
- */
97
58
  declare function doctor(options?: DoctorOptions): Promise<RunResult>;
98
- /**
99
- * Validate schema and optionally check for destructive changes or live drift.
100
- */
101
59
  declare function validate(options?: ValidateOptions): Promise<RunResult>;
102
- /**
103
- * Extract normalized live schema from PostgreSQL.
104
- */
105
60
  declare function introspect(options?: IntrospectOptions): Promise<RunResult>;
106
- /**
107
- * Import schema from SQL migrations.
108
- * @param inputPath - Path to .sql file or migrations directory
109
- */
110
61
  declare function importSchema(inputPath: string, options?: ImportOptions): Promise<RunResult>;
111
62
 
112
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) {
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
  }
@@ -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";
@@ -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.11.0",
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",
@@ -2882,13 +3101,9 @@ function getStatePath(root, config) {
2882
3101
 
2883
3102
  // src/utils/exitCodes.ts
2884
3103
  var EXIT_CODES = {
2885
- /** Successful operation */
2886
3104
  SUCCESS: 0,
2887
- /** Validation error (invalid DSL syntax, config errors, missing files, etc.) */
2888
3105
  VALIDATION_ERROR: 1,
2889
- /** Drift detected - Reserved for future use when comparing actual DB state vs schema */
2890
3106
  DRIFT_DETECTED: 2,
2891
- /** Destructive operation detected in CI environment without --force */
2892
3107
  CI_DESTRUCTIVE: 3
2893
3108
  };
2894
3109
  function shouldFailCIDestructive(isCIEnvironment, hasDestructiveFindings2, isForceEnabled) {
@@ -3739,6 +3954,35 @@ function getCliMetaPath() {
3739
3954
  function getReleaseUrl(version) {
3740
3955
  return `https://github.com/xubylele/schema-forge/releases/tag/v${version}`;
3741
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
+ }
3742
3986
  function shouldShowWhatsNew(argv) {
3743
3987
  if (argv.length === 0) {
3744
3988
  return false;
@@ -3758,7 +4002,14 @@ async function showWhatsNewIfUpdated(currentVersion, argv) {
3758
4002
  if (meta.lastSeenVersion === currentVersion) {
3759
4003
  return;
3760
4004
  }
3761
- 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
+ }
3762
4013
  await writeJsonFile(metaPath, { lastSeenVersion: currentVersion });
3763
4014
  } catch {
3764
4015
  }
@@ -3857,10 +4108,21 @@ program.command("validate").description("Detect destructive or risky schema chan
3857
4108
  await handleError(error2);
3858
4109
  }
3859
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
+ }
3860
4118
  async function main() {
3861
4119
  const argv = process.argv.slice(2);
3862
4120
  await seedLastSeenVersion(package_default.version);
3863
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
+ }
3864
4126
  program.parse(process.argv);
3865
4127
  if (!argv.length) {
3866
4128
  program.outputHelp();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xubylele/schema-forge",
3
- "version": "1.11.0",
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",