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