@noormdev/sdk 1.4.2 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { createRequire } from 'module';
2
- import { processFile, es_default, DtReader, isTemplate, FORMAT_VERSION, DT_EXTENSIONS, encryptWithPassphrase, ENCODED_TYPES } from './chunk-CDTFXLXU.js';
2
+ import { processFile, es_default, DtReader, isTemplate, FORMAT_VERSION, DT_EXTENSIONS, encryptWithPassphrase, ENCODED_TYPES } from './chunk-M7Q44VFJ.js';
3
3
  import './chunk-UOFJT5PY.js';
4
- import { resolveLegacyAccess, checkConfigPolicy, initializeVault, getVaultStatus, setVaultSecret, getVaultSecret, getAllVaultSecrets, listVaultSecretKeys, deleteVaultSecret, vaultSecretExists, getVaultKey, noormDb, getNoormTables, encryptVaultKey, assertVaultPolicy, assertPolicy, detectAgentHarness, guarded } from './chunk-OMY6CKR4.js';
5
- import { observer, getEnvConfigName, ObserverRelay, isDebug } from './chunk-A2OYZAVT.js';
6
- export { observer as noormObserver } from './chunk-A2OYZAVT.js';
4
+ import { resolveLegacyAccess, checkConfigPolicy, initializeVault, getVaultStatus, setVaultSecret, getVaultSecret, getAllVaultSecrets, listVaultSecretKeys, deleteVaultSecret, vaultSecretExists, getVaultKey, noormDb, getNoormTables, encryptVaultKey, assertVaultPolicy, assertPolicy, detectAgentHarness, guarded } from './chunk-24L5UNQR.js';
5
+ import { observer, getEnvConfigName, ObserverRelay, isDebug } from './chunk-6LG4ENOJ.js';
6
+ export { observer as noormObserver } from './chunk-6LG4ENOJ.js';
7
7
  import { explainConnectionError, DatabaseConnectionError } from './chunk-OXTXFKWJ.js';
8
8
  import { makeNestedConfig, gigabytes, attempt, attemptSync, clone, merge, equals, retry, runWithTimeout, isTimeoutError, wait } from './chunk-TXDGFA44.js';
9
9
  import { external_exports, PortSchema, connectTimeoutFor, DEFAULT_PORTS } from './chunk-WE2JLJMT.js';
@@ -4786,6 +4786,33 @@ function hydrateDate(value, dialect) {
4786
4786
  }
4787
4787
  return /* @__PURE__ */ new Date(`${value.replace(" ", "T")}Z`);
4788
4788
  }
4789
+ function decideNeedsRunFile(record, checksum, force) {
4790
+ if (force) {
4791
+ return { needsRun: true, reason: "force" };
4792
+ }
4793
+ if (record instanceof Error || !record) {
4794
+ return { needsRun: true, reason: "new" };
4795
+ }
4796
+ if (record.exec_status === "failed") {
4797
+ return {
4798
+ needsRun: true,
4799
+ reason: "failed",
4800
+ previousChecksum: record.checksum
4801
+ };
4802
+ }
4803
+ if (record.checksum !== checksum) {
4804
+ return {
4805
+ needsRun: true,
4806
+ reason: "changed",
4807
+ previousChecksum: record.checksum
4808
+ };
4809
+ }
4810
+ return {
4811
+ needsRun: false,
4812
+ skipReason: "already applied",
4813
+ previousChecksum: record.checksum
4814
+ };
4815
+ }
4789
4816
  var ChangeHistory = class {
4790
4817
  #db;
4791
4818
  #ndb;
@@ -4965,31 +4992,19 @@ var ChangeHistory = class {
4965
4992
  };
4966
4993
  }
4967
4994
  /**
4968
- * Check if a single file within a change needs to run.
4969
- *
4970
- * Mirrors `Tracker.needsRun` (runner/tracker.ts) but scoped to this
4971
- * change's name+direction+config instead of a global filepath lookup,
4972
- * so file A's success under one change never satisfies file A's check
4973
- * under a different change.
4974
- *
4975
- * Excludes `pending` rows from consideration: `createFileRecords`
4976
- * inserts a fresh pending row for every file before the per-file loop
4977
- * runs, and that row (always the highest id for this filepath) would
4978
- * otherwise shadow the prior operation's real success/failure record,
4979
- * making retries re-run every file instead of just the one that failed.
4980
- *
4981
- * Also excludes `skipped` rows: `status: 'skipped'` is written for two
4982
- * different meanings — `skipRemainingFiles` writes it for files never
4983
- * reached after an earlier failure (must re-run), while this method's
4984
- * own per-file-skip path (called from `executor.ts`) writes it for a
4985
- * file that matched a prior success (must stay skipped). A `skipped`
4986
- * row is never itself a decision basis: a success-match skip still has
4987
- * its covering `success` row further back in history (found once the
4988
- * `skipped` row is excluded), and a never-reached skip has no terminal
4989
- * row at all, so the lookup falls through to `{ needsRun: true, reason:
4990
- * 'new' }` below and the file runs. Both resolve correctly without
4991
- * consulting the ambiguous row — excluding it here is what prevents a
4992
- * third attempt from re-running a file a prior success already covered.
4995
+ * Fetch the newest qualifying execution row per filepath for one
4996
+ * change and direction.
4997
+ *
4998
+ * Two SELECTs for any number of files: the direction boundary, then the
4999
+ * same query form as `Tracker.latestExecutions`.
5000
+ *
5001
+ * Rows are scoped to this change's name+direction+config, so file A's
5002
+ * success under one change never satisfies file A's check under another.
5003
+ *
5004
+ * `pending` and `skipped` rows never count. Neither records an outcome:
5005
+ * `pending` is a file its batch never reached, and `skipped` is written by
5006
+ * `skipRemainingFiles` for the same reason, or by older versions for a
5007
+ * file that matched a success still further back in history.
4993
5008
  *
4994
5009
  * A prior success only licenses a skip while it is still *standing*.
4995
5010
  * Two things retire it, and neither is visible on the execution row
@@ -5010,72 +5025,56 @@ var ChangeHistory = class {
5010
5025
  *
5011
5026
  * @param name - Change name
5012
5027
  * @param direction - 'change' or 'revert'
5013
- * @param filepath - Relative filepath as stored in execution records
5014
- * @param checksum - Current checksum of the file
5015
- * @param force - Force re-run regardless of status
5016
- * @returns Whether the file needs to run and why
5028
+ * @returns Map of relative filepath to its newest qualifying row, or the lookup error
5029
+ *
5030
+ * @example
5031
+ * ```typescript
5032
+ * const [latest, err] = await history.latestFileExecutions('2024-01-15-add-users', 'change')
5033
+ * const result = decideNeedsRunFile(err ?? latest.get(filepath), checksum, false)
5034
+ * ```
5017
5035
  */
