@noego/proper 0.0.9 → 0.2.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/bin/cli.js CHANGED
@@ -123,14 +123,10 @@ function migration_filter(_0) {
123
123
  }
124
124
 
125
125
  // framework/MigrationRunner.ts
126
- var import_fs3 = __toESM(require("fs"));
127
- var import_promise = __toESM(require("mysql2/promise"));
128
- var sqlite = __toESM(require("sqlite"));
129
- var sqlite3 = __toESM(require("sqlite3"));
126
+ var import_fs6 = __toESM(require("fs"));
130
127
 
131
128
  // framework/MigrationDirectoryReader.ts
132
- var import_fs = __toESM(require("fs"));
133
- var import_path = __toESM(require("path"));
129
+ var import_fs2 = __toESM(require("fs"));
134
130
 
135
131
  // framework/errors.ts
136
132
  var MigrationError = class _MigrationError extends Error {
@@ -245,6 +241,54 @@ ${originalError.message}`;
245
241
  return new _MigrationExecutionError(message);
246
242
  }
247
243
  };
244
+ var PatchError = class _PatchError extends MigrationError {
245
+ constructor(message, patchFile, patchKey) {
246
+ super(`Patch Error: ${message}`);
247
+ this.patchFile = patchFile;
248
+ this.patchKey = patchKey;
249
+ this.name = "PatchError";
250
+ Object.setPrototypeOf(this, _PatchError.prototype);
251
+ }
252
+ };
253
+ var PatchValidationError = class _PatchValidationError extends PatchError {
254
+ constructor(message, patchFile, patchKey) {
255
+ super(message, patchFile, patchKey);
256
+ this.name = "PatchValidationError";
257
+ Object.setPrototypeOf(this, _PatchValidationError.prototype);
258
+ }
259
+ };
260
+ var PatchIntegrityError = class _PatchIntegrityError extends PatchError {
261
+ constructor(message, patchFile, patchKey, expectedChecksum, actualChecksum) {
262
+ super(message, patchFile, patchKey);
263
+ this.expectedChecksum = expectedChecksum;
264
+ this.actualChecksum = actualChecksum;
265
+ this.name = "PatchIntegrityError";
266
+ Object.setPrototypeOf(this, _PatchIntegrityError.prototype);
267
+ }
268
+ };
269
+ var PatchConflictError = class _PatchConflictError extends PatchError {
270
+ constructor(message, patchFile, patchKey, operationIndex, operationVerb, migrationKeys, observedRowCounts) {
271
+ super(message, patchFile, patchKey);
272
+ this.operationIndex = operationIndex;
273
+ this.operationVerb = operationVerb;
274
+ this.migrationKeys = migrationKeys;
275
+ this.observedRowCounts = observedRowCounts;
276
+ this.name = "PatchConflictError";
277
+ Object.setPrototypeOf(this, _PatchConflictError.prototype);
278
+ }
279
+ };
280
+ var PatchExecutionError = class _PatchExecutionError extends PatchError {
281
+ constructor(message, patchFile, patchKey, operationIndex, operationVerb, originalError) {
282
+ super(originalError ? `${message}
283
+ Original Error:
284
+ ${originalError.message}` : message, patchFile, patchKey);
285
+ this.operationIndex = operationIndex;
286
+ this.operationVerb = operationVerb;
287
+ this.originalError = originalError;
288
+ this.name = "PatchExecutionError";
289
+ Object.setPrototypeOf(this, _PatchExecutionError.prototype);
290
+ }
291
+ };
248
292
  var CLIError = class _CLIError extends MigrationError {
249
293
  constructor(message) {
250
294
  super(`CLI Error: ${message}`);
@@ -407,6 +451,36 @@ var SqlMigrationBuilder = class {
407
451
  }
408
452
  };
409
453
 
454
+ // framework/MigrationManifest.ts
455
+ var import_fs = __toESM(require("fs"));
456
+ var import_path = __toESM(require("path"));
457
+ function canonicalMigrationKey(value) {
458
+ return value.replace(/(?:\.(mysql|sqlite|pg))?\.(up|down)\.(sql|js)$/i, "").toLowerCase();
459
+ }
460
+ function resolveMigrationFile(directory, baseName, direction, dialect) {
461
+ const dialectExt = dialect === "sql" ? "mysql" : dialect;
462
+ const dialectFile = import_path.default.join(directory, `${baseName}.${dialectExt}.${direction}.sql`);
463
+ if (import_fs.default.existsSync(dialectFile)) return dialectFile;
464
+ const genericFile = import_path.default.join(directory, `${baseName}.${direction}.sql`);
465
+ if (import_fs.default.existsSync(genericFile)) return genericFile;
466
+ return null;
467
+ }
468
+ function loadMigrationManifest(directory, dialect) {
469
+ const manifest = /* @__PURE__ */ new Map();
470
+ if (!import_fs.default.existsSync(directory)) return manifest;
471
+ const files = import_fs.default.readdirSync(directory, { withFileTypes: true }).filter((f) => f.isFile()).map((f) => f.name);
472
+ const uniqueKeys = /* @__PURE__ */ new Set();
473
+ files.forEach((file) => uniqueKeys.add(canonicalMigrationKey(file)));
474
+ uniqueKeys.forEach((key) => {
475
+ manifest.set(key, {
476
+ key,
477
+ upFile: resolveMigrationFile(directory, key, "up", dialect),
478
+ downFile: resolveMigrationFile(directory, key, "down", dialect)
479
+ });
480
+ });
481
+ return manifest;
482
+ }
483
+
410
484
  // framework/MigrationDirectoryReader.ts
411
485
  var MigrationDirectoryReader = class {
412
486
  constructor(directory, read_strategy, sqlrunner, dialect = "sql") {
@@ -418,27 +492,23 @@ var MigrationDirectoryReader = class {
418
492
  /**
419
493
  * Resolves the appropriate file for a migration based on dialect.
420
494
  * Priority: dialect-specific file > generic file
495
+ * File extensions: `.mysql.up.sql`, `.sqlite.up.sql`, `.pg.up.sql`.
421
496
  */
422
497
  resolveFile(baseName, direction) {
423
- const dialectExt = this.dialect === "sql" ? "mysql" : "sqlite";
424
- const dialectFile = import_path.default.join(this.directory, `${baseName}.${dialectExt}.${direction}.sql`);
425
- if (import_fs.default.existsSync(dialectFile)) return dialectFile;
426
- const genericFile = import_path.default.join(this.directory, `${baseName}.${direction}.sql`);
427
- if (import_fs.default.existsSync(genericFile)) return genericFile;
428
- return null;
498
+ return resolveMigrationFile(this.directory, baseName, direction, this.dialect);
429
499
  }
430
500
  /**
431
501
  * Checks if a file path is dialect-specific (contains .mysql. or .sqlite. in the name)
432
502
  */
433
503
  isDialectSpecific(filePath) {
434
- return /\.(mysql|sqlite)\.(up|down)\.sql$/i.test(filePath);
504
+ return /\.(mysql|sqlite|pg)\.(up|down)\.sql$/i.test(filePath);
435
505
  }
436
506
  loadMigrations(table, connection) {
437
- import_fs.default.existsSync(this.directory) || import_fs.default.mkdirSync(this.directory);
438
- const dir_content = import_fs.default.readdirSync(this.directory, { withFileTypes: true }).filter((file) => file.isFile()).map((file) => file.name);
507
+ import_fs2.default.existsSync(this.directory) || import_fs2.default.mkdirSync(this.directory);
508
+ const dir_content = import_fs2.default.readdirSync(this.directory, { withFileTypes: true }).filter((file) => file.isFile()).map((file) => file.name);
439
509
  const uniqueKeys = /* @__PURE__ */ new Set();
440
510
  dir_content.forEach((file) => {
441
- const key = file.replace(/(?:\.(mysql|sqlite))?\.(up|down)\.(sql|js)/i, "").toLowerCase();
511
+ const key = canonicalMigrationKey(file);
442
512
  uniqueKeys.add(key);
443
513
  });
444
514
  const migration_sorter = {};
@@ -478,14 +548,14 @@ var MigrationDirectoryReader = class {
478
548
  return builder;
479
549
  }
480
550
  sql_up(file) {
481
- let content = import_fs.default.readFileSync(file).toString();
551
+ let content = import_fs2.default.readFileSync(file).toString();
482
552
  if (!this.isDialectSpecific(file)) {
483
553
  content = this.read_strategy(content);
484
554
  }
485
555
  return content.trim();
486
556
  }
487
557
  sql_down(file) {
488
- let content = import_fs.default.readFileSync(file).toString();
558
+ let content = import_fs2.default.readFileSync(file).toString();
489
559
  if (!this.isDialectSpecific(file)) {
490
560
  content = this.read_strategy(content);
491
561
  }
@@ -494,7 +564,23 @@ var MigrationDirectoryReader = class {
494
564
  };
495
565
 
496
566
  // framework/MigrationSetup.ts
497
- var import_fs2 = __toESM(require("fs"));
567
+ var import_fs3 = __toESM(require("fs"));
568
+
569
+ // framework/PatchTypes.ts
570
+ var import_path2 = __toESM(require("path"));
571
+ var PATCH_FORMAT_VERSION = 1;
572
+ var DEFAULT_PATCH_TABLE = "proper_patches";
573
+ var PATCH_FILENAME_REGEX = new RegExp("^(?<stamp>[0-9]{13})_(?<name>[a-z0-9][a-z0-9_-]{0,119})\\.yaml$");
574
+ function resolvePatchFolder(config) {
575
+ if (config.patch_folder) return config.patch_folder;
576
+ const dir = import_path2.default.dirname(config.migration_folder);
577
+ return dir === "." && !config.migration_folder.includes(import_path2.default.sep) && !config.migration_folder.includes("/") ? "patches" : import_path2.default.join(dir, "patches");
578
+ }
579
+ function resolvePatchTable(config) {
580
+ return config.patch_table || DEFAULT_PATCH_TABLE;
581
+ }
582
+
583
+ // framework/MigrationSetup.ts
498
584
  var MigrationSetup = class {
499
585
  constructor(sqlrunner, config) {
500
586
  this.sqlrunner = sqlrunner;
@@ -502,7 +588,7 @@ var MigrationSetup = class {
502
588
  }
503
589
  setup() {
504
590
  return __async(this, null, function* () {
505
- import_fs2.default.existsSync(this.config.migration_folder) || import_fs2.default.mkdirSync(this.config.migration_folder);
591
+ import_fs3.default.existsSync(this.config.migration_folder) || import_fs3.default.mkdirSync(this.config.migration_folder);
506
592
  const tableName = this.config.migration_table;
507
593
  const createTableSql = this.config.database === "sqlite" ? `CREATE TABLE IF NOT EXISTS ${tableName} (
508
594
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -510,6 +596,12 @@ var MigrationSetup = class {
510
596
  up TEXT,
511
597
  down TEXT,
512
598
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
599
+ )` : this.config.database === "pg" ? `CREATE TABLE IF NOT EXISTS ${tableName} (
600
+ id SERIAL PRIMARY KEY,
601
+ migration_key TEXT,
602
+ up TEXT,
603
+ down TEXT,
604
+ created_at TIMESTAMPTZ DEFAULT now()
513
605
  )` : `CREATE TABLE IF NOT EXISTS ${tableName} (
514
606
  id INT AUTO_INCREMENT PRIMARY KEY,
515
607
  migration_key VARCHAR(255),
@@ -518,11 +610,39 @@ var MigrationSetup = class {
518
610
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
519
611
  )`;
520
612
  yield this.sqlrunner.query(createTableSql);
613
+ const patchTable = resolvePatchTable(this.config);
614
+ const createPatchTableSql = this.config.database === "sqlite" ? `CREATE TABLE IF NOT EXISTS ${patchTable} (
615
+ migration_table TEXT NOT NULL,
616
+ patch_key TEXT NOT NULL,
617
+ checksum TEXT NOT NULL,
618
+ format_version INTEGER NOT NULL,
619
+ description TEXT NOT NULL,
620
+ applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
621
+ PRIMARY KEY (migration_table, patch_key)
622
+ )` : this.config.database === "pg" ? `CREATE TABLE IF NOT EXISTS ${patchTable} (
623
+ migration_table TEXT NOT NULL,
624
+ patch_key TEXT NOT NULL,
625
+ checksum TEXT NOT NULL,
626
+ format_version INTEGER NOT NULL,
627
+ description TEXT NOT NULL,
628
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
629
+ PRIMARY KEY (migration_table, patch_key)
630
+ )` : `CREATE TABLE IF NOT EXISTS ${patchTable} (
631
+ migration_table VARCHAR(255) NOT NULL,
632
+ patch_key VARCHAR(255) NOT NULL,
633
+ checksum CHAR(64) NOT NULL,
634
+ format_version INT NOT NULL,
635
+ description TEXT NOT NULL,
636
+ applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
637
+ PRIMARY KEY (migration_table, patch_key)
638
+ )`;
639
+ yield this.sqlrunner.query(createPatchTableSql);
521
640
  });
522
641
  }
523
642
  teardown() {
524
643
  return __async(this, null, function* () {
525
644
  yield this.sqlrunner.execute(`DROP TABLE ${this.config.migration_table}`);
645
+ yield this.sqlrunner.execute(`DROP TABLE IF EXISTS ${resolvePatchTable(this.config)}`);
526
646
  });
527
647
  }
528
648
  };
@@ -554,32 +674,703 @@ function MySqlDialectParser(sql) {
554
674
  }
555
675
  return result;
556
676
  }
557
- function SqliteDialectParser(sql) {
558
- const lines = sql.split("\n");
559
- let result = "";
560
- let isInSqliteBlock = true;
561
- const startSqliteRegex = /^\s*--\s*\[\s*sqlite\s*\]\s*$/i;
677
+ function markerDialectParser(names) {
678
+ const startRegex = new RegExp(`^\\s*--\\s*\\[\\s*(${names.join("|")})\\s*\\]\\s*$`, "i");
562
679
  const anyDialectRegex = /^\s*--\s*\[\s*\w+\s*\]\s*$/i;
563
- for (const line of lines) {
564
- const trimmed = line.trim();
565
- if (startSqliteRegex.test(trimmed)) {
566
- isInSqliteBlock = true;
567
- result += line + "\n";
568
- continue;
569
- } else if (anyDialectRegex.test(trimmed) && !startSqliteRegex.test(trimmed)) {
570
- isInSqliteBlock = false;
571
- continue;
680
+ return function(sql) {
681
+ const lines = sql.split("\n");
682
+ let result = "";
683
+ let capturing = true;
684
+ for (const line of lines) {
685
+ const trimmed = line.trim();
686
+ if (startRegex.test(trimmed)) {
687
+ capturing = true;
688
+ result += line + "\n";
689
+ continue;
690
+ } else if (anyDialectRegex.test(trimmed)) {
691
+ capturing = false;
692
+ continue;
693
+ }
694
+ if (!capturing && line.toLowerCase().includes("create index")) {
695
+ capturing = true;
696
+ }
697
+ if (capturing) {
698
+ result += line + "\n";
699
+ }
572
700
  }
573
- if (!isInSqliteBlock && line.toLowerCase().includes("create index")) {
574
- isInSqliteBlock = true;
701
+ return result;
702
+ };
703
+ }
704
+ var SqliteDialectParser = markerDialectParser(["sqlite"]);
705
+ var PgDialectParser = markerDialectParser(["pg", "postgres", "postgresql"]);
706
+
707
+ // framework/PatchDirectoryReader.ts
708
+ var import_crypto = __toESM(require("crypto"));
709
+ var import_fs4 = __toESM(require("fs"));
710
+ var import_path3 = __toESM(require("path"));
711
+
712
+ // framework/PatchValidator.ts
713
+ var import_ajv = __toESM(require("ajv"));
714
+ var import_yaml = require("yaml");
715
+ var MAX_DESCRIPTION_LENGTH = 500;
716
+ var MAX_MIGRATION_KEY_LENGTH = 255;
717
+ var migrationKeySchema = {
718
+ type: "string",
719
+ minLength: 1,
720
+ maxLength: MAX_MIGRATION_KEY_LENGTH
721
+ };
722
+ var patchSchema = {
723
+ type: "object",
724
+ additionalProperties: false,
725
+ required: ["version", "description", "operations"],
726
+ properties: {
727
+ version: { type: "integer" },
728
+ description: { type: "string" },
729
+ operations: {
730
+ type: "array",
731
+ minItems: 1,
732
+ items: {
733
+ type: "object",
734
+ additionalProperties: false,
735
+ minProperties: 1,
736
+ maxProperties: 1,
737
+ properties: {
738
+ rename_migration: {
739
+ type: "object",
740
+ additionalProperties: false,
741
+ required: ["from", "to"],
742
+ properties: { from: migrationKeySchema, to: migrationKeySchema }
743
+ },
744
+ mark_applied: {
745
+ type: "object",
746
+ additionalProperties: false,
747
+ required: ["key"],
748
+ properties: { key: migrationKeySchema }
749
+ },
750
+ unmark_applied: {
751
+ type: "object",
752
+ additionalProperties: false,
753
+ required: ["key"],
754
+ properties: { key: migrationKeySchema }
755
+ }
756
+ }
757
+ }
575
758
  }
576
- if (isInSqliteBlock) {
577
- result += line + "\n";
759
+ }
760
+ };
761
+ var ajv = new import_ajv.default({ allErrors: true, strict: true });
762
+ var validateSchema = ajv.compile(patchSchema);
763
+ function fail(message, file, patchKey) {
764
+ throw new PatchValidationError(message, file, patchKey);
765
+ }
766
+ function checkMigrationKey(value, context, file, patchKey) {
767
+ if (value !== value.trim()) fail(`${context}: migration key has leading/trailing whitespace`, file, patchKey);
768
+ if (/[/\\]/.test(value)) fail(`${context}: migration key contains a path separator`, file, patchKey);
769
+ if (/[\x00-\x1f\x7f]/.test(value)) fail(`${context}: migration key contains control characters`, file, patchKey);
770
+ if (value.length === 0 || value.length > MAX_MIGRATION_KEY_LENGTH) {
771
+ fail(`${context}: migration key length out of bounds`, file, patchKey);
772
+ }
773
+ return canonicalMigrationKey(value);
774
+ }
775
+ function assertStrictYaml(doc, file, patchKey) {
776
+ if (doc.errors.length > 0) {
777
+ fail(`YAML parse error: ${doc.errors[0].message}`, file, patchKey);
778
+ }
779
+ if (doc.warnings.length > 0) {
780
+ fail(`YAML warning treated as error: ${doc.warnings[0].message}`, file, patchKey);
781
+ }
782
+ const visit = (node) => {
783
+ var _a, _b;
784
+ if (node == null || typeof node !== "object") return;
785
+ if ("source" in node && ((_a = node.constructor) == null ? void 0 : _a.name) === "Alias") {
786
+ fail("YAML aliases are not permitted in patch files", file, patchKey);
787
+ }
788
+ if (node.anchor) {
789
+ fail("YAML anchors are not permitted in patch files", file, patchKey);
790
+ }
791
+ if (node.tag && ![
792
+ "tag:yaml.org,2002:str",
793
+ "tag:yaml.org,2002:int",
794
+ "tag:yaml.org,2002:bool",
795
+ "tag:yaml.org,2002:null",
796
+ "tag:yaml.org,2002:map",
797
+ "tag:yaml.org,2002:seq"
798
+ ].includes(node.tag)) {
799
+ fail(`YAML tag '${node.tag}' is not permitted in patch files`, file, patchKey);
800
+ }
801
+ if (Array.isArray(node.items)) {
802
+ for (const item of node.items) {
803
+ if (item && typeof item === "object" && "key" in item) {
804
+ const keyValue = (_b = item.key) == null ? void 0 : _b.value;
805
+ if (keyValue === "<<") fail("YAML merge keys are not permitted in patch files", file, patchKey);
806
+ visit(item.key);
807
+ visit(item.value);
808
+ } else {
809
+ visit(item);
810
+ }
811
+ }
812
+ }
813
+ };
814
+ visit(doc.contents);
815
+ }
816
+ function parsePatchContent(content, fileName, patchKey) {
817
+ var _a;
818
+ const doc = (0, import_yaml.parseDocument)(content, {
819
+ uniqueKeys: true,
820
+ // duplicate mapping keys become errors
821
+ merge: false,
822
+ schema: "core",
823
+ version: "1.2"
824
+ });
825
+ assertStrictYaml(doc, fileName, patchKey);
826
+ const raw = doc.toJS({ mapAsMap: false });
827
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
828
+ fail("Patch document must be a YAML mapping", fileName, patchKey);
829
+ }
830
+ if (!validateSchema(raw)) {
831
+ const detail = ((_a = validateSchema.errors) != null ? _a : []).map((e) => `${e.instancePath || "/"} ${e.message}`).join("; ");
832
+ const anyRaw = raw;
833
+ if (typeof anyRaw.version === "number" && anyRaw.version !== PATCH_FORMAT_VERSION) {
834
+ fail(`Unknown patch format version: ${anyRaw.version} (supported: ${PATCH_FORMAT_VERSION})`, fileName, patchKey);
835
+ }
836
+ fail(`Schema validation failed: ${detail}`, fileName, patchKey);
837
+ }
838
+ const parsed = raw;
839
+ if (parsed.version !== PATCH_FORMAT_VERSION) {
840
+ fail(`Unknown patch format version: ${parsed.version} (supported: ${PATCH_FORMAT_VERSION})`, fileName, patchKey);
841
+ }
842
+ const description = parsed.description.trim();
843
+ if (description.length === 0) fail("description must be non-empty", fileName, patchKey);
844
+ if (description.length > MAX_DESCRIPTION_LENGTH) {
845
+ fail(`description exceeds ${MAX_DESCRIPTION_LENGTH} characters`, fileName, patchKey);
846
+ }
847
+ const operations = parsed.operations.map((op, index) => {
848
+ const verbs = Object.keys(op);
849
+ const verb = verbs[0];
850
+ const context = `operation ${index} (${verb})`;
851
+ switch (verb) {
852
+ case "rename_migration": {
853
+ const from = checkMigrationKey(op.rename_migration.from, context, fileName, patchKey);
854
+ const to = checkMigrationKey(op.rename_migration.to, context, fileName, patchKey);
855
+ if (from === to) {
856
+ fail(`${context}: 'from' and 'to' are identical after canonicalization ('${from}')`, fileName, patchKey);
857
+ }
858
+ return { verb: "rename_migration", from, to };
859
+ }
860
+ case "mark_applied":
861
+ return { verb: "mark_applied", key: checkMigrationKey(op.mark_applied.key, context, fileName, patchKey) };
862
+ case "unmark_applied":
863
+ return { verb: "unmark_applied", key: checkMigrationKey(op.unmark_applied.key, context, fileName, patchKey) };
864
+ default:
865
+ fail(`operation ${index}: unknown verb '${verb}'`, fileName, patchKey);
866
+ }
867
+ });
868
+ return { version: parsed.version, description, operations };
869
+ }
870
+ function validatePatchPlan(patches, manifest) {
871
+ const renames = [];
872
+ for (const patch of patches) {
873
+ patch.operations.forEach((op, index) => {
874
+ if (op.verb === "rename_migration") {
875
+ renames.push({ from: op.from, to: op.to, file: patch.fileName });
876
+ } else if (op.verb === "mark_applied") {
877
+ if (!manifest.has(op.key)) {
878
+ throw new PatchValidationError(
879
+ `operation ${index} (mark_applied): key '${op.key}' is not present in the current migration manifest`,
880
+ patch.fileName,
881
+ patch.patchKey
882
+ );
883
+ }
884
+ }
885
+ });
886
+ }
887
+ if (renames.length === 0) return;
888
+ const mentioned = /* @__PURE__ */ new Set();
889
+ renames.forEach((r) => {
890
+ mentioned.add(r.from);
891
+ mentioned.add(r.to);
892
+ });
893
+ const finalKey = (start) => {
894
+ let current = start;
895
+ for (const r of renames) {
896
+ if (current === r.from) current = r.to;
897
+ }
898
+ return current;
899
+ };
900
+ for (const key of mentioned) {
901
+ const finish = finalKey(key);
902
+ if (manifest.has(key)) {
903
+ if (finish !== key) {
904
+ throw new PatchValidationError(
905
+ `rename plan moves current migration '${key}' to '${finish}', which would make its file incorrectly pending`
906
+ );
907
+ }
908
+ } else {
909
+ if (!manifest.has(finish)) {
910
+ throw new PatchValidationError(
911
+ `rename plan leaves historical key '${key}' at '${finish}', which is not present in the current migration manifest`
912
+ );
913
+ }
578
914
  }
579
915
  }
580
- return result;
581
916
  }
582
917
 
918
+ // framework/PatchDirectoryReader.ts
919
+ var PatchDirectoryReader = class {
920
+ constructor(directory) {
921
+ this.directory = directory;
922
+ }
923
+ loadPatches() {
924
+ if (!import_fs4.default.existsSync(this.directory)) return [];
925
+ const entries = import_fs4.default.readdirSync(this.directory, { withFileTypes: true });
926
+ const patchFiles = [];
927
+ for (const entry of entries) {
928
+ if (!entry.name.endsWith(".yaml")) continue;
929
+ if (!entry.isFile() || entry.isSymbolicLink()) {
930
+ if (entry.isSymbolicLink()) {
931
+ throw new PatchValidationError(`patch file must be a regular file, not a symlink`, entry.name);
932
+ }
933
+ continue;
934
+ }
935
+ patchFiles.push(entry.name);
936
+ }
937
+ patchFiles.sort((a, b) => {
938
+ const stampA = parseInt(a.slice(0, 13), 10);
939
+ const stampB = parseInt(b.slice(0, 13), 10);
940
+ if (!Number.isNaN(stampA) && !Number.isNaN(stampB) && stampA !== stampB) {
941
+ return stampA - stampB;
942
+ }
943
+ return a < b ? -1 : a > b ? 1 : 0;
944
+ });
945
+ return patchFiles.map((fileName) => {
946
+ const match = PATCH_FILENAME_REGEX.exec(fileName);
947
+ if (!match) {
948
+ throw new PatchValidationError(
949
+ `invalid patch filename (expected <13-digit-stamp>_<name>.yaml with name matching [a-z0-9][a-z0-9_-]{0,119})`,
950
+ fileName
951
+ );
952
+ }
953
+ const patchKey = fileName.slice(0, -".yaml".length);
954
+ const filePath = import_path3.default.join(this.directory, fileName);
955
+ const bytes = import_fs4.default.readFileSync(filePath);
956
+ const checksum = import_crypto.default.createHash("sha256").update(bytes).digest("hex");
957
+ const content = bytes.toString("utf8");
958
+ const { version, description, operations } = parsePatchContent(content, fileName, patchKey);
959
+ return { patchKey, fileName, filePath, checksum, version, description, operations };
960
+ });
961
+ }
962
+ };
963
+
964
+ // framework/PatchRunner.ts
965
+ function toError(error) {
966
+ if (error instanceof Error) return error;
967
+ return new Error(String(error));
968
+ }
969
+ function extractRows(result) {
970
+ if (!Array.isArray(result)) return [];
971
+ if (Array.isArray(result[0])) return result[0];
972
+ if (result.length === 2 && result[0] && typeof result[0] === "object" && result[1] && typeof result[1] === "object" && !("rows" in result[1])) {
973
+ return result;
974
+ }
975
+ if (result[0] == null) return [];
976
+ return [result[0]];
977
+ }
978
+ var PatchRunner = class {
979
+ constructor(sqlrunner, config) {
980
+ this.sqlrunner = sqlrunner;
981
+ this.config = config;
982
+ this.patchTable = resolvePatchTable(config);
983
+ this.migrationTable = config.migration_table;
984
+ this.dialect = config.database;
985
+ }
986
+ /**
987
+ * Discovers, validates, and applies every unapplied patch in order.
988
+ * Each unapplied patch is its own transaction; earlier committed patches
989
+ * remain committed if a later patch fails.
990
+ */
991
+ applyPending() {
992
+ return __async(this, null, function* () {
993
+ const reader = new PatchDirectoryReader(resolvePatchFolder(this.config));
994
+ const patches = reader.loadPatches();
995
+ const history = yield this.loadHistory();
996
+ if (patches.length === 0 && history.length === 0) {
997
+ return [];
998
+ }
999
+ const byKey = new Map(patches.map((p) => [p.patchKey, p]));
1000
+ for (const row of history) {
1001
+ const file = byKey.get(row.patch_key);
1002
+ if (!file) {
1003
+ throw new PatchIntegrityError(
1004
+ `applied patch '${row.patch_key}' has no corresponding file in the patch folder; patch files are permanent and must never be renamed or deleted`,
1005
+ void 0,
1006
+ row.patch_key
1007
+ );
1008
+ }
1009
+ if (file.checksum !== row.checksum) {
1010
+ throw new PatchIntegrityError(
1011
+ `applied patch '${row.patch_key}' content changed after application; patch files are immutable once recorded`,
1012
+ file.fileName,
1013
+ row.patch_key,
1014
+ row.checksum,
1015
+ file.checksum
1016
+ );
1017
+ }
1018
+ }
1019
+ const manifest = loadMigrationManifest(this.config.migration_folder, this.dialect);
1020
+ validatePatchPlan(patches, manifest);
1021
+ const appliedKeys = new Set(history.map((r) => r.patch_key));
1022
+ const results = [];
1023
+ for (const patch of patches) {
1024
+ if (appliedKeys.has(patch.patchKey)) {
1025
+ results.push({
1026
+ patchKey: patch.patchKey,
1027
+ fileName: patch.fileName,
1028
+ status: "already_applied",
1029
+ operations: []
1030
+ });
1031
+ continue;
1032
+ }
1033
+ results.push(yield this.applyOne(patch, manifest));
1034
+ }
1035
+ return results;
1036
+ });
1037
+ }
1038
+ loadHistory() {
1039
+ return __async(this, null, function* () {
1040
+ try {
1041
+ const result = yield this.sqlrunner.query(
1042
+ `SELECT patch_key, checksum FROM ${this.patchTable} WHERE migration_table = ?`,
1043
+ [this.migrationTable]
1044
+ );
1045
+ return extractRows(result);
1046
+ } catch (error) {
1047
+ throw new PatchExecutionError(
1048
+ `failed to read patch history from '${this.patchTable}'`,
1049
+ void 0,
1050
+ void 0,
1051
+ void 0,
1052
+ void 0,
1053
+ toError(error)
1054
+ );
1055
+ }
1056
+ });
1057
+ }
1058
+ beginSql() {
1059
+ switch (this.dialect) {
1060
+ case "sqlite":
1061
+ return "BEGIN IMMEDIATE";
1062
+ case "pg":
1063
+ return "BEGIN";
1064
+ default:
1065
+ return "START TRANSACTION";
1066
+ }
1067
+ }
1068
+ begin(patch) {
1069
+ return __async(this, null, function* () {
1070
+ const deadline = Date.now() + 1e4;
1071
+ while (true) {
1072
+ try {
1073
+ yield this.sqlrunner.execute(this.beginSql());
1074
+ return;
1075
+ } catch (error) {
1076
+ const message = toError(error).message;
1077
+ if (/SQLITE_BUSY|database is locked/i.test(message) && Date.now() < deadline) {
1078
+ yield new Promise((resolve) => setTimeout(resolve, 50));
1079
+ continue;
1080
+ }
1081
+ throw new PatchExecutionError(
1082
+ "failed to start patch transaction",
1083
+ patch.fileName,
1084
+ patch.patchKey,
1085
+ void 0,
1086
+ void 0,
1087
+ toError(error)
1088
+ );
1089
+ }
1090
+ }
1091
+ });
1092
+ }
1093
+ rollbackQuietly() {
1094
+ return __async(this, null, function* () {
1095
+ try {
1096
+ yield this.sqlrunner.execute("ROLLBACK");
1097
+ } catch (e) {
1098
+ }
1099
+ });
1100
+ }
1101
+ applyOne(patch, manifest) {
1102
+ return __async(this, null, function* () {
1103
+ yield this.begin(patch);
1104
+ try {
1105
+ yield this.sqlrunner.execute(
1106
+ `INSERT INTO ${this.patchTable} (migration_table, patch_key, checksum, format_version, description)
1107
+ VALUES (?, ?, ?, ?, ?)`,
1108
+ [this.migrationTable, patch.patchKey, patch.checksum, patch.version, patch.description]
1109
+ );
1110
+ } catch (claimError) {
1111
+ yield this.rollbackQuietly();
1112
+ const committed = yield this.findCommittedRow(patch.patchKey);
1113
+ if (committed) {
1114
+ if (committed.checksum === patch.checksum) {
1115
+ return {
1116
+ patchKey: patch.patchKey,
1117
+ fileName: patch.fileName,
1118
+ status: "already_applied",
1119
+ operations: []
1120
+ };
1121
+ }
1122
+ throw new PatchIntegrityError(
1123
+ `patch '${patch.patchKey}' was applied elsewhere with a different checksum`,
1124
+ patch.fileName,
1125
+ patch.patchKey,
1126
+ committed.checksum,
1127
+ patch.checksum
1128
+ );
1129
+ }
1130
+ throw new PatchExecutionError(
1131
+ "failed to claim patch-history row",
1132
+ patch.fileName,
1133
+ patch.patchKey,
1134
+ void 0,
1135
+ void 0,
1136
+ toError(claimError)
1137
+ );
1138
+ }
1139
+ const operationResults = [];
1140
+ try {
1141
+ for (let index = 0; index < patch.operations.length; index++) {
1142
+ operationResults.push(
1143
+ yield this.applyOperation(patch, patch.operations[index], index, manifest)
1144
+ );
1145
+ }
1146
+ yield this.sqlrunner.execute("COMMIT");
1147
+ } catch (error) {
1148
+ yield this.rollbackQuietly();
1149
+ if (error instanceof PatchConflictError || error instanceof PatchExecutionError || error instanceof PatchIntegrityError) {
1150
+ throw error;
1151
+ }
1152
+ throw new PatchExecutionError(
1153
+ "patch application failed",
1154
+ patch.fileName,
1155
+ patch.patchKey,
1156
+ void 0,
1157
+ void 0,
1158
+ toError(error)
1159
+ );
1160
+ }
1161
+ return {
1162
+ patchKey: patch.patchKey,
1163
+ fileName: patch.fileName,
1164
+ status: "applied",
1165
+ operations: operationResults
1166
+ };
1167
+ });
1168
+ }
1169
+ findCommittedRow(patchKey) {
1170
+ return __async(this, null, function* () {
1171
+ const result = yield this.sqlrunner.query(
1172
+ `SELECT patch_key, checksum FROM ${this.patchTable} WHERE migration_table = ? AND patch_key = ?`,
1173
+ [this.migrationTable, patchKey]
1174
+ );
1175
+ const list = extractRows(result);
1176
+ return list.length > 0 ? list[0] : null;
1177
+ });
1178
+ }
1179
+ countRows(key) {
1180
+ return __async(this, null, function* () {
1181
+ var _a, _b, _c;
1182
+ const result = yield this.sqlrunner.query(
1183
+ `SELECT COUNT(*) AS row_count FROM ${this.migrationTable} WHERE migration_key = ?`,
1184
+ [key]
1185
+ );
1186
+ const rows = extractRows(result);
1187
+ const value = (_c = (_a = rows[0]) == null ? void 0 : _a.row_count) != null ? _c : Object.values((_b = rows[0]) != null ? _b : {})[0];
1188
+ return Number(value != null ? value : 0);
1189
+ });
1190
+ }
1191
+ conflict(patch, index, verb, message, keys, counts) {
1192
+ throw new PatchConflictError(
1193
+ `operation ${index} (${verb}): ${message}`,
1194
+ patch.fileName,
1195
+ patch.patchKey,
1196
+ index,
1197
+ verb,
1198
+ keys,
1199
+ counts
1200
+ );
1201
+ }
1202
+ applyOperation(patch, op, index, manifest) {
1203
+ return __async(this, null, function* () {
1204
+ var _a, _b, _c, _d;
1205
+ try {
1206
+ switch (op.verb) {
1207
+ case "rename_migration": {
1208
+ const fromCount = yield this.countRows(op.from);
1209
+ const toCount = yield this.countRows(op.to);
1210
+ const counts = { [op.from]: fromCount, [op.to]: toCount };
1211
+ if (fromCount > 1 || toCount > 1) {
1212
+ this.conflict(
1213
+ patch,
1214
+ index,
1215
+ op.verb,
1216
+ `ledger corruption: duplicate rows for a migration key`,
1217
+ [op.from, op.to],
1218
+ counts
1219
+ );
1220
+ }
1221
+ if (fromCount === 1 && toCount === 1) {
1222
+ this.conflict(
1223
+ patch,
1224
+ index,
1225
+ op.verb,
1226
+ `both '${op.from}' and '${op.to}' exist in the ledger`,
1227
+ [op.from, op.to],
1228
+ counts
1229
+ );
1230
+ }
1231
+ if (fromCount === 0) {
1232
+ return { verb: op.verb, changed: false };
1233
+ }
1234
+ const target = manifest.get(op.to);
1235
+ if (target) {
1236
+ yield this.sqlrunner.execute(
1237
+ `UPDATE ${this.migrationTable} SET migration_key = ?, up = ?, down = ? WHERE migration_key = ?`,
1238
+ [op.to, (_a = target.upFile) != null ? _a : "", (_b = target.downFile) != null ? _b : "", op.from]
1239
+ );
1240
+ } else {
1241
+ yield this.sqlrunner.execute(
1242
+ `UPDATE ${this.migrationTable} SET migration_key = ? WHERE migration_key = ?`,
1243
+ [op.to, op.from]
1244
+ );
1245
+ }
1246
+ return { verb: op.verb, changed: true };
1247
+ }
1248
+ case "mark_applied": {
1249
+ const count = yield this.countRows(op.key);
1250
+ if (count > 1) {
1251
+ this.conflict(
1252
+ patch,
1253
+ index,
1254
+ op.verb,
1255
+ `ledger corruption: duplicate rows for '${op.key}'`,
1256
+ [op.key],
1257
+ { [op.key]: count }
1258
+ );
1259
+ }
1260
+ if (count === 1) {
1261
+ return { verb: op.verb, changed: false };
1262
+ }
1263
+ const entry = manifest.get(op.key);
1264
+ yield this.sqlrunner.execute(
1265
+ `INSERT INTO ${this.migrationTable} (migration_key, up, down) VALUES (?, ?, ?)`,
1266
+ [op.key, (_c = entry == null ? void 0 : entry.upFile) != null ? _c : "", (_d = entry == null ? void 0 : entry.downFile) != null ? _d : ""]
1267
+ );
1268
+ return { verb: op.verb, changed: true };
1269
+ }
1270
+ case "unmark_applied": {
1271
+ const count = yield this.countRows(op.key);
1272
+ if (count > 1) {
1273
+ this.conflict(
1274
+ patch,
1275
+ index,
1276
+ op.verb,
1277
+ `ledger corruption: duplicate rows for '${op.key}'`,
1278
+ [op.key],
1279
+ { [op.key]: count }
1280
+ );
1281
+ }
1282
+ if (count === 0) {
1283
+ return { verb: op.verb, changed: false };
1284
+ }
1285
+ yield this.sqlrunner.execute(
1286
+ `DELETE FROM ${this.migrationTable} WHERE migration_key = ?`,
1287
+ [op.key]
1288
+ );
1289
+ return { verb: op.verb, changed: true };
1290
+ }
1291
+ }
1292
+ } catch (error) {
1293
+ if (error instanceof PatchConflictError) throw error;
1294
+ throw new PatchExecutionError(
1295
+ `operation failed`,
1296
+ patch.fileName,
1297
+ patch.patchKey,
1298
+ index,
1299
+ op.verb,
1300
+ toError(error)
1301
+ );
1302
+ }
1303
+ });
1304
+ }
1305
+ };
1306
+
1307
+ // framework/PatchCreator.ts
1308
+ var import_fs5 = __toESM(require("fs"));
1309
+ var import_path4 = __toESM(require("path"));
1310
+ var MAX_NAME_LENGTH = 120;
1311
+ var SCAFFOLD = `version: 1
1312
+ description: TODO
1313
+ operations: []
1314
+ `;
1315
+ var PatchCreator = class _PatchCreator {
1316
+ constructor(patchFolder) {
1317
+ this.patchFolder = patchFolder;
1318
+ }
1319
+ /**
1320
+ * Normalizes a patch name: trim, whitespace runs -> `_`, lowercase.
1321
+ * Rejects empty results, path separators, `..`, control characters,
1322
+ * characters outside [a-z0-9_-], and names longer than 120 characters.
1323
+ */
1324
+ static normalizeName(name) {
1325
+ const normalized = (name != null ? name : "").trim().replace(/\s+/g, "_").toLowerCase();
1326
+ if (normalized.length === 0) {
1327
+ throw new CLIError("Patch name is required");
1328
+ }
1329
+ if (normalized.includes("/") || normalized.includes("\\")) {
1330
+ throw new CLIError("Patch name must not contain path separators");
1331
+ }
1332
+ if (normalized.includes("..")) {
1333
+ throw new CLIError("Patch name must not contain '..'");
1334
+ }
1335
+ if (/[\x00-\x1f\x7f]/.test(normalized)) {
1336
+ throw new CLIError("Patch name must not contain control characters");
1337
+ }
1338
+ if (!/^[a-z0-9_-]+$/.test(normalized)) {
1339
+ throw new CLIError("Patch name may only contain characters [a-z0-9_-]");
1340
+ }
1341
+ if (normalized.length > MAX_NAME_LENGTH) {
1342
+ throw new CLIError(`Patch name exceeds ${MAX_NAME_LENGTH} characters after normalization`);
1343
+ }
1344
+ return normalized;
1345
+ }
1346
+ /**
1347
+ * Creates `<patch_folder>/<stamp>_<normalized_name>.yaml` with exclusive
1348
+ * file creation. On a millisecond-stamp collision, mints a later stamp
1349
+ * and retries. Returns the created path.
1350
+ */
1351
+ create(name) {
1352
+ const normalized = _PatchCreator.normalizeName(name);
1353
+ if (!import_fs5.default.existsSync(this.patchFolder)) {
1354
+ import_fs5.default.mkdirSync(this.patchFolder, { recursive: true });
1355
+ }
1356
+ let stamp = Date.now();
1357
+ for (let attempt = 0; attempt < 1e3; attempt++) {
1358
+ const filePath = import_path4.default.join(this.patchFolder, `${stamp}_${normalized}.yaml`);
1359
+ try {
1360
+ import_fs5.default.writeFileSync(filePath, SCAFFOLD, { flag: "wx" });
1361
+ return filePath;
1362
+ } catch (error) {
1363
+ if (error && error.code === "EEXIST") {
1364
+ stamp += 1;
1365
+ continue;
1366
+ }
1367
+ throw error;
1368
+ }
1369
+ }
1370
+ throw new CLIError("Unable to create patch file: too many filename collisions");
1371
+ }
1372
+ };
1373
+
583
1374
  // framework/SQLRunner.ts
584
1375
  var BaseSQLRunner = class {
585
1376
  /**
@@ -794,12 +1585,122 @@ ${sql}
794
1585
  });
795
1586
  }
796
1587
  };
1588
+ var PgRunner = class _PgRunner extends BaseSQLRunner {
1589
+ constructor(connection) {
1590
+ super();
1591
+ this.connection = connection;
1592
+ }
1593
+ /** `?` -> `$1`, `$2`, ... outside of string literals / comments. */
1594
+ static toPositional(sql) {
1595
+ let out = "";
1596
+ let n = 0;
1597
+ let inSingle = false;
1598
+ let inDouble = false;
1599
+ let inLineComment = false;
1600
+ let inBlockComment = false;
1601
+ for (let i = 0; i < sql.length; i++) {
1602
+ const ch = sql[i];
1603
+ const next = sql[i + 1];
1604
+ if (inLineComment) {
1605
+ out += ch;
1606
+ if (ch === "\n") inLineComment = false;
1607
+ continue;
1608
+ }
1609
+ if (inBlockComment) {
1610
+ out += ch;
1611
+ if (ch === "*" && next === "/") {
1612
+ out += next;
1613
+ i++;
1614
+ inBlockComment = false;
1615
+ }
1616
+ continue;
1617
+ }
1618
+ if (inSingle) {
1619
+ out += ch;
1620
+ if (ch === "'") inSingle = false;
1621
+ continue;
1622
+ }
1623
+ if (inDouble) {
1624
+ out += ch;
1625
+ if (ch === '"') inDouble = false;
1626
+ continue;
1627
+ }
1628
+ if (ch === "-" && next === "-") {
1629
+ out += ch;
1630
+ inLineComment = true;
1631
+ continue;
1632
+ }
1633
+ if (ch === "/" && next === "*") {
1634
+ out += ch + next;
1635
+ i++;
1636
+ inBlockComment = true;
1637
+ continue;
1638
+ }
1639
+ if (ch === "'") {
1640
+ out += ch;
1641
+ inSingle = true;
1642
+ continue;
1643
+ }
1644
+ if (ch === '"') {
1645
+ out += ch;
1646
+ inDouble = true;
1647
+ continue;
1648
+ }
1649
+ if (ch === "?") {
1650
+ out += `$${++n}`;
1651
+ continue;
1652
+ }
1653
+ out += ch;
1654
+ }
1655
+ return out;
1656
+ }
1657
+ run(sql, params) {
1658
+ return __async(this, null, function* () {
1659
+ if (params.length > 0) {
1660
+ return yield this.connection.query(_PgRunner.toPositional(sql), params);
1661
+ }
1662
+ return yield this.connection.query(sql);
1663
+ });
1664
+ }
1665
+ _query(_0) {
1666
+ return __async(this, arguments, function* (sql, params = []) {
1667
+ const result = yield this.run(sql, params);
1668
+ return [result.rows, result];
1669
+ });
1670
+ }
1671
+ _execute(_0) {
1672
+ return __async(this, arguments, function* (sql, params = []) {
1673
+ var _a;
1674
+ const result = yield this.run(sql, params);
1675
+ return [{ changes: (_a = result.rowCount) != null ? _a : 0, lastID: void 0 }, result];
1676
+ });
1677
+ }
1678
+ _end() {
1679
+ return __async(this, null, function* () {
1680
+ if (this.connection && typeof this.connection.end === "function") {
1681
+ yield this.connection.end();
1682
+ }
1683
+ });
1684
+ }
1685
+ };
797
1686
 
798
1687
  // framework/MigrationRunner.ts
799
- function toError(error) {
1688
+ function toError2(error) {
800
1689
  if (error instanceof Error) return error;
801
1690
  return new Error(String(error));
802
1691
  }
1692
+ function makeRunner(database, conn) {
1693
+ switch (database) {
1694
+ case "sql":
1695
+ return new SQLRunner(conn);
1696
+ case "sqlite":
1697
+ return new SQLiteRunner(conn);
1698
+ case "pg":
1699
+ return new PgRunner(conn);
1700
+ default:
1701
+ throw ConfigurationError.unknownDatabaseType(database);
1702
+ }
1703
+ }
803
1704
  var MigrationRunnerFactory = class _MigrationRunnerFactory {
804
1705
  static isSQLRunner(conn) {
805
1706
  return !!conn && typeof conn.query === "function" && typeof conn.execute === "function" && typeof conn.end === "function";
@@ -808,14 +1709,17 @@ var MigrationRunnerFactory = class _MigrationRunnerFactory {
808
1709
  return __async(this, null, function* () {
809
1710
  const configReader = new FileMigrationConfigReader(configFile);
810
1711
  const config = configReader.loadFile();
1712
+ let factoryOwnsConnection = false;
811
1713
  if (!conn) {
812
1714
  conn = yield this.createConnection(config);
1715
+ factoryOwnsConnection = true;
813
1716
  }
814
- return new _MigrationRunnerFactory().create(config, conn);
1717
+ return new _MigrationRunnerFactory().create(config, conn, factoryOwnsConnection);
815
1718
  });
816
1719
  }
817
1720
  static createConnection(config) {
818
1721
  return __async(this, null, function* () {
1722
+ var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k;
819
1723
  let conn = null;
820
1724
  switch (config.database) {
821
1725
  case "sql":
@@ -826,23 +1730,42 @@ var MigrationRunnerFactory = class _MigrationRunnerFactory {
826
1730
  password: process.env.SQL_PASSWORD
827
1731
  }, config.sql);
828
1732
  try {
829
- conn = yield import_promise.default.createConnection(settings);
1733
+ const mysql = yield import("mysql2/promise");
1734
+ conn = yield ((_b = (_a = mysql.default) == null ? void 0 : _a.createConnection) != null ? _b : mysql.createConnection)(settings);
830
1735
  return conn;
831
1736
  } catch (error) {
832
- throw DatabaseConnectionError.connectionFailed("sql", toError(error).message);
1737
+ throw DatabaseConnectionError.connectionFailed("sql", toError2(error).message);
833
1738
  }
834
1739
  case "sqlite":
835
1740
  if (!config.sqlite) {
836
1741
  throw ConfigurationError.missingDatabaseConfiguration("sqlite");
837
1742
  }
838
1743
  try {
839
- conn = yield sqlite.open({
1744
+ const sqlite = yield import("sqlite");
1745
+ const sqlite3 = yield import("sqlite3");
1746
+ conn = yield ((_d = (_c = sqlite.default) == null ? void 0 : _c.open) != null ? _d : sqlite.open)({
840
1747
  filename: config.sqlite.database,
841
- driver: sqlite3.Database
1748
+ driver: (_f = (_e = sqlite3.default) == null ? void 0 : _e.Database) != null ? _f : sqlite3.Database
842
1749
  });
843
1750
  return conn;
844
1751
  } catch (error) {
845
- throw DatabaseConnectionError.connectionFailed("sqlite", toError(error).message);
1752
+ throw DatabaseConnectionError.connectionFailed("sqlite", toError2(error).message);
1753
+ }
1754
+ case "pg":
1755
+ if (!config.pg && !process.env.DATABASE_URL) {
1756
+ throw ConfigurationError.missingDatabaseConfiguration("pg");
1757
+ }
1758
+ try {
1759
+ const pgcfg = (_g = config.pg) != null ? _g : {};
1760
+ const connectionString = (_h = pgcfg.connectionString) != null ? _h : process.env.DATABASE_URL;
1761
+ const settings2 = connectionString ? { connectionString, ssl: pgcfg.ssl } : __spreadProps(__spreadValues({}, pgcfg), { password: (_i = pgcfg.password) != null ? _i : process.env.PG_PASSWORD });
1762
+ const pg = yield import("pg");
1763
+ const Client = (_k = (_j = pg.default) == null ? void 0 : _j.Client) != null ? _k : pg.Client;
1764
+ conn = new Client(settings2);
1765
+ yield conn.connect();
1766
+ return conn;
1767
+ } catch (error) {
1768
+ throw DatabaseConnectionError.connectionFailed("pg", toError2(error).message);
846
1769
  }
847
1770
  default:
848
1771
  throw ConfigurationError.unknownDatabaseType(config.database);
@@ -856,7 +1779,7 @@ var MigrationRunnerFactory = class _MigrationRunnerFactory {
856
1779
  return new _MigrationRunnerFactory().createEmpty(config);
857
1780
  });
858
1781
  }
859
- create(config, conn) {
1782
+ create(config, conn, factoryOwnsConnection = false) {
860
1783
  return __async(this, null, function* () {
861
1784
  let sqlrunner;
862
1785
  let driverConnection = conn;
@@ -864,42 +1787,34 @@ var MigrationRunnerFactory = class _MigrationRunnerFactory {
864
1787
  sqlrunner = conn;
865
1788
  driverConnection = null;
866
1789
  } else {
867
- switch (config.database) {
868
- case "sql":
869
- sqlrunner = new SQLRunner(conn);
870
- break;
871
- case "sqlite":
872
- sqlrunner = new SQLiteRunner(conn);
873
- break;
874
- default:
875
- throw ConfigurationError.unknownDatabaseType(config.database);
876
- }
1790
+ sqlrunner = makeRunner(config.database, conn);
877
1791
  }
878
1792
  const setup = new MigrationSetup(sqlrunner, config);
879
1793
  const read_strategy = this.getReadStategy(config);
880
1794
  const migration_files = new MigrationDirectoryReader(config.migration_folder, read_strategy, sqlrunner, config.database);
881
- yield setup.setup();
882
- return new MySQLMigrationRunner(config, migration_files, setup, sqlrunner, driverConnection);
1795
+ const runner = new MySQLMigrationRunner(config, migration_files, setup, sqlrunner, driverConnection);
1796
+ try {
1797
+ yield runner.setup();
1798
+ } catch (error) {
1799
+ if (factoryOwnsConnection) {
1800
+ try {
1801
+ yield sqlrunner.end();
1802
+ } catch (e) {
1803
+ }
1804
+ }
1805
+ throw error;
1806
+ }
1807
+ return runner;
883
1808
  });
884
1809
  }
885
1810
  createEmpty(config) {
886
1811
  return __async(this, null, function* () {
887
1812
  const conn = null;
888
- let sqlrunner;
889
- switch (config.database) {
890
- case "sql":
891
- sqlrunner = new SQLRunner(conn);
892
- break;
893
- case "sqlite":
894
- sqlrunner = new SQLiteRunner(conn);
895
- break;
896
- default:
897
- throw ConfigurationError.unknownDatabaseType(config.database);
898
- }
1813
+ const sqlrunner = makeRunner(config.database, conn);
899
1814
  const setup = new MigrationSetup(sqlrunner, config);
900
1815
  const read_strategy = this.getReadStategy(config);
901
1816
  const migration_files = new MigrationDirectoryReader(config.migration_folder, read_strategy, sqlrunner, config.database);
902
- return new MySQLMigrationRunner(config, migration_files, setup, sqlrunner, conn);
1817
+ return new MySQLMigrationRunner(config, migration_files, setup, sqlrunner, conn, false);
903
1818
  });
904
1819
  }
905
1820
  getReadStategy(config) {
@@ -908,39 +1823,85 @@ var MigrationRunnerFactory = class _MigrationRunnerFactory {
908
1823
  return MySqlDialectParser;
909
1824
  case "sqlite":
910
1825
  return SqliteDialectParser;
1826
+ case "pg":
1827
+ return PgDialectParser;
911
1828
  default:
912
1829
  throw ConfigurationError.unknownDatabaseType(config.database);
913
1830
  }
914
1831
  }
915
1832
  };
916
1833
  var MySQLMigrationRunner = class {
917
- constructor(config, directory, setupRunner, sqlrunner, connection) {
1834
+ constructor(config, directory, setupRunner, sqlrunner, connection, preflightEnabled = true) {
918
1835
  this.config = config;
919
1836
  this.directory = directory;
920
1837
  this.setupRunner = setupRunner;
921
1838
  this.sqlrunner = sqlrunner;
922
1839
  this.connection = connection;
1840
+ this.preflightEnabled = preflightEnabled;
1841
+ /**
1842
+ * Memoized in-flight preflight promise. Simultaneous or repeated calls
1843
+ * to setup() on one runner execute the preflight (migration table setup
1844
+ * + patch application) exactly once. Cleared after rejection so a caller
1845
+ * may retry after fixing the cause.
1846
+ */
1847
+ this.preflightPromise = null;
1848
+ this.lastPatchResults = [];
923
1849
  }
924
1850
  setup() {
1851
+ return __async(this, null, function* () {
1852
+ if (!this.preflightEnabled) {
1853
+ return;
1854
+ }
1855
+ if (!this.preflightPromise) {
1856
+ this.preflightPromise = this.runPreflight();
1857
+ this.preflightPromise.catch(() => {
1858
+ this.preflightPromise = null;
1859
+ });
1860
+ }
1861
+ return this.preflightPromise;
1862
+ });
1863
+ }
1864
+ runPreflight() {
925
1865
  return __async(this, null, function* () {
926
1866
  try {
927
1867
  yield this.setupRunner.setup();
928
1868
  } catch (error) {
929
- throw new MigrationExecutionError("Failed to set up migration database", void 0, void 0, toError(error));
1869
+ throw new MigrationExecutionError("Failed to set up migration database", void 0, void 0, toError2(error));
930
1870
  }
1871
+ const patchRunner = new PatchRunner(this.sqlrunner, this.config);
1872
+ this.lastPatchResults = yield patchRunner.applyPending();
1873
+ });
1874
+ }
1875
+ /**
1876
+ * Delegates to the same idempotent preflight; returns the results of the
1877
+ * patch pass that ran (or is running) for this runner.
1878
+ */
1879
+ applyPendingPatches() {
1880
+ return __async(this, null, function* () {
1881
+ yield this.setup();
1882
+ return this.lastPatchResults;
931
1883
  });
932
1884
  }
1885
+ /**
1886
+ * Scaffolds a new ledger patch file and returns the created path.
1887
+ * Never connects to a database.
1888
+ */
1889
+ createPatch(name) {
1890
+ const creator = new PatchCreator(resolvePatchFolder(this.config));
1891
+ return creator.create(name);
1892
+ }
933
1893
  terminate() {
934
1894
  return __async(this, null, function* () {
935
1895
  try {
936
1896
  yield this.setupRunner.teardown();
937
1897
  } catch (error) {
938
- throw new MigrationExecutionError("Failed to tear down migration database", void 0, void 0, toError(error));
1898
+ throw new MigrationExecutionError("Failed to tear down migration database", void 0, void 0, toError2(error));
939
1899
  }
940
1900
  });
941
1901
  }
942
1902
  getMigrationsHistory() {
943
1903
  return __async(this, null, function* () {
1904
+ yield this.setup();
944
1905
  try {
945
1906
  const results = yield this.sqlrunner.query(`
946
1907
  select *
@@ -948,16 +1909,17 @@ var MySQLMigrationRunner = class {
948
1909
  `);
949
1910
  return results[0];
950
1911
  } catch (error) {
951
- throw new MigrationExecutionError("Failed to get migration history", void 0, void 0, toError(error));
1912
+ throw new MigrationExecutionError("Failed to get migration history", void 0, void 0, toError2(error));
952
1913
  }
953
1914
  });
954
1915
  }
955
1916
  getMigrations() {
956
1917
  return __async(this, null, function* () {
1918
+ yield this.setup();
957
1919
  try {
958
1920
  return this.directory.loadMigrations(this.config.migration_table, this.connection);
959
1921
  } catch (error) {
960
- throw new MigrationExecutionError("Failed to load migrations", void 0, void 0, toError(error));
1922
+ throw new MigrationExecutionError("Failed to load migrations", void 0, void 0, toError2(error));
961
1923
  }
962
1924
  });
963
1925
  }
@@ -970,7 +1932,7 @@ var MySQLMigrationRunner = class {
970
1932
  if (error instanceof MigrationExecutionError) {
971
1933
  throw error;
972
1934
  }
973
- throw new MigrationExecutionError("Failed to get pending migrations", void 0, void 0, toError(error));
1935
+ throw new MigrationExecutionError("Failed to get pending migrations", void 0, void 0, toError2(error));
974
1936
  }
975
1937
  });
976
1938
  }
@@ -983,12 +1945,13 @@ var MySQLMigrationRunner = class {
983
1945
  if (error instanceof MigrationExecutionError) {
984
1946
  throw error;
985
1947
  }
986
- throw new MigrationExecutionError("Failed to get completed migrations", void 0, void 0, toError(error));
1948
+ throw new MigrationExecutionError("Failed to get completed migrations", void 0, void 0, toError2(error));
987
1949
  }
988
1950
  });
989
1951
  }
990
1952
  migrate(migrationNodes, forward) {
991
1953
  return __async(this, null, function* () {
1954
+ yield this.setup();
992
1955
  for (let node of migrationNodes) {
993
1956
  try {
994
1957
  if (forward) {
@@ -1001,7 +1964,7 @@ var MySQLMigrationRunner = class {
1001
1964
  `Failed to ${forward ? "apply" : "rollback"} migration`,
1002
1965
  node.name || String(node),
1003
1966
  forward ? node.up_sql() : node.down_sql(),
1004
- toError(error)
1967
+ toError2(error)
1005
1968
  );
1006
1969
  }
1007
1970
  }
@@ -1009,6 +1972,7 @@ var MySQLMigrationRunner = class {
1009
1972
  }
1010
1973
  reset() {
1011
1974
  return __async(this, null, function* () {
1975
+ yield this.setup();
1012
1976
  try {
1013
1977
  let migrations = yield this.getMigrations();
1014
1978
  const rollback = yield migration_filter(migrations, true);
@@ -1020,7 +1984,7 @@ var MySQLMigrationRunner = class {
1020
1984
  if (error instanceof MigrationExecutionError) {
1021
1985
  throw error;
1022
1986
  }
1023
- throw new MigrationExecutionError("Failed to reset migrations", void 0, void 0, toError(error));
1987
+ throw new MigrationExecutionError("Failed to reset migrations", void 0, void 0, toError2(error));
1024
1988
  }
1025
1989
  });
1026
1990
  }
@@ -1029,7 +1993,7 @@ var MySQLMigrationRunner = class {
1029
1993
  const creator = new MigrationCreator(this.config);
1030
1994
  creator.create(name);
1031
1995
  } catch (error) {
1032
- throw new MigrationExecutionError(`Failed to create migration: ${name}`, void 0, void 0, toError(error));
1996
+ throw new MigrationExecutionError(`Failed to create migration: ${name}`, void 0, void 0, toError2(error));
1033
1997
  }
1034
1998
  }
1035
1999
  close() {
@@ -1038,7 +2002,7 @@ var MySQLMigrationRunner = class {
1038
2002
  try {
1039
2003
  yield this.sqlrunner.end();
1040
2004
  } catch (error) {
1041
- throw new DatabaseConnectionError(`Failed to close database connection: ${toError(error).message}`);
2005
+ throw new DatabaseConnectionError(`Failed to close database connection: ${toError2(error).message}`);
1042
2006
  }
1043
2007
  }
1044
2008
  });
@@ -1052,7 +2016,7 @@ var MySQLMigrationRunner = class {
1052
2016
  return __async(this, null, function* () {
1053
2017
  try {
1054
2018
  console.log(`Checking for ${config_file2}`);
1055
- const config_exist = import_fs3.default.existsSync(config_file2);
2019
+ const config_exist = import_fs6.default.existsSync(config_file2);
1056
2020
  if (!config_exist) {
1057
2021
  console.log(`Creating ${config_file2}`);
1058
2022
  const default_config = {
@@ -1066,11 +2030,11 @@ var MySQLMigrationRunner = class {
1066
2030
  "password": ""
1067
2031
  }
1068
2032
  };
1069
- import_fs3.default.writeFileSync(config_file2, JSON.stringify(default_config, null, 2));
2033
+ import_fs6.default.writeFileSync(config_file2, JSON.stringify(default_config, null, 2));
1070
2034
  console.log(`Created ${config_file2}`);
1071
2035
  }
1072
2036
  } catch (error) {
1073
- throw new ConfigurationError(`Failed to initialize config file: ${toError(error).message}`);
2037
+ throw new ConfigurationError(`Failed to initialize config file: ${toError2(error).message}`);
1074
2038
  }
1075
2039
  });
1076
2040
  }
@@ -1081,7 +2045,7 @@ var FileMigrationConfigReader = class {
1081
2045
  }
1082
2046
  loadFile() {
1083
2047
  try {
1084
- const fileContent = import_fs3.default.readFileSync(this.configFile);
2048
+ const fileContent = import_fs6.default.readFileSync(this.configFile);
1085
2049
  const config = JSON.parse(fileContent.toString());
1086
2050
  if (!config.migration_folder) {
1087
2051
  throw ConfigurationError.missingRequiredProperty("migration_folder");
@@ -1094,6 +2058,8 @@ var FileMigrationConfigReader = class {
1094
2058
  config.database = "sql";
1095
2059
  } else if (config.sqlite) {
1096
2060
  config.database = "sqlite";
2061
+ } else if (config.pg) {
2062
+ config.database = "pg";
1097
2063
  } else {
1098
2064
  throw ConfigurationError.missingRequiredProperty("database");
1099
2065
  }
@@ -1103,7 +2069,7 @@ var FileMigrationConfigReader = class {
1103
2069
  if (error instanceof ConfigurationError) {
1104
2070
  throw error;
1105
2071
  }
1106
- const err = toError(error);
2072
+ const err = toError2(error);
1107
2073
  if (err.message.includes("ENOENT")) {
1108
2074
  throw new ConfigurationError(`Config file not found: ${this.configFile}`);
1109
2075
  }
@@ -1120,16 +2086,16 @@ var MigrationCreator = class {
1120
2086
  throw new CLIError("Migration name is required");
1121
2087
  }
1122
2088
  try {
1123
- if (!import_fs3.default.existsSync(this.config.migration_folder)) {
1124
- import_fs3.default.mkdirSync(this.config.migration_folder, { recursive: true });
2089
+ if (!import_fs6.default.existsSync(this.config.migration_folder)) {
2090
+ import_fs6.default.mkdirSync(this.config.migration_folder, { recursive: true });
1125
2091
  }
1126
2092
  const now_timestamp = Date.now();
1127
2093
  const filename_up = `${now_timestamp}_${name}.up.sql`;
1128
2094
  const filename_down = `${now_timestamp}_${name}.down.sql`;
1129
- import_fs3.default.writeFileSync(`${this.config.migration_folder}/${filename_up}`, `
2095
+ import_fs6.default.writeFileSync(`${this.config.migration_folder}/${filename_up}`, `
1130
2096
  -- Write your up migration here
1131
2097
  `.trim());
1132
- import_fs3.default.writeFileSync(`${this.config.migration_folder}/${filename_down}`, `
2098
+ import_fs6.default.writeFileSync(`${this.config.migration_folder}/${filename_down}`, `
1133
2099
  -- Write your down migration here
1134
2100
  `.trim());
1135
2101
  console.log(`Created migration files:`);
@@ -1139,16 +2105,16 @@ var MigrationCreator = class {
1139
2105
  if (error instanceof CLIError) {
1140
2106
  throw error;
1141
2107
  }
1142
- throw new MigrationExecutionError(`Failed to create migration files: ${toError(error).message}`);
2108
+ throw new MigrationExecutionError(`Failed to create migration files: ${toError2(error).message}`);
1143
2109
  }
1144
2110
  }
1145
2111
  };
1146
2112
 
1147
2113
  // framework/SeedRunner.ts
1148
- var import_fs4 = __toESM(require("fs"));
1149
- var import_path2 = __toESM(require("path"));
2114
+ var import_fs7 = __toESM(require("fs"));
2115
+ var import_path5 = __toESM(require("path"));
1150
2116
  var import_url = require("url");
1151
- var import_ajv = __toESM(require("ajv"));
2117
+ var import_ajv2 = __toESM(require("ajv"));
1152
2118
  var import_ajv_formats = __toESM(require("ajv-formats"));
1153
2119
  var import_api = require("tsx/esm/api");
1154
2120
  function resolveAlias(name, aliasMap) {
@@ -1157,10 +2123,10 @@ function resolveAlias(name, aliasMap) {
1157
2123
  return (_a = aliasMap[name]) != null ? _a : name;
1158
2124
  }
1159
2125
  function walkForFile(rootDir, fileName) {
1160
- if (!import_fs4.default.existsSync(rootDir)) return null;
1161
- const entries = import_fs4.default.readdirSync(rootDir, { withFileTypes: true });
2126
+ if (!import_fs7.default.existsSync(rootDir)) return null;
2127
+ const entries = import_fs7.default.readdirSync(rootDir, { withFileTypes: true });
1162
2128
  for (const entry of entries) {
1163
- const full = import_path2.default.join(rootDir, entry.name);
2129
+ const full = import_path5.default.join(rootDir, entry.name);
1164
2130
  if (entry.isDirectory()) {
1165
2131
  const found = walkForFile(full, fileName);
1166
2132
  if (found) return found;
@@ -1254,39 +2220,39 @@ function resolveSeed(name, migrationConfig, options) {
1254
2220
  }
1255
2221
  function loadJson(filePath) {
1256
2222
  return __async(this, null, function* () {
1257
- const content = yield import_fs4.default.promises.readFile(filePath, "utf8");
2223
+ const content = yield import_fs7.default.promises.readFile(filePath, "utf8");
1258
2224
  return JSON.parse(content);
1259
2225
  });
1260
2226
  }
1261
2227
  function createValidator() {
1262
- const ajv = new import_ajv.default({ allErrors: true, strict: false });
1263
- (0, import_ajv_formats.default)(ajv);
1264
- return ajv;
2228
+ const ajv2 = new import_ajv2.default({ allErrors: true, strict: false });
2229
+ (0, import_ajv_formats.default)(ajv2);
2230
+ return ajv2;
1265
2231
  }
1266
2232
  function validateData(schemaPath, data, validate, log) {
1267
2233
  return __async(this, null, function* () {
1268
2234
  if (!validate || !schemaPath) return;
1269
- const content = yield import_fs4.default.promises.readFile(schemaPath, "utf8");
2235
+ const content = yield import_fs7.default.promises.readFile(schemaPath, "utf8");
1270
2236
  const schema = JSON.parse(content);
1271
- const ajv = createValidator();
1272
- const validateFn = ajv.compile(schema);
2237
+ const ajv2 = createValidator();
2238
+ const validateFn = ajv2.compile(schema);
1273
2239
  const ok = validateFn(data);
1274
2240
  if (!ok) {
1275
2241
  log == null ? void 0 : log(`Validation failed for seed data (${schemaPath})`);
1276
- throw new Error(`Seed data validation failed: ${ajv.errorsText(validateFn.errors || [])}`);
2242
+ throw new Error(`Seed data validation failed: ${ajv2.errorsText(validateFn.errors || [])}`);
1277
2243
  }
1278
2244
  });
1279
2245
  }
1280
2246
  function runSqlSeed(runner, resolved, direction) {
1281
2247
  return __async(this, null, function* () {
1282
2248
  const sqlPath = direction === "up" ? resolved.upPath : resolved.downPath;
1283
- const sql = yield import_fs4.default.promises.readFile(sqlPath, "utf8");
2249
+ const sql = yield import_fs7.default.promises.readFile(sqlPath, "utf8");
1284
2250
  yield runner.query(sql);
1285
2251
  });
1286
2252
  }
1287
2253
  function loadSeedModule(modulePath) {
1288
2254
  return __async(this, null, function* () {
1289
- const resolved = import_path2.default.resolve(modulePath);
2255
+ const resolved = import_path5.default.resolve(modulePath);
1290
2256
  if (resolved.endsWith(".ts")) {
1291
2257
  const fileUrl = (0, import_url.pathToFileURL)(resolved).href;
1292
2258
  return (0, import_api.tsImport)(fileUrl, fileUrl);
@@ -1376,6 +2342,10 @@ function runSeedsWithRunner(runner, migrationConfig, direction, options) {
1376
2342
 
1377
2343
  // cli.ts
1378
2344
  var args = MigrationCLIFactory.setup(process.argv);
2345
+ if (args.flags.help) {
2346
+ printUsage();
2347
+ process.exit(0);
2348
+ }
1379
2349
  if (!args.commands || args.commands.length === 0) {
1380
2350
  console.error("Error: No command specified");
1381
2351
  printUsage();
@@ -1383,7 +2353,7 @@ if (!args.commands || args.commands.length === 0) {
1383
2353
  }
1384
2354
  var commands = args.commands;
1385
2355
  var command = commands[0];
1386
- var load_database = !["init", "create", "help"].includes(command.toLowerCase());
2356
+ var load_database = !["init", "create", "help", "patch"].includes(command.toLowerCase());
1387
2357
  var config_file = args.flags.config || "proper.json";
1388
2358
  if (command.toLowerCase() === "help") {
1389
2359
  printUsage();
@@ -1392,9 +2362,17 @@ if (command.toLowerCase() === "help") {
1392
2362
  console.log(`Loading database: ${load_database}`);
1393
2363
  var pending_runner = load_database ? MigrationRunnerFactory.create(config_file) : MigrationRunnerFactory.createEmpty(config_file);
1394
2364
  pending_runner.then((runner) => __async(null, null, function* () {
2365
+ let failed = false;
1395
2366
  try {
1396
2367
  if (load_database) {
1397
- yield runner.setup();
2368
+ const patch_results = yield runner.applyPendingPatches();
2369
+ for (const patch of patch_results) {
2370
+ if (patch.status === "applied") {
2371
+ const changed = patch.operations.filter((op) => op.changed).length;
2372
+ const noop = patch.operations.length - changed;
2373
+ console.log(`Applied patch ${patch.fileName}: ${changed} changed, ${noop} no-op operation(s)`);
2374
+ }
2375
+ }
1398
2376
  }
1399
2377
  switch (command.toLowerCase()) {
1400
2378
  case "up":
@@ -1414,7 +2392,6 @@ pending_runner.then((runner) => __async(null, null, function* () {
1414
2392
  } else {
1415
2393
  console.log("No pending migrations");
1416
2394
  }
1417
- yield runner.close();
1418
2395
  break;
1419
2396
  case "down":
1420
2397
  const migrations_rollback = yield runner.getMigrations();
@@ -1435,13 +2412,11 @@ pending_runner.then((runner) => __async(null, null, function* () {
1435
2412
  } else {
1436
2413
  console.log("No migrations to roll back");
1437
2414
  }
1438
- yield runner.close();
1439
2415
  break;
1440
2416
  case "reset":
1441
2417
  console.log("Resetting all migrations...");
1442
2418
  yield runner.reset();
1443
2419
  console.log("Reset completed successfully");
1444
- yield runner.close();
1445
2420
  break;
1446
2421
  case "create":
1447
2422
  let filename = args.flags.name || commands[1];
@@ -1450,11 +2425,22 @@ pending_runner.then((runner) => __async(null, null, function* () {
1450
2425
  }
1451
2426
  filename = filename.replace(/\s/g, "_");
1452
2427
  runner.createMigration(filename);
1453
- runner.close();
1454
2428
  break;
2429
+ case "patch": {
2430
+ const patch_name = args.flags.name || commands[1];
2431
+ if (!patch_name) {
2432
+ throw new CLIError("Patch name is required. Usage: proper patch <name>");
2433
+ }
2434
+ const created_path = runner.createPatch(patch_name);
2435
+ console.log(`Created patch file:`);
2436
+ console.log(` ${created_path}`);
2437
+ console.log("");
2438
+ console.log("Complete this file before running another database-backed");
2439
+ console.log("Proper command: 'operations: []' is intentionally not runnable.");
2440
+ break;
2441
+ }
1455
2442
  case "init":
1456
2443
  yield runner.init(config_file);
1457
- runner.close();
1458
2444
  break;
1459
2445
  case "status":
1460
2446
  const { printTable } = require("console-table-printer");
@@ -1468,7 +2454,6 @@ pending_runner.then((runner) => __async(null, null, function* () {
1468
2454
  };
1469
2455
  }));
1470
2456
  printTable(yield Promise.all(table));
1471
- runner.close();
1472
2457
  break;
1473
2458
  case "query":
1474
2459
  const sql_query = args.flags.query || commands[1];
@@ -1512,7 +2497,6 @@ Returned ${results.length} row(s)`);
1512
2497
  } catch (error) {
1513
2498
  throw new CLIError(`Query execution failed: ${error.message}`);
1514
2499
  }
1515
- yield runner.close();
1516
2500
  break;
1517
2501
  case "seed": {
1518
2502
  const subCommands = commands.slice(1);
@@ -1545,7 +2529,6 @@ Returned ${results.length} row(s)`);
1545
2529
  const reader = new FileMigrationConfigReader(config_file);
1546
2530
  const migrationConfig = reader.loadFile();
1547
2531
  yield runSeedsWithRunner(runner, migrationConfig, action, seedOptions);
1548
- yield runner.close();
1549
2532
  break;
1550
2533
  }
1551
2534
  default:
@@ -1556,9 +2539,25 @@ Returned ${results.length} row(s)`);
1556
2539
  if (error.stack && process.env.DEBUG) {
1557
2540
  console.error(error.stack);
1558
2541
  }
2542
+ failed = true;
2543
+ } finally {
2544
+ try {
2545
+ yield runner.close();
2546
+ } catch (closeError) {
2547
+ console.error(`Error closing connection: ${closeError.message}`);
2548
+ failed = true;
2549
+ }
2550
+ }
2551
+ if (failed) {
1559
2552
  process.exit(1);
1560
2553
  }
1561
- }));
2554
+ })).catch((error) => {
2555
+ console.error(`Error: ${error.message}`);
2556
+ if (error.stack && process.env.DEBUG) {
2557
+ console.error(error.stack);
2558
+ }
2559
+ process.exit(1);
2560
+ });
1562
2561
  function printUsage() {
1563
2562
  console.log(`
1564
2563
  SQL Proper - Database migration tool
@@ -1571,6 +2570,7 @@ Commands:
1571
2570
  down Roll back completed migrations
1572
2571
  reset Roll back all migrations and reapply them
1573
2572
  create Create a new migration
2573
+ patch Create a new ledger patch file (repairs migration history; no database access)
1574
2574
  init Initialize a new config file
1575
2575
  status Show migration status
1576
2576
  query Execute a SQL query and display results
@@ -1589,6 +2589,7 @@ Examples:
1589
2589
  proper down --increment 3 Roll back the last 3 applied migrations
1590
2590
  proper down --all Roll back all completed migrations
1591
2591
  proper create my_migration Create a new migration named "my_migration"
2592
+ proper patch fix_renamed_keys Scaffold a ledger patch file in the patch folder
1592
2593
  proper init Create a new config file
1593
2594
  proper status Show the status of all migrations
1594
2595
  proper query "select * from users" Execute a SQL query