@noego/proper 0.1.0 → 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,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,36 @@ 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
+ 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
+
407
484
  // framework/MigrationDirectoryReader.ts
408
485
  var MigrationDirectoryReader = class {
409
486
  constructor(directory, read_strategy, sqlrunner, dialect = "sql") {
@@ -418,12 +495,7 @@ var MigrationDirectoryReader = class {
418
495
  * File extensions: `.mysql.up.sql`, `.sqlite.up.sql`, `.pg.up.sql`.
419
496
  */
420
497
  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;
498
+ return resolveMigrationFile(this.directory, baseName, direction, this.dialect);
427
499
  }
428
500
  /**
429
501
  * Checks if a file path is dialect-specific (contains .mysql. or .sqlite. in the name)
@@ -432,11 +504,11 @@ var MigrationDirectoryReader = class {
432
504
  return /\.(mysql|sqlite|pg)\.(up|down)\.sql$/i.test(filePath);
433
505
  }
434
506
  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);
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);
437
509
  const uniqueKeys = /* @__PURE__ */ new Set();
438
510
  dir_content.forEach((file) => {
439
- const key = file.replace(/(?:\.(mysql|sqlite|pg))?\.(up|down)\.(sql|js)/i, "").toLowerCase();
511
+ const key = canonicalMigrationKey(file);
440
512
  uniqueKeys.add(key);
441
513
  });
442
514
  const migration_sorter = {};
@@ -476,14 +548,14 @@ var MigrationDirectoryReader = class {
476
548
  return builder;
477
549
  }
478
550
  sql_up(file) {
479
- let content = import_fs.default.readFileSync(file).toString();
551
+ let content = import_fs2.default.readFileSync(file).toString();
480
552
  if (!this.isDialectSpecific(file)) {
481
553
  content = this.read_strategy(content);
482
554
  }
483
555
  return content.trim();
484
556
  }
485
557
  sql_down(file) {
486
- let content = import_fs.default.readFileSync(file).toString();
558
+ let content = import_fs2.default.readFileSync(file).toString();
487
559
  if (!this.isDialectSpecific(file)) {
488
560
  content = this.read_strategy(content);
489
561
  }
@@ -492,7 +564,23 @@ var MigrationDirectoryReader = class {
492
564
  };
493
565
 
494
566
  // framework/MigrationSetup.ts
495
- 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
496
584
  var MigrationSetup = class {
497
585
  constructor(sqlrunner, config) {
498
586
  this.sqlrunner = sqlrunner;
@@ -500,7 +588,7 @@ var MigrationSetup = class {
500
588
  }
501
589
  setup() {
502
590
  return __async(this, null, function* () {
503
- 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);
504
592
  const tableName = this.config.migration_table;
505
593
  const createTableSql = this.config.database === "sqlite" ? `CREATE TABLE IF NOT EXISTS ${tableName} (
506
594
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -522,11 +610,39 @@ var MigrationSetup = class {
522
610
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
523
611
  )`;
524
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);
525
640
  });
526
641
  }
527
642
  teardown() {
528
643
  return __async(this, null, function* () {
529
644
  yield this.sqlrunner.execute(`DROP TABLE ${this.config.migration_table}`);
645
+ yield this.sqlrunner.execute(`DROP TABLE IF EXISTS ${resolvePatchTable(this.config)}`);
530
646
  });
531
647
  }
532
648
  };
@@ -588,6 +704,673 @@ function markerDialectParser(names) {
588
704
  var SqliteDialectParser = markerDialectParser(["sqlite"]);
589
705
  var PgDialectParser = markerDialectParser(["pg", "postgres", "postgresql"]);
590
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
+ }
758
+ }
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
+ }
914
+ }
915
+ }
916
+ }
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
+
591
1374
  // framework/SQLRunner.ts