5018
- async needsRunFile(name, direction, filepath, checksum, force) {
5019
- if (force) {
5020
- return { needsRun: true, reason: "force" };
5021
- }
5036
+ async latestFileExecutions(name, direction) {
5037
+ const { executions, change } = this.#tables;
5022
5038
  const opposite = direction === "change" ? "revert" : "change";
5023
5039
  const [boundary, boundaryErr] = await attempt(
5024
- () => this.#ndb.selectFrom(this.#tables.change).select(["id"]).where("name", "=", name).where("change_type", "=", "change").where("direction", "=", opposite).where("config_name", "=", this.#configName).orderBy("id", "desc").limit(1).executeTakeFirst()
5040
+ () => this.#ndb.selectFrom(change).select(["id"]).where("name", "=", name).where("change_type", "=", "change").where("direction", "=", opposite).where("config_name", "=", this.#configName).orderBy("id", "desc").limit(1).executeTakeFirst()
5025
5041
  );
5026
5042
  if (boundaryErr) {
5027
5043
  observer.emit("error", {
5028
5044
  source: "change",
5029
5045
  error: boundaryErr,
5030
- context: { name, filepath, operation: "needs-run-file-boundary" }
5046
+ context: { name, operation: "latest-file-executions-boundary" }
5031
5047
  });
5032
- return { needsRun: true, reason: "new" };
5048
+ return [null, boundaryErr];
5033
5049
  }
5034
- const [record, err] = await attempt(() => {
5035
- let query = this.#ndb.selectFrom(this.#tables.executions).innerJoin(
5036
- this.#tables.change,
5037
- `${this.#tables.change}.id`,
5038
- `${this.#tables.executions}.change_id`
5050
+ let qualifying = this.#ndb.selectFrom(executions).innerJoin(change, `${change}.id`, `${executions}.change_id`).where(`${change}.name`, "=", name).where(`${change}.direction`, "=", direction).where(`${change}.config_name`, "=", this.#configName).where(`${change}.status`, "not in", ["reverted", "stale"]).where(`${executions}.status`, "not in", ["pending", "skipped"]);
5051
+ if (boundary) {
5052
+ qualifying = qualifying.where(`${change}.id`, ">", boundary.id);
5053
+ }
5054
+ const newestIds = qualifying.select((eb) => eb.fn.max(`${executions}.id`).as("id")).groupBy(`${executions}.filepath`).as("newest");
5055
+ const [rows, err] = await attempt(
5056
+ () => (
5039
5057
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
5040
- ).select((eb) => [
5041
- eb.ref(`${this.#tables.executions}.checksum`).as("checksum"),
5042
- eb.ref(`${this.#tables.executions}.status`).as("exec_status")
5043
- ]).where(`${this.#tables.change}.name`, "=", name).where(`${this.#tables.change}.direction`, "=", direction).where(`${this.#tables.change}.config_name`, "=", this.#configName).where(`${this.#tables.change}.status`, "not in", ["reverted", "stale"]).where(`${this.#tables.executions}.filepath`, "=", filepath).where(`${this.#tables.executions}.status`, "not in", ["pending", "skipped"]);
5044
- if (boundary) {
5045
- query = query.where(`${this.#tables.change}.id`, ">", boundary.id);
5046
- }
5047
- return query.orderBy(`${this.#tables.executions}.id`, "desc").limit(1).executeTakeFirst();
5048
- });
5058
+ this.#ndb.selectFrom(executions).innerJoin(newestIds, "newest.id", `${executions}.id`).select((eb) => [
5059
+ eb.ref(`${executions}.filepath`).as("filepath"),
5060
+ eb.ref(`${executions}.checksum`).as("checksum"),
5061
+ eb.ref(`${executions}.status`).as("exec_status")
5062
+ ]).execute()
5063
+ )
5064
+ );
5049
5065
  if (err) {
5050
5066
  observer.emit("error", {
5051
5067
  source: "change",
5052
5068
  error: err,
5053
- context: { name, filepath, operation: "needs-run-file-check" }
5069
+ context: { name, operation: "latest-file-executions" }
5054
5070
  });
5055
- return { needsRun: true, reason: "new" };
5056
- }
5057
- if (!record) {
5058
- return { needsRun: true, reason: "new" };
5059
- }
5060
- if (record.exec_status === "failed") {
5061
- return {
5062
- needsRun: true,
5063
- reason: "failed",
5064
- previousChecksum: record.checksum
5065
- };
5071
+ return [null, err];
5066
5072
  }
5067
- if (record.checksum !== checksum) {
5068
- return {
5069
- needsRun: true,
5070
- reason: "changed",
5071
- previousChecksum: record.checksum
5072
- };
5073
+ const latest = /* @__PURE__ */ new Map();
5074
+ for (const { filepath, ...record } of rows) {
5075
+ latest.set(filepath, record);
5073
5076
  }
5074
- return {
5075
- needsRun: false,
5076
- skipReason: "already applied",
5077
- previousChecksum: record.checksum
5078
- };
5077
+ return [latest, null];
5079
5078
  }
5080
5079
  // ─────────────────────────────────────────────────────────
5081
5080
  // Create Records
@@ -5113,9 +5112,10 @@ var ChangeHistory = class {
5113
5112
  return id;
5114
5113
  }
5115
5114
  /**
5116
- * Create pending file records for all files.
5115
+ * Create pending file records for the files about to run.
5117
5116
  *
5118
- * Creates records upfront so we can mark remaining as skipped on failure.
5117
+ * The pending rows let `skipRemainingFiles` mark the files a
5118
+ * failure left unreached.
5119
5119
  *
5120
5120
  * @returns Error message if creation failed, null on success
5121
5121
  */
@@ -5481,6 +5481,44 @@ var ChangeHistory = class {
5481
5481
  };
5482
5482
 
5483
5483
  // src/core/runner/tracker.ts
