@noego/proper 0.0.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/index.mjs CHANGED
@@ -39,14 +39,10 @@ var __async = (__this, __arguments, generator) => {
39
39
  };
40
40
 
41
41
  // framework/MigrationRunner.ts
42
- import fs3 from "fs";
43
- import mysql from "mysql2/promise";
44
- import * as sqlite from "sqlite";
45
- import * as sqlite3 from "sqlite3";
42
+ import fs6 from "fs";
46
43
 
47
44
  // framework/MigrationDirectoryReader.ts
48
- import fs from "fs";
49
- import path from "path";
45
+ import fs2 from "fs";
50
46
 
51
47
  // framework/errors.ts
52
48
  var MigrationError = class _MigrationError extends Error {
@@ -161,6 +157,54 @@ ${originalError.message}`;
161
157
  return new _MigrationExecutionError(message);
162
158
  }
163
159
  };
160
+ var PatchError = class _PatchError extends MigrationError {
161
+ constructor(message, patchFile, patchKey) {
162
+ super(`Patch Error: ${message}`);
163
+ this.patchFile = patchFile;
164
+ this.patchKey = patchKey;
165
+ this.name = "PatchError";
166
+ Object.setPrototypeOf(this, _PatchError.prototype);
167
+ }
168
+ };
169
+ var PatchValidationError = class _PatchValidationError extends PatchError {
170
+ constructor(message, patchFile, patchKey) {
171
+ super(message, patchFile, patchKey);
172
+ this.name = "PatchValidationError";
173
+ Object.setPrototypeOf(this, _PatchValidationError.prototype);
174
+ }
175
+ };
176
+ var PatchIntegrityError = class _PatchIntegrityError extends PatchError {
177
+ constructor(message, patchFile, patchKey, expectedChecksum, actualChecksum) {
178
+ super(message, patchFile, patchKey);
179
+ this.expectedChecksum = expectedChecksum;
180
+ this.actualChecksum = actualChecksum;
181
+ this.name = "PatchIntegrityError";
182
+ Object.setPrototypeOf(this, _PatchIntegrityError.prototype);
183
+ }
184
+ };
185
+ var PatchConflictError = class _PatchConflictError extends PatchError {
186
+ constructor(message, patchFile, patchKey, operationIndex, operationVerb, migrationKeys, observedRowCounts) {
187
+ super(message, patchFile, patchKey);
188
+ this.operationIndex = operationIndex;
189
+ this.operationVerb = operationVerb;
190
+ this.migrationKeys = migrationKeys;
191
+ this.observedRowCounts = observedRowCounts;
192
+ this.name = "PatchConflictError";
193
+ Object.setPrototypeOf(this, _PatchConflictError.prototype);
194
+ }
195
+ };
196
+ var PatchExecutionError = class _PatchExecutionError extends PatchError {
197
+ constructor(message, patchFile, patchKey, operationIndex, operationVerb, originalError) {
198
+ super(originalError ? `${message}
199
+ Original Error:
200
+ ${originalError.message}` : message, patchFile, patchKey);
201
+ this.operationIndex = operationIndex;
202
+ this.operationVerb = operationVerb;
203
+ this.originalError = originalError;
204
+ this.name = "PatchExecutionError";
205
+ Object.setPrototypeOf(this, _PatchExecutionError.prototype);
206
+ }
207
+ };
164
208
  var CLIError = class _CLIError extends MigrationError {
165
209
  constructor(message) {
166
210
  super(`CLI Error: ${message}`);
@@ -323,6 +367,36 @@ var SqlMigrationBuilder = class {
323
367
  }
324
368
  };
325
369
 
370
+ // framework/MigrationManifest.ts
371
+ import fs from "fs";
372
+ import path from "path";
373
+ function canonicalMigrationKey(value) {
374
+ return value.replace(/(?:\.(mysql|sqlite|pg))?\.(up|down)\.(sql|js)$/i, "").toLowerCase();
375
+ }
376
+ function resolveMigrationFile(directory, baseName, direction, dialect) {
377
+ const dialectExt = dialect === "sql" ? "mysql" : dialect;
378
+ const dialectFile = path.join(directory, `${baseName}.${dialectExt}.${direction}.sql`);
379
+ if (fs.existsSync(dialectFile)) return dialectFile;
380
+ const genericFile = path.join(directory, `${baseName}.${direction}.sql`);
381
+ if (fs.existsSync(genericFile)) return genericFile;
382
+ return null;
383
+ }
384
+ function loadMigrationManifest(directory, dialect) {
385
+ const manifest = /* @__PURE__ */ new Map();
386
+ if (!fs.existsSync(directory)) return manifest;
387
+ const files = fs.readdirSync(directory, { withFileTypes: true }).filter((f) => f.isFile()).map((f) => f.name);
388
+ const uniqueKeys = /* @__PURE__ */ new Set();
389
+ files.forEach((file) => uniqueKeys.add(canonicalMigrationKey(file)));
390
+ uniqueKeys.forEach((key) => {
391
+ manifest.set(key, {
392
+ key,
393
+ upFile: resolveMigrationFile(directory, key, "up", dialect),
394
+ downFile: resolveMigrationFile(directory, key, "down", dialect)
395
+ });
396
+ });
397
+ return manifest;
398
+ }
399
+
326
400
  // framework/MigrationDirectoryReader.ts
327
401
  var MigrationDirectoryReader = class {
328
402
  constructor(directory, read_strategy, sqlrunner, dialect = "sql") {
@@ -334,27 +408,23 @@ var MigrationDirectoryReader = class {
334
408
  /**
335
409
  * Resolves the appropriate file for a migration based on dialect.
336
410
  * Priority: dialect-specific file > generic file
411
+ * File extensions: `.mysql.up.sql`, `.sqlite.up.sql`, `.pg.up.sql`.
337
412
  */
338
413
  resolveFile(baseName, direction) {
339
- const dialectExt = this.dialect === "sql" ? "mysql" : "sqlite";
340
- const dialectFile = path.join(this.directory, `${baseName}.${dialectExt}.${direction}.sql`);
341
- if (fs.existsSync(dialectFile)) return dialectFile;
342
- const genericFile = path.join(this.directory, `${baseName}.${direction}.sql`);
343
- if (fs.existsSync(genericFile)) return genericFile;
344
- return null;
414
+ return resolveMigrationFile(this.directory, baseName, direction, this.dialect);
345
415
  }
346
416
  /**
347
417
  * Checks if a file path is dialect-specific (contains .mysql. or .sqlite. in the name)
348
418
  */
349
419
  isDialectSpecific(filePath) {
350
- return /\.(mysql|sqlite)\.(up|down)\.sql$/i.test(filePath);
420
+ return /\.(mysql|sqlite|pg)\.(up|down)\.sql$/i.test(filePath);
351
421
  }
352
422
  loadMigrations(table, connection) {
353
- fs.existsSync(this.directory) || fs.mkdirSync(this.directory);
354
- const dir_content = fs.readdirSync(this.directory, { withFileTypes: true }).filter((file) => file.isFile()).map((file) => file.name);
423
+ fs2.existsSync(this.directory) || fs2.mkdirSync(this.directory);
424
+ const dir_content = fs2.readdirSync(this.directory, { withFileTypes: true }).filter((file) => file.isFile()).map((file) => file.name);
355
425
  const uniqueKeys = /* @__PURE__ */ new Set();
356
426
  dir_content.forEach((file) => {
357
- const key = file.replace(/(?:\.(mysql|sqlite))?\.(up|down)\.(sql|js)/i, "").toLowerCase();
427
+ const key = canonicalMigrationKey(file);
358
428
  uniqueKeys.add(key);
359
429
  });
360
430
  const migration_sorter = {};
@@ -394,14 +464,14 @@ var MigrationDirectoryReader = class {
394
464
  return builder;
395
465
  }
396
466
  sql_up(file) {
397
- let content = fs.readFileSync(file).toString();
467
+ let content = fs2.readFileSync(file).toString();
398
468
  if (!this.isDialectSpecific(file)) {
399
469
  content = this.read_strategy(content);
400
470
  }
401
471
  return content.trim();
402
472
  }
403
473
  sql_down(file) {
404
- let content = fs.readFileSync(file).toString();
474
+ let content = fs2.readFileSync(file).toString();
405
475
  if (!this.isDialectSpecific(file)) {
406
476
  content = this.read_strategy(content);
407
477
  }
@@ -410,7 +480,23 @@ var MigrationDirectoryReader = class {
410
480
  };
411
481
 
412
482
  // framework/MigrationSetup.ts
413
- import fs2 from "fs";
483
+ import fs3 from "fs";
484
+
485
+ // framework/PatchTypes.ts
486
+ import path2 from "path";
487
+ var PATCH_FORMAT_VERSION = 1;
488
+ var DEFAULT_PATCH_TABLE = "proper_patches";
489
+ var PATCH_FILENAME_REGEX = new RegExp("^(?<stamp>[0-9]{13})_(?<name>[a-z0-9][a-z0-9_-]{0,119})\\.yaml$");
490
+ function resolvePatchFolder(config) {
491
+ if (config.patch_folder) return config.patch_folder;
492
+ const dir = path2.dirname(config.migration_folder);
493
+ return dir === "." && !config.migration_folder.includes(path2.sep) && !config.migration_folder.includes("/") ? "patches" : path2.join(dir, "patches");
494
+ }
495
+ function resolvePatchTable(config) {
496
+ return config.patch_table || DEFAULT_PATCH_TABLE;
497
+ }
498
+
499
+ // framework/MigrationSetup.ts
414
500
  var MigrationSetup = class {
415
501
  constructor(sqlrunner, config) {
416
502
  this.sqlrunner = sqlrunner;
@@ -418,7 +504,7 @@ var MigrationSetup = class {
418
504
  }
419
505
  setup() {
420
506
  return __async(this, null, function* () {
421
- fs2.existsSync(this.config.migration_folder) || fs2.mkdirSync(this.config.migration_folder);
507
+ fs3.existsSync(this.config.migration_folder) || fs3.mkdirSync(this.config.migration_folder);
422
508
  const tableName = this.config.migration_table;
423
509
  const createTableSql = this.config.database === "sqlite" ? `CREATE TABLE IF NOT EXISTS ${tableName} (
424
510
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -426,6 +512,12 @@ var MigrationSetup = class {
426
512
  up TEXT,
427
513
  down TEXT,
428
514
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
515
+ )` : this.config.database === "pg" ? `CREATE TABLE IF NOT EXISTS ${tableName} (
516
+ id SERIAL PRIMARY KEY,
517
+ migration_key TEXT,
518
+ up TEXT,
519
+ down TEXT,
520
+ created_at TIMESTAMPTZ DEFAULT now()
429
521
  )` : `CREATE TABLE IF NOT EXISTS ${tableName} (
430
522
  id INT AUTO_INCREMENT PRIMARY KEY,
431
523
  migration_key VARCHAR(255),
@@ -434,11 +526,39 @@ var MigrationSetup = class {
434
526
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
435
527
  )`;
436
528
  yield this.sqlrunner.query(createTableSql);
529
+ const patchTable = resolvePatchTable(this.config);
530
+ const createPatchTableSql = this.config.database === "sqlite" ? `CREATE TABLE IF NOT EXISTS ${patchTable} (
531
+ migration_table TEXT NOT NULL,
532
+ patch_key TEXT NOT NULL,
533
+ checksum TEXT NOT NULL,
534
+ format_version INTEGER NOT NULL,
535
+ description TEXT NOT NULL,
536
+ applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
537
+ PRIMARY KEY (migration_table, patch_key)
538
+ )` : this.config.database === "pg" ? `CREATE TABLE IF NOT EXISTS ${patchTable} (
539
+ migration_table TEXT NOT NULL,
540
+ patch_key TEXT NOT NULL,
541
+ checksum TEXT NOT NULL,
542
+ format_version INTEGER NOT NULL,
543
+ description TEXT NOT NULL,
544
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
545
+ PRIMARY KEY (migration_table, patch_key)
546
+ )` : `CREATE TABLE IF NOT EXISTS ${patchTable} (
547
+ migration_table VARCHAR(255) NOT NULL,
548
+ patch_key VARCHAR(255) NOT NULL,
549
+ checksum CHAR(64) NOT NULL,
550
+ format_version INT NOT NULL,
551
+ description TEXT NOT NULL,
552
+ applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
553
+ PRIMARY KEY (migration_table, patch_key)
554
+ )`;
555
+ yield this.sqlrunner.query(createPatchTableSql);
437
556
  });
438
557
  }
439
558
  teardown() {
440
559
  return __async(this, null, function* () {
441
560
  yield this.sqlrunner.execute(`DROP TABLE ${this.config.migration_table}`);
561
+ yield this.sqlrunner.execute(`DROP TABLE IF EXISTS ${resolvePatchTable(this.config)}`);
442
562
  });
443
563
  }
444
564
  };
@@ -485,31 +605,702 @@ function MySqlDialectParser(sql) {
485
605
  }
486
606
  return result;
487
607
  }
488
- function SqliteDialectParser(sql) {
489
- const lines = sql.split("\n");
490
- let result = "";
491
- let isInSqliteBlock = true;
492
- const startSqliteRegex = /^\s*--\s*\[\s*sqlite\s*\]\s*$/i;
608
+ function markerDialectParser(names) {
609
+ const startRegex = new RegExp(`^\\s*--\\s*\\[\\s*(${names.join("|")})\\s*\\]\\s*$`, "i");
493
610
  const anyDialectRegex = /^\s*--\s*\[\s*\w+\s*\]\s*$/i;
494
- for (const line of lines) {
495
- const trimmed = line.trim();
496
- if (startSqliteRegex.test(trimmed)) {
497
- isInSqliteBlock = true;
498
- result += line + "\n";
499
- continue;
500
- } else if (anyDialectRegex.test(trimmed) && !startSqliteRegex.test(trimmed)) {
501
- isInSqliteBlock = false;
502
- continue;
611
+ return function(sql) {
612
+ const lines = sql.split("\n");
613
+ let result = "";
614
+ let capturing = true;
615
+ for (const line of lines) {
616
+ const trimmed = line.trim();
617
+ if (startRegex.test(trimmed)) {
618
+ capturing = true;
619
+ result += line + "\n";
620
+ continue;
621
+ } else if (anyDialectRegex.test(trimmed)) {
622
+ capturing = false;
623
+ continue;
624
+ }
625
+ if (!capturing && line.toLowerCase().includes("create index")) {
626
+ capturing = true;
627
+ }
628
+ if (capturing) {
629
+ result += line + "\n";
630
+ }
503
631
  }
504
- if (!isInSqliteBlock && line.toLowerCase().includes("create index")) {
505
- isInSqliteBlock = true;
632
+ return result;
633
+ };
634
+ }
635
+ var SqliteDialectParser = markerDialectParser(["sqlite"]);
636
+ var PgDialectParser = markerDialectParser(["pg", "postgres", "postgresql"]);
637
+
638
+ // framework/PatchDirectoryReader.ts
639
+ import crypto from "crypto";
640
+ import fs4 from "fs";
641
+ import path3 from "path";
642
+
643
+ // framework/PatchValidator.ts
644
+ import Ajv from "ajv";
645
+ import { parseDocument } from "yaml";
646
+ var MAX_DESCRIPTION_LENGTH = 500;
647
+ var MAX_MIGRATION_KEY_LENGTH = 255;
648
+ var migrationKeySchema = {
649
+ type: "string",
650
+ minLength: 1,
651
+ maxLength: MAX_MIGRATION_KEY_LENGTH
652
+ };
653
+ var patchSchema = {
654
+ type: "object",
655
+ additionalProperties: false,
656
+ required: ["version", "description", "operations"],
657
+ properties: {
658
+ version: { type: "integer" },
659
+ description: { type: "string" },
660
+ operations: {
661
+ type: "array",
662
+ minItems: 1,
663
+ items: {
664
+ type: "object",
665
+ additionalProperties: false,
666
+ minProperties: 1,
667
+ maxProperties: 1,
668
+ properties: {
669
+ rename_migration: {
670
+ type: "object",
671
+ additionalProperties: false,
672
+ required: ["from", "to"],
673
+ properties: { from: migrationKeySchema, to: migrationKeySchema }
674
+ },
675
+ mark_applied: {
676
+ type: "object",
677
+ additionalProperties: false,
678
+ required: ["key"],
679
+ properties: { key: migrationKeySchema }
680
+ },
681
+ unmark_applied: {
682
+ type: "object",
683
+ additionalProperties: false,
684
+ required: ["key"],
685
+ properties: { key: migrationKeySchema }
686
+ }
687
+ }
688
+ }
506
689
  }
507
- if (isInSqliteBlock) {
508
- result += line + "\n";
690
+ }
691
+ };
692
+ var ajv = new Ajv({ allErrors: true, strict: true });
693
+ var validateSchema = ajv.compile(patchSchema);
694
+ function fail(message, file, patchKey) {
695
+ throw new PatchValidationError(message, file, patchKey);
696
+ }
697
+ function checkMigrationKey(value, context, file, patchKey) {
698
+ if (value !== value.trim()) fail(`${context}: migration key has leading/trailing whitespace`, file, patchKey);
699
+ if (/[/\\]/.test(value)) fail(`${context}: migration key contains a path separator`, file, patchKey);
700
+ if (/[\x00-\x1f\x7f]/.test(value)) fail(`${context}: migration key contains control characters`, file, patchKey);
701
+ if (value.length === 0 || value.length > MAX_MIGRATION_KEY_LENGTH) {
702
+ fail(`${context}: migration key length out of bounds`, file, patchKey);
703
+ }
704
+ return canonicalMigrationKey(value);
705
+ }
706
+ function assertStrictYaml(doc, file, patchKey) {
707
+ if (doc.errors.length > 0) {
708
+ fail(`YAML parse error: ${doc.errors[0].message}`, file, patchKey);
709
+ }
710
+ if (doc.warnings.length > 0) {
711
+ fail(`YAML warning treated as error: ${doc.warnings[0].message}`, file, patchKey);
712
+ }
713
+ const visit = (node) => {
714
+ var _a, _b;
715
+ if (node == null || typeof node !== "object") return;
716
+ if ("source" in node && ((_a = node.constructor) == null ? void 0 : _a.name) === "Alias") {
717
+ fail("YAML aliases are not permitted in patch files", file, patchKey);
718
+ }
719
+ if (node.anchor) {
720
+ fail("YAML anchors are not permitted in patch files", file, patchKey);
721
+ }
722
+ if (node.tag && ![
723
+ "tag:yaml.org,2002:str",
724
+ "tag:yaml.org,2002:int",
725
+ "tag:yaml.org,2002:bool",
726
+ "tag:yaml.org,2002:null",
727
+ "tag:yaml.org,2002:map",
728
+ "tag:yaml.org,2002:seq"
729
+ ].includes(node.tag)) {
730
+ fail(`YAML tag '${node.tag}' is not permitted in patch files`, file, patchKey);
731
+ }
732
+ if (Array.isArray(node.items)) {
733
+ for (const item of node.items) {
734
+ if (item && typeof item === "object" && "key" in item) {
735
+ const keyValue = (_b = item.key) == null ? void 0 : _b.value;
736
+ if (keyValue === "<<") fail("YAML merge keys are not permitted in patch files", file, patchKey);
737
+ visit(item.key);
738
+ visit(item.value);
739
+ } else {
740
+ visit(item);
741
+ }
742
+ }
743
+ }
744
+ };
745
+ visit(doc.contents);
746
+ }
747
+ function parsePatchContent(content, fileName, patchKey) {
748
+ var _a;
749
+ const doc = parseDocument(content, {
750
+ uniqueKeys: true,
751
+ // duplicate mapping keys become errors
752
+ merge: false,
753
+ schema: "core",
754
+ version: "1.2"
755
+ });
756
+ assertStrictYaml(doc, fileName, patchKey);
757
+ const raw = doc.toJS({ mapAsMap: false });
758
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
759
+ fail("Patch document must be a YAML mapping", fileName, patchKey);
760
+ }
761
+ if (!validateSchema(raw)) {
762
+ const detail = ((_a = validateSchema.errors) != null ? _a : []).map((e) => `${e.instancePath || "/"} ${e.message}`).join("; ");
763
+ const anyRaw = raw;
764
+ if (typeof anyRaw.version === "number" && anyRaw.version !== PATCH_FORMAT_VERSION) {
765
+ fail(`Unknown patch format version: ${anyRaw.version} (supported: ${PATCH_FORMAT_VERSION})`, fileName, patchKey);
766
+ }
767
+ fail(`Schema validation failed: ${detail}`, fileName, patchKey);
768
+ }
769
+ const parsed = raw;
770
+ if (parsed.version !== PATCH_FORMAT_VERSION) {
771
+ fail(`Unknown patch format version: ${parsed.version} (supported: ${PATCH_FORMAT_VERSION})`, fileName, patchKey);
772
+ }
773
+ const description = parsed.description.trim();
774
+ if (description.length === 0) fail("description must be non-empty", fileName, patchKey);
775
+ if (description.length > MAX_DESCRIPTION_LENGTH) {
776
+ fail(`description exceeds ${MAX_DESCRIPTION_LENGTH} characters`, fileName, patchKey);
777
+ }
778
+ const operations = parsed.operations.map((op, index) => {
779
+ const verbs = Object.keys(op);
780
+ const verb = verbs[0];
781
+ const context = `operation ${index} (${verb})`;
782
+ switch (verb) {
783
+ case "rename_migration": {
784
+ const from = checkMigrationKey(op.rename_migration.from, context, fileName, patchKey);
785
+ const to = checkMigrationKey(op.rename_migration.to, context, fileName, patchKey);
786
+ if (from === to) {
787
+ fail(`${context}: 'from' and 'to' are identical after canonicalization ('${from}')`, fileName, patchKey);
788
+ }
789
+ return { verb: "rename_migration", from, to };
790
+ }
791
+ case "mark_applied":
792
+ return { verb: "mark_applied", key: checkMigrationKey(op.mark_applied.key, context, fileName, patchKey) };
793
+ case "unmark_applied":
794
+ return { verb: "unmark_applied", key: checkMigrationKey(op.unmark_applied.key, context, fileName, patchKey) };
795
+ default:
796
+ fail(`operation ${index}: unknown verb '${verb}'`, fileName, patchKey);
509
797
  }
798
+ });
799
+ return { version: parsed.version, description, operations };
800
+ }
801
+ function validatePatchPlan(patches, manifest) {
802
+ const renames = [];
803
+ for (const patch of patches) {
804
+ patch.operations.forEach((op, index) => {
805
+ if (op.verb === "rename_migration") {
806
+ renames.push({ from: op.from, to: op.to, file: patch.fileName });
807
+ } else if (op.verb === "mark_applied") {
808
+ if (!manifest.has(op.key)) {
809
+ throw new PatchValidationError(
810
+ `operation ${index} (mark_applied): key '${op.key}' is not present in the current migration manifest`,
811
+ patch.fileName,
812
+ patch.patchKey
813
+ );
814
+ }
815
+ }
816
+ });
510
817
  }
511
- return result;
818
+ if (renames.length === 0) return;
819
+ const mentioned = /* @__PURE__ */ new Set();
820
+ renames.forEach((r) => {
821
+ mentioned.add(r.from);
822
+ mentioned.add(r.to);
823
+ });
824
+ const finalKey = (start) => {
825
+ let current = start;
826
+ for (const r of renames) {
827
+ if (current === r.from) current = r.to;
828
+ }
829
+ return current;
830
+ };
831
+ for (const key of mentioned) {
832
+ const finish = finalKey(key);
833
+ if (manifest.has(key)) {
834
+ if (finish !== key) {
835
+ throw new PatchValidationError(
836
+ `rename plan moves current migration '${key}' to '${finish}', which would make its file incorrectly pending`
837
+ );
838
+ }
839
+ } else {
840
+ if (!manifest.has(finish)) {
841
+ throw new PatchValidationError(
842
+ `rename plan leaves historical key '${key}' at '${finish}', which is not present in the current migration manifest`
843
+ );
844
+ }
845
+ }
846
+ }
847
+ }
848
+
849
+ // framework/PatchDirectoryReader.ts
850
+ var PatchDirectoryReader = class {
851
+ constructor(directory) {
852
+ this.directory = directory;
853
+ }
854
+ loadPatches() {
855
+ if (!fs4.existsSync(this.directory)) return [];
856
+ const entries = fs4.readdirSync(this.directory, { withFileTypes: true });
857
+ const patchFiles = [];
858
+ for (const entry of entries) {
859
+ if (!entry.name.endsWith(".yaml")) continue;
860
+ if (!entry.isFile() || entry.isSymbolicLink()) {
861
+ if (entry.isSymbolicLink()) {
862
+ throw new PatchValidationError(`patch file must be a regular file, not a symlink`, entry.name);
863
+ }
864
+ continue;
865
+ }
866
+ patchFiles.push(entry.name);
867
+ }
868
+ patchFiles.sort((a, b) => {
869
+ const stampA = parseInt(a.slice(0, 13), 10);
870
+ const stampB = parseInt(b.slice(0, 13), 10);
871
+ if (!Number.isNaN(stampA) && !Number.isNaN(stampB) && stampA !== stampB) {
872
+ return stampA - stampB;
873
+ }
874
+ return a < b ? -1 : a > b ? 1 : 0;
875
+ });
876
+ return patchFiles.map((fileName) => {
877
+ const match = PATCH_FILENAME_REGEX.exec(fileName);
878
+ if (!match) {
879
+ throw new PatchValidationError(
880
+ `invalid patch filename (expected <13-digit-stamp>_<name>.yaml with name matching [a-z0-9][a-z0-9_-]{0,119})`,
881
+ fileName
882
+ );
883
+ }
884
+ const patchKey = fileName.slice(0, -".yaml".length);
885
+ const filePath = path3.join(this.directory, fileName);
886
+ const bytes = fs4.readFileSync(filePath);
887
+ const checksum = crypto.createHash("sha256").update(bytes).digest("hex");
888
+ const content = bytes.toString("utf8");
889
+ const { version, description, operations } = parsePatchContent(content, fileName, patchKey);
890
+ return { patchKey, fileName, filePath, checksum, version, description, operations };
891
+ });
892
+ }
893
+ };
894
+
895
+ // framework/PatchRunner.ts
896
+ function toError(error) {
897
+ if (error instanceof Error) return error;
898
+ return new Error(String(error));
512
899
  }
900
+ function extractRows(result) {
901
+ if (!Array.isArray(result)) return [];
902
+ if (Array.isArray(result[0])) return result[0];
903
+ if (result.length === 2 && result[0] && typeof result[0] === "object" && result[1] && typeof result[1] === "object" && !("rows" in result[1])) {
904
+ return result;
905
+ }
906
+ if (result[0] == null) return [];
907
+ return [result[0]];
908
+ }
909
+ var PatchRunner = class {
910
+ constructor(sqlrunner, config) {
911
+ this.sqlrunner = sqlrunner;
912
+ this.config = config;
913
+ this.patchTable = resolvePatchTable(config);
914
+ this.migrationTable = config.migration_table;
915
+ this.dialect = config.database;
916
+ }
917
+ /**
918
+ * Discovers, validates, and applies every unapplied patch in order.
919
+ * Each unapplied patch is its own transaction; earlier committed patches
920
+ * remain committed if a later patch fails.
921
+ */
922
+ applyPending() {
923
+ return __async(this, null, function* () {
924
+ const reader = new PatchDirectoryReader(resolvePatchFolder(this.config));
925
+ const patches = reader.loadPatches();
926
+ const history = yield this.loadHistory();
927
+ if (patches.length === 0 && history.length === 0) {
928
+ return [];
929
+ }
930
+ const byKey = new Map(patches.map((p) => [p.patchKey, p]));
931
+ for (const row of history) {
932
+ const file = byKey.get(row.patch_key);
933
+ if (!file) {
934
+ throw new PatchIntegrityError(
935
+ `applied patch '${row.patch_key}' has no corresponding file in the patch folder; patch files are permanent and must never be renamed or deleted`,
936
+ void 0,
937
+ row.patch_key
938
+ );
939
+ }
940
+ if (file.checksum !== row.checksum) {
941
+ throw new PatchIntegrityError(
942
+ `applied patch '${row.patch_key}' content changed after application; patch files are immutable once recorded`,
943
+ file.fileName,
944
+ row.patch_key,
945
+ row.checksum,
946
+ file.checksum
947
+ );
948
+ }
949
+ }
950
+ const manifest = loadMigrationManifest(this.config.migration_folder, this.dialect);
951
+ validatePatchPlan(patches, manifest);
952
+ const appliedKeys = new Set(history.map((r) => r.patch_key));
953
+ const results = [];
954
+ for (const patch of patches) {
955
+ if (appliedKeys.has(patch.patchKey)) {
956
+ results.push({
957
+ patchKey: patch.patchKey,
958
+ fileName: patch.fileName,
959
+ status: "already_applied",
960
+ operations: []
961
+ });
962
+ continue;
963
+ }
964
+ results.push(yield this.applyOne(patch, manifest));
965
+ }
966
+ return results;
967
+ });
968
+ }
969
+ loadHistory() {
970
+ return __async(this, null, function* () {
971
+ try {
972
+ const result = yield this.sqlrunner.query(
973
+ `SELECT patch_key, checksum FROM ${this.patchTable} WHERE migration_table = ?`,
974
+ [this.migrationTable]
975
+ );
976
+ return extractRows(result);
977
+ } catch (error) {
978
+ throw new PatchExecutionError(
979
+ `failed to read patch history from '${this.patchTable}'`,
980
+ void 0,
981
+ void 0,
982
+ void 0,
983
+ void 0,
984
+ toError(error)
985
+ );
986
+ }
987
+ });
988
+ }
989
+ beginSql() {
990
+ switch (this.dialect) {
991
+ case "sqlite":
992
+ return "BEGIN IMMEDIATE";
993
+ case "pg":
994
+ return "BEGIN";
995
+ default:
996
+ return "START TRANSACTION";
997
+ }
998
+ }
999
+ begin(patch) {
1000
+ return __async(this, null, function* () {
1001
+ const deadline = Date.now() + 1e4;
1002
+ while (true) {
1003
+ try {
1004
+ yield this.sqlrunner.execute(this.beginSql());
1005
+ return;
1006
+ } catch (error) {
1007
+ const message = toError(error).message;
1008
+ if (/SQLITE_BUSY|database is locked/i.test(message) && Date.now() < deadline) {
1009
+ yield new Promise((resolve) => setTimeout(resolve, 50));
1010
+ continue;
1011
+ }
1012
+ throw new PatchExecutionError(
1013
+ "failed to start patch transaction",
1014
+ patch.fileName,
1015
+ patch.patchKey,
1016
+ void 0,
1017
+ void 0,
1018
+ toError(error)
1019
+ );
1020
+ }
1021
+ }
1022
+ });
1023
+ }
1024
+ rollbackQuietly() {
1025
+ return __async(this, null, function* () {
1026
+ try {
1027
+ yield this.sqlrunner.execute("ROLLBACK");
1028
+ } catch (e) {
1029
+ }
1030
+ });
1031
+ }
1032
+ applyOne(patch, manifest) {
1033
+ return __async(this, null, function* () {
1034
+ yield this.begin(patch);
1035
+ try {
1036
+ yield this.sqlrunner.execute(
1037
+ `INSERT INTO ${this.patchTable} (migration_table, patch_key, checksum, format_version, description)
1038
+ VALUES (?, ?, ?, ?, ?)`,
1039
+ [this.migrationTable, patch.patchKey, patch.checksum, patch.version, patch.description]
1040
+ );
1041
+ } catch (claimError) {
1042
+ yield this.rollbackQuietly();
1043
+ const committed = yield this.findCommittedRow(patch.patchKey);
1044
+ if (committed) {
1045
+ if (committed.checksum === patch.checksum) {
1046
+ return {
1047
+ patchKey: patch.patchKey,
1048
+ fileName: patch.fileName,
1049
+ status: "already_applied",
1050
+ operations: []
1051
+ };
1052
+ }
1053
+ throw new PatchIntegrityError(
1054
+ `patch '${patch.patchKey}' was applied elsewhere with a different checksum`,
1055
+ patch.fileName,
1056
+ patch.patchKey,
1057
+ committed.checksum,
1058
+ patch.checksum
1059
+ );
1060
+ }
1061
+ throw new PatchExecutionError(
1062
+ "failed to claim patch-history row",
1063
+ patch.fileName,
1064
+ patch.patchKey,
1065
+ void 0,
1066
+ void 0,
1067
+ toError(claimError)
1068
+ );
1069
+ }
1070
+ const operationResults = [];
1071
+ try {
1072
+ for (let index = 0; index < patch.operations.length; index++) {
1073
+ operationResults.push(
1074
+ yield this.applyOperation(patch, patch.operations[index], index, manifest)
1075
+ );
1076
+ }
1077
+ yield this.sqlrunner.execute("COMMIT");
1078
+ } catch (error) {
1079
+ yield this.rollbackQuietly();
1080
+ if (error instanceof PatchConflictError || error instanceof PatchExecutionError || error instanceof PatchIntegrityError) {
1081
+ throw error;
1082
+ }
1083
+ throw new PatchExecutionError(
1084
+ "patch application failed",
1085
+ patch.fileName,
1086
+ patch.patchKey,
1087
+ void 0,
1088
+ void 0,
1089
+ toError(error)
1090
+ );
1091
+ }
1092
+ return {
1093
+ patchKey: patch.patchKey,
1094
+ fileName: patch.fileName,
1095
+ status: "applied",
1096
+ operations: operationResults
1097
+ };
1098
+ });
1099
+ }
1100
+ findCommittedRow(patchKey) {
1101
+ return __async(this, null, function* () {
1102
+ const result = yield this.sqlrunner.query(
1103
+ `SELECT patch_key, checksum FROM ${this.patchTable} WHERE migration_table = ? AND patch_key = ?`,
1104
+ [this.migrationTable, patchKey]
1105
+ );
1106
+ const list = extractRows(result);
1107
+ return list.length > 0 ? list[0] : null;
1108
+ });
1109
+ }
1110
+ countRows(key) {
1111
+ return __async(this, null, function* () {
1112
+ var _a, _b, _c;
1113
+ const result = yield this.sqlrunner.query(
1114
+ `SELECT COUNT(*) AS row_count FROM ${this.migrationTable} WHERE migration_key = ?`,
1115
+ [key]
1116
+ );
1117
+ const rows = extractRows(result);
1118
+ const value = (_c = (_a = rows[0]) == null ? void 0 : _a.row_count) != null ? _c : Object.values((_b = rows[0]) != null ? _b : {})[0];
1119
+ return Number(value != null ? value : 0);
1120
+ });
1121
+ }
1122
+ conflict(patch, index, verb, message, keys, counts) {
1123
+ throw new PatchConflictError(
1124
+ `operation ${index} (${verb}): ${message}`,
1125
+ patch.fileName,
1126
+ patch.patchKey,
1127
+ index,
1128
+ verb,
1129
+ keys,
1130
+ counts
1131
+ );
1132
+ }
1133
+ applyOperation(patch, op, index, manifest) {
1134
+ return __async(this, null, function* () {
1135
+ var _a, _b, _c, _d;
1136
+ try {
1137
+ switch (op.verb) {
1138
+ case "rename_migration": {
1139
+ const fromCount = yield this.countRows(op.from);
1140
+ const toCount = yield this.countRows(op.to);
1141
+ const counts = { [op.from]: fromCount, [op.to]: toCount };
1142
+ if (fromCount > 1 || toCount > 1) {
1143
+ this.conflict(
1144
+ patch,
1145
+ index,
1146
+ op.verb,
1147
+ `ledger corruption: duplicate rows for a migration key`,
1148
+ [op.from, op.to],
1149
+ counts
1150
+ );
1151
+ }
1152
+ if (fromCount === 1 && toCount === 1) {
1153
+ this.conflict(
1154
+ patch,
1155
+ index,
1156
+ op.verb,
1157
+ `both '${op.from}' and '${op.to}' exist in the ledger`,
1158
+ [op.from, op.to],
1159
+ counts
1160
+ );
1161
+ }
1162
+ if (fromCount === 0) {
1163
+ return { verb: op.verb, changed: false };
1164
+ }
1165
+ const target = manifest.get(op.to);
1166
+ if (target) {
1167
+ yield this.sqlrunner.execute(
1168
+ `UPDATE ${this.migrationTable} SET migration_key = ?, up = ?, down = ? WHERE migration_key = ?`,
1169
+ [op.to, (_a = target.upFile) != null ? _a : "", (_b = target.downFile) != null ? _b : "", op.from]
1170
+ );
1171
+ } else {
1172
+ yield this.sqlrunner.execute(
1173
+ `UPDATE ${this.migrationTable} SET migration_key = ? WHERE migration_key = ?`,
1174
+ [op.to, op.from]
1175
+ );
1176
+ }
1177
+ return { verb: op.verb, changed: true };
1178
+ }
1179
+ case "mark_applied": {
1180
+ const count = yield this.countRows(op.key);
1181
+ if (count > 1) {
1182
+ this.conflict(
1183
+ patch,
1184
+ index,
1185
+ op.verb,
1186
+ `ledger corruption: duplicate rows for '${op.key}'`,
1187
+ [op.key],
1188
+ { [op.key]: count }
1189
+ );
1190
+ }
1191
+ if (count === 1) {
1192
+ return { verb: op.verb, changed: false };
1193
+ }
1194
+ const entry = manifest.get(op.key);
1195
+ yield this.sqlrunner.execute(
1196
+ `INSERT INTO ${this.migrationTable} (migration_key, up, down) VALUES (?, ?, ?)`,
1197
+ [op.key, (_c = entry == null ? void 0 : entry.upFile) != null ? _c : "", (_d = entry == null ? void 0 : entry.downFile) != null ? _d : ""]
1198
+ );
1199
+ return { verb: op.verb, changed: true };
1200
+ }
1201
+ case "unmark_applied": {
1202
+ const count = yield this.countRows(op.key);
1203
+ if (count > 1) {
1204
+ this.conflict(
1205
+ patch,
1206
+ index,
1207
+ op.verb,
1208
+ `ledger corruption: duplicate rows for '${op.key}'`,
1209
+ [op.key],
1210
+ { [op.key]: count }
1211
+ );
1212
+ }
1213
+ if (count === 0) {
1214
+ return { verb: op.verb, changed: false };
1215
+ }
1216
+ yield this.sqlrunner.execute(
1217
+ `DELETE FROM ${this.migrationTable} WHERE migration_key = ?`,
1218
+ [op.key]
1219
+ );
1220
+ return { verb: op.verb, changed: true };
1221
+ }
1222
+ }
1223
+ } catch (error) {
1224
+ if (error instanceof PatchConflictError) throw error;
1225
+ throw new PatchExecutionError(
1226
+ `operation failed`,
1227
+ patch.fileName,
1228
+ patch.patchKey,
1229
+ index,
1230
+ op.verb,
1231
+ toError(error)
1232
+ );
1233
+ }
1234
+ });
1235
+ }
1236
+ };
1237
+
1238
+ // framework/PatchCreator.ts
1239
+ import fs5 from "fs";
1240
+ import path4 from "path";
1241
+ var MAX_NAME_LENGTH = 120;
1242
+ var SCAFFOLD = `version: 1
1243
+ description: TODO
1244
+ operations: []
1245
+ `;
1246
+ var PatchCreator = class _PatchCreator {
1247
+ constructor(patchFolder) {
1248
+ this.patchFolder = patchFolder;
1249
+ }
1250
+ /**
1251
+ * Normalizes a patch name: trim, whitespace runs -> `_`, lowercase.
1252
+ * Rejects empty results, path separators, `..`, control characters,
1253
+ * characters outside [a-z0-9_-], and names longer than 120 characters.
1254
+ */
1255
+ static normalizeName(name) {
1256
+ const normalized = (name != null ? name : "").trim().replace(/\s+/g, "_").toLowerCase();
1257
+ if (normalized.length === 0) {
1258
+ throw new CLIError("Patch name is required");
1259
+ }
1260
+ if (normalized.includes("/") || normalized.includes("\\")) {
1261
+ throw new CLIError("Patch name must not contain path separators");
1262
+ }
1263
+ if (normalized.includes("..")) {
1264
+ throw new CLIError("Patch name must not contain '..'");
1265
+ }
1266
+ if (/[\x00-\x1f\x7f]/.test(normalized)) {
1267
+ throw new CLIError("Patch name must not contain control characters");
1268
+ }
1269
+ if (!/^[a-z0-9_-]+$/.test(normalized)) {
1270
+ throw new CLIError("Patch name may only contain characters [a-z0-9_-]");
1271
+ }
1272
+ if (normalized.length > MAX_NAME_LENGTH) {
1273
+ throw new CLIError(`Patch name exceeds ${MAX_NAME_LENGTH} characters after normalization`);
1274
+ }
1275
+ return normalized;
1276
+ }
1277
+ /**
1278
+ * Creates `<patch_folder>/<stamp>_<normalized_name>.yaml` with exclusive
1279
+ * file creation. On a millisecond-stamp collision, mints a later stamp
1280
+ * and retries. Returns the created path.
1281
+ */
1282
+ create(name) {
1283
+ const normalized = _PatchCreator.normalizeName(name);
1284
+ if (!fs5.existsSync(this.patchFolder)) {
1285
+ fs5.mkdirSync(this.patchFolder, { recursive: true });
1286
+ }
1287
+ let stamp = Date.now();
1288
+ for (let attempt = 0; attempt < 1e3; attempt++) {
1289
+ const filePath = path4.join(this.patchFolder, `${stamp}_${normalized}.yaml`);
1290
+ try {
1291
+ fs5.writeFileSync(filePath, SCAFFOLD, { flag: "wx" });
1292
+ return filePath;
1293
+ } catch (error) {
1294
+ if (error && error.code === "EEXIST") {
1295
+ stamp += 1;
1296
+ continue;
1297
+ }
1298
+ throw error;
1299
+ }
1300
+ }
1301
+ throw new CLIError("Unable to create patch file: too many filename collisions");
1302
+ }
1303
+ };
513
1304
 
514
1305
  // framework/SQLRunner.ts
515
1306
  var BaseSQLRunner = class {
@@ -725,12 +1516,122 @@ ${sql}
725
1516
  });
726
1517
  }
727
1518
  };
1519
+ var PgRunner = class _PgRunner extends BaseSQLRunner {
1520
+ constructor(connection) {
1521
+ super();
1522
+ this.connection = connection;
1523
+ }
1524
+ /** `?` -> `$1`, `$2`, ... outside of string literals / comments. */
1525
+ static toPositional(sql) {
1526
+ let out = "";
1527
+ let n = 0;
1528
+ let inSingle = false;
1529
+ let inDouble = false;
1530
+ let inLineComment = false;
1531
+ let inBlockComment = false;
1532
+ for (let i = 0; i < sql.length; i++) {
1533
+ const ch = sql[i];
1534
+ const next = sql[i + 1];
1535
+ if (inLineComment) {
1536
+ out += ch;
1537
+ if (ch === "\n") inLineComment = false;
1538
+ continue;
1539
+ }
1540
+ if (inBlockComment) {
1541
+ out += ch;
1542
+ if (ch === "*" && next === "/") {
1543
+ out += next;
1544
+ i++;
1545
+ inBlockComment = false;
1546
+ }
1547
+ continue;
1548
+ }
1549
+ if (inSingle) {
1550
+ out += ch;
1551
+ if (ch === "'") inSingle = false;
1552
+ continue;
1553
+ }
1554
+ if (inDouble) {
1555
+ out += ch;
1556
+ if (ch === '"') inDouble = false;
1557
+ continue;
1558
+ }
1559
+ if (ch === "-" && next === "-") {
1560
+ out += ch;
1561
+ inLineComment = true;
1562
+ continue;
1563
+ }
1564
+ if (ch === "/" && next === "*") {
1565
+ out += ch + next;
1566
+ i++;
1567
+ inBlockComment = true;
1568
+ continue;
1569
+ }
1570
+ if (ch === "'") {
1571
+ out += ch;
1572
+ inSingle = true;
1573
+ continue;
1574
+ }
1575
+ if (ch === '"') {
1576
+ out += ch;
1577
+ inDouble = true;
1578
+ continue;
1579
+ }
1580
+ if (ch === "?") {
1581
+ out += `$${++n}`;
1582
+ continue;
1583
+ }
1584
+ out += ch;
1585
+ }
1586
+ return out;
1587
+ }
1588
+ run(sql, params) {
1589
+ return __async(this, null, function* () {
1590
+ if (params.length > 0) {
1591
+ return yield this.connection.query(_PgRunner.toPositional(sql), params);
1592
+ }
1593
+ return yield this.connection.query(sql);
1594
+ });
1595
+ }
1596
+ _query(_0) {
1597
+ return __async(this, arguments, function* (sql, params = []) {
1598
+ const result = yield this.run(sql, params);
1599
+ return [result.rows, result];
1600
+ });
1601
+ }
1602
+ _execute(_0) {
1603
+ return __async(this, arguments, function* (sql, params = []) {
1604
+ var _a;
1605
+ const result = yield this.run(sql, params);
1606
+ return [{ changes: (_a = result.rowCount) != null ? _a : 0, lastID: void 0 }, result];
1607
+ });
1608
+ }
1609
+ _end() {
1610
+ return __async(this, null, function* () {
1611
+ if (this.connection && typeof this.connection.end === "function") {
1612
+ yield this.connection.end();
1613
+ }
1614
+ });
1615
+ }
1616
+ };
728
1617
 
729
1618
  // framework/MigrationRunner.ts
730
- function toError(error) {
1619
+ function toError2(error) {
731
1620
  if (error instanceof Error) return error;
732
1621
  return new Error(String(error));
733
1622
  }
1623
+ function makeRunner(database, conn) {
1624
+ switch (database) {
1625
+ case "sql":
1626
+ return new SQLRunner(conn);
1627
+ case "sqlite":
1628
+ return new SQLiteRunner(conn);
1629
+ case "pg":
1630
+ return new PgRunner(conn);
1631
+ default:
1632
+ throw ConfigurationError.unknownDatabaseType(database);
1633
+ }
1634
+ }
734
1635
  function loadMigrationConfig(configFile) {
735
1636
  const reader = new FileMigrationConfigReader(configFile);
736
1637
  return reader.loadFile();
@@ -743,14 +1644,17 @@ var MigrationRunnerFactory = class _MigrationRunnerFactory {
743
1644
  return __async(this, null, function* () {
744
1645
  const configReader = new FileMigrationConfigReader(configFile);
745
1646
  const config = configReader.loadFile();
1647
+ let factoryOwnsConnection = false;
746
1648
  if (!conn) {
747
1649
  conn = yield this.createConnection(config);
1650
+ factoryOwnsConnection = true;
748
1651
  }
749
- return new _MigrationRunnerFactory().create(config, conn);
1652
+ return new _MigrationRunnerFactory().create(config, conn, factoryOwnsConnection);
750
1653
  });
751
1654
  }
752
1655
  static createConnection(config) {
753
1656
  return __async(this, null, function* () {
1657
+ var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k;
754
1658
  let conn = null;
755
1659
  switch (config.database) {
756
1660
  case "sql":
@@ -761,23 +1665,42 @@ var MigrationRunnerFactory = class _MigrationRunnerFactory {
761
1665
  password: process.env.SQL_PASSWORD
762
1666
  }, config.sql);
763
1667
  try {
764
- conn = yield mysql.createConnection(settings);
1668
+ const mysql = yield import("mysql2/promise");
1669
+ conn = yield ((_b = (_a = mysql.default) == null ? void 0 : _a.createConnection) != null ? _b : mysql.createConnection)(settings);
765
1670
  return conn;
766
1671
  } catch (error) {
767
- throw DatabaseConnectionError.connectionFailed("sql", toError(error).message);
1672
+ throw DatabaseConnectionError.connectionFailed("sql", toError2(error).message);
768
1673
  }
769
1674
  case "sqlite":
770
1675
  if (!config.sqlite) {
771
1676
  throw ConfigurationError.missingDatabaseConfiguration("sqlite");
772
1677
  }
773
1678
  try {
774
- conn = yield sqlite.open({
1679
+ const sqlite = yield import("sqlite");
1680
+ const sqlite3 = yield import("sqlite3");
1681
+ conn = yield ((_d = (_c = sqlite.default) == null ? void 0 : _c.open) != null ? _d : sqlite.open)({
775
1682
  filename: config.sqlite.database,
776
- driver: sqlite3.Database
1683
+ driver: (_f = (_e = sqlite3.default) == null ? void 0 : _e.Database) != null ? _f : sqlite3.Database
777
1684
  });
778
1685
  return conn;
779
1686
  } catch (error) {
780
- throw DatabaseConnectionError.connectionFailed("sqlite", toError(error).message);
1687
+ throw DatabaseConnectionError.connectionFailed("sqlite", toError2(error).message);
1688
+ }
1689
+ case "pg":
1690
+ if (!config.pg && !process.env.DATABASE_URL) {
1691
+ throw ConfigurationError.missingDatabaseConfiguration("pg");
1692
+ }
1693
+ try {
1694
+ const pgcfg = (_g = config.pg) != null ? _g : {};
1695
+ const connectionString = (_h = pgcfg.connectionString) != null ? _h : process.env.DATABASE_URL;
1696
+ const settings2 = connectionString ? { connectionString, ssl: pgcfg.ssl } : __spreadProps(__spreadValues({}, pgcfg), { password: (_i = pgcfg.password) != null ? _i : process.env.PG_PASSWORD });
1697
+ const pg = yield import("pg");
1698
+ const Client = (_k = (_j = pg.default) == null ? void 0 : _j.Client) != null ? _k : pg.Client;
1699
+ conn = new Client(settings2);
1700
+ yield conn.connect();
1701
+ return conn;
1702
+ } catch (error) {
1703
+ throw DatabaseConnectionError.connectionFailed("pg", toError2(error).message);
781
1704
  }
782
1705
  default:
783
1706
  throw ConfigurationError.unknownDatabaseType(config.database);
@@ -791,7 +1714,7 @@ var MigrationRunnerFactory = class _MigrationRunnerFactory {
791
1714
  return new _MigrationRunnerFactory().createEmpty(config);
792
1715
  });
793
1716
  }
794
- create(config, conn) {
1717
+ create(config, conn, factoryOwnsConnection = false) {
795
1718
  return __async(this, null, function* () {
796
1719
  let sqlrunner;
797
1720
  let driverConnection = conn;
@@ -799,42 +1722,34 @@ var MigrationRunnerFactory = class _MigrationRunnerFactory {
799
1722
  sqlrunner = conn;
800
1723
  driverConnection = null;
801
1724
  } else {
802
- switch (config.database) {
803
- case "sql":
804
- sqlrunner = new SQLRunner(conn);
805
- break;
806
- case "sqlite":
807
- sqlrunner = new SQLiteRunner(conn);
808
- break;
809
- default:
810
- throw ConfigurationError.unknownDatabaseType(config.database);
811
- }
1725
+ sqlrunner = makeRunner(config.database, conn);
812
1726
  }
813
1727
  const setup = new MigrationSetup(sqlrunner, config);
814
1728
  const read_strategy = this.getReadStategy(config);
815
1729
  const migration_files = new MigrationDirectoryReader(config.migration_folder, read_strategy, sqlrunner, config.database);
816
- yield setup.setup();
817
- return new MySQLMigrationRunner(config, migration_files, setup, sqlrunner, driverConnection);
1730
+ const runner = new MySQLMigrationRunner(config, migration_files, setup, sqlrunner, driverConnection);
1731
+ try {
1732
+ yield runner.setup();
1733
+ } catch (error) {
1734
+ if (factoryOwnsConnection) {
1735
+ try {
1736
+ yield sqlrunner.end();
1737
+ } catch (e) {
1738
+ }
1739
+ }
1740
+ throw error;
1741
+ }
1742
+ return runner;
818
1743
  });
819
1744
  }
820
1745
  createEmpty(config) {
821
1746
  return __async(this, null, function* () {
822
1747
  const conn = null;
823
- let sqlrunner;
824
- switch (config.database) {
825
- case "sql":
826
- sqlrunner = new SQLRunner(conn);
827
- break;
828
- case "sqlite":
829
- sqlrunner = new SQLiteRunner(conn);
830
- break;
831
- default:
832
- throw ConfigurationError.unknownDatabaseType(config.database);
833
- }
1748
+ const sqlrunner = makeRunner(config.database, conn);
834
1749
  const setup = new MigrationSetup(sqlrunner, config);
835
1750
  const read_strategy = this.getReadStategy(config);
836
1751
  const migration_files = new MigrationDirectoryReader(config.migration_folder, read_strategy, sqlrunner, config.database);
837
- return new MySQLMigrationRunner(config, migration_files, setup, sqlrunner, conn);
1752
+ return new MySQLMigrationRunner(config, migration_files, setup, sqlrunner, conn, false);
838
1753
  });
839
1754
  }
840
1755
  getReadStategy(config) {
@@ -843,39 +1758,85 @@ var MigrationRunnerFactory = class _MigrationRunnerFactory {
843
1758
  return MySqlDialectParser;
844
1759
  case "sqlite":
845
1760
  return SqliteDialectParser;
1761
+ case "pg":
1762
+ return PgDialectParser;
846
1763
  default:
847
1764
  throw ConfigurationError.unknownDatabaseType(config.database);
848
1765
  }
849
1766
  }
850
1767
  };
851
1768
  var MySQLMigrationRunner = class {
852
- constructor(config, directory, setupRunner, sqlrunner, connection) {
1769
+ constructor(config, directory, setupRunner, sqlrunner, connection, preflightEnabled = true) {
853
1770
  this.config = config;
854
1771
  this.directory = directory;
855
1772
  this.setupRunner = setupRunner;
856
1773
  this.sqlrunner = sqlrunner;
857
1774
  this.connection = connection;
1775
+ this.preflightEnabled = preflightEnabled;
1776
+ /**
1777
+ * Memoized in-flight preflight promise. Simultaneous or repeated calls
1778
+ * to setup() on one runner execute the preflight (migration table setup
1779
+ * + patch application) exactly once. Cleared after rejection so a caller
1780
+ * may retry after fixing the cause.
1781
+ */
1782
+ this.preflightPromise = null;
1783
+ this.lastPatchResults = [];
858
1784
  }
859
1785
  setup() {
1786
+ return __async(this, null, function* () {
1787
+ if (!this.preflightEnabled) {
1788
+ return;
1789
+ }
1790
+ if (!this.preflightPromise) {
1791
+ this.preflightPromise = this.runPreflight();
1792
+ this.preflightPromise.catch(() => {
1793
+ this.preflightPromise = null;
1794
+ });
1795
+ }
1796
+ return this.preflightPromise;
1797
+ });
1798
+ }
1799
+ runPreflight() {
860
1800
  return __async(this, null, function* () {
861
1801
  try {
862
1802
  yield this.setupRunner.setup();
863
1803
  } catch (error) {
864
- throw new MigrationExecutionError("Failed to set up migration database", void 0, void 0, toError(error));
1804
+ throw new MigrationExecutionError("Failed to set up migration database", void 0, void 0, toError2(error));
865
1805
  }
1806
+ const patchRunner = new PatchRunner(this.sqlrunner, this.config);
1807
+ this.lastPatchResults = yield patchRunner.applyPending();
866
1808
  });
867
1809
  }
1810
+ /**
1811
+ * Delegates to the same idempotent preflight; returns the results of the
1812
+ * patch pass that ran (or is running) for this runner.
1813
+ */
1814
+ applyPendingPatches() {
1815
+ return __async(this, null, function* () {
1816
+ yield this.setup();
1817
+ return this.lastPatchResults;
1818
+ });
1819
+ }
1820
+ /**
1821
+ * Scaffolds a new ledger patch file and returns the created path.
1822
+ * Never connects to a database.
1823
+ */
1824
+ createPatch(name) {
1825
+ const creator = new PatchCreator(resolvePatchFolder(this.config));
1826
+ return creator.create(name);
1827
+ }
868
1828
  terminate() {
869
1829
  return __async(this, null, function* () {
870
1830
  try {
871
1831
  yield this.setupRunner.teardown();
872
1832
  } catch (error) {
873
- throw new MigrationExecutionError("Failed to tear down migration database", void 0, void 0, toError(error));
1833
+ throw new MigrationExecutionError("Failed to tear down migration database", void 0, void 0, toError2(error));
874
1834
  }
875
1835
  });
876
1836
  }
877
1837
  getMigrationsHistory() {
878
1838
  return __async(this, null, function* () {
1839
+ yield this.setup();
879
1840
  try {
880
1841
  const results = yield this.sqlrunner.query(`
881
1842
  select *
@@ -883,16 +1844,17 @@ var MySQLMigrationRunner = class {
883
1844
  `);
884
1845
  return results[0];
885
1846
  } catch (error) {
886
- throw new MigrationExecutionError("Failed to get migration history", void 0, void 0, toError(error));
1847
+ throw new MigrationExecutionError("Failed to get migration history", void 0, void 0, toError2(error));
887
1848
  }
888
1849
  });
889
1850
  }
890
1851
  getMigrations() {
891
1852
  return __async(this, null, function* () {
1853
+ yield this.setup();
892
1854
  try {
893
1855
  return this.directory.loadMigrations(this.config.migration_table, this.connection);
894
1856
  } catch (error) {
895
- throw new MigrationExecutionError("Failed to load migrations", void 0, void 0, toError(error));
1857
+ throw new MigrationExecutionError("Failed to load migrations", void 0, void 0, toError2(error));
896
1858
  }
897
1859
  });
898
1860
  }
@@ -905,7 +1867,7 @@ var MySQLMigrationRunner = class {
905
1867
  if (error instanceof MigrationExecutionError) {
906
1868
  throw error;
907
1869
  }
908
- throw new MigrationExecutionError("Failed to get pending migrations", void 0, void 0, toError(error));
1870
+ throw new MigrationExecutionError("Failed to get pending migrations", void 0, void 0, toError2(error));
909
1871
  }
910
1872
  });
911
1873
  }
@@ -918,12 +1880,13 @@ var MySQLMigrationRunner = class {
918
1880
  if (error instanceof MigrationExecutionError) {
919
1881
  throw error;
920
1882
  }
921
- throw new MigrationExecutionError("Failed to get completed migrations", void 0, void 0, toError(error));
1883
+ throw new MigrationExecutionError("Failed to get completed migrations", void 0, void 0, toError2(error));
922
1884
  }
923
1885
  });
924
1886
  }
925
1887
  migrate(migrationNodes, forward) {
926
1888
  return __async(this, null, function* () {
1889
+ yield this.setup();
927
1890
  for (let node of migrationNodes) {
928
1891
  try {
929
1892
  if (forward) {
@@ -936,7 +1899,7 @@ var MySQLMigrationRunner = class {
936
1899
  `Failed to ${forward ? "apply" : "rollback"} migration`,
937
1900
  node.name || String(node),
938
1901
  forward ? node.up_sql() : node.down_sql(),
939
- toError(error)
1902
+ toError2(error)
940
1903
  );
941
1904
  }
942
1905
  }
@@ -944,6 +1907,7 @@ var MySQLMigrationRunner = class {
944
1907
  }
945
1908
  reset() {
946
1909
  return __async(this, null, function* () {
1910
+ yield this.setup();
947
1911
  try {
948
1912
  let migrations = yield this.getMigrations();
949
1913
  const rollback = yield migration_filter(migrations, true);
@@ -955,7 +1919,7 @@ var MySQLMigrationRunner = class {
955
1919
  if (error instanceof MigrationExecutionError) {
956
1920
  throw error;
957
1921
  }
958
- throw new MigrationExecutionError("Failed to reset migrations", void 0, void 0, toError(error));
1922
+ throw new MigrationExecutionError("Failed to reset migrations", void 0, void 0, toError2(error));
959
1923
  }
960
1924
  });
961
1925
  }
@@ -964,7 +1928,7 @@ var MySQLMigrationRunner = class {
964
1928
  const creator = new MigrationCreator(this.config);
965
1929
  creator.create(name);
966
1930
  } catch (error) {
967
- throw new MigrationExecutionError(`Failed to create migration: ${name}`, void 0, void 0, toError(error));
1931
+ throw new MigrationExecutionError(`Failed to create migration: ${name}`, void 0, void 0, toError2(error));
968
1932
  }
969
1933
  }
970
1934
  close() {
@@ -973,7 +1937,7 @@ var MySQLMigrationRunner = class {
973
1937
  try {
974
1938
  yield this.sqlrunner.end();
975
1939
  } catch (error) {
976
- throw new DatabaseConnectionError(`Failed to close database connection: ${toError(error).message}`);
1940
+ throw new DatabaseConnectionError(`Failed to close database connection: ${toError2(error).message}`);
977
1941
  }
978
1942
  }
979
1943
  });
@@ -987,7 +1951,7 @@ var MySQLMigrationRunner = class {
987
1951
  return __async(this, null, function* () {
988
1952
  try {
989
1953
  console.log(`Checking for ${config_file}`);
990
- const config_exist = fs3.existsSync(config_file);
1954
+ const config_exist = fs6.existsSync(config_file);
991
1955
  if (!config_exist) {
992
1956
  console.log(`Creating ${config_file}`);
993
1957
  const default_config = {
@@ -1001,11 +1965,11 @@ var MySQLMigrationRunner = class {
1001
1965
  "password": ""
1002
1966
  }
1003
1967
  };
1004
- fs3.writeFileSync(config_file, JSON.stringify(default_config, null, 2));
1968
+ fs6.writeFileSync(config_file, JSON.stringify(default_config, null, 2));
1005
1969
  console.log(`Created ${config_file}`);
1006
1970
  }
1007
1971
  } catch (error) {
1008
- throw new ConfigurationError(`Failed to initialize config file: ${toError(error).message}`);
1972
+ throw new ConfigurationError(`Failed to initialize config file: ${toError2(error).message}`);
1009
1973
  }
1010
1974
  });
1011
1975
  }
@@ -1016,7 +1980,7 @@ var FileMigrationConfigReader = class {
1016
1980
  }
1017
1981
  loadFile() {
1018
1982
  try {
1019
- const fileContent = fs3.readFileSync(this.configFile);
1983
+ const fileContent = fs6.readFileSync(this.configFile);
1020
1984
  const config = JSON.parse(fileContent.toString());
1021
1985
  if (!config.migration_folder) {
1022
1986
  throw ConfigurationError.missingRequiredProperty("migration_folder");
@@ -1029,6 +1993,8 @@ var FileMigrationConfigReader = class {
1029
1993
  config.database = "sql";
1030
1994
  } else if (config.sqlite) {
1031
1995
  config.database = "sqlite";
1996
+ } else if (config.pg) {
1997
+ config.database = "pg";
1032
1998
  } else {
1033
1999
  throw ConfigurationError.missingRequiredProperty("database");
1034
2000
  }
@@ -1038,7 +2004,7 @@ var FileMigrationConfigReader = class {
1038
2004
  if (error instanceof ConfigurationError) {
1039
2005
  throw error;
1040
2006
  }
1041
- const err = toError(error);
2007
+ const err = toError2(error);
1042
2008
  if (err.message.includes("ENOENT")) {
1043
2009
  throw new ConfigurationError(`Config file not found: ${this.configFile}`);
1044
2010
  }
@@ -1055,16 +2021,16 @@ var MigrationCreator = class {
1055
2021
  throw new CLIError("Migration name is required");
1056
2022
  }
1057
2023
  try {
1058
- if (!fs3.existsSync(this.config.migration_folder)) {
1059
- fs3.mkdirSync(this.config.migration_folder, { recursive: true });
2024
+ if (!fs6.existsSync(this.config.migration_folder)) {
2025
+ fs6.mkdirSync(this.config.migration_folder, { recursive: true });
1060
2026
  }
1061
2027
  const now_timestamp = Date.now();
1062
2028
  const filename_up = `${now_timestamp}_${name}.up.sql`;
1063
2029
  const filename_down = `${now_timestamp}_${name}.down.sql`;
1064
- fs3.writeFileSync(`${this.config.migration_folder}/${filename_up}`, `
2030
+ fs6.writeFileSync(`${this.config.migration_folder}/${filename_up}`, `
1065
2031
  -- Write your up migration here
1066
2032
  `.trim());
1067
- fs3.writeFileSync(`${this.config.migration_folder}/${filename_down}`, `
2033
+ fs6.writeFileSync(`${this.config.migration_folder}/${filename_down}`, `
1068
2034
  -- Write your down migration here
1069
2035
  `.trim());
1070
2036
  console.log(`Created migration files:`);
@@ -1074,16 +2040,16 @@ var MigrationCreator = class {
1074
2040
  if (error instanceof CLIError) {
1075
2041
  throw error;
1076
2042
  }
1077
- throw new MigrationExecutionError(`Failed to create migration files: ${toError(error).message}`);
2043
+ throw new MigrationExecutionError(`Failed to create migration files: ${toError2(error).message}`);
1078
2044
  }
1079
2045
  }
1080
2046
  };
1081
2047
 
1082
2048
  // framework/SeedRunner.ts
1083
- import fs4 from "fs";
1084
- import path2 from "path";
2049
+ import fs7 from "fs";
2050
+ import path5 from "path";
1085
2051
  import { pathToFileURL } from "url";
1086
- import Ajv from "ajv";
2052
+ import Ajv2 from "ajv";
1087
2053
  import addFormats from "ajv-formats";
1088
2054
  import { tsImport } from "tsx/esm/api";
1089
2055
  function resolveAlias(name, aliasMap) {
@@ -1092,10 +2058,10 @@ function resolveAlias(name, aliasMap) {
1092
2058
  return (_a = aliasMap[name]) != null ? _a : name;
1093
2059
  }
1094
2060
  function walkForFile(rootDir, fileName) {
1095
- if (!fs4.existsSync(rootDir)) return null;
1096
- const entries = fs4.readdirSync(rootDir, { withFileTypes: true });
2061
+ if (!fs7.existsSync(rootDir)) return null;
2062
+ const entries = fs7.readdirSync(rootDir, { withFileTypes: true });
1097
2063
  for (const entry of entries) {
1098
- const full = path2.join(rootDir, entry.name);
2064
+ const full = path5.join(rootDir, entry.name);
1099
2065
  if (entry.isDirectory()) {
1100
2066
  const found = walkForFile(full, fileName);
1101
2067
  if (found) return found;
@@ -1189,39 +2155,39 @@ function resolveSeed(name, migrationConfig, options) {
1189
2155
  }
1190
2156
  function loadJson(filePath) {
1191
2157
  return __async(this, null, function* () {
1192
- const content = yield fs4.promises.readFile(filePath, "utf8");
2158
+ const content = yield fs7.promises.readFile(filePath, "utf8");
1193
2159
  return JSON.parse(content);
1194
2160
  });
1195
2161
  }
1196
2162
  function createValidator() {
1197
- const ajv = new Ajv({ allErrors: true, strict: false });
1198
- addFormats(ajv);
1199
- return ajv;
2163
+ const ajv2 = new Ajv2({ allErrors: true, strict: false });
2164
+ addFormats(ajv2);
2165
+ return ajv2;
1200
2166
  }
1201
2167
  function validateData(schemaPath, data, validate, log) {
1202
2168
  return __async(this, null, function* () {
1203
2169
  if (!validate || !schemaPath) return;
1204
- const content = yield fs4.promises.readFile(schemaPath, "utf8");
2170
+ const content = yield fs7.promises.readFile(schemaPath, "utf8");
1205
2171
  const schema = JSON.parse(content);
1206
- const ajv = createValidator();
1207
- const validateFn = ajv.compile(schema);
2172
+ const ajv2 = createValidator();
2173
+ const validateFn = ajv2.compile(schema);
1208
2174
  const ok = validateFn(data);
1209
2175
  if (!ok) {
1210
2176
  log == null ? void 0 : log(`Validation failed for seed data (${schemaPath})`);
1211
- throw new Error(`Seed data validation failed: ${ajv.errorsText(validateFn.errors || [])}`);
2177
+ throw new Error(`Seed data validation failed: ${ajv2.errorsText(validateFn.errors || [])}`);
1212
2178
  }
1213
2179
  });
1214
2180
  }
1215
2181
  function runSqlSeed(runner, resolved, direction) {
1216
2182
  return __async(this, null, function* () {
1217
2183
  const sqlPath = direction === "up" ? resolved.upPath : resolved.downPath;
1218
- const sql = yield fs4.promises.readFile(sqlPath, "utf8");
2184
+ const sql = yield fs7.promises.readFile(sqlPath, "utf8");
1219
2185
  yield runner.query(sql);
1220
2186
  });
1221
2187
  }
1222
2188
  function loadSeedModule(modulePath) {
1223
2189
  return __async(this, null, function* () {
1224
- const resolved = path2.resolve(modulePath);
2190
+ const resolved = path5.resolve(modulePath);
1225
2191
  if (resolved.endsWith(".ts")) {
1226
2192
  const fileUrl = pathToFileURL(resolved).href;
1227
2193
  return tsImport(fileUrl, fileUrl);
@@ -1341,10 +2307,23 @@ function createSeedFactory(options) {
1341
2307
  };
1342
2308
  }
1343
2309
  export {
2310
+ MigrationError,
1344
2311
  MySQLMigrationRunner as MigrationRunner,
1345
2312
  MigrationRunnerFactory,
2313
+ PatchConflictError,
2314
+ PatchCreator,
2315
+ PatchError,
2316
+ PatchExecutionError,
2317
+ PatchIntegrityError,
2318
+ PatchRunner,
2319
+ PatchValidationError,
2320
+ PgRunner,
2321
+ SQLRunner,
2322
+ SQLiteRunner,
1346
2323
  createSeedFactory,
1347
2324
  loadMigrationConfig,
2325
+ resolvePatchFolder,
2326
+ resolvePatchTable,
1348
2327
  runSeedsWithRunner
1349
2328
  };
1350
2329
  //# sourceMappingURL=index.mjs.map