592
1375
  var BaseSQLRunner = class {
593
1376
  /**
@@ -902,7 +1685,7 @@ var PgRunner = class _PgRunner extends BaseSQLRunner {
902
1685
  };
903
1686
 
904
1687
  // framework/MigrationRunner.ts
905
- function toError(error) {
1688
+ function toError2(error) {
906
1689
  if (error instanceof Error) return error;
907
1690
  return new Error(String(error));
908
1691
  }
@@ -926,10 +1709,12 @@ var MigrationRunnerFactory = class _MigrationRunnerFactory {
926
1709
  return __async(this, null, function* () {
927
1710
  const configReader = new FileMigrationConfigReader(configFile);
928
1711
  const config = configReader.loadFile();
1712
+ let factoryOwnsConnection = false;
929
1713
  if (!conn) {
930
1714
  conn = yield this.createConnection(config);
1715
+ factoryOwnsConnection = true;
931
1716
  }
932
- return new _MigrationRunnerFactory().create(config, conn);
1717
+ return new _MigrationRunnerFactory().create(config, conn, factoryOwnsConnection);
933
1718
  });
934
1719
  }
935
1720
  static createConnection(config) {
@@ -949,7 +1734,7 @@ var MigrationRunnerFactory = class _MigrationRunnerFactory {
949
1734
  conn = yield ((_b = (_a = mysql.default) == null ? void 0 : _a.createConnection) != null ? _b : mysql.createConnection)(settings);
950
1735
  return conn;
951
1736
  } catch (error) {
952
- throw DatabaseConnectionError.connectionFailed("sql", toError(error).message);
1737
+ throw DatabaseConnectionError.connectionFailed("sql", toError2(error).message);
953
1738
  }
954
1739
  case "sqlite":
955
1740
  if (!config.sqlite) {
@@ -964,7 +1749,7 @@ var MigrationRunnerFactory = class _MigrationRunnerFactory {
964
1749
  });
965
1750
  return conn;
966
1751
  } catch (error) {
967
- throw DatabaseConnectionError.connectionFailed("sqlite", toError(error).message);
1752
+ throw DatabaseConnectionError.connectionFailed("sqlite", toError2(error).message);
968
1753
  }
969
1754
  case "pg":
970
1755
  if (!config.pg && !process.env.DATABASE_URL) {
@@ -980,7 +1765,7 @@ var MigrationRunnerFactory = class _MigrationRunnerFactory {
980
1765
  yield conn.connect();
981
1766
  return conn;
982
1767
  } catch (error) {
983
- throw DatabaseConnectionError.connectionFailed("pg", toError(error).message);
1768
+ throw DatabaseConnectionError.connectionFailed("pg", toError2(error).message);
984
1769
  }
985
1770
  default:
986
1771
  throw ConfigurationError.unknownDatabaseType(config.database);
@@ -994,7 +1779,7 @@ var MigrationRunnerFactory = class _MigrationRunnerFactory {
994
1779
  return new _MigrationRunnerFactory().createEmpty(config);
995
1780
  });
996
1781
  }
997
- create(config, conn) {
1782
+ create(config, conn, factoryOwnsConnection = false) {
998
1783
  return __async(this, null, function* () {
999
1784
  let sqlrunner;
1000
1785
  let driverConnection = conn;
@@ -1007,8 +1792,19 @@ var MigrationRunnerFactory = class _MigrationRunnerFactory {
1007
1792
  const setup = new MigrationSetup(sqlrunner, config);
1008
1793
  const read_strategy = this.getReadStategy(config);
1009
1794
  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);
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;
1012
1808
  });
1013
1809
  }
1014
1810
  createEmpty(config) {
@@ -1018,7 +1814,7 @@ var MigrationRunnerFactory = class _MigrationRunnerFactory {
1018
1814
  const setup = new MigrationSetup(sqlrunner, config);
1019
1815
  const read_strategy = this.getReadStategy(config);
1020
1816
  const migration_files = new MigrationDirectoryReader(config.migration_folder, read_strategy, sqlrunner, config.database);
1021
- return new MySQLMigrationRunner(config, migration_files, setup, sqlrunner, conn);
1817
+ return new MySQLMigrationRunner(config, migration_files, setup, sqlrunner, conn, false);
1022
1818
  });
1023
1819
  }
1024
1820
  getReadStategy(config) {
@@ -1035,33 +1831,77 @@ var MigrationRunnerFactory = class _MigrationRunnerFactory {
1035
1831
  }
1036
1832
  };
1037
1833
  var MySQLMigrationRunner = class {
1038
- constructor(config, directory, setupRunner, sqlrunner, connection) {
1834
+ constructor(config, directory, setupRunner, sqlrunner, connection, preflightEnabled = true) {
1039
1835
  this.config = config;
1040
1836
  this.directory = directory;
1041
1837
  this.setupRunner = setupRunner;
1042
1838
  this.sqlrunner = sqlrunner;
1043
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 = [];
1044
1849
  }
1045
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() {
1046
1865
  return __async(this, null, function* () {
1047
1866
  try {
1048
1867
  yield this.setupRunner.setup();
1049
1868
  } catch (error) {
1050
- 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));
1051
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;
1052
1883
  });
1053
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
+ }
1054
1893
  terminate() {
1055
1894
  return __async(this, null, function* () {
1056
1895
  try {
1057
1896
  yield this.setupRunner.teardown();
1058
1897
  } catch (error) {
1059
- 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));
1060
1899
  }
1061
1900
  });
1062
1901
  }
1063
1902
  getMigrationsHistory() {
1064
1903
  return __async(this, null, function* () {
1904
+ yield this.setup();
1065
1905
  try {
1066
1906
  const results = yield this.sqlrunner.query(`
1067
1907
  select *
@@ -1069,16 +1909,17 @@ var MySQLMigrationRunner = class {
1069
1909
  `);
1070
1910
  return results[0];
1071
1911
  } catch (error) {
1072
- 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));
1073
1913
  }
1074
1914
  });
1075
1915
  }
1076
1916
  getMigrations() {
1077
1917
  return __async(this, null, function* () {
1918
+ yield this.setup();
1078
1919
  try {
1079
1920
  return this.directory.loadMigrations(this.config.migration_table, this.connection);
1080
1921
  } catch (error) {
1081
- 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));
1082
1923
  }
1083
1924
  });
1084
1925
  }
@@ -1091,7 +1932,7 @@ var MySQLMigrationRunner = class {
1091
1932
  if (error instanceof MigrationExecutionError) {
1092
1933
  throw error;
1093
1934
  }
1094
- 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));
1095
1936
  }
1096
1937
  });
1097
1938
  }
@@ -1104,12 +1945,13 @@ var MySQLMigrationRunner = class {
1104
1945
  if (error instanceof MigrationExecutionError) {
1105
1946
  throw error;
1106
1947
  }
1107
- 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));
1108
1949
  }
1109
1950
  });
1110
1951
  }
1111
1952
  migrate(migrationNodes, forward) {
1112
1953
  return __async(this, null, function* () {
1954
+ yield this.setup();
1113
1955
  for (let node of migrationNodes) {
1114
1956
  try {
1115
1957
  if (forward) {
@@ -1122,7 +1964,7 @@ var MySQLMigrationRunner = class {
1122
1964
  `Failed to ${forward ? "apply" : "rollback"} migration`,
1123
1965
  node.name || String(node),
1124
1966
  forward ? node.up_sql() : node.down_sql(),
1125
- toError(error)
1967
+ toError2(error)
1126
1968
  );
1127
1969
  }
1128
1970
  }
@@ -1130,6 +1972,7 @@ var MySQLMigrationRunner = class {
1130
1972
  }
1131
1973
  reset() {
1132
1974
  return __async(this, null, function* () {
1975
+ yield this.setup();
1133
1976
  try {
1134
1977
  let migrations = yield this.getMigrations();
1135
1978
  const rollback = yield migration_filter(migrations, true);
@@ -1141,7 +1984,7 @@ var MySQLMigrationRunner = class {
1141
1984
  if (error instanceof MigrationExecutionError) {
1142
1985
  throw error;
1143
1986
  }
1144
- 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));
1145
1988
  }
1146
1989
  });
1147
1990
  }
@@ -1150,7 +1993,7 @@ var MySQLMigrationRunner = class {
1150
1993
  const creator = new MigrationCreator(this.config);
1151
1994
  creator.create(name);
1152
1995
  } catch (error) {
1153
- 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));
1154
1997
  }
1155
1998
  }
1156
1999
  close() {
@@ -1159,7 +2002,7 @@ var MySQLMigrationRunner = class {
1159
2002
  try {
1160
2003
  yield this.sqlrunner.end();
1161
2004
  } catch (error) {
1162
- throw new DatabaseConnectionError(`Failed to close database connection: ${toError(error).message}`);
2005
+ throw new DatabaseConnectionError(`Failed to close database connection: ${toError2(error).message}`);
1163
2006
  }
1164
2007
  }
1165
2008
  });
@@ -1173,7 +2016,7 @@ var MySQLMigrationRunner = class {
1173
2016
  return __async(this, null, function* () {
1174
2017
  try {
1175
2018
  console.log(`Checking for ${config_file2}`);
1176
- const config_exist = import_fs3.default.existsSync(config_file2);
2019
+ const config_exist = import_fs6.default.existsSync(config_file2);
1177
2020
  if (!config_exist) {
1178
2021
  console.log(`Creating ${config_file2}`);
1179
2022
  const default_config = {
@@ -1187,11 +2030,11 @@ var MySQLMigrationRunner = class {
1187
2030
  "password": ""
1188
2031
  }
1189
2032
  };
1190
- 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));
1191
2034
  console.log(`Created ${config_file2}`);
1192
2035
  }
1193
2036
  } catch (error) {
1194
- throw new ConfigurationError(`Failed to initialize config file: ${toError(error).message}`);
2037
+ throw new ConfigurationError(`Failed to initialize config file: ${toError2(error).message}`);
1195
2038
  }
1196
2039
  });
1197
2040
  }
@@ -1202,7 +2045,7 @@ var FileMigrationConfigReader = class {
1202
2045
  }
1203
2046
  loadFile() {
1204
2047
  try {
1205
- const fileContent = import_fs3.default.readFileSync(this.configFile);
2048
+ const fileContent = import_fs6.default.readFileSync(this.configFile);
1206
2049
  const config = JSON.parse(fileContent.toString());
1207
2050
  if (!config.migration_folder) {
1208
2051
  throw ConfigurationError.missingRequiredProperty("migration_folder");
@@ -1226,7 +2069,7 @@ var FileMigrationConfigReader = class {
1226
2069
  if (error instanceof ConfigurationError) {
1227
2070
  throw error;
1228
2071
  }
1229
- const err = toError(error);
2072
+ const err = toError2(error);
1230
2073
  if (err.message.includes("ENOENT")) {
1231
2074
  throw new ConfigurationError(`Config file not found: ${this.configFile}`);
1232
2075
  }
@@ -1243,16 +2086,16 @@ var MigrationCreator = class {
1243
2086
  throw new CLIError("Migration name is required");
1244
2087
  }
1245
2088
  try {
1246
- if (!import_fs3.default.existsSync(this.config.migration_folder)) {
1247
- 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 });
1248
2091
  }
1249
2092
  const now_timestamp = Date.now();
1250
2093
  const filename_up = `${now_timestamp}_${name}.up.sql`;
1251
2094
  const filename_down = `${now_timestamp}_${name}.down.sql`;
1252
- import_fs3.default.writeFileSync(`${this.config.migration_folder}/${filename_up}`, `
2095
+ import_fs6.default.writeFileSync(`${this.config.migration_folder}/${filename_up}`, `
1253
2096
  -- Write your up migration here
1254
2097
  `.trim());
1255
- import_fs3.default.writeFileSync(`${this.config.migration_folder}/${filename_down}`, `
2098
+ import_fs6.default.writeFileSync(`${this.config.migration_folder}/${filename_down}`, `
1256
2099
  -- Write your down migration here
1257
2100
  `.trim());
1258
2101
  console.log(`Created migration files:`);
@@ -1262,16 +2105,16 @@ var MigrationCreator = class {
1262
2105
  if (error instanceof CLIError) {
1263
2106
  throw error;
1264
2107
  }
1265
- throw new MigrationExecutionError(`Failed to create migration files: ${toError(error).message}`);
2108
+ throw new MigrationExecutionError(`Failed to create migration files: ${toError2(error).message}`);
1266
2109
  }
1267
2110
  }
1268
2111
  };
1269
2112
 
1270
2113
  // framework/SeedRunner.ts
1271
- var import_fs4 = __toESM(require("fs"));
1272
- var import_path2 = __toESM(require("path"));
2114
+ var import_fs7 = __toESM(require("fs"));
2115
+ var import_path5 = __toESM(require("path"));
1273
2116
  var import_url = require("url");
1274
- var import_ajv = __toESM(require("ajv"));
2117
+ var import_ajv2 = __toESM(require("ajv"));
1275
2118
  var import_ajv_formats = __toESM(require("ajv-formats"));
1276
2119
  var import_api = require("tsx/esm/api");
1277
2120
  function resolveAlias(name, aliasMap) {
@@ -1280,10 +2123,10 @@ function resolveAlias(name, aliasMap) {
1280
2123
  return (_a = aliasMap[name]) != null ? _a : name;
1281
2124
  }
1282
2125
  function walkForFile(rootDir, fileName) {
1283
- if (!import_fs4.default.existsSync(rootDir)) return null;
1284
- 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 });
1285
2128
  for (const entry of entries) {
1286
- const full = import_path2.default.join(rootDir, entry.name);
2129
+ const full = import_path5.default.join(rootDir, entry.name);
1287
2130
  if (entry.isDirectory()) {
1288
2131
  const found = walkForFile(full, fileName);
1289
2132
  if (found) return found;
@@ -1377,39 +2220,39 @@ function resolveSeed(name, migrationConfig, options) {
1377
2220
  }
1378
2221
  function loadJson(filePath) {
1379
2222
  return __async(this, null, function* () {
1380
- const content = yield import_fs4.default.promises.readFile(filePath, "utf8");
2223
+ const content = yield import_fs7.default.promises.readFile(filePath, "utf8");
1381
2224
  return JSON.parse(content);
1382
2225
  });
1383
2226
  }
1384
2227
  function createValidator() {
1385
- const ajv = new import_ajv.default({ allErrors: true, strict: false });
1386
- (0, import_ajv_formats.default)(ajv);
1387
- return ajv;
2228
+ const ajv2 = new import_ajv2.default({ allErrors: true, strict: false });
2229
+ (0, import_ajv_formats.default)(ajv2);
2230
+ return ajv2;
1388
2231
  }
1389
2232
  function validateData(schemaPath, data, validate, log) {
1390
2233
  return __async(this, null, function* () {
1391
2234
  if (!validate || !schemaPath) return;
1392
- const content = yield import_fs4.default.promises.readFile(schemaPath, "utf8");
2235
+ const content = yield import_fs7.default.promises.readFile(schemaPath, "utf8");
1393
2236
  const schema = JSON.parse(content);
1394
- const ajv = createValidator();
1395
- const validateFn = ajv.compile(schema);
2237
+ const ajv2 = createValidator();
2238
+ const validateFn = ajv2.compile(schema);
1396
2239
  const ok = validateFn(data);
1397
2240
  if (!ok) {
1398
2241
  log == null ? void 0 : log(`Validation failed for seed data (${schemaPath})`);
1399
- throw new Error(`Seed data validation failed: ${ajv.errorsText(validateFn.errors || [])}`);
2242
+ throw new Error(`Seed data validation failed: ${ajv2.errorsText(validateFn.errors || [])}`);
1400
2243
  }
1401
2244
  });
1402
2245
  }
1403
2246
  function runSqlSeed(runner, resolved, direction) {
1404
2247
  return __async(this, null, function* () {
1405
2248
  const sqlPath = direction === "up" ? resolved.upPath : resolved.downPath;
1406
- const sql = yield import_fs4.default.promises.readFile(sqlPath, "utf8");
2249
+ const sql = yield import_fs7.default.promises.readFile(sqlPath, "utf8");
1407
2250
  yield runner.query(sql);
1408
2251
  });
1409
2252
  }
1410
2253
  function loadSeedModule(modulePath) {
1411
2254
  return __async(this, null, function* () {
1412
- const resolved = import_path2.default.resolve(modulePath);
2255
+ const resolved = import_path5.default.resolve(modulePath);
1413
2256
  if (resolved.endsWith(".ts")) {
1414
2257
  const fileUrl = (0, import_url.pathToFileURL)(resolved).href;
1415
2258
  return (0, import_api.tsImport)(fileUrl, fileUrl);
@@ -1510,7 +2353,7 @@ if (!args.commands || args.commands.length === 0) {
1510
2353
  }
1511
2354
  var commands = args.commands;
1512
2355
  var command = commands[0];
1513
- var load_database = !["init", "create", "help"].includes(command.toLowerCase());
2356
+ var load_database = !["init", "create", "help", "patch"].includes(command.toLowerCase());
1514
2357
  var config_file = args.flags.config || "proper.json";
1515
2358
  if (command.toLowerCase() === "help") {
1516
2359
  printUsage();
@@ -1519,9 +2362,17 @@ if (command.toLowerCase() === "help") {
1519
2362
  console.log(`Loading database: ${load_database}`);
1520
2363
  var pending_runner = load_database ? MigrationRunnerFactory.create(config_file) : MigrationRunnerFactory.createEmpty(config_file);
1521
2364
  pending_runner.then((runner) => __async(null, null, function* () {
2365
+ let failed = false;
1522
2366
  try {
1523
2367
  if (load_database) {
1524
- 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
+ }
1525
2376
  }
1526
2377
  switch (command.toLowerCase()) {
1527
2378
  case "up":
@@ -1541,7 +2392,6 @@ pending_runner.then((runner) => __async(null, null, function* () {
1541
2392
  } else {
1542
2393
  console.log("No pending migrations");
1543
2394
  }
1544
- yield runner.close();
1545
2395
  break;
1546
2396
  case "down":
1547
2397
  const migrations_rollback = yield runner.getMigrations();
@@ -1562,13 +2412,11 @@ pending_runner.then((runner) => __async(null, null, function* () {
1562
2412
  } else {
1563
2413
  console.log("No migrations to roll back");
1564
2414
  }
1565
- yield runner.close();
1566
2415
  break;
1567
2416
  case "reset":
1568
2417
  console.log("Resetting all migrations...");
1569
2418
  yield runner.reset();
1570
2419
  console.log("Reset completed successfully");
1571
- yield runner.close();
1572
2420
  break;
1573
2421
  case "create":
1574
2422
  let filename = args.flags.name || commands[1];
@@ -1577,11 +2425,22 @@ pending_runner.then((runner) => __async(null, null, function* () {
1577
2425
  }
1578
2426
  filename = filename.replace(/\s/g, "_");
1579
2427
  runner.createMigration(filename);
1580
- runner.close();
1581
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
+ }
1582
2442
  case "init":
1583
2443
  yield runner.init(config_file);
1584
- runner.close();
1585
2444
  break;
1586
2445
  case "status":
1587
2446
  const { printTable } = require("console-table-printer");
@@ -1595,7 +2454,6 @@ pending_runner.then((runner) => __async(null, null, function* () {
1595
2454
  };
1596
2455
  }));
1597
2456
  printTable(yield Promise.all(table));
1598
- runner.close();
1599
2457
  break;
1600
2458
  case "query":
1601
2459
  const sql_query = args.flags.query || commands[1];
@@ -1639,7 +2497,6 @@ Returned ${results.length} row(s)`);
1639
2497
  } catch (error) {
1640
2498
  throw new CLIError(`Query execution failed: ${error.message}`);
1641
2499
  }
1642
- yield runner.close();
1643
2500
  break;
1644
2501
  case "seed": {
1645
2502
  const subCommands = commands.slice(1);
@@ -1672,7 +2529,6 @@ Returned ${results.length} row(s)`);
1672
2529
  const reader = new FileMigrationConfigReader(config_file);
1673
2530
  const migrationConfig = reader.loadFile();
1674
2531
  yield runSeedsWithRunner(runner, migrationConfig, action, seedOptions);
1675
- yield runner.close();
1676
2532
  break;
1677
2533
  }
1678
2534
  default:
@@ -1683,9 +2539,25 @@ Returned ${results.length} row(s)`);
1683
2539
  if (error.stack && process.env.DEBUG) {
1684
2540
  console.error(error.stack);
1685
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) {
1686
2552
  process.exit(1);
1687
2553
  }
1688
- }));
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
+ });
1689
2561
  function printUsage() {
1690
2562
  console.log(`
1691
2563
  SQL Proper - Database migration tool
@@ -1698,6 +2570,7 @@ Commands:
1698
2570
  down Roll back completed migrations
1699
2571
  reset Roll back all migrations and reapply them
1700
2572
  create Create a new migration
2573
+ patch Create a new ledger patch file (repairs migration history; no database access)
1701
2574
  init Initialize a new config file
1702
2575
  status Show migration status
1703
2576
  query Execute a SQL query and display results
@@ -1716,6 +2589,7 @@ Examples:
1716
2589
  proper down --increment 3 Roll back the last 3 applied migrations
1717
2590
  proper down --all Roll back all completed migrations
1718
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
1719
2593
  proper init Create a new config file
1720
2594
  proper status Show the status of all migrations
1721
2595
  proper query "select * from users" Execute a SQL query