5484
+ function decideNeedsRun(record, checksum, force) {
5485
+ if (record instanceof Error) {
5486
+ return { needsRun: true, reason: "error" };
5487
+ }
5488
+ if (!record) {
5489
+ return { needsRun: true, reason: "new" };
5490
+ }
5491
+ if (record.exec_status === "failed") {
5492
+ return {
5493
+ needsRun: true,
5494
+ reason: "failed",
5495
+ previousChecksum: record.checksum
5496
+ };
5497
+ }
5498
+ const isUnchangedSkip = record.exec_status === "skipped" && record.skip_reason === "unchanged";
5499
+ if (record.exec_status === "pending" || record.exec_status === "skipped" && !isUnchangedSkip) {
5500
+ return { needsRun: true, reason: "new" };
5501
+ }
5502
+ if (record.change_status === "stale") {
5503
+ return {
5504
+ needsRun: true,
5505
+ reason: "stale",
5506
+ previousChecksum: record.checksum
5507
+ };
5508
+ }
5509
+ if (record.checksum !== checksum) {
5510
+ return {
5511
+ needsRun: true,
5512
+ reason: "changed",
5513
+ previousChecksum: record.checksum
5514
+ };
5515
+ }
5516
+ return {
5517
+ needsRun: false,
5518
+ skipReason: "unchanged",
5519
+ previousChecksum: record.checksum
5520
+ };
5521
+ }
5484
5522
  var Tracker = class {
5485
5523
  #db;
5486
5524
  #ndb;
@@ -5495,89 +5533,45 @@ var Tracker = class {
5495
5533
  this.#dialect = dialect;
5496
5534
  }
5497
5535
  /**
5498
- * Check if a file needs to run.
5536
+ * Fetch the newest execution row per filepath for this config.
5499
5537
  *
5500
- * A file needs to run if:
5501
- * - Force flag is set
5502
- * - No previous execution exists (new file)
5503
- * - Previous execution failed
5504
- * - Parent change is stale (schema was torn down)
5505
- * - Checksum differs (file changed)
5538
+ * One SELECT for any number of files, so a batch can decide every file
5539
+ * with `decideNeedsRun` before writing any tracking rows. Query form:
5540
+ * `docs/design/prefetch-run-gate.md`.
5506
5541
  *
5507
- * @param filepath - File path to check
5508
- * @param checksum - Current file checksum
5509
- * @param force - Force re-run regardless of status
5510
- * @param excludeOperationId - Operation whose own rows should be ignored.
5511
- * `executeFiles` inserts a `pending` row for every discovered file
5512
- * *before* running any of them (for batch visibility), so within that
5513
- * same operation the newest row for a file is always its own pending
5514
- * record — reading as "new" forever and making checksum-based skipping
5515
- * unreachable. Passing the running operation's id here excludes those
5516
- * rows so the lookup finds the last *completed* operation instead.
5517
- * @returns Whether file needs to run and why
5518
- */
5519
- async needsRun(filepath, checksum, force, excludeOperationId) {
5520
- if (force) {
5521
- return { needsRun: true, reason: "force" };
5522
- }
5523
- let query = this.#ndb.selectFrom(this.#tables.executions).innerJoin(
5524
- this.#tables.change,
5525
- `${this.#tables.change}.id`,
5526
- `${this.#tables.executions}.change_id`
5527
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
5528
- ).select((eb) => [
5529
- eb.ref(`${this.#tables.executions}.checksum`).as("checksum"),
5530
- eb.ref(`${this.#tables.executions}.status`).as("exec_status"),
5531
- eb.ref(`${this.#tables.executions}.skip_reason`).as("skip_reason"),
5532
- eb.ref(`${this.#tables.change}.status`).as("change_status")
5533
- ]).where(`${this.#tables.executions}.filepath`, "=", filepath).where(`${this.#tables.change}.config_name`, "=", this.#configName);
5534
- if (excludeOperationId !== void 0) {
5535
- query = query.where(`${this.#tables.executions}.change_id`, "<>", excludeOperationId);
5536
- }
5537
- const [record, err] = await attempt(
5538
- () => query.orderBy(`${this.#tables.executions}.id`, "desc").limit(1).executeTakeFirst()
5542
+ * @returns Map of filepath to its newest record, or the lookup error
5543
+ *
5544
+ * @example
5545
+ * ```typescript
5546
+ * const [latest, err] = await tracker.latestExecutions()
5547
+ * const result = decideNeedsRun(err ?? latest.get(filepath), checksum, false)
5548
+ * ```
5549
+ */
5550
+ async latestExecutions() {
5551
+ const { executions, change } = this.#tables;
5552
+ const newestIds = this.#ndb.selectFrom(executions).innerJoin(change, `${change}.id`, `${executions}.change_id`).where(`${change}.config_name`, "=", this.#configName).select((eb) => eb.fn.max(`${executions}.id`).as("id")).groupBy(`${executions}.filepath`).as("newest");
5553
+ const [rows, err] = await attempt(
5554
+ () => this.#ndb.selectFrom(executions).innerJoin(change, `${change}.id`, `${executions}.change_id`).innerJoin(newestIds, "newest.id", `${executions}.id`).select((eb) => [
5555
+ eb.ref(`${executions}.filepath`).as("filepath"),
5556
+ eb.ref(`${executions}.checksum`).as("checksum"),
5557
+ eb.ref(`${executions}.status`).as("exec_status"),
5558
+ eb.ref(`${executions}.skip_reason`).as("skip_reason"),
5559
+ eb.ref(`${change}.status`).as("change_status")
5560
+ ]).execute()
5539
5561
  );
5540
5562
  if (err) {
5541
5563
  observer.emit("error", {
5542
5564
  source: "runner",
5543
5565
  error: err,
5544
- context: { filepath, operation: "needs-run-check" }
5566
+ context: { operation: "latest-executions" }
5545
5567
  });
5546
- return { needsRun: true, reason: "error" };
5547
- }
5548
- if (!record) {
5549
- return { needsRun: true, reason: "new" };
5550
- }
5551
- if (record.exec_status === "failed") {
5552
- return {
5553
- needsRun: true,
5554
- reason: "failed",
5555
- previousChecksum: record.checksum
5556
- };
5557
- }
5558
- const isUnchangedSkip = record.exec_status === "skipped" && record.skip_reason === "unchanged";
5559
- if (record.exec_status === "pending" || record.exec_status === "skipped" && !isUnchangedSkip) {
5560
- return { needsRun: true, reason: "new" };
5568
+ return [null, err];
5561
5569
  }
5562
- if (record.change_status === "stale") {
5563
- return {
5564
- needsRun: true,
5565
- reason: "stale",
5566
- previousChecksum: record.checksum
5567
- };
5570
+ const latest = /* @__PURE__ */ new Map();
5571
+ for (const { filepath, ...record } of rows) {
5572
+ latest.set(filepath, record);
5568
5573
  }
5569
- if (record.checksum !== checksum) {
5570
- return {
5571
- needsRun: true,
5572
- reason: "changed",
5573
- previousChecksum: record.checksum
5574
- };
5575
- }
5576
- return {
5577
- needsRun: false,
5578
- skipReason: "unchanged",
5579
- previousChecksum: record.checksum
5580
- };
5574
+ return [latest, null];
5581
5575
  }
5582
5576
  /**
5583
5577
  * Create a new operation record.
@@ -5613,35 +5607,6 @@ var Tracker = class {
5613
5607
  }
5614
5608
  return id;
5615
5609
  }
5616
- /**
5617
- * Record a file execution.
5618
- *
5619
- * Creates a child record in __noorm_executions__ linked
5620
- * to the parent operation.
5621
- *
5622
- * @param data - Execution data
5623
- */
5624
- async recordExecution(data2) {
5625
- const [, err] = await attempt(
5626
- () => this.#ndb.insertInto(this.#tables.executions).values({
5627
- change_id: data2.changeId,
5628
- filepath: data2.filepath,
5629
- file_type: "sql",
5630
- checksum: data2.checksum,
5631
- status: data2.status,
5632
- skip_reason: data2.skipReason ?? "",
5633
- error_message: data2.errorMessage ?? "",
5634
- duration_ms: Math.round(data2.durationMs ?? 0)
5635
- }).execute()
5636
- );
5637
- if (err) {
5638
- observer.emit("error", {
5639
- source: "runner",
5640
- error: err,
5641
- context: { filepath: data2.filepath, operation: "record-execution" }
5642
- });
5643
- }
5644
- }
5645
5610
  /**
5646
5611
  * Finalize an operation.
5647
5612
  *
@@ -5689,10 +5654,10 @@ var Tracker = class {
5689
5654
  // Batch File Operations (Shared by Runner and Changes)
5690
5655
  // ─────────────────────────────────────────────────────────
5691
5656
  /**
5692
- * Create pending file records for all files upfront.
5657
+ * Create pending file records for the files about to run.
5693
5658
  *
5694
- * Creates records so the batch is fully visible. On failure,
5695
- * remaining files can be marked as skipped.
5659
+ * The pending rows let `skipRemainingFiles` mark the files a
5660
+ * failure left unreached.
5696
5661
  *
5697
5662
  * @param operationId - Parent operation ID
5698
5663
  * @param files - Files to create records for
@@ -5732,23 +5697,15 @@ var Tracker = class {
5732
5697
  * @param durationMs - Execution time
5733
5698
  * @param errorMessage - Error message if failed
5734
5699
  * @param skipReason - Skip reason if skipped
5735
- * @param checksum - Checksum of the SQL that actually reached the
5736
- * database. `createFileRecords` seeds the pending row with the *raw*
5737
- * file hash because rendering every template upfront would execute them
5738
- * twice; for a `.sql.tmpl` that hash is not what `needsRun` compares
5739
- * against, so leaving it in place made template dedup unreachable.
5740
- * Omitted leaves the seeded value alone — correct only where no render
5741
- * happened (e.g. the file could not be read).
5742
5700
  * @returns Error message if update failed, null on success
5743
5701
  */
5744
- async updateFileExecution(operationId, filepath, status, durationMs, errorMessage, skipReason, checksum) {
5702
+ async updateFileExecution(operationId, filepath, status, durationMs, errorMessage, skipReason) {
5745
5703
  const [result, err] = await attempt(
5746
5704
  () => this.#ndb.updateTable(this.#tables.executions).set({
5747
5705
  status,
5748
5706
  duration_ms: Math.round(durationMs),
5749
5707
  error_message: errorMessage ?? "",
5750
- skip_reason: skipReason ?? "",
5751
- ...checksum === void 0 ? {} : { checksum }
5708
+ skip_reason: skipReason ?? ""
5752
5709
  }).where("change_id", "=", operationId).where("filepath", "=", filepath).executeTakeFirst()
5753
5710
  );
