@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.mjs CHANGED
@@ -121,7 +121,7 @@ var init_MigrationFilter = __esm({
121
121
  });
122
122
 
123
123
  // framework/errors.ts
124
- var MigrationError, ConfigurationError, DatabaseConnectionError, MigrationExecutionError, CLIError;
124
+ var MigrationError, ConfigurationError, DatabaseConnectionError, MigrationExecutionError, PatchError, PatchValidationError, PatchIntegrityError, PatchConflictError, PatchExecutionError, CLIError;
125
125
  var init_errors = __esm({
126
126
  "framework/errors.ts"() {
127
127
  MigrationError = class _MigrationError extends Error {
@@ -236,6 +236,54 @@ ${originalError.message}`;
236
236
  return new _MigrationExecutionError(message);
237
237
  }
238
238
  };
239
+ PatchError = class _PatchError extends MigrationError {
240
+ constructor(message, patchFile, patchKey) {
241
+ super(`Patch Error: ${message}`);
242
+ this.patchFile = patchFile;
243
+ this.patchKey = patchKey;
244
+ this.name = "PatchError";
245
+ Object.setPrototypeOf(this, _PatchError.prototype);
246
+ }
247
+ };
248
+ PatchValidationError = class _PatchValidationError extends PatchError {
249
+ constructor(message, patchFile, patchKey) {
250
+ super(message, patchFile, patchKey);
251
+ this.name = "PatchValidationError";
252
+ Object.setPrototypeOf(this, _PatchValidationError.prototype);
253
+ }
254
+ };
255
+ PatchIntegrityError = class _PatchIntegrityError extends PatchError {
256
+ constructor(message, patchFile, patchKey, expectedChecksum, actualChecksum) {
257
+ super(message, patchFile, patchKey);
258
+ this.expectedChecksum = expectedChecksum;
259
+ this.actualChecksum = actualChecksum;
260
+ this.name = "PatchIntegrityError";
261
+ Object.setPrototypeOf(this, _PatchIntegrityError.prototype);
262
+ }
263
+ };
264
+ PatchConflictError = class _PatchConflictError extends PatchError {
265
+ constructor(message, patchFile, patchKey, operationIndex, operationVerb, migrationKeys, observedRowCounts) {
266
+ super(message, patchFile, patchKey);
267
+ this.operationIndex = operationIndex;
268
+ this.operationVerb = operationVerb;
269
+ this.migrationKeys = migrationKeys;
270
+ this.observedRowCounts = observedRowCounts;
271
+ this.name = "PatchConflictError";
272
+ Object.setPrototypeOf(this, _PatchConflictError.prototype);
273
+ }
274
+ };
275
+ PatchExecutionError = class _PatchExecutionError extends PatchError {
276
+ constructor(message, patchFile, patchKey, operationIndex, operationVerb, originalError) {
277
+ super(originalError ? `${message}
278
+ Original Error:
279
+ ${originalError.message}` : message, patchFile, patchKey);
280
+ this.operationIndex = operationIndex;
281
+ this.operationVerb = operationVerb;
282
+ this.originalError = originalError;
283
+ this.name = "PatchExecutionError";
284
+ Object.setPrototypeOf(this, _PatchExecutionError.prototype);
285
+ }
286
+ };
239
287
  CLIError = class _CLIError extends MigrationError {
240
288
  constructor(message) {
241
289
  super(`CLI Error: ${message}`);
@@ -412,13 +460,52 @@ var init_SqlMigrationBuilder = __esm({
412
460
  }
413
461
  });
414
462
 
415
- // framework/MigrationDirectoryReader.ts
463
+ // framework/MigrationManifest.ts
416
464
  import fs from "fs";
417
465
  import path from "path";
466
+ function isMigrationFile(fileName) {
467
+ return MIGRATION_FILE_REGEX.test(fileName);
468
+ }
469
+ function canonicalMigrationKey(value) {
470
+ return value.replace(MIGRATION_FILE_REGEX, "").toLowerCase();
471
+ }
472
+ function resolveMigrationFile(directory, baseName, direction, dialect) {
473
+ const dialectExt = dialect === "sql" ? "mysql" : dialect;
474
+ const dialectFile = path.join(directory, `${baseName}.${dialectExt}.${direction}.sql`);
475
+ if (fs.existsSync(dialectFile)) return dialectFile;
476
+ const genericFile = path.join(directory, `${baseName}.${direction}.sql`);
477
+ if (fs.existsSync(genericFile)) return genericFile;
478
+ return null;
479
+ }
480
+ function loadMigrationManifest(directory, dialect) {
481
+ const manifest = /* @__PURE__ */ new Map();
482
+ if (!fs.existsSync(directory)) return manifest;
483
+ const files = fs.readdirSync(directory, { withFileTypes: true }).filter((f) => f.isFile()).map((f) => f.name).filter(isMigrationFile);
484
+ const uniqueKeys = /* @__PURE__ */ new Set();
485
+ files.forEach((file) => uniqueKeys.add(canonicalMigrationKey(file)));
486
+ uniqueKeys.forEach((key) => {
487
+ manifest.set(key, {
488
+ key,
489
+ upFile: resolveMigrationFile(directory, key, "up", dialect),
490
+ downFile: resolveMigrationFile(directory, key, "down", dialect)
491
+ });
492
+ });
493
+ return manifest;
494
+ }
495
+ var MIGRATION_FILE_REGEX;
496
+ var init_MigrationManifest = __esm({
497
+ "framework/MigrationManifest.ts"() {
498
+ MIGRATION_FILE_REGEX = /(?:\.(mysql|sqlite|pg))?\.(up|down)\.(sql|js)$/i;
499
+ }
500
+ });
501
+
502
+ // framework/MigrationDirectoryReader.ts
503
+ import fs2 from "fs";
418
504
  var MigrationDirectoryReader;
419
505
  var init_MigrationDirectoryReader = __esm({
420
506
  "framework/MigrationDirectoryReader.ts"() {
421
507
  init_SqlMigrationBuilder();
508
+ init_MigrationManifest();
422
509
  MigrationDirectoryReader = class {
423
510
  constructor(directory, read_strategy, sqlrunner, dialect = "sql") {
424
511
  this.directory = directory;
@@ -432,12 +519,7 @@ var init_MigrationDirectoryReader = __esm({
432
519
  * File extensions: `.mysql.up.sql`, `.sqlite.up.sql`, `.pg.up.sql`.
433
520
  */
434
521
  resolveFile(baseName, direction) {
435
- const dialectExt = this.dialect === "sql" ? "mysql" : this.dialect;
436
- const dialectFile = path.join(this.directory, `${baseName}.${dialectExt}.${direction}.sql`);
437
- if (fs.existsSync(dialectFile)) return dialectFile;
438
- const genericFile = path.join(this.directory, `${baseName}.${direction}.sql`);
439
- if (fs.existsSync(genericFile)) return genericFile;
440
- return null;
522
+ return resolveMigrationFile(this.directory, baseName, direction, this.dialect);
441
523
  }
442
524
  /**
443
525
  * Checks if a file path is dialect-specific (contains .mysql. or .sqlite. in the name)
@@ -446,11 +528,11 @@ var init_MigrationDirectoryReader = __esm({
446
528
  return /\.(mysql|sqlite|pg)\.(up|down)\.sql$/i.test(filePath);
447
529
  }
448
530
  loadMigrations(table, connection) {
449
- fs.existsSync(this.directory) || fs.mkdirSync(this.directory);
450
- const dir_content = fs.readdirSync(this.directory, { withFileTypes: true }).filter((file) => file.isFile()).map((file) => file.name);
531
+ fs2.existsSync(this.directory) || fs2.mkdirSync(this.directory);
532
+ const dir_content = fs2.readdirSync(this.directory, { withFileTypes: true }).filter((file) => file.isFile()).map((file) => file.name).filter(isMigrationFile);
451
533
  const uniqueKeys = /* @__PURE__ */ new Set();
452
534
  dir_content.forEach((file) => {
453
- const key = file.replace(/(?:\.(mysql|sqlite|pg))?\.(up|down)\.(sql|js)/i, "").toLowerCase();
535
+ const key = canonicalMigrationKey(file);
454
536
  uniqueKeys.add(key);
455
537
  });
456
538
  const migration_sorter = {};
@@ -490,14 +572,14 @@ var init_MigrationDirectoryReader = __esm({
490
572
  return builder;
491
573
  }
492
574
  sql_up(file) {
493
- let content = fs.readFileSync(file).toString();
575
+ let content = fs2.readFileSync(file).toString();
494
576
  if (!this.isDialectSpecific(file)) {
495
577
  content = this.read_strategy(content);
496
578
  }
497
579
  return content.trim();
498
580
  }
499
581
  sql_down(file) {
500
- let content = fs.readFileSync(file).toString();
582
+ let content = fs2.readFileSync(file).toString();
501
583
  if (!this.isDialectSpecific(file)) {
502
584
  content = this.read_strategy(content);
503
585
  }
@@ -507,11 +589,31 @@ var init_MigrationDirectoryReader = __esm({
507
589
  }
508
590
  });
509
591
 
592
+ // framework/PatchTypes.ts
593
+ import path2 from "path";
594
+ function resolvePatchFolder(config) {
595
+ if (config.patch_folder) return config.patch_folder;
596
+ const dir = path2.dirname(config.migration_folder);
597
+ return dir === "." && !config.migration_folder.includes(path2.sep) && !config.migration_folder.includes("/") ? "patches" : path2.join(dir, "patches");
598
+ }
599
+ function resolvePatchTable(config) {
600
+ return config.patch_table || DEFAULT_PATCH_TABLE;
601
+ }
602
+ var PATCH_FORMAT_VERSION, DEFAULT_PATCH_TABLE, PATCH_FILENAME_REGEX;
603
+ var init_PatchTypes = __esm({
604
+ "framework/PatchTypes.ts"() {
605
+ PATCH_FORMAT_VERSION = 1;
606
+ DEFAULT_PATCH_TABLE = "proper_patches";
607
+ PATCH_FILENAME_REGEX = new RegExp("^(?<stamp>[0-9]{13})_(?<name>[a-z0-9][a-z0-9_-]{0,119})\\.yaml$");
608
+ }
609
+ });
610
+
510
611
  // framework/MigrationSetup.ts
511
- import fs2 from "fs";
612
+ import fs3 from "fs";
512
613
  var MigrationSetup;
513
614
  var init_MigrationSetup = __esm({
514
615
  "framework/MigrationSetup.ts"() {
616
+ init_PatchTypes();
515
617
  MigrationSetup = class {
516
618
  constructor(sqlrunner, config) {
517
619
  this.sqlrunner = sqlrunner;
@@ -519,7 +621,7 @@ var init_MigrationSetup = __esm({
519
621
  }
520
622
  setup() {
521
623
  return __async(this, null, function* () {
522
- fs2.existsSync(this.config.migration_folder) || fs2.mkdirSync(this.config.migration_folder);
624
+ fs3.existsSync(this.config.migration_folder) || fs3.mkdirSync(this.config.migration_folder);
523
625
  const tableName = this.config.migration_table;
524
626
  const createTableSql = this.config.database === "sqlite" ? `CREATE TABLE IF NOT EXISTS ${tableName} (
525
627
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -541,11 +643,39 @@ var init_MigrationSetup = __esm({
541
643
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
542
644
  )`;
543
645
  yield this.sqlrunner.query(createTableSql);
646
+ const patchTable = resolvePatchTable(this.config);
647
+ const createPatchTableSql = this.config.database === "sqlite" ? `CREATE TABLE IF NOT EXISTS ${patchTable} (
648
+ migration_table TEXT NOT NULL,
649
+ patch_key TEXT NOT NULL,
650
+ checksum TEXT NOT NULL,
651
+ format_version INTEGER NOT NULL,
652
+ description TEXT NOT NULL,
653
+ applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
654
+ PRIMARY KEY (migration_table, patch_key)
655
+ )` : this.config.database === "pg" ? `CREATE TABLE IF NOT EXISTS ${patchTable} (
656
+ migration_table TEXT NOT NULL,
657
+ patch_key TEXT NOT NULL,
658
+ checksum TEXT NOT NULL,
659
+ format_version INTEGER NOT NULL,
660
+ description TEXT NOT NULL,
661
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
662
+ PRIMARY KEY (migration_table, patch_key)
663
+ )` : `CREATE TABLE IF NOT EXISTS ${patchTable} (
664
+ migration_table VARCHAR(255) NOT NULL,
665
+ patch_key VARCHAR(255) NOT NULL,
666
+ checksum CHAR(64) NOT NULL,
667
+ format_version INT NOT NULL,
668
+ description TEXT NOT NULL,
669
+ applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
670
+ PRIMARY KEY (migration_table, patch_key)
671
+ )`;
672
+ yield this.sqlrunner.query(createPatchTableSql);
544
673
  });
545
674
  }
546
675
  teardown() {
547
676
  return __async(this, null, function* () {
548
677
  yield this.sqlrunner.execute(`DROP TABLE ${this.config.migration_table}`);
678
+ yield this.sqlrunner.execute(`DROP TABLE IF EXISTS ${resolvePatchTable(this.config)}`);
549
679
  });
550
680
  }
551
681
  };
@@ -614,6 +744,703 @@ var init_MigrationDialectParser = __esm({
614
744
  }
615
745
  });
616
746
 
747
+ // framework/PatchValidator.ts
748
+ import Ajv from "ajv";
749
+ import { parseDocument } from "yaml";
750
+ function fail(message, file, patchKey) {
751
+ throw new PatchValidationError(message, file, patchKey);
752
+ }
753
+ function checkMigrationKey(value, context, file, patchKey) {
754
+ if (value !== value.trim()) fail(`${context}: migration key has leading/trailing whitespace`, file, patchKey);
755
+ if (/[/\\]/.test(value)) fail(`${context}: migration key contains a path separator`, file, patchKey);
756
+ if (/[\x00-\x1f\x7f]/.test(value)) fail(`${context}: migration key contains control characters`, file, patchKey);
757
+ if (value.length === 0 || value.length > MAX_MIGRATION_KEY_LENGTH) {
758
+ fail(`${context}: migration key length out of bounds`, file, patchKey);
759
+ }
760
+ return canonicalMigrationKey(value);
761
+ }
762
+ function assertStrictYaml(doc, file, patchKey) {
763
+ if (doc.errors.length > 0) {
764
+ fail(`YAML parse error: ${doc.errors[0].message}`, file, patchKey);
765
+ }
766
+ if (doc.warnings.length > 0) {
767
+ fail(`YAML warning treated as error: ${doc.warnings[0].message}`, file, patchKey);
768
+ }
769
+ const visit = (node) => {
770
+ var _a, _b;
771
+ if (node == null || typeof node !== "object") return;
772
+ if ("source" in node && ((_a = node.constructor) == null ? void 0 : _a.name) === "Alias") {
773
+ fail("YAML aliases are not permitted in patch files", file, patchKey);
774
+ }
775
+ if (node.anchor) {
776
+ fail("YAML anchors are not permitted in patch files", file, patchKey);
777
+ }
778
+ if (node.tag && ![
779
+ "tag:yaml.org,2002:str",
780
+ "tag:yaml.org,2002:int",
781
+ "tag:yaml.org,2002:bool",
782
+ "tag:yaml.org,2002:null",
783
+ "tag:yaml.org,2002:map",
784
+ "tag:yaml.org,2002:seq"
785
+ ].includes(node.tag)) {
786
+ fail(`YAML tag '${node.tag}' is not permitted in patch files`, file, patchKey);
787
+ }
788
+ if (Array.isArray(node.items)) {
789
+ for (const item of node.items) {
790
+ if (item && typeof item === "object" && "key" in item) {
791
+ const keyValue = (_b = item.key) == null ? void 0 : _b.value;
792
+ if (keyValue === "<<") fail("YAML merge keys are not permitted in patch files", file, patchKey);
793
+ visit(item.key);
794
+ visit(item.value);
795
+ } else {
796
+ visit(item);
797
+ }
798
+ }
799
+ }
800
+ };
801
+ visit(doc.contents);
802
+ }
803
+ function parsePatchContent(content, fileName, patchKey) {
804
+ var _a;
805
+ const doc = parseDocument(content, {
806
+ uniqueKeys: true,
807
+ // duplicate mapping keys become errors
808
+ merge: false,
809
+ schema: "core",
810
+ version: "1.2"
811
+ });
812
+ assertStrictYaml(doc, fileName, patchKey);
813
+ const raw = doc.toJS({ mapAsMap: false });
814
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
815
+ fail("Patch document must be a YAML mapping", fileName, patchKey);
816
+ }
817
+ if (!validateSchema(raw)) {
818
+ const detail = ((_a = validateSchema.errors) != null ? _a : []).map((e) => `${e.instancePath || "/"} ${e.message}`).join("; ");
819
+ const anyRaw = raw;
820
+ if (typeof anyRaw.version === "number" && anyRaw.version !== PATCH_FORMAT_VERSION) {
821
+ fail(`Unknown patch format version: ${anyRaw.version} (supported: ${PATCH_FORMAT_VERSION})`, fileName, patchKey);
822
+ }
823
+ fail(`Schema validation failed: ${detail}`, fileName, patchKey);
824
+ }
825
+ const parsed = raw;
826
+ if (parsed.version !== PATCH_FORMAT_VERSION) {
827
+ fail(`Unknown patch format version: ${parsed.version} (supported: ${PATCH_FORMAT_VERSION})`, fileName, patchKey);
828
+ }
829
+ const description = parsed.description.trim();
830
+ if (description.length === 0) fail("description must be non-empty", fileName, patchKey);
831
+ if (description.length > MAX_DESCRIPTION_LENGTH) {
832
+ fail(`description exceeds ${MAX_DESCRIPTION_LENGTH} characters`, fileName, patchKey);
833
+ }
834
+ const operations = parsed.operations.map((op, index) => {
835
+ const verbs = Object.keys(op);
836
+ const verb = verbs[0];
837
+ const context = `operation ${index} (${verb})`;
838
+ switch (verb) {
839
+ case "rename_migration": {
840
+ const from = checkMigrationKey(op.rename_migration.from, context, fileName, patchKey);
841
+ const to = checkMigrationKey(op.rename_migration.to, context, fileName, patchKey);
842
+ if (from === to) {
843
+ fail(`${context}: 'from' and 'to' are identical after canonicalization ('${from}')`, fileName, patchKey);
844
+ }
845
+ return { verb: "rename_migration", from, to };
846
+ }
847
+ case "mark_applied":
848
+ return { verb: "mark_applied", key: checkMigrationKey(op.mark_applied.key, context, fileName, patchKey) };
849
+ case "unmark_applied":
850
+ return { verb: "unmark_applied", key: checkMigrationKey(op.unmark_applied.key, context, fileName, patchKey) };
851
+ default:
852
+ fail(`operation ${index}: unknown verb '${verb}'`, fileName, patchKey);
853
+ }
854
+ });
855
+ return { version: parsed.version, description, operations };
856
+ }
857
+ function validatePatchPlan(patches, manifest) {
858
+ const renames = [];
859
+ for (const patch of patches) {
860
+ patch.operations.forEach((op, index) => {
861
+ if (op.verb === "rename_migration") {
862
+ renames.push({ from: op.from, to: op.to, file: patch.fileName });
863
+ } else if (op.verb === "mark_applied") {
864
+ if (!manifest.has(op.key)) {
865
+ throw new PatchValidationError(
866
+ `operation ${index} (mark_applied): key '${op.key}' is not present in the current migration manifest`,
867
+ patch.fileName,
868
+ patch.patchKey
869
+ );
870
+ }
871
+ }
872
+ });
873
+ }
874
+ if (renames.length === 0) return;
875
+ const mentioned = /* @__PURE__ */ new Set();
876
+ renames.forEach((r) => {
877
+ mentioned.add(r.from);
878
+ mentioned.add(r.to);
879
+ });
880
+ const finalKey = (start) => {
881
+ let current = start;
882
+ for (const r of renames) {
883
+ if (current === r.from) current = r.to;
884
+ }
885
+ return current;
886
+ };
887
+ for (const key of mentioned) {
888
+ const finish = finalKey(key);
889
+ if (manifest.has(key)) {
890
+ if (finish !== key) {
891
+ throw new PatchValidationError(
892
+ `rename plan moves current migration '${key}' to '${finish}', which would make its file incorrectly pending`
893
+ );
894
+ }
895
+ } else {
896
+ if (!manifest.has(finish)) {
897
+ throw new PatchValidationError(
898
+ `rename plan leaves historical key '${key}' at '${finish}', which is not present in the current migration manifest`
899
+ );
900
+ }
901
+ }
902
+ }
903
+ }
904
+ var MAX_DESCRIPTION_LENGTH, MAX_MIGRATION_KEY_LENGTH, migrationKeySchema, patchSchema, ajv, validateSchema;
905
+ var init_PatchValidator = __esm({
906
+ "framework/PatchValidator.ts"() {
907
+ init_errors();
908
+ init_MigrationManifest();
909
+ init_PatchTypes();
910
+ MAX_DESCRIPTION_LENGTH = 500;
911
+ MAX_MIGRATION_KEY_LENGTH = 255;
912
+ migrationKeySchema = {
913
+ type: "string",
914
+ minLength: 1,
915
+ maxLength: MAX_MIGRATION_KEY_LENGTH
916
+ };
917
+ patchSchema = {
918
+ type: "object",
919
+ additionalProperties: false,
920
+ required: ["version", "description", "operations"],
921
+ properties: {
922
+ version: { type: "integer" },
923
+ description: { type: "string" },
924
+ operations: {
925
+ type: "array",
926
+ minItems: 1,
927
+ items: {
928
+ type: "object",
929
+ additionalProperties: false,
930
+ minProperties: 1,
931
+ maxProperties: 1,
932
+ properties: {
933
+ rename_migration: {
934
+ type: "object",
935
+ additionalProperties: false,
936
+ required: ["from", "to"],
937
+ properties: { from: migrationKeySchema, to: migrationKeySchema }
938
+ },
939
+ mark_applied: {
940
+ type: "object",
941
+ additionalProperties: false,
942
+ required: ["key"],
943
+ properties: { key: migrationKeySchema }
944
+ },
945
+ unmark_applied: {
946
+ type: "object",
947
+ additionalProperties: false,
948
+ required: ["key"],
949
+ properties: { key: migrationKeySchema }
950
+ }
951
+ }
952
+ }
953
+ }
954
+ }
955
+ };
956
+ ajv = new Ajv({ allErrors: true, strict: true });
957
+ validateSchema = ajv.compile(patchSchema);
958
+ }
959
+ });
960
+
961
+ // framework/PatchDirectoryReader.ts
962
+ import crypto from "crypto";
963
+ import fs4 from "fs";
964
+ import path3 from "path";
965
+ var PatchDirectoryReader;
966
+ var init_PatchDirectoryReader = __esm({
967
+ "framework/PatchDirectoryReader.ts"() {
968
+ init_errors();
969
+ init_PatchValidator();
970
+ init_PatchTypes();
971
+ PatchDirectoryReader = class {
972
+ constructor(directory) {
973
+ this.directory = directory;
974
+ }
975
+ loadPatches() {
976
+ if (!fs4.existsSync(this.directory)) return [];
977
+ const entries = fs4.readdirSync(this.directory, { withFileTypes: true });
978
+ const patchFiles = [];
979
+ for (const entry of entries) {
980
+ if (!entry.name.endsWith(".yaml")) continue;
981
+ if (!entry.isFile() || entry.isSymbolicLink()) {
982
+ if (entry.isSymbolicLink()) {
983
+ throw new PatchValidationError(`patch file must be a regular file, not a symlink`, entry.name);
984
+ }
985
+ continue;
986
+ }
987
+ patchFiles.push(entry.name);
988
+ }
989
+ patchFiles.sort((a, b) => {
990
+ const stampA = parseInt(a.slice(0, 13), 10);
991
+ const stampB = parseInt(b.slice(0, 13), 10);
992
+ if (!Number.isNaN(stampA) && !Number.isNaN(stampB) && stampA !== stampB) {
993
+ return stampA - stampB;
994
+ }
995
+ return a < b ? -1 : a > b ? 1 : 0;
996
+ });
997
+ return patchFiles.map((fileName) => {
998
+ const match = PATCH_FILENAME_REGEX.exec(fileName);
999
+ if (!match) {
1000
+ throw new PatchValidationError(
1001
+ `invalid patch filename (expected <13-digit-stamp>_<name>.yaml with name matching [a-z0-9][a-z0-9_-]{0,119})`,
1002
+ fileName
1003
+ );
1004
+ }
1005
+ const patchKey = fileName.slice(0, -".yaml".length);
1006
+ const filePath = path3.join(this.directory, fileName);
1007
+ const bytes = fs4.readFileSync(filePath);
1008
+ const checksum = crypto.createHash("sha256").update(bytes).digest("hex");
1009
+ const content = bytes.toString("utf8");
1010
+ const { version, description, operations } = parsePatchContent(content, fileName, patchKey);
1011
+ return { patchKey, fileName, filePath, checksum, version, description, operations };
1012
+ });
1013
+ }
1014
+ };
1015
+ }
1016
+ });
1017
+
1018
+ // framework/PatchRunner.ts
1019
+ function toError(error) {
1020
+ if (error instanceof Error) return error;
1021
+ return new Error(String(error));
1022
+ }
1023
+ function extractRows(result) {
1024
+ if (!Array.isArray(result)) return [];
1025
+ if (Array.isArray(result[0])) return result[0];
1026
+ if (result.length === 2 && result[0] && typeof result[0] === "object" && result[1] && typeof result[1] === "object" && !("rows" in result[1])) {
1027
+ return result;
1028
+ }
1029
+ if (result[0] == null) return [];
1030
+ return [result[0]];
1031
+ }
1032
+ var PatchRunner;
1033
+ var init_PatchRunner = __esm({
1034
+ "framework/PatchRunner.ts"() {
1035
+ init_PatchDirectoryReader();
1036
+ init_PatchValidator();
1037
+ init_MigrationManifest();
1038
+ init_PatchTypes();
1039
+ init_errors();
1040
+ PatchRunner = class {
1041
+ constructor(sqlrunner, config) {
1042
+ this.sqlrunner = sqlrunner;
1043
+ this.config = config;
1044
+ this.patchTable = resolvePatchTable(config);
1045
+ this.migrationTable = config.migration_table;
1046
+ this.dialect = config.database;
1047
+ }
1048
+ /**
1049
+ * Discovers, validates, and applies every unapplied patch in order.
1050
+ * Each unapplied patch is its own transaction; earlier committed patches
1051
+ * remain committed if a later patch fails.
1052
+ */
1053
+ applyPending() {
1054
+ return __async(this, null, function* () {
1055
+ const reader = new PatchDirectoryReader(resolvePatchFolder(this.config));
1056
+ const patches = reader.loadPatches();
1057
+ const history = yield this.loadHistory();
1058
+ if (patches.length === 0 && history.length === 0) {
1059
+ return [];
1060
+ }
1061
+ const byKey = new Map(patches.map((p) => [p.patchKey, p]));
1062
+ for (const row of history) {
1063
+ const file = byKey.get(row.patch_key);
1064
+ if (!file) {
1065
+ throw new PatchIntegrityError(
1066
+ `applied patch '${row.patch_key}' has no corresponding file in the patch folder; patch files are permanent and must never be renamed or deleted`,
1067
+ void 0,
1068
+ row.patch_key
1069
+ );
1070
+ }
1071
+ if (file.checksum !== row.checksum) {
1072
+ throw new PatchIntegrityError(
1073
+ `applied patch '${row.patch_key}' content changed after application; patch files are immutable once recorded`,
1074
+ file.fileName,
1075
+ row.patch_key,
1076
+ row.checksum,
1077
+ file.checksum
1078
+ );
1079
+ }
1080
+ }
1081
+ const manifest = loadMigrationManifest(this.config.migration_folder, this.dialect);
1082
+ validatePatchPlan(patches, manifest);
1083
+ const appliedKeys = new Set(history.map((r) => r.patch_key));
1084
+ const results = [];
1085
+ for (const patch of patches) {
1086
+ if (appliedKeys.has(patch.patchKey)) {
1087
+ results.push({
1088
+ patchKey: patch.patchKey,
1089
+ fileName: patch.fileName,
1090
+ status: "already_applied",
1091
+ operations: []
1092
+ });
1093
+ continue;
1094
+ }
1095
+ results.push(yield this.applyOne(patch, manifest));
1096
+ }
1097
+ return results;
1098
+ });
1099
+ }
1100
+ loadHistory() {
1101
+ return __async(this, null, function* () {
1102
+ try {
1103
+ const result = yield this.sqlrunner.query(
1104
+ `SELECT patch_key, checksum FROM ${this.patchTable} WHERE migration_table = ?`,
1105
+ [this.migrationTable]
1106
+ );
1107
+ return extractRows(result);
1108
+ } catch (error) {
1109
+ throw new PatchExecutionError(
1110
+ `failed to read patch history from '${this.patchTable}'`,
1111
+ void 0,
1112
+ void 0,
1113
+ void 0,
1114
+ void 0,
1115
+ toError(error)
1116
+ );
1117
+ }
1118
+ });
1119
+ }
1120
+ beginSql() {
1121
+ switch (this.dialect) {
1122
+ case "sqlite":
1123
+ return "BEGIN IMMEDIATE";
1124
+ case "pg":
1125
+ return "BEGIN";
1126
+ default:
1127
+ return "START TRANSACTION";
1128
+ }
1129
+ }
1130
+ begin(patch) {
1131
+ return __async(this, null, function* () {
1132
+ const deadline = Date.now() + 1e4;
1133
+ while (true) {
1134
+ try {
1135
+ yield this.sqlrunner.execute(this.beginSql());
1136
+ return;
1137
+ } catch (error) {
1138
+ const message = toError(error).message;
1139
+ if (/SQLITE_BUSY|database is locked/i.test(message) && Date.now() < deadline) {
1140
+ yield new Promise((resolve) => setTimeout(resolve, 50));
1141
+ continue;
1142
+ }
1143
+ throw new PatchExecutionError(
1144
+ "failed to start patch transaction",
1145
+ patch.fileName,
1146
+ patch.patchKey,
1147
+ void 0,
1148
+ void 0,
1149
+ toError(error)
1150
+ );
1151
+ }
1152
+ }
1153
+ });
1154
+ }
1155
+ rollbackQuietly() {
1156
+ return __async(this, null, function* () {
1157
+ try {
1158
+ yield this.sqlrunner.execute("ROLLBACK");
1159
+ } catch (e) {
1160
+ }
1161
+ });
1162
+ }
1163
+ applyOne(patch, manifest) {
1164
+ return __async(this, null, function* () {
1165
+ yield this.begin(patch);
1166
+ try {
1167
+ yield this.sqlrunner.execute(
1168
+ `INSERT INTO ${this.patchTable} (migration_table, patch_key, checksum, format_version, description)
1169
+ VALUES (?, ?, ?, ?, ?)`,
1170
+ [this.migrationTable, patch.patchKey, patch.checksum, patch.version, patch.description]
1171
+ );
1172
+ } catch (claimError) {
1173
+ yield this.rollbackQuietly();
1174
+ const committed = yield this.findCommittedRow(patch.patchKey);
1175
+ if (committed) {
1176
+ if (committed.checksum === patch.checksum) {
1177
+ return {
1178
+ patchKey: patch.patchKey,
1179
+ fileName: patch.fileName,
1180
+ status: "already_applied",
1181
+ operations: []
1182
+ };
1183
+ }
1184
+ throw new PatchIntegrityError(
1185
+ `patch '${patch.patchKey}' was applied elsewhere with a different checksum`,
1186
+ patch.fileName,
1187
+ patch.patchKey,
1188
+ committed.checksum,
1189
+ patch.checksum
1190
+ );
1191
+ }
1192
+ throw new PatchExecutionError(
1193
+ "failed to claim patch-history row",
1194
+ patch.fileName,
1195
+ patch.patchKey,
1196
+ void 0,
1197
+ void 0,
1198
+ toError(claimError)
1199
+ );
1200
+ }
1201
+ const operationResults = [];
1202
+ try {
1203
+ for (let index = 0; index < patch.operations.length; index++) {
1204
+ operationResults.push(
1205
+ yield this.applyOperation(patch, patch.operations[index], index, manifest)
1206
+ );
1207
+ }
1208
+ yield this.sqlrunner.execute("COMMIT");
1209
+ } catch (error) {
1210
+ yield this.rollbackQuietly();
1211
+ if (error instanceof PatchConflictError || error instanceof PatchExecutionError || error instanceof PatchIntegrityError) {
1212
+ throw error;
1213
+ }
1214
+ throw new PatchExecutionError(
1215
+ "patch application failed",
1216
+ patch.fileName,
1217
+ patch.patchKey,
1218
+ void 0,
1219
+ void 0,
1220
+ toError(error)
1221
+ );
1222
+ }
1223
+ return {
1224
+ patchKey: patch.patchKey,
1225
+ fileName: patch.fileName,
1226
+ status: "applied",
1227
+ operations: operationResults
1228
+ };
1229
+ });
1230
+ }
1231
+ findCommittedRow(patchKey) {
1232
+ return __async(this, null, function* () {
1233
+ const result = yield this.sqlrunner.query(
1234
+ `SELECT patch_key, checksum FROM ${this.patchTable} WHERE migration_table = ? AND patch_key = ?`,
1235
+ [this.migrationTable, patchKey]
1236
+ );
1237
+ const list = extractRows(result);
1238
+ return list.length > 0 ? list[0] : null;
1239
+ });
1240
+ }
1241
+ countRows(key) {
1242
+ return __async(this, null, function* () {
1243
+ var _a, _b, _c;
1244
+ const result = yield this.sqlrunner.query(
1245
+ `SELECT COUNT(*) AS row_count FROM ${this.migrationTable} WHERE migration_key = ?`,
1246
+ [key]
1247
+ );
1248
+ const rows = extractRows(result);
1249
+ const value = (_c = (_a = rows[0]) == null ? void 0 : _a.row_count) != null ? _c : Object.values((_b = rows[0]) != null ? _b : {})[0];
1250
+ return Number(value != null ? value : 0);
1251
+ });
1252
+ }
1253
+ conflict(patch, index, verb, message, keys, counts) {
1254
+ throw new PatchConflictError(
1255
+ `operation ${index} (${verb}): ${message}`,
1256
+ patch.fileName,
1257
+ patch.patchKey,
1258
+ index,
1259
+ verb,
1260
+ keys,
1261
+ counts
1262
+ );
1263
+ }
1264
+ applyOperation(patch, op, index, manifest) {
1265
+ return __async(this, null, function* () {
1266
+ var _a, _b, _c, _d;
1267
+ try {
1268
+ switch (op.verb) {
1269
+ case "rename_migration": {
1270
+ const fromCount = yield this.countRows(op.from);
1271
+ const toCount = yield this.countRows(op.to);
1272
+ const counts = { [op.from]: fromCount, [op.to]: toCount };
1273
+ if (fromCount > 1 || toCount > 1) {
1274
+ this.conflict(
1275
+ patch,
1276
+ index,
1277
+ op.verb,
1278
+ `ledger corruption: duplicate rows for a migration key`,
1279
+ [op.from, op.to],
1280
+ counts
1281
+ );
1282
+ }
1283
+ if (fromCount === 1 && toCount === 1) {
1284
+ this.conflict(
1285
+ patch,
1286
+ index,
1287
+ op.verb,
1288
+ `both '${op.from}' and '${op.to}' exist in the ledger`,
1289
+ [op.from, op.to],
1290
+ counts
1291
+ );
1292
+ }
1293
+ if (fromCount === 0) {
1294
+ return { verb: op.verb, changed: false };
1295
+ }
1296
+ const target = manifest.get(op.to);
1297
+ if (target) {
1298
+ yield this.sqlrunner.execute(
1299
+ `UPDATE ${this.migrationTable} SET migration_key = ?, up = ?, down = ? WHERE migration_key = ?`,
1300
+ [op.to, (_a = target.upFile) != null ? _a : "", (_b = target.downFile) != null ? _b : "", op.from]
1301
+ );
1302
+ } else {
1303
+ yield this.sqlrunner.execute(
1304
+ `UPDATE ${this.migrationTable} SET migration_key = ? WHERE migration_key = ?`,
1305
+ [op.to, op.from]
1306
+ );
1307
+ }
1308
+ return { verb: op.verb, changed: true };
1309
+ }
1310
+ case "mark_applied": {
1311
+ const count = yield this.countRows(op.key);
1312
+ if (count > 1) {
1313
+ this.conflict(
1314
+ patch,
1315
+ index,
1316
+ op.verb,
1317
+ `ledger corruption: duplicate rows for '${op.key}'`,
1318
+ [op.key],
1319
+ { [op.key]: count }
1320
+ );
1321
+ }
1322
+ if (count === 1) {
1323
+ return { verb: op.verb, changed: false };
1324
+ }
1325
+ const entry = manifest.get(op.key);
1326
+ yield this.sqlrunner.execute(
1327
+ `INSERT INTO ${this.migrationTable} (migration_key, up, down) VALUES (?, ?, ?)`,
1328
+ [op.key, (_c = entry == null ? void 0 : entry.upFile) != null ? _c : "", (_d = entry == null ? void 0 : entry.downFile) != null ? _d : ""]
1329
+ );
1330
+ return { verb: op.verb, changed: true };
1331
+ }
1332
+ case "unmark_applied": {
1333
+ const count = yield this.countRows(op.key);
1334
+ if (count > 1) {
1335
+ this.conflict(
1336
+ patch,
1337
+ index,
1338
+ op.verb,
1339
+ `ledger corruption: duplicate rows for '${op.key}'`,
1340
+ [op.key],
1341
+ { [op.key]: count }
1342
+ );
1343
+ }
1344
+ if (count === 0) {
1345
+ return { verb: op.verb, changed: false };
1346
+ }
1347
+ yield this.sqlrunner.execute(
1348
+ `DELETE FROM ${this.migrationTable} WHERE migration_key = ?`,
1349
+ [op.key]
1350
+ );
1351
+ return { verb: op.verb, changed: true };
1352
+ }
1353
+ }
1354
+ } catch (error) {
1355
+ if (error instanceof PatchConflictError) throw error;
1356
+ throw new PatchExecutionError(
1357
+ `operation failed`,
1358
+ patch.fileName,
1359
+ patch.patchKey,
1360
+ index,
1361
+ op.verb,
1362
+ toError(error)
1363
+ );
1364
+ }
1365
+ });
1366
+ }
1367
+ };
1368
+ }
1369
+ });
1370
+
1371
+ // framework/PatchCreator.ts
1372
+ import fs5 from "fs";
1373
+ import path4 from "path";
1374
+ var MAX_NAME_LENGTH, SCAFFOLD, PatchCreator;
1375
+ var init_PatchCreator = __esm({
1376
+ "framework/PatchCreator.ts"() {
1377
+ init_errors();
1378
+ MAX_NAME_LENGTH = 120;
1379
+ SCAFFOLD = `version: 1
1380
+ description: TODO
1381
+ operations: []
1382
+ `;
1383
+ PatchCreator = class _PatchCreator {
1384
+ constructor(patchFolder) {
1385
+ this.patchFolder = patchFolder;
1386
+ }
1387
+ /**
1388
+ * Normalizes a patch name: trim, whitespace runs -> `_`, lowercase.
1389
+ * Rejects empty results, path separators, `..`, control characters,
1390
+ * characters outside [a-z0-9_-], and names longer than 120 characters.
1391
+ */
1392
+ static normalizeName(name) {
1393
+ const normalized = (name != null ? name : "").trim().replace(/\s+/g, "_").toLowerCase();
1394
+ if (normalized.length === 0) {
1395
+ throw new CLIError("Patch name is required");
1396
+ }
1397
+ if (normalized.includes("/") || normalized.includes("\\")) {
1398
+ throw new CLIError("Patch name must not contain path separators");
1399
+ }
1400
+ if (normalized.includes("..")) {
1401
+ throw new CLIError("Patch name must not contain '..'");
1402
+ }
1403
+ if (/[\x00-\x1f\x7f]/.test(normalized)) {
1404
+ throw new CLIError("Patch name must not contain control characters");
1405
+ }
1406
+ if (!/^[a-z0-9_-]+$/.test(normalized)) {
1407
+ throw new CLIError("Patch name may only contain characters [a-z0-9_-]");
1408
+ }
1409
+ if (normalized.length > MAX_NAME_LENGTH) {
1410
+ throw new CLIError(`Patch name exceeds ${MAX_NAME_LENGTH} characters after normalization`);
1411
+ }
1412
+ return normalized;
1413
+ }
1414
+ /**
1415
+ * Creates `<patch_folder>/<stamp>_<normalized_name>.yaml` with exclusive
1416
+ * file creation. On a millisecond-stamp collision, mints a later stamp
1417
+ * and retries. Returns the created path.
1418
+ */
1419
+ create(name) {
1420
+ const normalized = _PatchCreator.normalizeName(name);
1421
+ if (!fs5.existsSync(this.patchFolder)) {
1422
+ fs5.mkdirSync(this.patchFolder, { recursive: true });
1423
+ }
1424
+ let stamp = Date.now();
1425
+ for (let attempt = 0; attempt < 1e3; attempt++) {
1426
+ const filePath = path4.join(this.patchFolder, `${stamp}_${normalized}.yaml`);
1427
+ try {
1428
+ fs5.writeFileSync(filePath, SCAFFOLD, { flag: "wx" });
1429
+ return filePath;
1430
+ } catch (error) {
1431
+ if (error && error.code === "EEXIST") {
1432
+ stamp += 1;
1433
+ continue;
1434
+ }
1435
+ throw error;
1436
+ }
1437
+ }
1438
+ throw new CLIError("Unable to create patch file: too many filename collisions");
1439
+ }
1440
+ };
1441
+ }
1442
+ });
1443
+
617
1444
  // framework/SQLRunner.ts
618
1445
  function isPromiseLike(value) {
619
1446
  return !!value && typeof value.then === "function";
@@ -933,8 +1760,8 @@ ${sql}
933
1760
  });
934
1761
 
935
1762
  // framework/MigrationRunner.ts
936
- import fs3 from "fs";
937
- function toError(error) {
1763
+ import fs6 from "fs";
1764
+ function toError2(error) {
938
1765
  if (error instanceof Error) return error;
939
1766
  return new Error(String(error));
940
1767
  }
@@ -957,6 +1784,9 @@ var init_MigrationRunner = __esm({
957
1784
  init_MigrationSetup();
958
1785
  init_MigrationFilter();
959
1786
  init_MigrationDialectParser();
1787
+ init_PatchRunner();
1788
+ init_PatchCreator();
1789
+ init_PatchTypes();
960
1790
  init_SQLRunner();
961
1791
  init_errors();
962
1792
  MigrationRunnerFactory = class _MigrationRunnerFactory {
@@ -967,10 +1797,12 @@ var init_MigrationRunner = __esm({
967
1797
  return __async(this, null, function* () {
968
1798
  const configReader = new FileMigrationConfigReader(configFile);
969
1799
  const config = configReader.loadFile();
1800
+ let factoryOwnsConnection = false;
970
1801
  if (!conn) {
971
1802
  conn = yield this.createConnection(config);
1803
+ factoryOwnsConnection = true;
972
1804
  }
973
- return new _MigrationRunnerFactory().create(config, conn);
1805
+ return new _MigrationRunnerFactory().create(config, conn, factoryOwnsConnection);
974
1806
  });
975
1807
  }
976
1808
  static createConnection(config) {
@@ -990,7 +1822,7 @@ var init_MigrationRunner = __esm({
990
1822
  conn = yield ((_b = (_a = mysql.default) == null ? void 0 : _a.createConnection) != null ? _b : mysql.createConnection)(settings);
991
1823
  return conn;
992
1824
  } catch (error) {
993
- throw DatabaseConnectionError.connectionFailed("sql", toError(error).message);
1825
+ throw DatabaseConnectionError.connectionFailed("sql", toError2(error).message);
994
1826
  }
995
1827
  case "sqlite":
996
1828
  if (!config.sqlite) {
@@ -1005,7 +1837,7 @@ var init_MigrationRunner = __esm({
1005
1837
  });
1006
1838
  return conn;
1007
1839
  } catch (error) {
1008
- throw DatabaseConnectionError.connectionFailed("sqlite", toError(error).message);
1840
+ throw DatabaseConnectionError.connectionFailed("sqlite", toError2(error).message);
1009
1841
  }
1010
1842
  case "pg":
1011
1843
  if (!config.pg && !process.env.DATABASE_URL) {
@@ -1021,7 +1853,7 @@ var init_MigrationRunner = __esm({
1021
1853
  yield conn.connect();
1022
1854
  return conn;
1023
1855
  } catch (error) {
1024
- throw DatabaseConnectionError.connectionFailed("pg", toError(error).message);
1856
+ throw DatabaseConnectionError.connectionFailed("pg", toError2(error).message);
1025
1857
  }
1026
1858
  default:
1027
1859
  throw ConfigurationError.unknownDatabaseType(config.database);
@@ -1035,7 +1867,7 @@ var init_MigrationRunner = __esm({
1035
1867
  return new _MigrationRunnerFactory().createEmpty(config);
1036
1868
  });
1037
1869
  }
1038
- create(config, conn) {
1870
+ create(config, conn, factoryOwnsConnection = false) {
1039
1871
  return __async(this, null, function* () {
1040
1872
  let sqlrunner;
1041
1873
  let driverConnection = conn;
@@ -1048,8 +1880,19 @@ var init_MigrationRunner = __esm({
1048
1880
  const setup = new MigrationSetup(sqlrunner, config);
1049
1881
  const read_strategy = this.getReadStategy(config);
1050
1882
  const migration_files = new MigrationDirectoryReader(config.migration_folder, read_strategy, sqlrunner, config.database);
1051
- yield setup.setup();
1052
- return new MySQLMigrationRunner(config, migration_files, setup, sqlrunner, driverConnection);
1883
+ const runner = new MySQLMigrationRunner(config, migration_files, setup, sqlrunner, driverConnection);
1884
+ try {
1885
+ yield runner.setup();
1886
+ } catch (error) {
1887
+ if (factoryOwnsConnection) {
1888
+ try {
1889
+ yield sqlrunner.end();
1890
+ } catch (e) {
1891
+ }
1892
+ }
1893
+ throw error;
1894
+ }
1895
+ return runner;
1053
1896
  });
1054
1897
  }
1055
1898
  createEmpty(config) {
@@ -1059,7 +1902,7 @@ var init_MigrationRunner = __esm({
1059
1902
  const setup = new MigrationSetup(sqlrunner, config);
1060
1903
  const read_strategy = this.getReadStategy(config);
1061
1904
  const migration_files = new MigrationDirectoryReader(config.migration_folder, read_strategy, sqlrunner, config.database);
1062
- return new MySQLMigrationRunner(config, migration_files, setup, sqlrunner, conn);
1905
+ return new MySQLMigrationRunner(config, migration_files, setup, sqlrunner, conn, false);
1063
1906
  });
1064
1907
  }
1065
1908
  getReadStategy(config) {
@@ -1076,33 +1919,77 @@ var init_MigrationRunner = __esm({
1076
1919
  }
1077
1920
  };
1078
1921
  MySQLMigrationRunner = class {
1079
- constructor(config, directory, setupRunner, sqlrunner, connection) {
1922
+ constructor(config, directory, setupRunner, sqlrunner, connection, preflightEnabled = true) {
1080
1923
  this.config = config;
1081
1924
  this.directory = directory;
1082
1925
  this.setupRunner = setupRunner;
1083
1926
  this.sqlrunner = sqlrunner;
1084
1927
  this.connection = connection;
1928
+ this.preflightEnabled = preflightEnabled;
1929
+ /**
1930
+ * Memoized in-flight preflight promise. Simultaneous or repeated calls
1931
+ * to setup() on one runner execute the preflight (migration table setup
1932
+ * + patch application) exactly once. Cleared after rejection so a caller
1933
+ * may retry after fixing the cause.
1934
+ */
1935
+ this.preflightPromise = null;
1936
+ this.lastPatchResults = [];
1085
1937
  }
1086
1938
  setup() {
1939
+ return __async(this, null, function* () {
1940
+ if (!this.preflightEnabled) {
1941
+ return;
1942
+ }
1943
+ if (!this.preflightPromise) {
1944
+ this.preflightPromise = this.runPreflight();
1945
+ this.preflightPromise.catch(() => {
1946
+ this.preflightPromise = null;
1947
+ });
1948
+ }
1949
+ return this.preflightPromise;
1950
+ });
1951
+ }
1952
+ runPreflight() {
1087
1953
  return __async(this, null, function* () {
1088
1954
  try {
1089
1955
  yield this.setupRunner.setup();
1090
1956
  } catch (error) {
1091
- throw new MigrationExecutionError("Failed to set up migration database", void 0, void 0, toError(error));
1957
+ throw new MigrationExecutionError("Failed to set up migration database", void 0, void 0, toError2(error));
1092
1958
  }
1959
+ const patchRunner = new PatchRunner(this.sqlrunner, this.config);
1960
+ this.lastPatchResults = yield patchRunner.applyPending();
1961
+ });
1962
+ }
1963
+ /**
1964
+ * Delegates to the same idempotent preflight; returns the results of the
1965
+ * patch pass that ran (or is running) for this runner.
1966
+ */
1967
+ applyPendingPatches() {
1968
+ return __async(this, null, function* () {
1969
+ yield this.setup();
1970
+ return this.lastPatchResults;
1093
1971
  });
1094
1972
  }
1973
+ /**
1974
+ * Scaffolds a new ledger patch file and returns the created path.
1975
+ * Never connects to a database.
1976
+ */
1977
+ createPatch(name) {
1978
+ const creator = new PatchCreator(resolvePatchFolder(this.config));
1979
+ return creator.create(name);
1980
+ }
1095
1981
  terminate() {
1096
1982
  return __async(this, null, function* () {
1097
1983
  try {
1098
1984
  yield this.setupRunner.teardown();
1099
1985
  } catch (error) {
1100
- throw new MigrationExecutionError("Failed to tear down migration database", void 0, void 0, toError(error));
1986
+ throw new MigrationExecutionError("Failed to tear down migration database", void 0, void 0, toError2(error));
1101
1987
  }
1102
1988
  });
1103
1989
  }
1104
1990
  getMigrationsHistory() {
1105
1991
  return __async(this, null, function* () {
1992
+ yield this.setup();
1106
1993
  try {
1107
1994
  const results = yield this.sqlrunner.query(`
1108
1995
  select *
@@ -1110,16 +1997,17 @@ var init_MigrationRunner = __esm({
1110
1997
  `);
1111
1998
  return results[0];
1112
1999
  } catch (error) {
1113
- throw new MigrationExecutionError("Failed to get migration history", void 0, void 0, toError(error));
2000
+ throw new MigrationExecutionError("Failed to get migration history", void 0, void 0, toError2(error));
1114
2001
  }
1115
2002
  });
1116
2003
  }
1117
2004
  getMigrations() {
1118
2005
  return __async(this, null, function* () {
2006
+ yield this.setup();
1119
2007
  try {
1120
2008
  return this.directory.loadMigrations(this.config.migration_table, this.connection);
1121
2009
  } catch (error) {
1122
- throw new MigrationExecutionError("Failed to load migrations", void 0, void 0, toError(error));
2010
+ throw new MigrationExecutionError("Failed to load migrations", void 0, void 0, toError2(error));
1123
2011
  }
1124
2012
  });
1125
2013
  }
@@ -1132,7 +2020,7 @@ var init_MigrationRunner = __esm({
1132
2020
  if (error instanceof MigrationExecutionError) {
1133
2021
  throw error;
1134
2022
  }
1135
- throw new MigrationExecutionError("Failed to get pending migrations", void 0, void 0, toError(error));
2023
+ throw new MigrationExecutionError("Failed to get pending migrations", void 0, void 0, toError2(error));
1136
2024
  }
1137
2025
  });
1138
2026
  }
@@ -1145,12 +2033,13 @@ var init_MigrationRunner = __esm({
1145
2033
  if (error instanceof MigrationExecutionError) {
1146
2034
  throw error;
1147
2035
  }
1148
- throw new MigrationExecutionError("Failed to get completed migrations", void 0, void 0, toError(error));
2036
+ throw new MigrationExecutionError("Failed to get completed migrations", void 0, void 0, toError2(error));
1149
2037
  }
1150
2038
  });
1151
2039
  }
1152
2040
  migrate(migrationNodes, forward) {
1153
2041
  return __async(this, null, function* () {
2042
+ yield this.setup();
1154
2043
  for (let node of migrationNodes) {
1155
2044
  try {
1156
2045
  if (forward) {
@@ -1163,7 +2052,7 @@ var init_MigrationRunner = __esm({
1163
2052
  `Failed to ${forward ? "apply" : "rollback"} migration`,
1164
2053
  node.name || String(node),
1165
2054
  forward ? node.up_sql() : node.down_sql(),
1166
- toError(error)
2055
+ toError2(error)
1167
2056
  );
1168
2057
  }
1169
2058
  }
@@ -1171,6 +2060,7 @@ var init_MigrationRunner = __esm({
1171
2060
  }
1172
2061
  reset() {
1173
2062
  return __async(this, null, function* () {
2063
+ yield this.setup();
1174
2064
  try {
1175
2065
  let migrations = yield this.getMigrations();
1176
2066
  const rollback = yield migration_filter(migrations, true);
@@ -1182,7 +2072,7 @@ var init_MigrationRunner = __esm({
1182
2072
  if (error instanceof MigrationExecutionError) {
1183
2073
  throw error;
1184
2074
  }
1185
- throw new MigrationExecutionError("Failed to reset migrations", void 0, void 0, toError(error));
2075
+ throw new MigrationExecutionError("Failed to reset migrations", void 0, void 0, toError2(error));
1186
2076
  }
1187
2077
  });
1188
2078
  }
@@ -1191,7 +2081,7 @@ var init_MigrationRunner = __esm({
1191
2081
  const creator = new MigrationCreator(this.config);
1192
2082
  creator.create(name);
1193
2083
  } catch (error) {
1194
- throw new MigrationExecutionError(`Failed to create migration: ${name}`, void 0, void 0, toError(error));
2084
+ throw new MigrationExecutionError(`Failed to create migration: ${name}`, void 0, void 0, toError2(error));
1195
2085
  }
1196
2086
  }
1197
2087
  close() {
@@ -1200,7 +2090,7 @@ var init_MigrationRunner = __esm({
1200
2090
  try {
1201
2091
  yield this.sqlrunner.end();
1202
2092
  } catch (error) {
1203
- throw new DatabaseConnectionError(`Failed to close database connection: ${toError(error).message}`);
2093
+ throw new DatabaseConnectionError(`Failed to close database connection: ${toError2(error).message}`);
1204
2094
  }
1205
2095
  }
1206
2096
  });
@@ -1214,7 +2104,7 @@ var init_MigrationRunner = __esm({
1214
2104
  return __async(this, null, function* () {
1215
2105
  try {
1216
2106
  console.log(`Checking for ${config_file}`);
1217
- const config_exist = fs3.existsSync(config_file);
2107
+ const config_exist = fs6.existsSync(config_file);
1218
2108
  if (!config_exist) {
1219
2109
  console.log(`Creating ${config_file}`);
1220
2110
  const default_config = {
@@ -1228,11 +2118,11 @@ var init_MigrationRunner = __esm({
1228
2118
  "password": ""
1229
2119
  }
1230
2120
  };
1231
- fs3.writeFileSync(config_file, JSON.stringify(default_config, null, 2));
2121
+ fs6.writeFileSync(config_file, JSON.stringify(default_config, null, 2));
1232
2122
  console.log(`Created ${config_file}`);
1233
2123
  }
1234
2124
  } catch (error) {
1235
- throw new ConfigurationError(`Failed to initialize config file: ${toError(error).message}`);
2125
+ throw new ConfigurationError(`Failed to initialize config file: ${toError2(error).message}`);
1236
2126
  }
1237
2127
  });
1238
2128
  }
@@ -1243,7 +2133,7 @@ var init_MigrationRunner = __esm({
1243
2133
  }
1244
2134
  loadFile() {
1245
2135
  try {
1246
- const fileContent = fs3.readFileSync(this.configFile);
2136
+ const fileContent = fs6.readFileSync(this.configFile);
1247
2137
  const config = JSON.parse(fileContent.toString());
1248
2138
  if (!config.migration_folder) {
1249
2139
  throw ConfigurationError.missingRequiredProperty("migration_folder");
@@ -1267,7 +2157,7 @@ var init_MigrationRunner = __esm({
1267
2157
  if (error instanceof ConfigurationError) {
1268
2158
  throw error;
1269
2159
  }
1270
- const err = toError(error);
2160
+ const err = toError2(error);
1271
2161
  if (err.message.includes("ENOENT")) {
1272
2162
  throw new ConfigurationError(`Config file not found: ${this.configFile}`);
1273
2163
  }
@@ -1284,16 +2174,16 @@ var init_MigrationRunner = __esm({
1284
2174
  throw new CLIError("Migration name is required");
1285
2175
  }
1286
2176
  try {
1287
- if (!fs3.existsSync(this.config.migration_folder)) {
1288
- fs3.mkdirSync(this.config.migration_folder, { recursive: true });
2177
+ if (!fs6.existsSync(this.config.migration_folder)) {
2178
+ fs6.mkdirSync(this.config.migration_folder, { recursive: true });
1289
2179
  }
1290
2180
  const now_timestamp = Date.now();
1291
2181
  const filename_up = `${now_timestamp}_${name}.up.sql`;
1292
2182
  const filename_down = `${now_timestamp}_${name}.down.sql`;
1293
- fs3.writeFileSync(`${this.config.migration_folder}/${filename_up}`, `
2183
+ fs6.writeFileSync(`${this.config.migration_folder}/${filename_up}`, `
1294
2184
  -- Write your up migration here
1295
2185
  `.trim());
1296
- fs3.writeFileSync(`${this.config.migration_folder}/${filename_down}`, `
2186
+ fs6.writeFileSync(`${this.config.migration_folder}/${filename_down}`, `
1297
2187
  -- Write your down migration here
1298
2188
  `.trim());
1299
2189
  console.log(`Created migration files:`);
@@ -1303,7 +2193,7 @@ var init_MigrationRunner = __esm({
1303
2193
  if (error instanceof CLIError) {
1304
2194
  throw error;
1305
2195
  }
1306
- throw new MigrationExecutionError(`Failed to create migration files: ${toError(error).message}`);
2196
+ throw new MigrationExecutionError(`Failed to create migration files: ${toError2(error).message}`);
1307
2197
  }
1308
2198
  }
1309
2199
  };
@@ -1311,10 +2201,10 @@ var init_MigrationRunner = __esm({
1311
2201
  });
1312
2202
 
1313
2203
  // framework/SeedRunner.ts
1314
- import fs4 from "fs";
1315
- import path2 from "path";
2204
+ import fs7 from "fs";
2205
+ import path5 from "path";
1316
2206
  import { pathToFileURL } from "url";
1317
- import Ajv from "ajv";
2207
+ import Ajv2 from "ajv";
1318
2208
  import addFormats from "ajv-formats";
1319
2209
  import { tsImport } from "tsx/esm/api";
1320
2210
  function resolveAlias(name, aliasMap) {
@@ -1323,10 +2213,10 @@ function resolveAlias(name, aliasMap) {
1323
2213
  return (_a = aliasMap[name]) != null ? _a : name;
1324
2214
  }
1325
2215
  function walkForFile(rootDir, fileName) {
1326
- if (!fs4.existsSync(rootDir)) return null;
1327
- const entries = fs4.readdirSync(rootDir, { withFileTypes: true });
2216
+ if (!fs7.existsSync(rootDir)) return null;
2217
+ const entries = fs7.readdirSync(rootDir, { withFileTypes: true });
1328
2218
  for (const entry of entries) {
1329
- const full = path2.join(rootDir, entry.name);
2219
+ const full = path5.join(rootDir, entry.name);
1330
2220
  if (entry.isDirectory()) {
1331
2221
  const found = walkForFile(full, fileName);
1332
2222
  if (found) return found;
@@ -1420,39 +2310,39 @@ function resolveSeed(name, migrationConfig, options) {
1420
2310
  }
1421
2311
  function loadJson(filePath) {
1422
2312
  return __async(this, null, function* () {
1423
- const content = yield fs4.promises.readFile(filePath, "utf8");
2313
+ const content = yield fs7.promises.readFile(filePath, "utf8");
1424
2314
  return JSON.parse(content);
1425
2315
  });
1426
2316
  }
1427
2317
  function createValidator() {
1428
- const ajv = new Ajv({ allErrors: true, strict: false });
1429
- addFormats(ajv);
1430
- return ajv;
2318
+ const ajv2 = new Ajv2({ allErrors: true, strict: false });
2319
+ addFormats(ajv2);
2320
+ return ajv2;
1431
2321
  }
1432
2322
  function validateData(schemaPath, data, validate, log) {
1433
2323
  return __async(this, null, function* () {
1434
2324
  if (!validate || !schemaPath) return;
1435
- const content = yield fs4.promises.readFile(schemaPath, "utf8");
2325
+ const content = yield fs7.promises.readFile(schemaPath, "utf8");
1436
2326
  const schema = JSON.parse(content);
1437
- const ajv = createValidator();
1438
- const validateFn = ajv.compile(schema);
2327
+ const ajv2 = createValidator();
2328
+ const validateFn = ajv2.compile(schema);
1439
2329
  const ok = validateFn(data);
1440
2330
  if (!ok) {
1441
2331
  log == null ? void 0 : log(`Validation failed for seed data (${schemaPath})`);
1442
- throw new Error(`Seed data validation failed: ${ajv.errorsText(validateFn.errors || [])}`);
2332
+ throw new Error(`Seed data validation failed: ${ajv2.errorsText(validateFn.errors || [])}`);
1443
2333
  }
1444
2334
  });
1445
2335
  }
1446
2336
  function runSqlSeed(runner, resolved, direction) {
1447
2337
  return __async(this, null, function* () {
1448
2338
  const sqlPath = direction === "up" ? resolved.upPath : resolved.downPath;
1449
- const sql = yield fs4.promises.readFile(sqlPath, "utf8");
2339
+ const sql = yield fs7.promises.readFile(sqlPath, "utf8");
1450
2340
  yield runner.query(sql);
1451
2341
  });
1452
2342
  }
1453
2343
  function loadSeedModule(modulePath) {
1454
2344
  return __async(this, null, function* () {
1455
- const resolved = path2.resolve(modulePath);
2345
+ const resolved = path5.resolve(modulePath);
1456
2346
  if (resolved.endsWith(".ts")) {
1457
2347
  const fileUrl = pathToFileURL(resolved).href;
1458
2348
  return tsImport(fileUrl, fileUrl);
@@ -1567,7 +2457,7 @@ var require_cli = __commonJS({
1567
2457
  }
1568
2458
  var commands = args.commands;
1569
2459
  var command = commands[0];
1570
- var load_database = !["init", "create", "help"].includes(command.toLowerCase());
2460
+ var load_database = !["init", "create", "help", "patch"].includes(command.toLowerCase());
1571
2461
  var config_file = args.flags.config || "proper.json";
1572
2462
  if (command.toLowerCase() === "help") {
1573
2463
  printUsage();
@@ -1576,9 +2466,17 @@ var require_cli = __commonJS({
1576
2466
  console.log(`Loading database: ${load_database}`);
1577
2467
  var pending_runner = load_database ? MigrationRunnerFactory.create(config_file) : MigrationRunnerFactory.createEmpty(config_file);
1578
2468
  pending_runner.then((runner) => __async(null, null, function* () {
2469
+ let failed = false;
1579
2470
  try {
1580
2471
  if (load_database) {
1581
- yield runner.setup();
2472
+ const patch_results = yield runner.applyPendingPatches();
2473
+ for (const patch of patch_results) {
2474
+ if (patch.status === "applied") {
2475
+ const changed = patch.operations.filter((op) => op.changed).length;
2476
+ const noop = patch.operations.length - changed;
2477
+ console.log(`Applied patch ${patch.fileName}: ${changed} changed, ${noop} no-op operation(s)`);
2478
+ }
2479
+ }
1582
2480
  }
1583
2481
  switch (command.toLowerCase()) {
1584
2482
  case "up":
@@ -1598,7 +2496,6 @@ var require_cli = __commonJS({
1598
2496
  } else {
1599
2497
  console.log("No pending migrations");
1600
2498
  }
1601
- yield runner.close();
1602
2499
  break;
1603
2500
  case "down":
1604
2501
  const migrations_rollback = yield runner.getMigrations();
@@ -1619,13 +2516,11 @@ var require_cli = __commonJS({
1619
2516
  } else {
1620
2517
  console.log("No migrations to roll back");
1621
2518
  }
1622
- yield runner.close();
1623
2519
  break;
1624
2520
  case "reset":
1625
2521
  console.log("Resetting all migrations...");
1626
2522
  yield runner.reset();
1627
2523
  console.log("Reset completed successfully");
1628
- yield runner.close();
1629
2524
  break;
1630
2525
  case "create":
1631
2526
  let filename = args.flags.name || commands[1];
@@ -1634,11 +2529,22 @@ var require_cli = __commonJS({
1634
2529
  }
1635
2530
  filename = filename.replace(/\s/g, "_");
1636
2531
  runner.createMigration(filename);
1637
- runner.close();
1638
2532
  break;
2533
+ case "patch": {
2534
+ const patch_name = args.flags.name || commands[1];
2535
+ if (!patch_name) {
2536
+ throw new CLIError("Patch name is required. Usage: proper patch <name>");
2537
+ }
2538
+ const created_path = runner.createPatch(patch_name);
2539
+ console.log(`Created patch file:`);
2540
+ console.log(` ${created_path}`);
2541
+ console.log("");
2542
+ console.log("Complete this file before running another database-backed");
2543
+ console.log("Proper command: 'operations: []' is intentionally not runnable.");
2544
+ break;
2545
+ }
1639
2546
  case "init":
1640
2547
  yield runner.init(config_file);
1641
- runner.close();
1642
2548
  break;
1643
2549
  case "status":
1644
2550
  const { printTable } = __require("console-table-printer");
@@ -1652,7 +2558,6 @@ var require_cli = __commonJS({
1652
2558
  };
1653
2559
  }));
1654
2560
  printTable(yield Promise.all(table));
1655
- runner.close();
1656
2561
  break;
1657
2562
  case "query":
1658
2563
  const sql_query = args.flags.query || commands[1];
@@ -1696,7 +2601,6 @@ Returned ${results.length} row(s)`);
1696
2601
  } catch (error) {
1697
2602
  throw new CLIError(`Query execution failed: ${error.message}`);
1698
2603
  }
1699
- yield runner.close();
1700
2604
  break;
1701
2605
  case "seed": {
1702
2606
  const subCommands = commands.slice(1);
@@ -1729,7 +2633,6 @@ Returned ${results.length} row(s)`);
1729
2633
  const reader = new FileMigrationConfigReader(config_file);
1730
2634
  const migrationConfig = reader.loadFile();
1731
2635
  yield runSeedsWithRunner(runner, migrationConfig, action, seedOptions);
1732
- yield runner.close();
1733
2636
  break;
1734
2637
  }
1735
2638
  default:
@@ -1740,9 +2643,25 @@ Returned ${results.length} row(s)`);
1740
2643
  if (error.stack && process.env.DEBUG) {
1741
2644
  console.error(error.stack);
1742
2645
  }
2646
+ failed = true;
2647
+ } finally {
2648
+ try {
2649
+ yield runner.close();
2650
+ } catch (closeError) {
2651
+ console.error(`Error closing connection: ${closeError.message}`);
2652
+ failed = true;
2653
+ }
2654
+ }
2655
+ if (failed) {
1743
2656
  process.exit(1);
1744
2657
  }
1745
- }));
2658
+ })).catch((error) => {
2659
+ console.error(`Error: ${error.message}`);
2660
+ if (error.stack && process.env.DEBUG) {
2661
+ console.error(error.stack);
2662
+ }
2663
+ process.exit(1);
2664
+ });
1746
2665
  function printUsage() {
1747
2666
  console.log(`
1748
2667
  SQL Proper - Database migration tool
@@ -1755,6 +2674,7 @@ Commands:
1755
2674
  down Roll back completed migrations
1756
2675
  reset Roll back all migrations and reapply them
1757
2676
  create Create a new migration
2677
+ patch Create a new ledger patch file (repairs migration history; no database access)
1758
2678
  init Initialize a new config file
1759
2679
  status Show migration status
1760
2680
  query Execute a SQL query and display results
@@ -1773,6 +2693,7 @@ Examples:
1773
2693
  proper down --increment 3 Roll back the last 3 applied migrations
1774
2694
  proper down --all Roll back all completed migrations
1775
2695
  proper create my_migration Create a new migration named "my_migration"
2696
+ proper patch fix_renamed_keys Scaffold a ledger patch file in the patch folder
1776
2697
  proper init Create a new config file
1777
2698
  proper status Show the status of all migrations
1778
2699
  proper query "select * from users" Execute a SQL query