@noego/proper 0.1.0 → 0.2.1

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,11 +123,10 @@ function migration_filter(_0) {
123
123
  }
124
124
 
125
125
  // framework/MigrationRunner.ts
126
- var import_fs3 = __toESM(require("fs"));
126
+ var import_fs6 = __toESM(require("fs"));
127
127
 
128
128
  // framework/MigrationDirectoryReader.ts
129
- var import_fs = __toESM(require("fs"));
130
- var import_path = __toESM(require("path"));
129
+ var import_fs2 = __toESM(require("fs"));
131
130
 
132
131
  // framework/errors.ts
133
132
  var MigrationError = class _MigrationError extends Error {
@@ -242,6 +241,54 @@ ${originalError.message}`;
242
241
  return new _MigrationExecutionError(message);
243
242
  }
244
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
+ };
245
292
  var CLIError = class _CLIError extends MigrationError {
246
293
  constructor(message) {
247
294
  super(`CLI Error: ${message}`);
@@ -404,6 +451,40 @@ var SqlMigrationBuilder = class {
404
451
  }
405
452
  };
406
453
 
454
+ // framework/MigrationManifest.ts
455
+ var import_fs = __toESM(require("fs"));
456
+ var import_path = __toESM(require("path"));
457
+ var MIGRATION_FILE_REGEX = /(?:\.(mysql|sqlite|pg))?\.(up|down)\.(sql|js)$/i;
458
+ function isMigrationFile(fileName) {
459
+ return MIGRATION_FILE_REGEX.test(fileName);
460
+ }
461
+ function canonicalMigrationKey(value) {
462
+ return value.replace(MIGRATION_FILE_REGEX, "").toLowerCase();
463
+ }
464
+ function resolveMigrationFile(directory, baseName, direction, dialect) {
465
+ const dialectExt = dialect === "sql" ? "mysql" : dialect;
466
+ const dialectFile = import_path.default.join(directory, `${baseName}.${dialectExt}.${direction}.sql`);
467
+ if (import_fs.default.existsSync(dialectFile)) return dialectFile;
468
+ const genericFile = import_path.default.join(directory, `${baseName}.${direction}.sql`);
469
+ if (import_fs.default.existsSync(genericFile)) return genericFile;
470
+ return null;
471
+ }
472
+ function loadMigrationManifest(directory, dialect) {
473
+ const manifest = /* @__PURE__ */ new Map();
474
+ if (!import_fs.default.existsSync(directory)) return manifest;
475
+ const files = import_fs.default.readdirSync(directory, { withFileTypes: true }).filter((f) => f.isFile()).map((f) => f.name).filter(isMigrationFile);
476
+ const uniqueKeys = /* @__PURE__ */ new Set();
477
+ files.forEach((file) => uniqueKeys.add(canonicalMigrationKey(file)));
478
+ uniqueKeys.forEach((key) => {
479
+ manifest.set(key, {
480
+ key,
481
+ upFile: resolveMigrationFile(directory, key, "up", dialect),
482
+ downFile: resolveMigrationFile(directory, key, "down", dialect)
483
+ });
484
+ });
485
+ return manifest;
486
+ }
487
+
407
488
  // framework/MigrationDirectoryReader.ts
408
489
  var MigrationDirectoryReader = class {
409
490
  constructor(directory, read_strategy, sqlrunner, dialect = "sql") {
@@ -418,12 +499,7 @@ var MigrationDirectoryReader = class {
418
499
  * File extensions: `.mysql.up.sql`, `.sqlite.up.sql`, `.pg.up.sql`.
419
500
  */
420
501
  resolveFile(baseName, direction) {
421
- const dialectExt = this.dialect === "sql" ? "mysql" : this.dialect;
422
- const dialectFile = import_path.default.join(this.directory, `${baseName}.${dialectExt}.${direction}.sql`);
423
- if (import_fs.default.existsSync(dialectFile)) return dialectFile;
424
- const genericFile = import_path.default.join(this.directory, `${baseName}.${direction}.sql`);
425
- if (import_fs.default.existsSync(genericFile)) return genericFile;
426
- return null;
502
+ return resolveMigrationFile(this.directory, baseName, direction, this.dialect);
427
503
  }
428
504
  /**
429
505
  * Checks if a file path is dialect-specific (contains .mysql. or .sqlite. in the name)
@@ -432,11 +508,11 @@ var MigrationDirectoryReader = class {
432
508
  return /\.(mysql|sqlite|pg)\.(up|down)\.sql$/i.test(filePath);
433
509
  }
434
510
  loadMigrations(table, connection) {
435
- import_fs.default.existsSync(this.directory) || import_fs.default.mkdirSync(this.directory);
436
- const dir_content = import_fs.default.readdirSync(this.directory, { withFileTypes: true }).filter((file) => file.isFile()).map((file) => file.name);
511
+ import_fs2.default.existsSync(this.directory) || import_fs2.default.mkdirSync(this.directory);
512
+ const dir_content = import_fs2.default.readdirSync(this.directory, { withFileTypes: true }).filter((file) => file.isFile()).map((file) => file.name).filter(isMigrationFile);
437
513
  const uniqueKeys = /* @__PURE__ */ new Set();
438
514
  dir_content.forEach((file) => {
439
- const key = file.replace(/(?:\.(mysql|sqlite|pg))?\.(up|down)\.(sql|js)/i, "").toLowerCase();
515
+ const key = canonicalMigrationKey(file);
440
516
  uniqueKeys.add(key);
441
517
  });
442
518
  const migration_sorter = {};
@@ -476,14 +552,14 @@ var MigrationDirectoryReader = class {
476
552
  return builder;
477
553
  }
478
554
  sql_up(file) {
479
- let content = import_fs.default.readFileSync(file).toString();
555
+ let content = import_fs2.default.readFileSync(file).toString();
480
556
  if (!this.isDialectSpecific(file)) {
481
557
  content = this.read_strategy(content);
482
558
  }
483
559
  return content.trim();
484
560
  }
485
561
  sql_down(file) {
486
- let content = import_fs.default.readFileSync(file).toString();
562
+ let content = import_fs2.default.readFileSync(file).toString();
487
563
  if (!this.isDialectSpecific(file)) {
488
564
  content = this.read_strategy(content);
489
565
  }
@@ -492,7 +568,23 @@ var MigrationDirectoryReader = class {
492
568
  };
493
569
 
494
570
  // framework/MigrationSetup.ts
495
- var import_fs2 = __toESM(require("fs"));
571
+ var import_fs3 = __toESM(require("fs"));
572
+
573
+ // framework/PatchTypes.ts
574
+ var import_path2 = __toESM(require("path"));
575
+ var PATCH_FORMAT_VERSION = 1;
576
+ var DEFAULT_PATCH_TABLE = "proper_patches";
577
+ var PATCH_FILENAME_REGEX = new RegExp("^(?<stamp>[0-9]{13})_(?<name>[a-z0-9][a-z0-9_-]{0,119})\\.yaml$");
578
+ function resolvePatchFolder(config) {
579
+ if (config.patch_folder) return config.patch_folder;
580
+ const dir = import_path2.default.dirname(config.migration_folder);
581
+ return dir === "." && !config.migration_folder.includes(import_path2.default.sep) && !config.migration_folder.includes("/") ? "patches" : import_path2.default.join(dir, "patches");
582
+ }
583
+ function resolvePatchTable(config) {
584
+ return config.patch_table || DEFAULT_PATCH_TABLE;
585
+ }
586
+
587
+ // framework/MigrationSetup.ts
496
588
  var MigrationSetup = class {
497
589
  constructor(sqlrunner, config) {
498
590
  this.sqlrunner = sqlrunner;
@@ -500,7 +592,7 @@ var MigrationSetup = class {
500
592
  }
501
593
  setup() {
502
594
  return __async(this, null, function* () {
503
- import_fs2.default.existsSync(this.config.migration_folder) || import_fs2.default.mkdirSync(this.config.migration_folder);
595
+ import_fs3.default.existsSync(this.config.migration_folder) || import_fs3.default.mkdirSync(this.config.migration_folder);
504
596
  const tableName = this.config.migration_table;
505
597
  const createTableSql = this.config.database === "sqlite" ? `CREATE TABLE IF NOT EXISTS ${tableName} (
506
598
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -522,11 +614,39 @@ var MigrationSetup = class {
522
614
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
523
615
  )`;
524
616
  yield this.sqlrunner.query(createTableSql);
617
+ const patchTable = resolvePatchTable(this.config);
618
+ const createPatchTableSql = this.config.database === "sqlite" ? `CREATE TABLE IF NOT EXISTS ${patchTable} (
619
+ migration_table TEXT NOT NULL,
620
+ patch_key TEXT NOT NULL,
621
+ checksum TEXT NOT NULL,
622
+ format_version INTEGER NOT NULL,
623
+ description TEXT NOT NULL,
624
+ applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
625
+ PRIMARY KEY (migration_table, patch_key)
626
+ )` : this.config.database === "pg" ? `CREATE TABLE IF NOT EXISTS ${patchTable} (
627
+ migration_table TEXT NOT NULL,
628
+ patch_key TEXT NOT NULL,
629
+ checksum TEXT NOT NULL,
630
+ format_version INTEGER NOT NULL,
631
+ description TEXT NOT NULL,
632
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
633
+ PRIMARY KEY (migration_table, patch_key)
634
+ )` : `CREATE TABLE IF NOT EXISTS ${patchTable} (
635
+ migration_table VARCHAR(255) NOT NULL,
636
+ patch_key VARCHAR(255) NOT NULL,
637
+ checksum CHAR(64) NOT NULL,
638
+ format_version INT NOT NULL,
639
+ description TEXT NOT NULL,
640
+ applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
641
+ PRIMARY KEY (migration_table, patch_key)
642
+ )`;
643
+ yield this.sqlrunner.query(createPatchTableSql);
525
644
  });
526
645
  }
527
646
  teardown() {
528
647
  return __async(this, null, function* () {
529
648
  yield this.sqlrunner.execute(`DROP TABLE ${this.config.migration_table}`);
649
+ yield this.sqlrunner.execute(`DROP TABLE IF EXISTS ${resolvePatchTable(this.config)}`);
530
650
  });
531
651
  }
532
652
  };
@@ -588,6 +708,673 @@ function markerDialectParser(names) {
588
708
  var SqliteDialectParser = markerDialectParser(["sqlite"]);
589
709
  var PgDialectParser = markerDialectParser(["pg", "postgres", "postgresql"]);
590
710
 
711
+ // framework/PatchDirectoryReader.ts
712
+ var import_crypto = __toESM(require("crypto"));
713
+ var import_fs4 = __toESM(require("fs"));
714
+ var import_path3 = __toESM(require("path"));
715
+
716
+ // framework/PatchValidator.ts
717
+ var import_ajv = __toESM(require("ajv"));
718
+ var import_yaml = require("yaml");
719
+ var MAX_DESCRIPTION_LENGTH = 500;
720
+ var MAX_MIGRATION_KEY_LENGTH = 255;
721
+ var migrationKeySchema = {
722
+ type: "string",
723
+ minLength: 1,
724
+ maxLength: MAX_MIGRATION_KEY_LENGTH
725
+ };
726
+ var patchSchema = {
727
+ type: "object",
728
+ additionalProperties: false,
729
+ required: ["version", "description", "operations"],
730
+ properties: {
731
+ version: { type: "integer" },
732
+ description: { type: "string" },
733
+ operations: {
734
+ type: "array",
735
+ minItems: 1,
736
+ items: {
737
+ type: "object",
738
+ additionalProperties: false,
739
+ minProperties: 1,
740
+ maxProperties: 1,
741
+ properties: {
742
+ rename_migration: {
743
+ type: "object",
744
+ additionalProperties: false,
745
+ required: ["from", "to"],
746
+ properties: { from: migrationKeySchema, to: migrationKeySchema }
747
+ },
748
+ mark_applied: {
749
+ type: "object",
750
+ additionalProperties: false,
751
+ required: ["key"],
752
+ properties: { key: migrationKeySchema }
753
+ },
754
+ unmark_applied: {
755
+ type: "object",
756
+ additionalProperties: false,
757
+ required: ["key"],
758
+ properties: { key: migrationKeySchema }
759
+ }
760
+ }
761
+ }
762
+ }
763
+ }
764
+ };
765
+ var ajv = new import_ajv.default({ allErrors: true, strict: true });
766
+ var validateSchema = ajv.compile(patchSchema);
767
+ function fail(message, file, patchKey) {
768
+ throw new PatchValidationError(message, file, patchKey);
769
+ }
770
+ function checkMigrationKey(value, context, file, patchKey) {
771
+ if (value !== value.trim()) fail(`${context}: migration key has leading/trailing whitespace`, file, patchKey);
772
+ if (/[/\\]/.test(value)) fail(`${context}: migration key contains a path separator`, file, patchKey);
773
+ if (/[\x00-\x1f\x7f]/.test(value)) fail(`${context}: migration key contains control characters`, file, patchKey);
774
+ if (value.length === 0 || value.length > MAX_MIGRATION_KEY_LENGTH) {
775
+ fail(`${context}: migration key length out of bounds`, file, patchKey);
776
+ }
777
+ return canonicalMigrationKey(value);
778
+ }
779
+ function assertStrictYaml(doc, file, patchKey) {
780
+ if (doc.errors.length > 0) {
781
+ fail(`YAML parse error: ${doc.errors[0].message}`, file, patchKey);
782
+ }
783
+ if (doc.warnings.length > 0) {
784
+ fail(`YAML warning treated as error: ${doc.warnings[0].message}`, file, patchKey);
785
+ }
786
+ const visit = (node) => {
787
+ var _a, _b;
788
+ if (node == null || typeof node !== "object") return;
789
+ if ("source" in node && ((_a = node.constructor) == null ? void 0 : _a.name) === "Alias") {
790
+ fail("YAML aliases are not permitted in patch files", file, patchKey);
791
+ }
792
+ if (node.anchor) {
793
+ fail("YAML anchors are not permitted in patch files", file, patchKey);
794
+ }
795
+ if (node.tag && ![
796
+ "tag:yaml.org,2002:str",
797
+ "tag:yaml.org,2002:int",
798
+ "tag:yaml.org,2002:bool",
799
+ "tag:yaml.org,2002:null",
800
+ "tag:yaml.org,2002:map",
801
+ "tag:yaml.org,2002:seq"
802
+ ].includes(node.tag)) {
803
+ fail(`YAML tag '${node.tag}' is not permitted in patch files`, file, patchKey);
804
+ }
805
+ if (Array.isArray(node.items)) {
806
+ for (const item of node.items) {
807
+ if (item && typeof item === "object" && "key" in item) {
808
+ const keyValue = (_b = item.key) == null ? void 0 : _b.value;
809
+ if (keyValue === "<<") fail("YAML merge keys are not permitted in patch files", file, patchKey);
810
+ visit(item.key);
811
+ visit(item.value);
812
+ } else {
813
+ visit(item);
814
+ }
815
+ }
816
+ }
817
+ };
818
+ visit(doc.contents);
819
+ }
820
+ function parsePatchContent(content, fileName, patchKey) {
821
+ var _a;
822
+ const doc = (0, import_yaml.parseDocument)(content, {
823
+ uniqueKeys: true,
824
+ // duplicate mapping keys become errors
825
+ merge: false,
826
+ schema: "core",
827
+ version: "1.2"
828
+ });
829
+ assertStrictYaml(doc, fileName, patchKey);
830
+ const raw = doc.toJS({ mapAsMap: false });
831
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
832
+ fail("Patch document must be a YAML mapping", fileName, patchKey);
833
+ }
834
+ if (!validateSchema(raw)) {
835
+ const detail = ((_a = validateSchema.errors) != null ? _a : []).map((e) => `${e.instancePath || "/"} ${e.message}`).join("; ");
836
+ const anyRaw = raw;
837
+ if (typeof anyRaw.version === "number" && anyRaw.version !== PATCH_FORMAT_VERSION) {
838
+ fail(`Unknown patch format version: ${anyRaw.version} (supported: ${PATCH_FORMAT_VERSION})`, fileName, patchKey);
839
+ }
840
+ fail(`Schema validation failed: ${detail}`, fileName, patchKey);
841
+ }
842
+ const parsed = raw;
843
+ if (parsed.version !== PATCH_FORMAT_VERSION) {
844
+ fail(`Unknown patch format version: ${parsed.version} (supported: ${PATCH_FORMAT_VERSION})`, fileName, patchKey);
845
+ }
846
+ const description = parsed.description.trim();
847
+ if (description.length === 0) fail("description must be non-empty", fileName, patchKey);
848
+ if (description.length > MAX_DESCRIPTION_LENGTH) {
849
+ fail(`description exceeds ${MAX_DESCRIPTION_LENGTH} characters`, fileName, patchKey);
850
+ }
851
+ const operations = parsed.operations.map((op, index) => {
852
+ const verbs = Object.keys(op);
853
+ const verb = verbs[0];
854
+ const context = `operation ${index} (${verb})`;
855
+ switch (verb) {
856
+ case "rename_migration": {
857
+ const from = checkMigrationKey(op.rename_migration.from, context, fileName, patchKey);
858
+ const to = checkMigrationKey(op.rename_migration.to, context, fileName, patchKey);
859
+ if (from === to) {
860
+ fail(`${context}: 'from' and 'to' are identical after canonicalization ('${from}')`, fileName, patchKey);
861
+ }
862
+ return { verb: "rename_migration", from, to };
863
+ }
864
+ case "mark_applied":
865
+ return { verb: "mark_applied", key: checkMigrationKey(op.mark_applied.key, context, fileName, patchKey) };
866
+ case "unmark_applied":
867
+ return { verb: "unmark_applied", key: checkMigrationKey(op.unmark_applied.key, context, fileName, patchKey) };
868
+ default:
869
+ fail(`operation ${index}: unknown verb '${verb}'`, fileName, patchKey);
870
+ }
871
+ });
872
+ return { version: parsed.version, description, operations };
873
+ }
874
+ function validatePatchPlan(patches, manifest) {
875
+ const renames = [];
876
+ for (const patch of patches) {
877
+ patch.operations.forEach((op, index) => {
878
+ if (op.verb === "rename_migration") {
879
+ renames.push({ from: op.from, to: op.to, file: patch.fileName });
880
+ } else if (op.verb === "mark_applied") {
881
+ if (!manifest.has(op.key)) {
882
+ throw new PatchValidationError(
883
+ `operation ${index} (mark_applied): key '${op.key}' is not present in the current migration manifest`,
884
+ patch.fileName,
885
+ patch.patchKey
886
+ );
887
+ }
888
+ }
889
+ });
890
+ }
891
+ if (renames.length === 0) return;
892
+ const mentioned = /* @__PURE__ */ new Set();
893
+ renames.forEach((r) => {
894
+ mentioned.add(r.from);
895
+ mentioned.add(r.to);
896
+ });
897
+ const finalKey = (start) => {
898
+ let current = start;
899
+ for (const r of renames) {
900
+ if (current === r.from) current = r.to;
901
+ }
902
+ return current;
903
+ };
904
+ for (const key of mentioned) {
905
+ const finish = finalKey(key);
906
+ if (manifest.has(key)) {
907
+ if (finish !== key) {
908
+ throw new PatchValidationError(
909
+ `rename plan moves current migration '${key}' to '${finish}', which would make its file incorrectly pending`
910
+ );
911
+ }
912
+ } else {
913
+ if (!manifest.has(finish)) {
914
+ throw new PatchValidationError(
915
+ `rename plan leaves historical key '${key}' at '${finish}', which is not present in the current migration manifest`
916
+ );
917
+ }
918
+ }
919
+ }
920
+ }
921
+
922
+ // framework/PatchDirectoryReader.ts
923
+ var PatchDirectoryReader = class {
924
+ constructor(directory) {
925
+ this.directory = directory;
926
+ }
927
+ loadPatches() {
928
+ if (!import_fs4.default.existsSync(this.directory)) return [];
929
+ const entries = import_fs4.default.readdirSync(this.directory, { withFileTypes: true });
930
+ const patchFiles = [];
931
+ for (const entry of entries) {
932
+ if (!entry.name.endsWith(".yaml")) continue;
933
+ if (!entry.isFile() || entry.isSymbolicLink()) {
934
+ if (entry.isSymbolicLink()) {
935
+ throw new PatchValidationError(`patch file must be a regular file, not a symlink`, entry.name);
936
+ }
937
+ continue;
938
+ }
939
+ patchFiles.push(entry.name);
940
+ }
941
+ patchFiles.sort((a, b) => {
942
+ const stampA = parseInt(a.slice(0, 13), 10);
943
+ const stampB = parseInt(b.slice(0, 13), 10);
944
+ if (!Number.isNaN(stampA) && !Number.isNaN(stampB) && stampA !== stampB) {
945
+ return stampA - stampB;
946
+ }
947
+ return a < b ? -1 : a > b ? 1 : 0;
948
+ });
949
+ return patchFiles.map((fileName) => {
950
+ const match = PATCH_FILENAME_REGEX.exec(fileName);
951
+ if (!match) {
952
+ throw new PatchValidationError(
953
+ `invalid patch filename (expected <13-digit-stamp>_<name>.yaml with name matching [a-z0-9][a-z0-9_-]{0,119})`,
954
+ fileName
955
+ );
956
+ }
957
+ const patchKey = fileName.slice(0, -".yaml".length);
958
+ const filePath = import_path3.default.join(this.directory, fileName);
959
+ const bytes = import_fs4.default.readFileSync(filePath);
960
+ const checksum = import_crypto.default.createHash("sha256").update(bytes).digest("hex");
961
+ const content = bytes.toString("utf8");
962
+ const { version, description, operations } = parsePatchContent(content, fileName, patchKey);
963
+ return { patchKey, fileName, filePath, checksum, version, description, operations };
964
+ });
965
+ }
966
+ };
967
+
968
+ // framework/PatchRunner.ts
969
+ function toError(error) {
970
+ if (error instanceof Error) return error;
971
+ return new Error(String(error));
972
+ }
973
+ function extractRows(result) {
974
+ if (!Array.isArray(result)) return [];
975
+ if (Array.isArray(result[0])) return result[0];
976
+ if (result.length === 2 && result[0] && typeof result[0] === "object" && result[1] && typeof result[1] === "object" && !("rows" in result[1])) {
977
+ return result;
978
+ }
979
+ if (result[0] == null) return [];
980
+ return [result[0]];
981
+ }
982
+ var PatchRunner = class {
983
+ constructor(sqlrunner, config) {
984
+ this.sqlrunner = sqlrunner;
985
+ this.config = config;
986
+ this.patchTable = resolvePatchTable(config);
987
+ this.migrationTable = config.migration_table;
988
+ this.dialect = config.database;
989
+ }
990
+ /**
991
+ * Discovers, validates, and applies every unapplied patch in order.
992
+ * Each unapplied patch is its own transaction; earlier committed patches
993
+ * remain committed if a later patch fails.
994
+ */
995
+ applyPending() {
996
+ return __async(this, null, function* () {
997
+ const reader = new PatchDirectoryReader(resolvePatchFolder(this.config));
998
+ const patches = reader.loadPatches();
999
+ const history = yield this.loadHistory();
1000
+ if (patches.length === 0 && history.length === 0) {
1001
+ return [];
1002
+ }
1003
+ const byKey = new Map(patches.map((p) => [p.patchKey, p]));
1004
+ for (const row of history) {
1005
+ const file = byKey.get(row.patch_key);
1006
+ if (!file) {
1007
+ throw new PatchIntegrityError(
1008
+ `applied patch '${row.patch_key}' has no corresponding file in the patch folder; patch files are permanent and must never be renamed or deleted`,
1009
+ void 0,
1010
+ row.patch_key
1011
+ );
1012
+ }
1013
+ if (file.checksum !== row.checksum) {
1014
+ throw new PatchIntegrityError(
1015
+ `applied patch '${row.patch_key}' content changed after application; patch files are immutable once recorded`,
1016
+ file.fileName,
1017
+ row.patch_key,
1018
+ row.checksum,
1019
+ file.checksum
1020
+ );
1021
+ }
1022
+ }
1023
+ const manifest = loadMigrationManifest(this.config.migration_folder, this.dialect);
1024
+ validatePatchPlan(patches, manifest);
1025
+ const appliedKeys = new Set(history.map((r) => r.patch_key));
1026
+ const results = [];
1027
+ for (const patch of patches) {
1028
+ if (appliedKeys.has(patch.patchKey)) {
1029
+ results.push({
1030
+ patchKey: patch.patchKey,
1031
+ fileName: patch.fileName,
1032
+ status: "already_applied",
1033
+ operations: []
1034
+ });
1035
+ continue;
1036
+ }
1037
+ results.push(yield this.applyOne(patch, manifest));
1038
+ }
1039
+ return results;
1040
+ });
1041
+ }
1042
+ loadHistory() {
1043
+ return __async(this, null, function* () {
1044
+ try {
1045
+ const result = yield this.sqlrunner.query(
1046
+ `SELECT patch_key, checksum FROM ${this.patchTable} WHERE migration_table = ?`,
1047
+ [this.migrationTable]
1048
+ );
1049
+ return extractRows(result);
1050
+ } catch (error) {
1051
+ throw new PatchExecutionError(
1052
+ `failed to read patch history from '${this.patchTable}'`,
1053
+ void 0,
1054
+ void 0,
1055
+ void 0,
1056
+ void 0,
1057
+ toError(error)
1058
+ );
1059
+ }
1060
+ });
1061
+ }
1062
+ beginSql() {
1063
+ switch (this.dialect) {
1064
+ case "sqlite":
1065
+ return "BEGIN IMMEDIATE";
1066
+ case "pg":
1067
+ return "BEGIN";
1068
+ default:
1069
+ return "START TRANSACTION";
1070
+ }
1071
+ }
1072
+ begin(patch) {
1073
+ return __async(this, null, function* () {
1074
+ const deadline = Date.now() + 1e4;
1075
+ while (true) {
1076
+ try {
1077
+ yield this.sqlrunner.execute(this.beginSql());
1078
+ return;
1079
+ } catch (error) {
1080
+ const message = toError(error).message;
1081
+ if (/SQLITE_BUSY|database is locked/i.test(message) && Date.now() < deadline) {
1082
+ yield new Promise((resolve) => setTimeout(resolve, 50));
1083
+ continue;
1084
+ }
1085
+ throw new PatchExecutionError(
1086
+ "failed to start patch transaction",
1087
+ patch.fileName,
1088
+ patch.patchKey,
1089
+ void 0,
1090
+ void 0,
1091
+ toError(error)
1092
+ );
1093
+ }
1094
+ }
1095
+ });
1096
+ }
1097
+ rollbackQuietly() {
1098
+ return __async(this, null, function* () {
1099
+ try {
1100
+ yield this.sqlrunner.execute("ROLLBACK");
1101
+ } catch (e) {
1102
+ }
1103
+ });
1104
+ }
1105
+ applyOne(patch, manifest) {
1106
+ return __async(this, null, function* () {
1107
+ yield this.begin(patch);
1108
+ try {
1109
+ yield this.sqlrunner.execute(
1110
+ `INSERT INTO ${this.patchTable} (migration_table, patch_key, checksum, format_version, description)
1111
+ VALUES (?, ?, ?, ?, ?)`,
1112
+ [this.migrationTable, patch.patchKey, patch.checksum, patch.version, patch.description]
1113
+ );
1114
+ } catch (claimError) {
1115
+ yield this.rollbackQuietly();
1116
+ const committed = yield this.findCommittedRow(patch.patchKey);
1117
+ if (committed) {
1118
+ if (committed.checksum === patch.checksum) {
1119
+ return {
1120
+ patchKey: patch.patchKey,
1121
+ fileName: patch.fileName,
1122
+ status: "already_applied",
1123
+ operations: []
1124
+ };
1125
+ }
1126
+ throw new PatchIntegrityError(
1127
+ `patch '${patch.patchKey}' was applied elsewhere with a different checksum`,
1128
+ patch.fileName,
1129
+ patch.patchKey,
1130
+ committed.checksum,
1131
+ patch.checksum
1132
+ );
1133
+ }
1134
+ throw new PatchExecutionError(
1135
+ "failed to claim patch-history row",
1136
+ patch.fileName,
1137
+ patch.patchKey,
1138
+ void 0,
1139
+ void 0,
1140
+ toError(claimError)
1141
+ );
1142
+ }
1143
+ const operationResults = [];
1144
+ try {
1145
+ for (let index = 0; index < patch.operations.length; index++) {
1146
+ operationResults.push(
1147
+ yield this.applyOperation(patch, patch.operations[index], index, manifest)
1148
+ );
1149
+ }
1150
+ yield this.sqlrunner.execute("COMMIT");
1151
+ } catch (error) {
1152
+ yield this.rollbackQuietly();
1153
+ if (error instanceof PatchConflictError || error instanceof PatchExecutionError || error instanceof PatchIntegrityError) {
1154
+ throw error;
1155
+ }
1156
+ throw new PatchExecutionError(
1157
+ "patch application failed",
1158
+ patch.fileName,
1159
+ patch.patchKey,
1160
+ void 0,
1161
+ void 0,
1162
+ toError(error)
1163
+ );
1164
+ }
1165
+ return {
1166
+ patchKey: patch.patchKey,
1167
+ fileName: patch.fileName,
1168
+ status: "applied",
1169
+ operations: operationResults
1170
+ };
1171
+ });
1172
+ }
1173
+ findCommittedRow(patchKey) {
1174
+ return __async(this, null, function* () {
1175
+ const result = yield this.sqlrunner.query(
1176
+ `SELECT patch_key, checksum FROM ${this.patchTable} WHERE migration_table = ? AND patch_key = ?`,
1177
+ [this.migrationTable, patchKey]
1178
+ );
1179
+ const list = extractRows(result);
1180
+ return list.length > 0 ? list[0] : null;
1181
+ });
1182
+ }
1183
+ countRows(key) {
1184
+ return __async(this, null, function* () {
1185
+ var _a, _b, _c;
1186
+ const result = yield this.sqlrunner.query(
1187
+ `SELECT COUNT(*) AS row_count FROM ${this.migrationTable} WHERE migration_key = ?`,
1188
+ [key]
1189
+ );
1190
+ const rows = extractRows(result);
1191
+ const value = (_c = (_a = rows[0]) == null ? void 0 : _a.row_count) != null ? _c : Object.values((_b = rows[0]) != null ? _b : {})[0];
1192
+ return Number(value != null ? value : 0);
1193
+ });
1194
+ }
1195
+ conflict(patch, index, verb, message, keys, counts) {
1196
+ throw new PatchConflictError(
1197
+ `operation ${index} (${verb}): ${message}`,
1198
+ patch.fileName,
1199
+ patch.patchKey,
1200
+ index,
1201
+ verb,
1202
+ keys,
1203
+ counts
1204
+ );
1205
+ }
1206
+ applyOperation(patch, op, index, manifest) {
1207
+ return __async(this, null, function* () {
1208
+ var _a, _b, _c, _d;
1209
+ try {
1210
+ switch (op.verb) {
1211
+ case "rename_migration": {
1212
+ const fromCount = yield this.countRows(op.from);
1213
+ const toCount = yield this.countRows(op.to);
1214
+ const counts = { [op.from]: fromCount, [op.to]: toCount };
1215
+ if (fromCount > 1 || toCount > 1) {
1216
+ this.conflict(
1217
+ patch,
1218
+ index,
1219
+ op.verb,
1220
+ `ledger corruption: duplicate rows for a migration key`,
1221
+ [op.from, op.to],
1222
+ counts
1223
+ );
1224
+ }
1225
+ if (fromCount === 1 && toCount === 1) {
1226
+ this.conflict(
1227
+ patch,
1228
+ index,
1229
+ op.verb,
1230
+ `both '${op.from}' and '${op.to}' exist in the ledger`,
1231
+ [op.from, op.to],
1232
+ counts
1233
+ );
1234
+ }
1235
+ if (fromCount === 0) {
1236
+ return { verb: op.verb, changed: false };
1237
+ }
1238
+ const target = manifest.get(op.to);
1239
+ if (target) {
1240
+ yield this.sqlrunner.execute(
1241
+ `UPDATE ${this.migrationTable} SET migration_key = ?, up = ?, down = ? WHERE migration_key = ?`,
1242
+ [op.to, (_a = target.upFile) != null ? _a : "", (_b = target.downFile) != null ? _b : "", op.from]
1243
+ );
1244
+ } else {
1245
+ yield this.sqlrunner.execute(
1246
+ `UPDATE ${this.migrationTable} SET migration_key = ? WHERE migration_key = ?`,
1247
+ [op.to, op.from]
1248
+ );
1249
+ }
1250
+ return { verb: op.verb, changed: true };
1251
+ }
1252
+ case "mark_applied": {
1253
+ const count = yield this.countRows(op.key);
1254
+ if (count > 1) {
1255
+ this.conflict(
1256
+ patch,
1257
+ index,
1258
+ op.verb,
1259
+ `ledger corruption: duplicate rows for '${op.key}'`,
1260
+ [op.key],
1261
+ { [op.key]: count }
1262
+ );
1263
+ }
1264
+ if (count === 1) {
1265
+ return { verb: op.verb, changed: false };
1266
+ }
1267
+ const entry = manifest.get(op.key);
1268
+ yield this.sqlrunner.execute(
1269
+ `INSERT INTO ${this.migrationTable} (migration_key, up, down) VALUES (?, ?, ?)`,
1270
+ [op.key, (_c = entry == null ? void 0 : entry.upFile) != null ? _c : "", (_d = entry == null ? void 0 : entry.downFile) != null ? _d : ""]
1271
+ );
1272
+ return { verb: op.verb, changed: true };
1273
+ }
1274
+ case "unmark_applied": {
1275
+ const count = yield this.countRows(op.key);
1276
+ if (count > 1) {
1277
+ this.conflict(
1278
+ patch,
1279
+ index,
1280
+ op.verb,
1281
+ `ledger corruption: duplicate rows for '${op.key}'`,
1282
+ [op.key],
1283
+ { [op.key]: count }
1284
+ );
1285
+ }
1286
+ if (count === 0) {
1287
+ return { verb: op.verb, changed: false };
1288
+ }
1289
+ yield this.sqlrunner.execute(
1290
+ `DELETE FROM ${this.migrationTable} WHERE migration_key = ?`,
1291
+ [op.key]
1292
+ );
1293
+ return { verb: op.verb, changed: true };
1294
+ }
1295
+ }
1296
+ } catch (error) {
1297
+ if (error instanceof PatchConflictError) throw error;
1298
+ throw new PatchExecutionError(
1299
+ `operation failed`,
1300
+ patch.fileName,
1301
+ patch.patchKey,
1302
+ index,
1303
+ op.verb,
1304
+ toError(error)
1305
+ );
1306
+ }
1307
+ });
1308
+ }
1309
+ };
1310
+
1311
+ // framework/PatchCreator.ts
1312
+ var import_fs5 = __toESM(require("fs"));
1313
+ var import_path4 = __toESM(require("path"));
1314
+ var MAX_NAME_LENGTH = 120;
1315
+ var SCAFFOLD = `version: 1
1316
+ description: TODO
1317
+ operations: []
1318
+ `;
1319
+ var PatchCreator = class _PatchCreator {
1320
+ constructor(patchFolder) {
1321
+ this.patchFolder = patchFolder;
1322
+ }
1323
+ /**
1324
+ * Normalizes a patch name: trim, whitespace runs -> `_`, lowercase.
1325
+ * Rejects empty results, path separators, `..`, control characters,
1326
+ * characters outside [a-z0-9_-], and names longer than 120 characters.
1327
+ */
1328
+ static normalizeName(name) {
1329
+ const normalized = (name != null ? name : "").trim().replace(/\s+/g, "_").toLowerCase();
1330
+ if (normalized.length === 0) {
1331
+ throw new CLIError("Patch name is required");
1332
+ }
1333
+ if (normalized.includes("/") || normalized.includes("\\")) {
1334
+ throw new CLIError("Patch name must not contain path separators");
1335
+ }
1336
+ if (normalized.includes("..")) {
1337
+ throw new CLIError("Patch name must not contain '..'");
1338
+ }
1339
+ if (/[\x00-\x1f\x7f]/.test(normalized)) {
1340
+ throw new CLIError("Patch name must not contain control characters");
1341
+ }
1342
+ if (!/^[a-z0-9_-]+$/.test(normalized)) {
1343
+ throw new CLIError("Patch name may only contain characters [a-z0-9_-]");
1344
+ }
1345
+ if (normalized.length > MAX_NAME_LENGTH) {
1346
+ throw new CLIError(`Patch name exceeds ${MAX_NAME_LENGTH} characters after normalization`);
1347
+ }
1348
+ return normalized;
1349
+ }
1350
+ /**
1351
+ * Creates `<patch_folder>/<stamp>_<normalized_name>.yaml` with exclusive
1352
+ * file creation. On a millisecond-stamp collision, mints a later stamp
1353
+ * and retries. Returns the created path.
1354
+ */
1355
+ create(name) {
1356
+ const normalized = _PatchCreator.normalizeName(name);
1357
+ if (!import_fs5.default.existsSync(this.patchFolder)) {
1358
+ import_fs5.default.mkdirSync(this.patchFolder, { recursive: true });
1359
+ }
1360
+ let stamp = Date.now();
1361
+ for (let attempt = 0; attempt < 1e3; attempt++) {
1362
+ const filePath = import_path4.default.join(this.patchFolder, `${stamp}_${normalized}.yaml`);
1363
+ try {
1364
+ import_fs5.default.writeFileSync(filePath, SCAFFOLD, { flag: "wx" });
1365
+ return filePath;
1366
+ } catch (error) {
1367
+ if (error && error.code === "EEXIST") {
1368
+ stamp += 1;
1369
+ continue;
1370
+ }
1371
+ throw error;
1372
+ }
1373
+ }
1374
+ throw new CLIError("Unable to create patch file: too many filename collisions");
1375
+ }
1376
+ };
1377
+
591
1378
  // framework/SQLRunner.ts
592
1379
  var BaseSQLRunner = class {
593
1380
  /**
@@ -902,7 +1689,7 @@ var PgRunner = class _PgRunner extends BaseSQLRunner {
902
1689
  };
903
1690
 
904
1691
  // framework/MigrationRunner.ts
905
- function toError(error) {
1692
+ function toError2(error) {
906
1693
  if (error instanceof Error) return error;
907
1694
  return new Error(String(error));
908
1695
  }
@@ -926,10 +1713,12 @@ var MigrationRunnerFactory = class _MigrationRunnerFactory {
926
1713
  return __async(this, null, function* () {
927
1714
  const configReader = new FileMigrationConfigReader(configFile);
928
1715
  const config = configReader.loadFile();
1716
+ let factoryOwnsConnection = false;
929
1717
  if (!conn) {
930
1718
  conn = yield this.createConnection(config);
1719
+ factoryOwnsConnection = true;
931
1720
  }
932
- return new _MigrationRunnerFactory().create(config, conn);
1721
+ return new _MigrationRunnerFactory().create(config, conn, factoryOwnsConnection);
933
1722
  });
934
1723
  }
935
1724
  static createConnection(config) {
@@ -949,7 +1738,7 @@ var MigrationRunnerFactory = class _MigrationRunnerFactory {
949
1738
  conn = yield ((_b = (_a = mysql.default) == null ? void 0 : _a.createConnection) != null ? _b : mysql.createConnection)(settings);
950
1739
  return conn;
951
1740
  } catch (error) {
952
- throw DatabaseConnectionError.connectionFailed("sql", toError(error).message);
1741
+ throw DatabaseConnectionError.connectionFailed("sql", toError2(error).message);
953
1742
  }
954
1743
  case "sqlite":
955
1744
  if (!config.sqlite) {
@@ -964,7 +1753,7 @@ var MigrationRunnerFactory = class _MigrationRunnerFactory {
964
1753
  });
965
1754
  return conn;
966
1755
  } catch (error) {
967
- throw DatabaseConnectionError.connectionFailed("sqlite", toError(error).message);
1756
+ throw DatabaseConnectionError.connectionFailed("sqlite", toError2(error).message);
968
1757
  }
969
1758
  case "pg":
970
1759
  if (!config.pg && !process.env.DATABASE_URL) {
@@ -980,7 +1769,7 @@ var MigrationRunnerFactory = class _MigrationRunnerFactory {
980
1769
  yield conn.connect();
981
1770
  return conn;
982
1771
  } catch (error) {
983
- throw DatabaseConnectionError.connectionFailed("pg", toError(error).message);
1772
+ throw DatabaseConnectionError.connectionFailed("pg", toError2(error).message);
984
1773
  }
985
1774
  default:
986
1775
  throw ConfigurationError.unknownDatabaseType(config.database);
@@ -994,7 +1783,7 @@ var MigrationRunnerFactory = class _MigrationRunnerFactory {
994
1783
  return new _MigrationRunnerFactory().createEmpty(config);
995
1784
  });
996
1785
  }
997
- create(config, conn) {
1786
+ create(config, conn, factoryOwnsConnection = false) {
998
1787
  return __async(this, null, function* () {
999
1788
  let sqlrunner;
1000
1789
  let driverConnection = conn;
@@ -1007,8 +1796,19 @@ var MigrationRunnerFactory = class _MigrationRunnerFactory {
1007
1796
  const setup = new MigrationSetup(sqlrunner, config);
1008
1797
  const read_strategy = this.getReadStategy(config);
1009
1798
  const migration_files = new MigrationDirectoryReader(config.migration_folder, read_strategy, sqlrunner, config.database);
1010
- yield setup.setup();
1011
- return new MySQLMigrationRunner(config, migration_files, setup, sqlrunner, driverConnection);
1799
+ const runner = new MySQLMigrationRunner(config, migration_files, setup, sqlrunner, driverConnection);
1800
+ try {
1801
+ yield runner.setup();
1802
+ } catch (error) {
1803
+ if (factoryOwnsConnection) {
1804
+ try {
1805
+ yield sqlrunner.end();
1806
+ } catch (e) {
1807
+ }
1808
+ }
1809
+ throw error;
1810
+ }
1811
+ return runner;
1012
1812
  });
1013
1813
  }
1014
1814
  createEmpty(config) {
@@ -1018,7 +1818,7 @@ var MigrationRunnerFactory = class _MigrationRunnerFactory {
1018
1818
  const setup = new MigrationSetup(sqlrunner, config);
1019
1819
  const read_strategy = this.getReadStategy(config);
1020
1820
  const migration_files = new MigrationDirectoryReader(config.migration_folder, read_strategy, sqlrunner, config.database);
1021
- return new MySQLMigrationRunner(config, migration_files, setup, sqlrunner, conn);
1821
+ return new MySQLMigrationRunner(config, migration_files, setup, sqlrunner, conn, false);
1022
1822
  });
1023
1823
  }
1024
1824
  getReadStategy(config) {
@@ -1035,33 +1835,77 @@ var MigrationRunnerFactory = class _MigrationRunnerFactory {
1035
1835
  }
1036
1836
  };
1037
1837
  var MySQLMigrationRunner = class {
1038
- constructor(config, directory, setupRunner, sqlrunner, connection) {
1838
+ constructor(config, directory, setupRunner, sqlrunner, connection, preflightEnabled = true) {
1039
1839
  this.config = config;
1040
1840
  this.directory = directory;
1041
1841
  this.setupRunner = setupRunner;
1042
1842
  this.sqlrunner = sqlrunner;
1043
1843
  this.connection = connection;
1844
+ this.preflightEnabled = preflightEnabled;
1845
+ /**
1846
+ * Memoized in-flight preflight promise. Simultaneous or repeated calls
1847
+ * to setup() on one runner execute the preflight (migration table setup
1848
+ * + patch application) exactly once. Cleared after rejection so a caller
1849
+ * may retry after fixing the cause.
1850
+ */
1851
+ this.preflightPromise = null;
1852
+ this.lastPatchResults = [];
1044
1853
  }
1045
1854
  setup() {
1855
+ return __async(this, null, function* () {
1856
+ if (!this.preflightEnabled) {
1857
+ return;
1858
+ }
1859
+ if (!this.preflightPromise) {
1860
+ this.preflightPromise = this.runPreflight();
1861
+ this.preflightPromise.catch(() => {
1862
+ this.preflightPromise = null;
1863
+ });
1864
+ }
1865
+ return this.preflightPromise;
1866
+ });
1867
+ }
1868
+ runPreflight() {
1046
1869
  return __async(this, null, function* () {
1047
1870
  try {
1048
1871
  yield this.setupRunner.setup();
1049
1872
  } catch (error) {
1050
- throw new MigrationExecutionError("Failed to set up migration database", void 0, void 0, toError(error));
1873
+ throw new MigrationExecutionError("Failed to set up migration database", void 0, void 0, toError2(error));
1051
1874
  }
1875
+ const patchRunner = new PatchRunner(this.sqlrunner, this.config);
1876
+ this.lastPatchResults = yield patchRunner.applyPending();
1877
+ });
1878
+ }
1879
+ /**
1880
+ * Delegates to the same idempotent preflight; returns the results of the
1881
+ * patch pass that ran (or is running) for this runner.
1882
+ */
1883
+ applyPendingPatches() {
1884
+ return __async(this, null, function* () {
1885
+ yield this.setup();
1886
+ return this.lastPatchResults;
1052
1887
  });
1053
1888
  }
1889
+ /**
1890
+ * Scaffolds a new ledger patch file and returns the created path.
1891
+ * Never connects to a database.
1892
+ */
1893
+ createPatch(name) {
1894
+ const creator = new PatchCreator(resolvePatchFolder(this.config));
1895
+ return creator.create(name);
1896
+ }
1054
1897
  terminate() {
1055
1898
  return __async(this, null, function* () {
1056
1899
  try {
1057
1900
  yield this.setupRunner.teardown();
1058
1901
  } catch (error) {
1059
- throw new MigrationExecutionError("Failed to tear down migration database", void 0, void 0, toError(error));
1902
+ throw new MigrationExecutionError("Failed to tear down migration database", void 0, void 0, toError2(error));
1060
1903
  }
1061
1904
  });
1062
1905
  }
1063
1906
  getMigrationsHistory() {
1064
1907
  return __async(this, null, function* () {
1908
+ yield this.setup();
1065
1909
  try {
1066
1910
  const results = yield this.sqlrunner.query(`
1067
1911
  select *
@@ -1069,16 +1913,17 @@ var MySQLMigrationRunner = class {
1069
1913
  `);
1070
1914
  return results[0];
1071
1915
  } catch (error) {
1072
- throw new MigrationExecutionError("Failed to get migration history", void 0, void 0, toError(error));
1916
+ throw new MigrationExecutionError("Failed to get migration history", void 0, void 0, toError2(error));
1073
1917
  }
1074
1918
  });
1075
1919
  }
1076
1920
  getMigrations() {
1077
1921
  return __async(this, null, function* () {
1922
+ yield this.setup();
1078
1923
  try {
1079
1924
  return this.directory.loadMigrations(this.config.migration_table, this.connection);
1080
1925
  } catch (error) {
1081
- throw new MigrationExecutionError("Failed to load migrations", void 0, void 0, toError(error));
1926
+ throw new MigrationExecutionError("Failed to load migrations", void 0, void 0, toError2(error));
1082
1927
  }
1083
1928
  });
1084
1929
  }
@@ -1091,7 +1936,7 @@ var MySQLMigrationRunner = class {
1091
1936
  if (error instanceof MigrationExecutionError) {
1092
1937
  throw error;
1093
1938
  }
1094
- throw new MigrationExecutionError("Failed to get pending migrations", void 0, void 0, toError(error));
1939
+ throw new MigrationExecutionError("Failed to get pending migrations", void 0, void 0, toError2(error));
1095
1940
  }
1096
1941
  });
1097
1942
  }
@@ -1104,12 +1949,13 @@ var MySQLMigrationRunner = class {
1104
1949
  if (error instanceof MigrationExecutionError) {
1105
1950
  throw error;
1106
1951
  }
1107
- throw new MigrationExecutionError("Failed to get completed migrations", void 0, void 0, toError(error));
1952
+ throw new MigrationExecutionError("Failed to get completed migrations", void 0, void 0, toError2(error));
1108
1953
  }
1109
1954
  });
1110
1955
  }
1111
1956
  migrate(migrationNodes, forward) {
1112
1957
  return __async(this, null, function* () {
1958
+ yield this.setup();
1113
1959
  for (let node of migrationNodes) {
1114
1960
  try {
1115
1961
  if (forward) {
@@ -1122,7 +1968,7 @@ var MySQLMigrationRunner = class {
1122
1968
  `Failed to ${forward ? "apply" : "rollback"} migration`,
1123
1969
  node.name || String(node),
1124
1970
  forward ? node.up_sql() : node.down_sql(),
1125
- toError(error)
1971
+ toError2(error)
1126
1972
  );
1127
1973
  }
1128
1974
  }
@@ -1130,6 +1976,7 @@ var MySQLMigrationRunner = class {
1130
1976
  }
1131
1977
  reset() {
1132
1978
  return __async(this, null, function* () {
1979
+ yield this.setup();
1133
1980
  try {
1134
1981
  let migrations = yield this.getMigrations();
1135
1982
  const rollback = yield migration_filter(migrations, true);
@@ -1141,7 +1988,7 @@ var MySQLMigrationRunner = class {
1141
1988
  if (error instanceof MigrationExecutionError) {
1142
1989
  throw error;
1143
1990
  }
1144
- throw new MigrationExecutionError("Failed to reset migrations", void 0, void 0, toError(error));
1991
+ throw new MigrationExecutionError("Failed to reset migrations", void 0, void 0, toError2(error));
1145
1992
  }
1146
1993
  });
1147
1994
  }
@@ -1150,7 +1997,7 @@ var MySQLMigrationRunner = class {
1150
1997
  const creator = new MigrationCreator(this.config);
1151
1998
  creator.create(name);
1152
1999
  } catch (error) {
1153
- throw new MigrationExecutionError(`Failed to create migration: ${name}`, void 0, void 0, toError(error));
2000
+ throw new MigrationExecutionError(`Failed to create migration: ${name}`, void 0, void 0, toError2(error));
1154
2001
  }
1155
2002
  }
1156
2003
  close() {
@@ -1159,7 +2006,7 @@ var MySQLMigrationRunner = class {
1159
2006
  try {
1160
2007
  yield this.sqlrunner.end();
1161
2008
  } catch (error) {
1162
- throw new DatabaseConnectionError(`Failed to close database connection: ${toError(error).message}`);
2009
+ throw new DatabaseConnectionError(`Failed to close database connection: ${toError2(error).message}`);
1163
2010
  }
1164
2011
  }
1165
2012
  });
@@ -1173,7 +2020,7 @@ var MySQLMigrationRunner = class {
1173
2020
  return __async(this, null, function* () {
1174
2021
  try {
1175
2022
  console.log(`Checking for ${config_file2}`);
1176
- const config_exist = import_fs3.default.existsSync(config_file2);
2023
+ const config_exist = import_fs6.default.existsSync(config_file2);
1177
2024
  if (!config_exist) {
1178
2025
  console.log(`Creating ${config_file2}`);
1179
2026
  const default_config = {
@@ -1187,11 +2034,11 @@ var MySQLMigrationRunner = class {
1187
2034
  "password": ""
1188
2035
  }
1189
2036
  };
1190
- import_fs3.default.writeFileSync(config_file2, JSON.stringify(default_config, null, 2));
2037
+ import_fs6.default.writeFileSync(config_file2, JSON.stringify(default_config, null, 2));
1191
2038
  console.log(`Created ${config_file2}`);
1192
2039
  }
1193
2040
  } catch (error) {
1194
- throw new ConfigurationError(`Failed to initialize config file: ${toError(error).message}`);
2041
+ throw new ConfigurationError(`Failed to initialize config file: ${toError2(error).message}`);
1195
2042
  }
1196
2043
  });
1197
2044
  }
@@ -1202,7 +2049,7 @@ var FileMigrationConfigReader = class {
1202
2049
  }
1203
2050
  loadFile() {
1204
2051
  try {
1205
- const fileContent = import_fs3.default.readFileSync(this.configFile);
2052
+ const fileContent = import_fs6.default.readFileSync(this.configFile);
1206
2053
  const config = JSON.parse(fileContent.toString());
1207
2054
  if (!config.migration_folder) {
1208
2055
  throw ConfigurationError.missingRequiredProperty("migration_folder");
@@ -1226,7 +2073,7 @@ var FileMigrationConfigReader = class {
1226
2073
  if (error instanceof ConfigurationError) {
1227
2074
  throw error;
1228
2075
  }
1229
- const err = toError(error);
2076
+ const err = toError2(error);
1230
2077
  if (err.message.includes("ENOENT")) {
1231
2078
  throw new ConfigurationError(`Config file not found: ${this.configFile}`);
1232
2079
  }
@@ -1243,16 +2090,16 @@ var MigrationCreator = class {
1243
2090
  throw new CLIError("Migration name is required");
1244
2091
  }
1245
2092
  try {
1246
- if (!import_fs3.default.existsSync(this.config.migration_folder)) {
1247
- import_fs3.default.mkdirSync(this.config.migration_folder, { recursive: true });
2093
+ if (!import_fs6.default.existsSync(this.config.migration_folder)) {
2094
+ import_fs6.default.mkdirSync(this.config.migration_folder, { recursive: true });
1248
2095
  }
1249
2096
  const now_timestamp = Date.now();
1250
2097
  const filename_up = `${now_timestamp}_${name}.up.sql`;
1251
2098
  const filename_down = `${now_timestamp}_${name}.down.sql`;
1252
- import_fs3.default.writeFileSync(`${this.config.migration_folder}/${filename_up}`, `
2099
+ import_fs6.default.writeFileSync(`${this.config.migration_folder}/${filename_up}`, `
1253
2100
  -- Write your up migration here
1254
2101
  `.trim());
1255
- import_fs3.default.writeFileSync(`${this.config.migration_folder}/${filename_down}`, `
2102
+ import_fs6.default.writeFileSync(`${this.config.migration_folder}/${filename_down}`, `
1256
2103
  -- Write your down migration here
1257
2104
  `.trim());
1258
2105
  console.log(`Created migration files:`);
@@ -1262,16 +2109,16 @@ var MigrationCreator = class {
1262
2109
  if (error instanceof CLIError) {
1263
2110
  throw error;
1264
2111
  }
1265
- throw new MigrationExecutionError(`Failed to create migration files: ${toError(error).message}`);
2112
+ throw new MigrationExecutionError(`Failed to create migration files: ${toError2(error).message}`);
1266
2113
  }
1267
2114
  }
1268
2115
  };
1269
2116
 
1270
2117
  // framework/SeedRunner.ts
1271
- var import_fs4 = __toESM(require("fs"));
1272
- var import_path2 = __toESM(require("path"));
2118
+ var import_fs7 = __toESM(require("fs"));
2119
+ var import_path5 = __toESM(require("path"));
1273
2120
  var import_url = require("url");
1274
- var import_ajv = __toESM(require("ajv"));
2121
+ var import_ajv2 = __toESM(require("ajv"));
1275
2122
  var import_ajv_formats = __toESM(require("ajv-formats"));
1276
2123
  var import_api = require("tsx/esm/api");
1277
2124
  function resolveAlias(name, aliasMap) {
@@ -1280,10 +2127,10 @@ function resolveAlias(name, aliasMap) {
1280
2127
  return (_a = aliasMap[name]) != null ? _a : name;
1281
2128
  }
1282
2129
  function walkForFile(rootDir, fileName) {
1283
- if (!import_fs4.default.existsSync(rootDir)) return null;
1284
- const entries = import_fs4.default.readdirSync(rootDir, { withFileTypes: true });
2130
+ if (!import_fs7.default.existsSync(rootDir)) return null;
2131
+ const entries = import_fs7.default.readdirSync(rootDir, { withFileTypes: true });
1285
2132
  for (const entry of entries) {
1286
- const full = import_path2.default.join(rootDir, entry.name);
2133
+ const full = import_path5.default.join(rootDir, entry.name);
1287
2134
  if (entry.isDirectory()) {
1288
2135
  const found = walkForFile(full, fileName);
1289
2136
  if (found) return found;
@@ -1377,39 +2224,39 @@ function resolveSeed(name, migrationConfig, options) {
1377
2224
  }
1378
2225
  function loadJson(filePath) {
1379
2226
  return __async(this, null, function* () {
1380
- const content = yield import_fs4.default.promises.readFile(filePath, "utf8");
2227
+ const content = yield import_fs7.default.promises.readFile(filePath, "utf8");
1381
2228
  return JSON.parse(content);
1382
2229
  });
1383
2230
  }
1384
2231
  function createValidator() {
1385
- const ajv = new import_ajv.default({ allErrors: true, strict: false });
1386
- (0, import_ajv_formats.default)(ajv);
1387
- return ajv;
2232
+ const ajv2 = new import_ajv2.default({ allErrors: true, strict: false });
2233
+ (0, import_ajv_formats.default)(ajv2);
2234
+ return ajv2;
1388
2235
  }
1389
2236
  function validateData(schemaPath, data, validate, log) {
1390
2237
  return __async(this, null, function* () {
1391
2238
  if (!validate || !schemaPath) return;
1392
- const content = yield import_fs4.default.promises.readFile(schemaPath, "utf8");
2239
+ const content = yield import_fs7.default.promises.readFile(schemaPath, "utf8");
1393
2240
  const schema = JSON.parse(content);
1394
- const ajv = createValidator();
1395
- const validateFn = ajv.compile(schema);
2241
+ const ajv2 = createValidator();
2242
+ const validateFn = ajv2.compile(schema);
1396
2243
  const ok = validateFn(data);
1397
2244
  if (!ok) {
1398
2245
  log == null ? void 0 : log(`Validation failed for seed data (${schemaPath})`);
1399
- throw new Error(`Seed data validation failed: ${ajv.errorsText(validateFn.errors || [])}`);
2246
+ throw new Error(`Seed data validation failed: ${ajv2.errorsText(validateFn.errors || [])}`);
1400
2247
  }
1401
2248
  });
1402
2249
  }
1403
2250
  function runSqlSeed(runner, resolved, direction) {
1404
2251
  return __async(this, null, function* () {
1405
2252
  const sqlPath = direction === "up" ? resolved.upPath : resolved.downPath;
1406
- const sql = yield import_fs4.default.promises.readFile(sqlPath, "utf8");
2253
+ const sql = yield import_fs7.default.promises.readFile(sqlPath, "utf8");
1407
2254
  yield runner.query(sql);
1408
2255
  });
1409
2256
  }
1410
2257
  function loadSeedModule(modulePath) {
1411
2258
  return __async(this, null, function* () {
1412
- const resolved = import_path2.default.resolve(modulePath);
2259
+ const resolved = import_path5.default.resolve(modulePath);
1413
2260
  if (resolved.endsWith(".ts")) {
1414
2261
  const fileUrl = (0, import_url.pathToFileURL)(resolved).href;
1415
2262
  return (0, import_api.tsImport)(fileUrl, fileUrl);
@@ -1510,7 +2357,7 @@ if (!args.commands || args.commands.length === 0) {
1510
2357
  }
1511
2358
  var commands = args.commands;
1512
2359
  var command = commands[0];
1513
- var load_database = !["init", "create", "help"].includes(command.toLowerCase());
2360
+ var load_database = !["init", "create", "help", "patch"].includes(command.toLowerCase());
1514
2361
  var config_file = args.flags.config || "proper.json";
1515
2362
  if (command.toLowerCase() === "help") {
1516
2363
  printUsage();
@@ -1519,9 +2366,17 @@ if (command.toLowerCase() === "help") {
1519
2366
  console.log(`Loading database: ${load_database}`);
1520
2367
  var pending_runner = load_database ? MigrationRunnerFactory.create(config_file) : MigrationRunnerFactory.createEmpty(config_file);
1521
2368
  pending_runner.then((runner) => __async(null, null, function* () {
2369
+ let failed = false;
1522
2370
  try {
1523
2371
  if (load_database) {
1524
- yield runner.setup();
2372
+ const patch_results = yield runner.applyPendingPatches();
2373
+ for (const patch of patch_results) {
2374
+ if (patch.status === "applied") {
2375
+ const changed = patch.operations.filter((op) => op.changed).length;
2376
+ const noop = patch.operations.length - changed;
2377
+ console.log(`Applied patch ${patch.fileName}: ${changed} changed, ${noop} no-op operation(s)`);
2378
+ }
2379
+ }
1525
2380
  }
1526
2381
  switch (command.toLowerCase()) {
1527
2382
  case "up":
@@ -1541,7 +2396,6 @@ pending_runner.then((runner) => __async(null, null, function* () {
1541
2396
  } else {
1542
2397
  console.log("No pending migrations");
1543
2398
  }
1544
- yield runner.close();
1545
2399
  break;
1546
2400
  case "down":
1547
2401
  const migrations_rollback = yield runner.getMigrations();
@@ -1562,13 +2416,11 @@ pending_runner.then((runner) => __async(null, null, function* () {
1562
2416
  } else {
1563
2417
  console.log("No migrations to roll back");
1564
2418
  }
1565
- yield runner.close();
1566
2419
  break;
1567
2420
  case "reset":
1568
2421
  console.log("Resetting all migrations...");
1569
2422
  yield runner.reset();
1570
2423
  console.log("Reset completed successfully");
1571
- yield runner.close();
1572
2424
  break;
1573
2425
  case "create":
1574
2426
  let filename = args.flags.name || commands[1];
@@ -1577,11 +2429,22 @@ pending_runner.then((runner) => __async(null, null, function* () {
1577
2429
  }
1578
2430
  filename = filename.replace(/\s/g, "_");
1579
2431
  runner.createMigration(filename);
1580
- runner.close();
1581
2432
  break;
2433
+ case "patch": {
2434
+ const patch_name = args.flags.name || commands[1];
2435
+ if (!patch_name) {
2436
+ throw new CLIError("Patch name is required. Usage: proper patch <name>");
2437
+ }
2438
+ const created_path = runner.createPatch(patch_name);
2439
+ console.log(`Created patch file:`);
2440
+ console.log(` ${created_path}`);
2441
+ console.log("");
2442
+ console.log("Complete this file before running another database-backed");
2443
+ console.log("Proper command: 'operations: []' is intentionally not runnable.");
2444
+ break;
2445
+ }
1582
2446
  case "init":
1583
2447
  yield runner.init(config_file);
1584
- runner.close();
1585
2448
  break;
1586
2449
  case "status":
1587
2450
  const { printTable } = require("console-table-printer");
@@ -1595,7 +2458,6 @@ pending_runner.then((runner) => __async(null, null, function* () {
1595
2458
  };
1596
2459
  }));
1597
2460
  printTable(yield Promise.all(table));
1598
- runner.close();
1599
2461
  break;
1600
2462
  case "query":
1601
2463
  const sql_query = args.flags.query || commands[1];
@@ -1639,7 +2501,6 @@ Returned ${results.length} row(s)`);
1639
2501
  } catch (error) {
1640
2502
  throw new CLIError(`Query execution failed: ${error.message}`);
1641
2503
  }
1642
- yield runner.close();
1643
2504
  break;
1644
2505
  case "seed": {
1645
2506
  const subCommands = commands.slice(1);
@@ -1672,7 +2533,6 @@ Returned ${results.length} row(s)`);
1672
2533
  const reader = new FileMigrationConfigReader(config_file);
1673
2534
  const migrationConfig = reader.loadFile();
1674
2535
  yield runSeedsWithRunner(runner, migrationConfig, action, seedOptions);
1675
- yield runner.close();
1676
2536
  break;
1677
2537
  }
1678
2538
  default:
@@ -1683,9 +2543,25 @@ Returned ${results.length} row(s)`);
1683
2543
  if (error.stack && process.env.DEBUG) {
1684
2544
  console.error(error.stack);
1685
2545
  }
2546
+ failed = true;
2547
+ } finally {
2548
+ try {
2549
+ yield runner.close();
2550
+ } catch (closeError) {
2551
+ console.error(`Error closing connection: ${closeError.message}`);
2552
+ failed = true;
2553
+ }
2554
+ }
2555
+ if (failed) {
1686
2556
  process.exit(1);
1687
2557
  }
1688
- }));
2558
+ })).catch((error) => {
2559
+ console.error(`Error: ${error.message}`);
2560
+ if (error.stack && process.env.DEBUG) {
2561
+ console.error(error.stack);
2562
+ }
2563
+ process.exit(1);
2564
+ });
1689
2565
  function printUsage() {
1690
2566
  console.log(`
1691
2567
  SQL Proper - Database migration tool
@@ -1698,6 +2574,7 @@ Commands:
1698
2574
  down Roll back completed migrations
1699
2575
  reset Roll back all migrations and reapply them
1700
2576
  create Create a new migration
2577
+ patch Create a new ledger patch file (repairs migration history; no database access)
1701
2578
  init Initialize a new config file
1702
2579
  status Show migration status
1703
2580
  query Execute a SQL query and display results
@@ -1716,6 +2593,7 @@ Examples:
1716
2593
  proper down --increment 3 Roll back the last 3 applied migrations
1717
2594
  proper down --all Roll back all completed migrations
1718
2595
  proper create my_migration Create a new migration named "my_migration"
2596
+ proper patch fix_renamed_keys Scaffold a ledger patch file in the patch folder
1719
2597
  proper init Create a new config file
1720
2598
  proper status Show the status of all migrations
1721
2599
  proper query "select * from users" Execute a SQL query