5754
5711
  if (err) {
@@ -5842,7 +5799,6 @@ var Tracker = class {
5842
5799
  /**
5843
5800
  * Check if a change needs to run by name.
5844
5801
  *
5845
- * Similar to needsRun but checks by change name instead of filepath.
5846
5802
  * Used for change sets where we track by change name, not individual files.
5847
5803
  *
5848
5804
  * @param name - Change name
@@ -6019,6 +5975,349 @@ function computeCombinedChecksum(checksums) {
6019
5975
  const combined = sorted.join("");
6020
5976
  return createHash("sha256").update(combined, "utf-8").digest("hex");
6021
5977
  }
5978
+ var SESSION_ID_SQL = {
5979
+ postgres: "select pg_backend_pid() as id",
5980
+ mysql: "select connection_id() as id",
5981
+ mssql: "select @@SPID as id"
5982
+ };
5983
+ var SERVER_CANCEL = {
5984
+ postgres: (db, sessionId) => sql`select pg_cancel_backend(${sessionId})`.execute(db),
5985
+ // KILL cannot be prepared, so the id is interpolated. It comes from
5986
+ // connection_id() and is checked by readSessionId before it gets here,
5987
+ // never from user input.
5988
+ mysql: (db, sessionId) => sql.raw(`kill query ${sessionId}`).execute(db)
5989
+ };
5990
+ function readSessionId(rows) {
5991
+ const raw = rows[0]?.id;
5992
+ const id = Number(raw);
5993
+ if (!Number.isInteger(id) || id <= 0) return void 0;
5994
+ return id;
5995
+ }
5996
+ function percentOf(done, total) {
5997
+ if (done === null || total === null || total <= 0) return null;
5998
+ return Math.min(100, done / total * 100);
5999
+ }
6000
+ function toNumber(value) {
6001
+ if (value === null || value === void 0) return null;
6002
+ const n = Number(value);
6003
+ return Number.isFinite(n) ? n : null;
6004
+ }
6005
+ function toText(value) {
6006
+ if (value === null || value === void 0 || value === "") return null;
6007
+ return String(value);
6008
+ }
6009
+ async function rowsOrEmpty(query) {
6010
+ const [result, err] = await attempt(query);
6011
+ return err ? [] : result.rows;
6012
+ }
6013
+ function toBlockers(rows) {
6014
+ return rows.map((row) => ({
6015
+ pid: Number(row.pid),
6016
+ query: toText(row.query),
6017
+ ageMs: toNumber(row.age_ms)
6018
+ }));
6019
+ }
6020
+ var postgresProbe = async (side, pid) => {
6021
+ const [activity] = await rowsOrEmpty(() => sql`
6022
+ select a.state, a.wait_event_type, a.wait_event,
6023
+ pg_blocking_pids(a.pid) as blockers,
6024
+ (select count(*)::int from pg_stat_activity w
6025
+ where w.leader_pid = a.pid and w.pid <> a.pid) as workers
6026
+ from pg_stat_activity a
6027
+ where a.pid = ${pid}
6028
+ `.execute(side));
6029
+ const blockerPids = (activity?.blockers ?? []).map(Number);
6030
+ const blockers = blockerPids.length === 0 ? [] : await rowsOrEmpty(() => sql`
6031
+ select pid, left(query, 120) as query,
6032
+ (extract(epoch from now() - coalesce(xact_start, query_start)) * 1000)::float8 as age_ms
6033
+ from pg_stat_activity
6034
+ where pid = any(${blockerPids}::int[])
6035
+ `.execute(side));
6036
+ const [progress] = await rowsOrEmpty(() => sql`
6037
+ select command as operation, phase,
6038
+ nullif(relid, 0)::regclass::text as relation,
6039
+ (case when tuples_total > 0 then tuples_done else blocks_done end)::float8 as done,
6040
+ (case when tuples_total > 0 then tuples_total else blocks_total end)::float8 as total
6041
+ from pg_stat_progress_create_index where pid = ${pid}
6042
+ union all
6043
+ select 'VACUUM', phase, nullif(relid, 0)::regclass::text,
6044
+ heap_blks_scanned::float8, heap_blks_total::float8
6045
+ from pg_stat_progress_vacuum where pid = ${pid}
6046
+ union all
6047
+ select command, phase, nullif(relid, 0)::regclass::text,
6048
+ heap_blks_scanned::float8, heap_blks_total::float8
6049
+ from pg_stat_progress_cluster where pid = ${pid}
6050
+ union all
6051
+ select command, null, nullif(relid, 0)::regclass::text,
6052
+ bytes_processed::float8, nullif(bytes_total, 0)::float8
6053
+ from pg_stat_progress_copy where pid = ${pid}
6054
+ union all
6055
+ select 'ANALYZE', phase, nullif(relid, 0)::regclass::text,
6056
+ sample_blks_scanned::float8, sample_blks_total::float8
6057
+ from pg_stat_progress_analyze where pid = ${pid}
6058
+ limit 1
6059
+ `.execute(side));
6060
+ const waitEvent = activity?.wait_event ? `${activity.wait_event_type ?? "Wait"}: ${activity.wait_event}` : null;
6061
+ return {
6062
+ state: toText(activity?.state),
6063
+ waitEvent,
6064
+ blockedBy: toBlockers(blockers),
6065
+ workers: toNumber(activity?.workers) ?? 0,
6066
+ progress: progress ? {
6067
+ operation: progress.operation,
6068
+ phase: toText(progress.phase),
6069
+ relation: toText(progress.relation),
6070
+ done: toNumber(progress.done),
6071
+ total: toNumber(progress.total),
6072
+ percent: percentOf(toNumber(progress.done), toNumber(progress.total))
6073
+ } : null
6074
+ };
6075
+ };
6076
+ var mssqlProbe = async (side, spid) => {
6077
+ const [request] = await rowsOrEmpty(() => sql`
6078
+ select status, wait_type, blocking_session_id, percent_complete, command
6079
+ from sys.dm_exec_requests
6080
+ where session_id = ${spid}
6081
+ `.execute(side));
6082
+ const blockerId = toNumber(request?.blocking_session_id);
6083
+ const blockers = !blockerId ? [] : await rowsOrEmpty(() => sql`
6084
+ select s.session_id as pid,
6085
+ coalesce(left(t.text, 120), r.command, s.status) as query,
6086
+ datediff_big(millisecond, coalesce(r.start_time, s.last_request_start_time), sysdatetime()) as age_ms
6087
+ from sys.dm_exec_sessions s
6088
+ left join sys.dm_exec_requests r on r.session_id = s.session_id
6089
+ outer apply sys.dm_exec_sql_text(r.sql_handle) t
6090
+ where s.session_id = ${blockerId}
6091
+ `.execute(side));
6092
+ const percent = toNumber(request?.percent_complete);
6093
+ return {
6094
+ state: toText(request?.status),
6095
+ waitEvent: toText(request?.wait_type),
6096
+ blockedBy: toBlockers(blockers),
6097
+ workers: 0,
6098
+ progress: request?.command && percent ? { operation: request.command, phase: null, relation: null, done: null, total: null, percent } : null
6099
+ };
6100
+ };
6101
+ var mysqlProbe = async (side, id) => {
6102
+ const [process2] = await rowsOrEmpty(() => sql`
6103
+ select state from information_schema.processlist where id = ${id}
6104
+ `.execute(side));
6105
+ const blockerIds = await rowsOrEmpty(() => sql`
6106
+ select blocking_pid as pid from sys.innodb_lock_waits where waiting_pid = ${id}
6107
+ union
6108
+ select blocking_pid from sys.schema_table_lock_waits where waiting_pid = ${id}
6109
+ `.execute(side));
6110
+ const ids = blockerIds.map((row) => Number(row.pid));
6111
+ const blockers = ids.length === 0 ? [] : await rowsOrEmpty(() => sql`
6112
+ select id as pid, left(coalesce(info, command), 120) as query, time * 1000 as age_ms
6113
+ from information_schema.processlist
6114
+ where id in (${sql.join(ids)})
6115
+ `.execute(side));
6116
+ const [stage] = await rowsOrEmpty(() => sql`
6117
+ select s.event_name, s.work_completed, s.work_estimated
6118
+ from performance_schema.events_stages_current s
6119
+ join performance_schema.threads t on t.thread_id = s.thread_id
6120
+ where t.processlist_id = ${id}
6121
+ `.execute(side));
6122
+ const state = toText(process2?.state);
6123
+ const done = toNumber(stage?.work_completed);
6124
+ const total = toNumber(stage?.work_estimated);
6125
+ return {
6126
+ state,
6127
+ waitEvent: state?.startsWith("Waiting") ? state : null,
6128
+ blockedBy: toBlockers(blockers),
6129
+ workers: 0,
6130
+ progress: stage ? {
6131
+ operation: stage.event_name.replace(/^stage\/\w+\//, ""),
6132
+ phase: null,
6133
+ relation: null,
6134
+ done,
6135
+ total,
6136
+ percent: percentOf(done, total)
6137
+ } : null
6138
+ };
6139
+ };
6140
+ var STATEMENT_PROBES = {
6141
+ postgres: postgresProbe,
6142
+ mssql: mssqlProbe,
6143
+ mysql: mysqlProbe
6144
+ };
6145
+
6146
+ // src/core/runner/statement-watcher.ts
6147
+ var DEFAULT_WATCH_DELAY_MS = 1e4;
6148
+ var DEFAULT_WATCH_INTERVAL_MS = 1e4;
6149
+ var SIDE_CONNECTION_WAIT_MS = 5e3;
6150
+ var StatementWatcher = class {
6151
+ #pool;
6152
+ #dialect;
6153
+ #signal;
6154
+ #delayMs;
6155
+ #intervalMs;
6156
+ #active = null;
6157
+ #side = null;
6158
+ #sideAbandoned = false;
6159
+ #releaseSide = null;
6160
+ #sideHeld = Promise.resolve();
6161
+ #closed = false;
6162
+ #onAbort = () => {
6163
+ const statement = this.#active;
6164
+ if (statement) void this.#cancel(statement);
6165
+ };
6166
+ /**
6167
+ * @param pool - Root connection pool the side connection is checked out
6168
+ * from. Never a transaction: the side connection must be a separate session.
6169
+ */
6170
+ constructor(pool, options = {}) {
6171
+ this.#pool = pool;
6172
+ this.#dialect = options.dialect;
6173
+ this.#signal = options.signal;
6174
+ this.#delayMs = options.delayMs ?? DEFAULT_WATCH_DELAY_MS;
6175
+ this.#intervalMs = options.intervalMs ?? DEFAULT_WATCH_INTERVAL_MS;
6176
+ this.#signal?.addEventListener("abort", this.#onAbort, { once: true });
6177
+ }
6178
+ /**
6179
+ * Run `fn` on one pinned connection while watching it.
6180
+ *
6181
+ * A transaction is already one connection, so it is used as is; anything
6182
+ * else is pinned with `connection()` so the session id read up front is
6183
+ * the session the SQL runs on.
6184
+ *
6185
+ * @throws OperationAbortedError when the signal aborted before `fn` started
6186
+ */
6187
+ async run(filepath, executor, fn) {
6188
+ if (executor.isTransaction) {
6189
+ return this.#watch(filepath, executor, fn);
6190
+ }
6191
+ return executor.connection().execute((conn) => this.#watch(filepath, conn, fn));
6192
+ }
6193
+ /**
6194
+ * Stop watching and return the side connection to the pool.
6195
+ */
6196
+ async close() {
6197
+ this.#closed = true;
6198
+ this.#signal?.removeEventListener("abort", this.#onAbort);
6199
+ this.#releaseSide?.();
6200
+ await this.#sideHeld;
6201
+ }
6202
+ async #watch(filepath, conn, fn) {
6203
+ const sessionId = await this.#readSessionId(conn);
6204
+ const statement = {
6205
+ filepath,
6206
+ sessionId,
6207
+ startedAt: performance.now(),
6208
+ timer: void 0,
6209
+ finished: false,
6210
+ cancelling: null
6211
+ };
6212
+ if (this.#signal?.aborted) {
6213
+ throw new OperationAbortedError("Run cancelled before the file started");
6214
+ }
6215
+ this.#active = statement;
6216
+ statement.timer = setTimeout(() => void this.#report(statement), this.#delayMs);
6217
+ try {
6218
+ return await fn(conn);
6219
+ } finally {
6220
+ clearTimeout(statement.timer);
6221
+ this.#active = null;
6222
+ statement.finished = true;
6223
+ await statement.cancelling;
6224
+ }
6225
+ }
6226
+ async #readSessionId(conn) {
6227
+ const query = this.#dialect ? SESSION_ID_SQL[this.#dialect] : void 0;
6228
+ if (!query) return null;
6229
+ const [result, err] = await attempt(() => sql.raw(query).execute(conn));
6230
+ if (err) return null;
6231
+ return readSessionId(result.rows) ?? null;
6232
+ }
6233
+ async #report(statement) {
6234
+ if (this.#active !== statement) return;
6235
+ const status = await this.#poll(statement);
6236
+ if (this.#active !== statement) return;
6237
+ observer.emit("file:progress", {
6238
+ filepath: statement.filepath,
6239
+ elapsedMs: performance.now() - statement.startedAt,
6240
+ sessionId: statement.sessionId,
6241
+ status
6242
+ });
6243
+ statement.timer = setTimeout(() => void this.#report(statement), this.#intervalMs);
6244
+ }
6245
+ async #poll(statement) {
6246
+ const probe = this.#dialect ? STATEMENT_PROBES[this.#dialect] : void 0;
6247
+ const sessionId = statement.sessionId;
6248
+ if (!probe || sessionId === null) return null;
6249
+ const side = await this.#sideConnection();
6250
+ if (!side) return null;
6251
+ const [status, err] = await attempt(() => probe(side, sessionId));
6252
+ return err ? null : status;
6253
+ }
6254
+ async #cancel(statement) {
6255
+ const cancel = this.#dialect ? SERVER_CANCEL[this.#dialect] : void 0;
6256
+ const sessionId = statement.sessionId;
6257
+ if (sessionId === null || !cancel) return;
6258
+ const side = await this.#sideConnection();
6259
+ if (statement.finished || this.#closed) return;
6260
+ if (!side) {
6261
+ observer.emit("error", {
6262
+ source: "runner:cancel",
6263
+ error: new Error("No free connection to send the cancel from"),
6264
+ context: { filepath: statement.filepath, sessionId }
6265
+ });
6266
+ return;
6267
+ }
6268
+ const sending = attempt(() => cancel(side, sessionId));
6269
+ statement.cancelling = sending.then(() => void 0);
6270
+ const [, err] = await sending;
6271
+ if (err) {
6272
+ observer.emit("error", {
6273
+ source: "runner:cancel",
6274
+ error: err,
6275
+ context: { filepath: statement.filepath, sessionId }
6276
+ });
6277
+ }
6278
+ }
6279
+ async #sideConnection() {
6280
+ if (this.#closed || this.#sideAbandoned) return null;
6281
+ this.#side ??= this.#checkoutSide();
6282
+ const pending = this.#side;
6283
+ const [side] = await attempt(() => runWithTimeout(() => pending, { timeout: SIDE_CONNECTION_WAIT_MS }));
6284
+ if (!side && this.#side === pending) this.#sideAbandoned = true;
6285
+ return side ?? null;
6286
+ }
6287
+ /**
6288
+ * Check a connection out of the pool and hold it until `close()`.
6289
+ *
6290
+ * Kysely only lends a dedicated connection for the duration of a callback,
6291
+ * so the callback parks on a promise that `close()` resolves. A checkout
6292
+ * that arrives after the watcher gave up on it returns at once.
6293
+ */
6294
+ #checkoutSide() {
6295
+ return new Promise((resolveSide) => {
6296
+ const released = new Promise((release) => {
6297
+ this.#releaseSide = release;
6298
+ });
6299
+ this.#sideHeld = attempt(() => this.#pool.connection().execute(async (side) => {
6300
+ if (this.#sideAbandoned || this.#closed) return;
6301
+ if (this.#active === null) {
6302
+ this.#side = null;
6303
+ resolveSide(null);
6304
+ return;
6305
+ }
6306
+ resolveSide(side);
6307
+ await released;
6308
+ })).then(([, err]) => {
6309
+ if (err) {
6310
+ resolveSide(null);
6311
+ observer.emit("error", {
6312
+ source: "runner:watch",
6313
+ error: err,
6314
+ context: { operation: "side-connection" }
6315
+ });
6316
+ }
6317
+ });
6318
+ });
6319
+ }
6320
+ };
6022
6321
 
6023
6322
  // src/core/lock/types.ts
6024
6323
  var DEFAULT_LOCK_OPTIONS = {
@@ -6618,6 +6917,7 @@ var ChangeRollback = class extends Error {
6618
6917
  async function executeFiles(context, change, files, direction, checksum, force, history, startTime) {
6619
6918
  const dialect = context.dialect ?? "postgres";
6620
6919
  const expandedFiles = await expandFiles(files, context.sqlDir);
6920
+ const watcher = new StatementWatcher(context.db, { dialect });
6621
6921
  if (!TRANSACTIONAL_DIALECTS.has(dialect)) {
6622
6922
  return runFileBatch(
6623
6923
  context,
@@ -6628,8 +6928,9 @@ async function executeFiles(context, change, files, direction, checksum, force,
6628
6928
  force,
6629
6929
  history,
6630
6930
  context.db,
6631
- startTime
6632
- );
6931
+ startTime,
6932
+ watcher
6933
+ ).finally(() => watcher.close());
6633
6934
  }
6634
6935
  const [result, err] = await attempt(
6635
6936
  () => context.db.transaction().execute(async (trx) => {
@@ -6643,7 +6944,8 @@ async function executeFiles(context, change, files, direction, checksum, force,
6643
6944
  force,
6644
6945
  trxHistory,
6645
6946
  trx,
6646
- startTime
6947
+ startTime,
6948
+ watcher
6647
6949
  );
6648
6950
  if (batchResult.status !== "success") {
6649
6951
  throw new ChangeRollback(batchResult);
@@ -6651,6 +6953,7 @@ async function executeFiles(context, change, files, direction, checksum, force,
6651
6953
  return batchResult;
6652
6954
  })
6653
6955
  );
6956
+ await watcher.close();
6654
6957
  if (err) {
6655
6958
  if (err instanceof ChangeRollback) {
6656
6959
  return { ...err.result, operationId: void 0 };
@@ -6659,7 +6962,13 @@ async function executeFiles(context, change, files, direction, checksum, force,
6659
6962
  }
6660
6963
  return result;
6661
6964
  }
6662
- async function runFileBatch(context, change, expandedFiles, direction, checksum, force, history, executor, startTime) {
6965
+ async function runFileBatch(context, change, expandedFiles, direction, checksum, force, history, executor, startTime, watcher) {
6966
+ const fileChecksums = /* @__PURE__ */ new Map();
6967
+ for (const file of expandedFiles) {
6968
+ const [cs] = await attempt(() => computeChecksum(file.path));
6969
+ fileChecksums.set(file.path, cs ?? "");
6970
+ }
6971
+ const skipReasons = await findSkippedFiles(context, change.name, direction, expandedFiles, fileChecksums, force, history);
6663
6972
  const [operationId, createErr] = await attempt(
6664
6973
  () => history.createOperation({
6665
6974
  name: change.name,
@@ -6675,14 +6984,9 @@ async function runFileBatch(context, change, expandedFiles, direction, checksum,
6675
6984
  });
6676
6985
  return createFailedResult(change.name, direction, createErr.message, startTime);
6677
6986
  }
6678
- const fileChecksums = /* @__PURE__ */ new Map();
6679
- for (const file of expandedFiles) {
6680
- const [cs] = await attempt(() => computeChecksum(file.path));
6681
- fileChecksums.set(file.path, cs ?? "");
6682
- }
6683
6987
  const createRecordsErr = await history.createFileRecords(
6684
6988
  operationId,
6685
- expandedFiles.map((f) => ({
6989
+ expandedFiles.filter((_, i) => !skipReasons[i]).map((f) => ({
6686
6990
  filepath: path.relative(context.projectRoot, f.path),
6687
6991
  fileType: f.type,
6688
6992
  checksum: fileChecksums.get(f.path) ?? ""
@@ -6722,36 +7026,15 @@ async function runFileBatch(context, change, expandedFiles, direction, checksum,
6722
7026
  const fileStart = performance.now();
6723
7027
  const relPath = path.relative(context.projectRoot, file.path);
6724
7028
  const fileChecksum = fileChecksums.get(file.path) ?? "";
6725
- const needsRunFileResult = await history.needsRunFile(
6726
- change.name,
6727
- direction,
6728
- relPath,
6729
- fileChecksum,
6730
- force
6731
- );
6732
- if (!needsRunFileResult.needsRun) {
7029
+ const skipReason = skipReasons[i];
7030
+ if (skipReason) {
6733
7031
  results.push({
6734
7032
  filepath: file.path,
6735
7033
  checksum: fileChecksum,
6736
7034
  status: "skipped",
6737
- skipReason: needsRunFileResult.skipReason,
7035
+ skipReason,
6738
7036
  durationMs: 0
6739
7037
  });
6740
- const skipUpdateErr = await history.updateFileExecution(
6741
- operationId,
6742
- relPath,
6743
- "skipped",
6744
- 0,
6745
- void 0,
6746
- needsRunFileResult.skipReason
6747
- );
6748
- if (skipUpdateErr) {
6749
- observer.emit("error", {
6750
- source: "change",
6751
- error: new Error(skipUpdateErr),
6752
- context: { filepath: relPath, operation: "update-skipped-record" }
6753
- });
6754
- }
6755
7038
  continue;
6756
7039
  }
6757
7040
  const [sqlContent, loadErr] = await attempt(() => loadAndRenderFile(context, file.path));
@@ -6783,7 +7066,9 @@ async function runFileBatch(context, change, expandedFiles, direction, checksum,
6783
7066
  }
6784
7067
  break;
6785
7068
  }
6786
- const [, execErr] = await attempt(() => sql.raw(sqlContent).execute(executor));
7069
+ const [, execErr] = await attempt(
7070
+ () => watcher.run(file.path, executor, (conn) => sql.raw(sqlContent).execute(conn))
7071
+ );
6787
7072
  const durationMs = performance.now() - fileStart;
6788
7073
  if (execErr) {
6789
7074
  const errorMessage2 = getSqlErrorMessage(execErr);
@@ -6884,6 +7169,22 @@ async function runFileBatch(context, change, expandedFiles, direction, checksum,
6884
7169
  operationId
6885
7170
  };
6886
7171
  }
7172
+ async function findSkippedFiles(context, changeName, direction, files, fileChecksums, force, history) {
7173
+ const skipReasons = [];
7174
+ const seen = /* @__PURE__ */ new Set();
7175
+ const [latest, latestErr] = force ? [null, null] : await history.latestFileExecutions(changeName, direction);
7176
+ for (const file of files) {
7177
+ const relPath = path.relative(context.projectRoot, file.path);
7178
+ if (seen.has(relPath)) {
7179
+ skipReasons.push("already applied");
7180
+ continue;
7181
+ }
7182
+ seen.add(relPath);
7183
+ const decision = decideNeedsRunFile(latestErr ?? latest?.get(relPath), fileChecksums.get(file.path) ?? "", force);
7184
+ skipReasons.push(decision.needsRun ? void 0 : decision.skipReason);
7185
+ }
7186
+ return skipReasons;
7187
+ }
6887
7188
  async function executePreview(context, change, files, direction, output, startTime) {
6888
7189
  const expandedFiles = await expandFiles(files, context.sqlDir);
6889
7190
  const results = [];
@@ -7554,7 +7855,7 @@ async function resolveVaultKey(db, dialect) {
7554
7855
  async function buildSecretsContext(stateManager, configName, db, vaultKey, dialect) {
7555
7856
  const secrets = {};
7556
7857
  if (db && vaultKey) {
7557
- const { getAllVaultSecrets: getAllVaultSecrets2 } = await import('./storage-V6BHWNS3.js');
7858
+ const { getAllVaultSecrets: getAllVaultSecrets2 } = await import('./storage-NQA4H7PK.js');
7558
7859
  const vaultSecrets = await getAllVaultSecrets2(db, vaultKey, dialect ?? "postgres");
7559
7860
  for (const [key, secret] of Object.entries(vaultSecrets)) {
7560
7861
  secrets[key] = secret.value;
@@ -8326,47 +8627,20 @@ async function runBuild(context, sqlPath, options = {}, preFilteredFiles) {
8326
8627
  }
8327
8628
  async function runFile(context, filepath, options = {}) {
8328
8629
  assertRunPolicy(context, "run:file");
8329
- const opts = { ...DEFAULT_RUN_OPTIONS_INTERNAL, ...options };
8330
8630
  observer.emit("run:file", {
8331
8631
  filepath,
8332
8632
  configName: context.configName
8333
8633
  });
8334
- if (opts.dryRun) {
8335
- const results = await executeDryRun2(context, [filepath]);
8336
- return results[0];
8337
- }
8338
- const tracker = new Tracker(context.db, context.configName, context.dialect ?? "postgres");
8339
- const operationName = `run:${(/* @__PURE__ */ new Date()).toISOString()}`;
8340
- const [operationId, createErr] = await attempt(
8341
- () => tracker.createOperation({
8342
- name: operationName,
8343
- changeType: "run",
8344
- configName: context.configName,
8345
- executedBy: formatIdentity(context.identity)
8346
- })
8347
- );
8348
- if (createErr) {
8349
- observer.emit("error", {
8350
- source: "runner",
8351
- error: createErr,
8352
- context: { filepath, operation: "create-operation" }
8353
- });
8354
- return {
8355
- filepath,
8356
- checksum: "",
8357
- status: "failed",
8358
- error: createErr.message
8359
- };
8360
- }
8361
- const result = await executeSingleFile(context, filepath, opts, tracker, operationId);
8362
- await tracker.finalizeOperation(
8363
- operationId,
8364
- result.status === "failed" ? "failed" : "success",
8365
- Math.round(result.durationMs ?? 0),
8366
- result.checksum,
8367
- result.error
8368
- );
8369
- return result;
8634
+ const batch = await executeFiles2(context, [{ path: filepath, type: "sql" }], options, {
8635
+ changeType: "run",
8636
+ operationName: `run:${(/* @__PURE__ */ new Date()).toISOString()}`
8637
+ });
8638
+ return batch.files[0] ?? {
8639
+ filepath,
8640
+ checksum: "",
8641
+ status: batch.error === RUN_CANCELLED ? "skipped" : "failed",
8642
+ error: batch.error
8643
+ };
8370
8644
  }
8371
8645
  async function runDir(context, dirpath, options = {}) {
8372
8646
  assertRunPolicy(context, "run:dir");
@@ -8445,6 +8719,31 @@ var DEFAULT_RUN_OPTIONS_INTERNAL = {
8445
8719
  preview: false,
8446
8720
  output: null
8447
8721
  };
8722
+ var RUN_CANCELLED = "Run cancelled";
8723
+ function createWatcher(context) {
8724
+ return new StatementWatcher(context.db, {
8725
+ dialect: context.dialect ?? "postgres",
8726
+ signal: context.signal
8727
+ });
8728
+ }
8729
+ var NOT_STARTED = /* @__PURE__ */ Symbol("not-started");
8730
+ async function runWatched(context, watcher, filepath, sqlContent) {
8731
+ const [execErrMsg, err] = await attempt(
8732
+ () => watcher.run(filepath, context.db, (conn) => executeSqlBody({ ...context, db: conn }, sqlContent))
8733
+ );
8734
+ if (err instanceof OperationAbortedError) return NOT_STARTED;
8735
+ return err ? getSqlErrorMessage(err) : execErrMsg;
8736
+ }
8737
+ async function skipRemaining(tracker, operationId, reason) {
8738
+ const skipErr = await tracker.skipRemainingFiles(operationId, reason.slice(0, 100));
8739
+ if (skipErr) {
8740
+ observer.emit("error", {
8741
+ source: "runner:skip-remaining",
8742
+ error: new Error(skipErr),
8743
+ context: { operationId }
8744
+ });
8745
+ }
8746
+ }
8448
8747
  async function executeFiles2(context, files, runOptions, execOptions) {
8449
8748
  const start = performance.now();
8450
8749
  const opts = { ...DEFAULT_RUN_OPTIONS_INTERNAL, ...runOptions };
@@ -8484,6 +8783,19 @@ async function executeFiles2(context, files, runOptions, execOptions) {
8484
8783
  };
8485
8784
  }
8486
8785
  const tracker = execOptions.tracker ?? new Tracker(context.db, context.configName, context.dialect ?? "postgres");
8786
+ const isChange = execOptions.changeType === "change";
8787
+ const prepared = await prepareFiles(context, files);
8788
+ const gated = isChange ? prepared : await applyRunGate(tracker, prepared, opts.force);
8789
+ if (!isChange && gated.every((entry) => entry.skipReason !== void 0)) {
8790
+ return {
8791
+ status: "success",
8792
+ files: gated.map(skipFile),
8793
+ filesRun: 0,
8794
+ filesSkipped: gated.length,
8795
+ filesFailed: 0,
8796
+ durationMs: performance.now() - start
8797
+ };
8798
+ }
8487
8799
  const [operationId, createErr] = await attempt(
8488
8800
  () => tracker.createOperation({
8489
8801
  name: execOptions.operationName,
@@ -8504,20 +8816,11 @@ async function executeFiles2(context, files, runOptions, execOptions) {
8504
8816
  performance.now() - start
8505
8817
  );
8506
8818
  }
8507
- const fileRecords = [];
8508
- for (const file of files) {
8509
- let checksum = file.checksum;
8510
- if (!checksum) {
8511
- const [computed, err] = await attempt(() => computeChecksum(file.path));
8512
- checksum = err ? "" : computed;
8513
- }
8514
- fileRecords.push({
8515
- filepath: path.relative(context.projectRoot, file.path),
8516
- fileType: file.type,
8517
- checksum
8518
- });
8519
- }
8520
- const createRecordsErr = await tracker.createFileRecords(operationId, fileRecords);
8819
+ const runSet = gated.filter((entry) => !entry.skipReason);
8820
+ const createRecordsErr = await tracker.createFileRecords(
8821
+ operationId,
8822
+ runSet.map((entry) => ({ filepath: entry.relFilepath, fileType: entry.file.type, checksum: entry.checksum }))
8823
+ );
8521
8824
  if (createRecordsErr) {
8522
8825
  observer.emit("error", {
8523
8826
  source: "runner:create-file-records",
@@ -8532,34 +8835,43 @@ async function executeFiles2(context, files, runOptions, execOptions) {
8532
8835
  }
8533
8836
  const results = [];
8534
8837
  let failed = false;
8535
- for (let i = 0; i < files.length; i++) {
8536
- const file = files[i];
8537
- const fileRecord = fileRecords[i];
8538
- const result = await executeSingleFileWithUpdate(
8539
- context,
8540
- file.path,
8541
- fileRecord.checksum,
8542
- opts,
8543
- tracker,
8544
- operationId,
8545
- execOptions.changeType
8546
- );
8547
- results.push(result);
8548
- if (result.status === "failed" && opts.abortOnError) {
8549
- failed = true;
8550
- const skipErr = await tracker.skipRemainingFiles(
8551
- operationId,
8552
- `Skipped: failure in ${path.basename(file.path)}`.slice(0, 100)
8553
- );
8554
- if (skipErr) {
8555
- observer.emit("error", {
8556
- source: "runner:skip-remaining",
8557
- error: new Error(skipErr),
8558
- context: { operationId }
8559
- });
8838
+ let cancelled = false;
8839
+ const watcher = createWatcher(context);
8840
+ const lastRunIndex = gated.map((entry) => !entry.skipReason).lastIndexOf(true);
8841
+ try {
8842
+ for (let i = 0; i < gated.length; i++) {
8843
+ const entry = gated[i];
8844
+ if (entry.skipReason) {
8845
+ results.push(skipFile(entry));
8846
+ continue;
8847
+ }
8848
+ if (context.signal?.aborted) {
8849
+ failed = true;
8850
+ cancelled = true;
8851
+ await skipRemaining(tracker, operationId, RUN_CANCELLED);
8852
+ break;
8853
+ }
8854
+ const result = await executeSingleFileWithUpdate(context, entry, tracker, operationId, watcher);
8855
+ if (!result) {
8856
+ failed = true;
8857
+ cancelled = true;
8858
+ await skipRemaining(tracker, operationId, RUN_CANCELLED);
8859
+ break;
8860
+ }
8861
+ results.push(result);
8862
+ cancelled = (context.signal?.aborted ?? false) && (i < lastRunIndex || result.status === "failed");
8863
+ if (cancelled || result.status === "failed" && opts.abortOnError) {
8864
+ failed = true;
8865
+ await skipRemaining(
8866
+ tracker,
8867
+ operationId,
8868
+ cancelled ? RUN_CANCELLED : `Skipped: failure in ${path.basename(entry.file.path)}`
8869
+ );
8870
+ break;
8560
8871
  }
8561
- break;
8562
8872
  }
8873
+ } finally {
8874
+ await watcher.close();
8563
8875
  }
8564
8876
  if (!failed && results.length !== files.length) {
8565
8877
  throw new Error(
@@ -8574,14 +8886,14 @@ async function executeFiles2(context, files, runOptions, execOptions) {
8574
8886
  if (filesFailed > 0 || failed) {
8575
8887
  status = filesRun > 0 ? "partial" : "failed";
8576
8888
  }
8577
- const combinedChecksum = execOptions.checksum ?? computeCombinedChecksum(fileRecords.map((f) => f.checksum));
8889
+ const combinedChecksum = execOptions.checksum ?? computeCombinedChecksum(gated.map((entry) => entry.checksum));
8578
8890
  const finalStatus = status === "success" ? "success" : "failed";
8579
8891
  const finalizeErr = await tracker.finalizeOperation(
8580
8892
  operationId,
8581
8893
  finalStatus,
8582
8894
  Math.round(durationMs),
8583
8895
  combinedChecksum,
8584
- failed ? results.find((r) => r.status === "failed")?.error : void 0
8896
+ cancelled ? RUN_CANCELLED : failed ? results.find((r) => r.status === "failed")?.error : void 0
8585
8897
  );
8586
8898
  if (finalizeErr) {
8587
8899
  observer.emit("error", {
@@ -8597,7 +8909,45 @@ async function executeFiles2(context, files, runOptions, execOptions) {
8597
8909
  filesSkipped,
8598
8910
  filesFailed,
8599
8911
  durationMs,
8600
- changeId: operationId
8912
+ changeId: operationId,
8913
+ error: cancelled ? RUN_CANCELLED : void 0
8914
+ };
8915
+ }
8916
+ async function prepareFiles(context, files) {
8917
+ const prepared = [];
8918
+ for (const file of files) {
8919
+ const relFilepath = path.relative(context.projectRoot, file.path);
8920
+ const [loaded, loadError] = await attempt(async () => {
8921
+ const sql28 = await loadAndRenderFile2(context, file.path);
8922
+ return { sql: sql28, checksum: computeChecksumFromContent(sql28) };
8923
+ });
8924
+ if (loadError) {
8925
+ prepared.push({ file, relFilepath, checksum: "", sql: "", loadError });
8926
+ continue;
8927
+ }
8928
+ prepared.push({ file, relFilepath, ...loaded });
8929
+ }
8930
+ return prepared;
8931
+ }
8932
+ async function applyRunGate(tracker, prepared, force) {
8933
+ if (force) return prepared;
8934
+ const [latest, latestErr] = await tracker.latestExecutions();
8935
+ return prepared.map((entry) => {
8936
+ if (entry.loadError) return entry;
8937
+ const decision = decideNeedsRun(latestErr ?? latest.get(entry.relFilepath), entry.checksum);
8938
+ return decision.skipReason ? { ...entry, skipReason: decision.skipReason } : entry;
8939
+ });
8940
+ }
8941
+ function skipFile(entry) {
8942
+ observer.emit("file:skip", {
8943
+ filepath: entry.file.path,
8944
+ reason: entry.skipReason
8945
+ });
8946
+ return {
8947
+ filepath: entry.file.path,
8948
+ checksum: entry.checksum,
8949
+ status: "skipped",
8950
+ skipReason: entry.skipReason
8601
8951
  };
8602
8952
  }
8603
8953
  async function executeFilesInternal(context, files, options, changeType, sqlPath) {
@@ -8613,17 +8963,16 @@ async function executeFilesInternal(context, files, options, changeType, sqlPath
8613
8963
  };
8614
8964
  return executeFiles2(context, fileInputs, options, execOptions);
8615
8965
  }
8616
- async function executeSingleFileWithUpdate(context, filepath, checksum, options, tracker, operationId, changeType) {
8966
+ async function executeSingleFileWithUpdate(context, entry, tracker, operationId, watcher) {
8617
8967
  const start = performance.now();
8618
- const relFilepath = path.relative(context.projectRoot, filepath);
8619
- const [sqlContent, loadErr] = await attempt(() => loadAndRenderFile2(context, filepath));
8620
- if (loadErr) {
8968
+ const { file: { path: filepath }, relFilepath, checksum } = entry;
8969
+ if (entry.loadError) {
8621
8970
  const durationMs2 = performance.now() - start;
8622
8971
  const result2 = {
8623
8972
  filepath,
8624
- checksum: checksum || "",
8973
+ checksum,
8625
8974
  status: "failed",
8626
- error: loadErr.message,
8975
+ error: entry.loadError.message,
8627
8976
  durationMs: durationMs2
8628
8977
  };
8629
8978
  await tracker.updateFileExecution(
@@ -8631,55 +8980,29 @@ async function executeSingleFileWithUpdate(context, filepath, checksum, options,
8631
8980
  relFilepath,
8632
8981
  "failed",
8633
8982
  Math.round(durationMs2),
8634
- loadErr.message
8983
+ entry.loadError.message
8635
8984
  );
8636
8985
  observer.emit("file:after", {
8637
8986
  filepath,
8638
8987
  status: "failed",
8639
8988
  durationMs: durationMs2,
8640
- error: loadErr.message
8989
+ error: entry.loadError.message
8641
8990
  });
8642
8991
  return result2;
8643
8992
  }
8644
- const [renderedChecksum, checksumErr] = attemptSync(() => computeChecksumFromContent(sqlContent));
8645
- const finalChecksum = checksumErr ? checksum : renderedChecksum;
8646
8993
  observer.emit("file:before", {
8647
8994
  filepath,
8648
- checksum: finalChecksum,
8995
+ checksum,
8649
8996
  configName: context.configName
8650
8997
  });
8651
- if (changeType !== "change") {
8652
- const needsRunResult = await tracker.needsRun(relFilepath, finalChecksum, options.force, operationId);
8653
- if (!needsRunResult.needsRun) {
8654
- const result2 = {
8655
- filepath,
8656
- checksum: finalChecksum,
8657
- status: "skipped",
8658
- skipReason: needsRunResult.skipReason
8659
- };
8660
- await tracker.updateFileExecution(
8661
- operationId,
8662
- relFilepath,
8663
- "skipped",
8664
- 0,
8665
- void 0,
8666
- needsRunResult.skipReason,
8667
- finalChecksum
8668
- );
8669
- observer.emit("file:skip", {
8670
- filepath,
8671
- reason: needsRunResult.skipReason
8672
- });
8673
- return result2;
8674
- }
8675
- }
8676
- const execErrMsg = await executeSqlBody(context, sqlContent);
8998
+ const execErrMsg = await runWatched(context, watcher, filepath, entry.sql);
8999
+ if (execErrMsg === NOT_STARTED) return null;
8677
9000
  const durationMs = performance.now() - start;
8678
9001
  if (execErrMsg) {
8679
9002
  const error = execErrMsg + await describePriorSuccesses(tracker, relFilepath, operationId);
8680
9003
  const result2 = {
8681
9004
  filepath,
8682
- checksum: finalChecksum,
9005
+ checksum,
8683
9006
  status: "failed",
8684
9007
  error,
8685
9008
  durationMs
@@ -8689,9 +9012,7 @@ async function executeSingleFileWithUpdate(context, filepath, checksum, options,
8689
9012
  relFilepath,
8690
9013
  "failed",
8691
9014
  Math.round(durationMs),
8692
- error,
8693
- void 0,
8694
- finalChecksum
9015
+ error
8695
9016
  );
8696
9017
  observer.emit("file:after", {
8697
9018
  filepath,
@@ -8703,7 +9024,7 @@ async function executeSingleFileWithUpdate(context, filepath, checksum, options,
8703
9024
  }
8704
9025
  const result = {
8705
9026
  filepath,
8706
- checksum: finalChecksum,
9027
+ checksum,
8707
9028
  status: "success",
8708
9029
  durationMs
8709
9030
  };
@@ -8711,10 +9032,7 @@ async function executeSingleFileWithUpdate(context, filepath, checksum, options,
8711
9032
  operationId,
8712
9033
  relFilepath,
8713
9034
  "success",
8714
- Math.round(durationMs),
8715
- void 0,
8716
- void 0,
8717
- finalChecksum
9035
+ Math.round(durationMs)
8718
9036
  );
8719
9037
  observer.emit("file:after", {
8720
9038
  filepath,
@@ -8723,133 +9041,6 @@ async function executeSingleFileWithUpdate(context, filepath, checksum, options,
8723
9041
  });
8724
9042
  return result;
8725
9043
  }
8726
- async function executeSingleFile(context, filepath, options, tracker, operationId) {
8727
- const start = performance.now();
8728
- const relFilepath = path.relative(context.projectRoot, filepath);
8729
- const [sqlContent, loadErr] = await attempt(() => loadAndRenderFile2(context, filepath));
8730
- if (loadErr) {
8731
- const durationMs2 = performance.now() - start;
8732
- const result2 = {
8733
- filepath,
8734
- checksum: "",
8735
- status: "failed",
8736
- error: loadErr.message,
8737
- durationMs: durationMs2
8738
- };
8739
- await tracker.recordExecution({
8740
- changeId: operationId,
8741
- filepath: relFilepath,
8742
- checksum: "",
8743
- status: "failed",
8744
- errorMessage: loadErr.message,
8745
- durationMs: Math.round(durationMs2)
8746
- });
8747
- observer.emit("file:after", {
8748
- filepath,
8749
- status: "failed",
8750
- durationMs: durationMs2,
8751
- error: loadErr.message
8752
- });
8753
- return result2;
8754
- }
8755
- const [checksum, checksumErr] = attemptSync(() => computeChecksumFromContent(sqlContent));
8756
- if (checksumErr) {
8757
- const result2 = {
8758
- filepath,
8759
- checksum: "",
8760
- status: "failed",
8761
- error: checksumErr.message,
8762
- durationMs: performance.now() - start
8763
- };
8764
- await tracker.recordExecution({
8765
- changeId: operationId,
8766
- filepath: relFilepath,
8767
- checksum: "",
8768
- status: "failed",
8769
- errorMessage: checksumErr.message,
8770
- durationMs: Math.round(result2.durationMs ?? 0)
8771
- });
8772
- observer.emit("file:after", {
8773
- filepath,
8774
- status: "failed",
8775
- durationMs: result2.durationMs ?? 0,
8776
- error: checksumErr.message
8777
- });
8778
- return result2;
8779
- }
8780
- observer.emit("file:before", {
8781
- filepath,
8782
- checksum,
8783
- configName: context.configName
8784
- });
8785
- const needsRunResult = await tracker.needsRun(relFilepath, checksum, options.force);
8786
- if (!needsRunResult.needsRun) {
8787
- const result2 = {
8788
- filepath,
8789
- checksum,
8790
- status: "skipped",
8791
- skipReason: needsRunResult.skipReason
8792
- };
8793
- await tracker.recordExecution({
8794
- changeId: operationId,
8795
- filepath: relFilepath,
8796
- checksum,
8797
- status: "skipped",
8798
- skipReason: needsRunResult.skipReason
8799
- });
8800
- observer.emit("file:skip", {
8801
- filepath,
8802
- reason: needsRunResult.skipReason
8803
- });
8804
- return result2;
8805
- }
8806
- const execErrMsg = await executeSqlBody(context, sqlContent);
8807
- const durationMs = performance.now() - start;
8808
- if (execErrMsg) {
8809
- const error = execErrMsg + await describePriorSuccesses(tracker, relFilepath, operationId);
8810
- const result2 = {
8811
- filepath,
8812
- checksum,
8813
- status: "failed",
8814
- error,
8815
- durationMs
8816
- };
8817
- await tracker.recordExecution({
8818
- changeId: operationId,
8819
- filepath: relFilepath,
8820
- checksum,
8821
- status: "failed",
8822
- errorMessage: error,
8823
- durationMs: Math.round(durationMs)
8824
- });
8825
- observer.emit("file:after", {
8826
- filepath,
8827
- status: "failed",
8828
- durationMs,
8829
- error
8830
- });
8831
- return result2;
8832
- }
8833
- const result = {
8834
- filepath,
8835
- checksum,
8836
- status: "success",
8837
- durationMs
8838
- };
8839
- await tracker.recordExecution({
8840
- changeId: operationId,
8841
- filepath: relFilepath,
8842
- checksum,
8843
- status: "success",
8844
- durationMs: Math.round(durationMs)
8845
- });
8846
- observer.emit("file:after", {
8847
- filepath,
8848
- status: "success",
8849
- durationMs
8850
- });
8851
- return result;
8852
- }
8853
9044
  async function loadAndRenderFile2(context, filepath) {
8854
9045
  if (isTemplate(filepath)) {
8855
9046
  const result = await processFile(filepath, {