@noormdev/sdk 1.5.0 → 1.6.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/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from 'module';
2
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-WUMCIKEQ.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
5
  import { observer, getEnvConfigName, ObserverRelay, isDebug } from './chunk-6LG4ENOJ.js';
6
6
  export { observer as noormObserver } from './chunk-6LG4ENOJ.js';
7
7
  import { explainConnectionError, DatabaseConnectionError } from './chunk-OXTXFKWJ.js';
@@ -13,7 +13,7 @@ import { __commonJS, __toESM } from './chunk-D2XW2UOP.js';
13
13
  import { existsSync, accessSync, constants, readFileSync, mkdirSync, chmodSync, createWriteStream, writeFileSync, openSync, writeSync, closeSync, unlinkSync, statSync, copyFileSync, fsyncSync, renameSync } from 'fs';
14
14
  import path, { join, dirname, resolve, relative, sep } from 'path';
15
15
  import { homedir, availableParallelism, userInfo } from 'os';
16
- import { stat, mkdir, writeFile, unlink, rename, rm, readdir, readFile, access } from 'fs/promises';
16
+ import { stat, mkdir, writeFile, unlink, rename, rm, readFile, readdir, access } from 'fs/promises';
17
17
  import { createHash, createPrivateKey, createPublicKey, createDecipheriv, randomBytes, createCipheriv, randomUUID, hkdfSync } from 'crypto';
18
18
  import { execSync } from 'child_process';
19
19
  import { sql } from 'kysely';
@@ -1731,9 +1731,12 @@ var DEFAULT_BUILD_CONFIG = {
1731
1731
  include: [],
1732
1732
  exclude: []
1733
1733
  };
1734
+ var DEFAULT_DB_DIR = "db";
1735
+ var DEFAULT_SQL_PATH = `./${DEFAULT_DB_DIR}/sql`;
1736
+ var DEFAULT_CHANGES_PATH = `./${DEFAULT_DB_DIR}/changes`;
1734
1737
  var DEFAULT_PATH_CONFIG = {
1735
- sql: "./sql",
1736
- changes: "./changes"
1738
+ sql: DEFAULT_SQL_PATH,
1739
+ changes: DEFAULT_CHANGES_PATH
1737
1740
  };
1738
1741
  var DEFAULT_STRICT_CONFIG = {
1739
1742
  enabled: false,
@@ -2108,6 +2111,10 @@ var SettingsManager = class {
2108
2111
  /**
2109
2112
  * Initialize a new settings file with defaults.
2110
2113
  *
2114
+ * An overwrite keeps the `paths` already in the file: they say where the
2115
+ * project's files are, not a preference, and resetting them would point a
2116
+ * project laid out elsewhere at directories that do not exist.
2117
+ *
2111
2118
  * @param force - Overwrite existing file if true
2112
2119
  * @throws Error if file exists and force is false
2113
2120
  */
@@ -2116,7 +2123,10 @@ var SettingsManager = class {
2116
2123
  if (fileExists2 && !force) {
2117
2124
  throw new Error("Settings file already exists. Use force=true to overwrite.");
2118
2125
  }
2126
+ const [previous] = fileExists2 ? await attempt(() => this.load()) : [void 0];
2127
+ const keptPaths = previous ? this.#document?.paths : void 0;
2119
2128
  this.#document = createDefaultSettings();
2129
+ if (keptPaths) this.#document.paths = clone(keptPaths);
2120
2130
  this.#refreshResolved();
2121
2131
  this.#loaded = true;
2122
2132
  await this.save();
@@ -4254,7 +4264,7 @@ function buildMssqlTvfWithTvp(rawName, params) {
4254
4264
 
4255
4265
  // src/core/change/types.ts
4256
4266
  function isPendingChange(item) {
4257
- return !item.orphaned && (item.status === "pending" || item.status === "reverted" || item.status === "stale");
4267
+ return !item.orphaned && (item.status === "pending" || item.status === "reverted" || item.status === "stale" || item.status === "failed" && item.isNew);
4258
4268
  }
4259
4269
  var ChangeValidationError = class extends Error {
4260
4270
  constructor(changeName, issue) {
@@ -4340,10 +4350,31 @@ async function parseChange(folderPath, sqlDir) {
4340
4350
  hasChangelog
4341
4351
  };
4342
4352
  }
4353
+ function failureListItem(failure, status) {
4354
+ const { date, description } = parseName(failure.name);
4355
+ return {
4356
+ name: failure.name,
4357
+ path: failure.path,
4358
+ date,
4359
+ description,
4360
+ status: status?.status ?? "failed",
4361
+ appliedAt: status?.appliedAt ?? null,
4362
+ appliedBy: status?.appliedBy ?? null,
4363
+ revertedAt: status?.revertedAt ?? null,
4364
+ errorMessage: status ? status.errorMessage : failure.message,
4365
+ appliedHistoryId: status?.appliedHistoryId ?? null,
4366
+ isNew: !status,
4367
+ orphaned: false
4368
+ };
4369
+ }
4343
4370
  async function discoverChanges(changesDir, sqlDir) {
4371
+ const { changes } = await scanChanges(changesDir, sqlDir);
4372
+ return changes;
4373
+ }
4374
+ async function scanChanges(changesDir, sqlDir) {
4344
4375
  const [exists] = await attempt(() => stat(changesDir));
4345
4376
  if (!exists) {
4346
- return [];
4377
+ return { changes: [], failures: [] };
4347
4378
  }
4348
4379
  const [entries, readErr] = await attempt(() => readdir(changesDir, { withFileTypes: true }));
4349
4380
  if (readErr) {
@@ -4352,12 +4383,14 @@ async function discoverChanges(changesDir, sqlDir) {
4352
4383
  error: readErr,
4353
4384
  context: { changesDir, operation: "discover" }
4354
4385
  });
4355
- return [];
4386
+ return { changes: [], failures: [] };
4356
4387
  }
4357
4388
  const folders = entries.filter((e) => e.isDirectory());
4358
4389
  const changes = [];
4390
+ const failures = [];
4359
4391
  for (const folder of folders) {
4360
4392
  const folderPath = path.join(changesDir, folder.name);
4393
+ if (!await looksLikeChange(folderPath)) continue;
4361
4394
  const [change, parseErr] = await attempt(() => parseChange(folderPath, sqlDir));
4362
4395
  if (parseErr) {
4363
4396
  observer.emit("error", {
@@ -4365,11 +4398,15 @@ async function discoverChanges(changesDir, sqlDir) {
4365
4398
  error: parseErr,
4366
4399
  context: { folder: folder.name, operation: "parse" }
4367
4400
  });
4401
+ failures.push({ name: folder.name, path: folderPath, message: parseErr.message });
4368
4402
  continue;
4369
4403
  }
4370
4404
  changes.push(change);
4371
4405
  }
4372
- return changes.sort((a, b) => a.name.localeCompare(b.name));
4406
+ return {
4407
+ changes: changes.sort((a, b) => a.name.localeCompare(b.name)),
4408
+ failures
4409
+ };
4373
4410
  }
4374
4411
  async function resolveManifest(manifestPath, sqlDir) {
4375
4412
  const [content, readErr] = await attempt(() => readFile(manifestPath, "utf-8"));
@@ -4428,6 +4465,13 @@ function parseName(name) {
4428
4465
  }
4429
4466
  return { date: null, description: name };
4430
4467
  }
4468
+ async function looksLikeChange(folderPath) {
4469
+ for (const subfolder of ["change", "revert"]) {
4470
+ const [entry] = await attempt(() => stat(path.join(folderPath, subfolder)));
4471
+ if (entry?.isDirectory()) return true;
4472
+ }
4473
+ return false;
4474
+ }
4431
4475
  async function scanFolder(folderPath, sqlDir) {
4432
4476
  const [entries, err] = await attempt(() => readdir(folderPath, { withFileTypes: true }));
4433
4477
  if (err) {
@@ -4786,6 +4830,33 @@ function hydrateDate(value, dialect) {
4786
4830
  }
4787
4831
  return /* @__PURE__ */ new Date(`${value.replace(" ", "T")}Z`);
4788
4832
  }
4833
+ function decideNeedsRunFile(record, checksum, force) {
4834
+ if (force) {
4835
+ return { needsRun: true, reason: "force" };
4836
+ }
4837
+ if (record instanceof Error || !record) {
4838
+ return { needsRun: true, reason: "new" };
4839
+ }
4840
+ if (record.exec_status === "failed") {
4841
+ return {
4842
+ needsRun: true,
4843
+ reason: "failed",
4844
+ previousChecksum: record.checksum
4845
+ };
4846
+ }
4847
+ if (record.checksum !== checksum) {
4848
+ return {
4849
+ needsRun: true,
4850
+ reason: "changed",
4851
+ previousChecksum: record.checksum
4852
+ };
4853
+ }
4854
+ return {
4855
+ needsRun: false,
4856
+ skipReason: "already applied",
4857
+ previousChecksum: record.checksum
4858
+ };
4859
+ }
4789
4860
  var ChangeHistory = class {
4790
4861
  #db;
4791
4862
  #ndb;
@@ -4965,31 +5036,19 @@ var ChangeHistory = class {
4965
5036
  };
4966
5037
  }
4967
5038
  /**
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.
5039
+ * Fetch the newest qualifying execution row per filepath for one
5040
+ * change and direction.
5041
+ *
5042
+ * Two SELECTs for any number of files: the direction boundary, then the
5043
+ * same query form as `Tracker.latestExecutions`.
5044
+ *
5045
+ * Rows are scoped to this change's name+direction+config, so file A's
5046
+ * success under one change never satisfies file A's check under another.
5047
+ *
5048
+ * `pending` and `skipped` rows never count. Neither records an outcome:
5049
+ * `pending` is a file its batch never reached, and `skipped` is written by
5050
+ * `skipRemainingFiles` for the same reason, or by older versions for a
5051
+ * file that matched a success still further back in history.
4993
5052
  *
4994
5053
  * A prior success only licenses a skip while it is still *standing*.
4995
5054
  * Two things retire it, and neither is visible on the execution row
@@ -5010,72 +5069,56 @@ var ChangeHistory = class {
5010
5069
  *
5011
5070
  * @param name - Change name
5012
5071
  * @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
5072
+ * @returns Map of relative filepath to its newest qualifying row, or the lookup error
5073
+ *
5074
+ * @example
5075
+ * ```typescript
5076
+ * const [latest, err] = await history.latestFileExecutions('2024-01-15-add-users', 'change')
5077
+ * const result = decideNeedsRunFile(err ?? latest.get(filepath), checksum, false)
5078
+ * ```
5017
5079
  */
5018
- async needsRunFile(name, direction, filepath, checksum, force) {
5019
- if (force) {
5020
- return { needsRun: true, reason: "force" };
5021
- }
5080
+ async latestFileExecutions(name, direction) {
5081
+ const { executions, change } = this.#tables;
5022
5082
  const opposite = direction === "change" ? "revert" : "change";
5023
5083
  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()
5084
+ () => 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
5085
  );
5026
5086
  if (boundaryErr) {
5027
5087
  observer.emit("error", {
5028
5088
  source: "change",
5029
5089
  error: boundaryErr,
5030
- context: { name, filepath, operation: "needs-run-file-boundary" }
5090
+ context: { name, operation: "latest-file-executions-boundary" }
5031
5091
  });
5032
- return { needsRun: true, reason: "new" };
5092
+ return [null, boundaryErr];
5093
+ }
5094
+ 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"]);
5095
+ if (boundary) {
5096
+ qualifying = qualifying.where(`${change}.id`, ">", boundary.id);
5033
5097
  }
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`
5098
+ const newestIds = qualifying.select((eb) => eb.fn.max(`${executions}.id`).as("id")).groupBy(`${executions}.filepath`).as("newest");
5099
+ const [rows, err] = await attempt(
5100
+ () => (
5039
5101
  // 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
- });
5102
+ this.#ndb.selectFrom(executions).innerJoin(newestIds, "newest.id", `${executions}.id`).select((eb) => [
5103
+ eb.ref(`${executions}.filepath`).as("filepath"),
5104
+ eb.ref(`${executions}.checksum`).as("checksum"),
5105
+ eb.ref(`${executions}.status`).as("exec_status")
5106
+ ]).execute()
5107
+ )
5108
+ );
5049
5109
  if (err) {
5050
5110
  observer.emit("error", {
5051
5111
  source: "change",
5052
5112
  error: err,
5053
- context: { name, filepath, operation: "needs-run-file-check" }
5113
+ context: { name, operation: "latest-file-executions" }
5054
5114
  });
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
- };
5115
+ return [null, err];
5066
5116
  }
5067
- if (record.checksum !== checksum) {
5068
- return {
5069
- needsRun: true,
5070
- reason: "changed",
5071
- previousChecksum: record.checksum
5072
- };
5117
+ const latest = /* @__PURE__ */ new Map();
5118
+ for (const { filepath, ...record } of rows) {
5119
+ latest.set(filepath, record);
5073
5120
  }
5074
- return {
5075
- needsRun: false,
5076
- skipReason: "already applied",
5077
- previousChecksum: record.checksum
5078
- };
5121
+ return [latest, null];
5079
5122
  }
5080
5123
  // ─────────────────────────────────────────────────────────
5081
5124
  // Create Records
@@ -5113,9 +5156,10 @@ var ChangeHistory = class {
5113
5156
  return id;
5114
5157
  }
5115
5158
  /**
5116
- * Create pending file records for all files.
5159
+ * Create pending file records for the files about to run.
5117
5160
  *
5118
- * Creates records upfront so we can mark remaining as skipped on failure.
5161
+ * The pending rows let `skipRemainingFiles` mark the files a
5162
+ * failure left unreached.
5119
5163
  *
5120
5164
  * @returns Error message if creation failed, null on success
5121
5165
  */
@@ -5481,6 +5525,44 @@ var ChangeHistory = class {
5481
5525
  };
5482
5526
 
5483
5527
  // src/core/runner/tracker.ts
5528
+ function decideNeedsRun(record, checksum, force) {
5529
+ if (record instanceof Error) {
5530
+ return { needsRun: true, reason: "error" };
5531
+ }
5532
+ if (!record) {
5533
+ return { needsRun: true, reason: "new" };
5534
+ }
5535
+ if (record.exec_status === "failed") {
5536
+ return {
5537
+ needsRun: true,
5538
+ reason: "failed",
5539
+ previousChecksum: record.checksum
5540
+ };
5541
+ }
5542
+ const isUnchangedSkip = record.exec_status === "skipped" && record.skip_reason === "unchanged";
5543
+ if (record.exec_status === "pending" || record.exec_status === "skipped" && !isUnchangedSkip) {
5544
+ return { needsRun: true, reason: "new" };
5545
+ }
5546
+ if (record.change_status === "stale") {
5547
+ return {
5548
+ needsRun: true,
5549
+ reason: "stale",
5550
+ previousChecksum: record.checksum
5551
+ };
5552
+ }
5553
+ if (record.checksum !== checksum) {
5554
+ return {
5555
+ needsRun: true,
5556
+ reason: "changed",
5557
+ previousChecksum: record.checksum
5558
+ };
5559
+ }
5560
+ return {
5561
+ needsRun: false,
5562
+ skipReason: "unchanged",
5563
+ previousChecksum: record.checksum
5564
+ };
5565
+ }
5484
5566
  var Tracker = class {
5485
5567
  #db;
5486
5568
  #ndb;
@@ -5495,89 +5577,45 @@ var Tracker = class {
5495
5577
  this.#dialect = dialect;
5496
5578
  }
5497
5579
  /**
5498
- * Check if a file needs to run.
5580
+ * Fetch the newest execution row per filepath for this config.
5499
5581
  *
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)
5582
+ * One SELECT for any number of files, so a batch can decide every file
5583
+ * with `decideNeedsRun` before writing any tracking rows. Query form:
5584
+ * `docs/design/prefetch-run-gate.md`.
5506
5585
  *
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()
5586
+ * @returns Map of filepath to its newest record, or the lookup error
5587
+ *
5588
+ * @example
5589
+ * ```typescript
5590
+ * const [latest, err] = await tracker.latestExecutions()
5591
+ * const result = decideNeedsRun(err ?? latest.get(filepath), checksum, false)
5592
+ * ```
5593
+ */
5594
+ async latestExecutions() {
5595
+ const { executions, change } = this.#tables;
5596
+ 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");
5597
+ const [rows, err] = await attempt(
5598
+ () => this.#ndb.selectFrom(executions).innerJoin(change, `${change}.id`, `${executions}.change_id`).innerJoin(newestIds, "newest.id", `${executions}.id`).select((eb) => [
5599
+ eb.ref(`${executions}.filepath`).as("filepath"),
5600
+ eb.ref(`${executions}.checksum`).as("checksum"),
5601
+ eb.ref(`${executions}.status`).as("exec_status"),
5602
+ eb.ref(`${executions}.skip_reason`).as("skip_reason"),
5603
+ eb.ref(`${change}.status`).as("change_status")
5604
+ ]).execute()
5539
5605
  );
5540
5606
  if (err) {
5541
5607
  observer.emit("error", {
5542
5608
  source: "runner",
5543
5609
  error: err,
5544
- context: { filepath, operation: "needs-run-check" }
5610
+ context: { operation: "latest-executions" }
5545
5611
  });
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" };
5561
- }
5562
- if (record.change_status === "stale") {
5563
- return {
5564
- needsRun: true,
5565
- reason: "stale",
5566
- previousChecksum: record.checksum
5567
- };
5612
+ return [null, err];
5568
5613
  }
5569
- if (record.checksum !== checksum) {
5570
- return {
5571
- needsRun: true,
5572
- reason: "changed",
5573
- previousChecksum: record.checksum
5574
- };
5614
+ const latest = /* @__PURE__ */ new Map();
5615
+ for (const { filepath, ...record } of rows) {
5616
+ latest.set(filepath, record);
5575
5617
  }
5576
- return {
5577
- needsRun: false,
5578
- skipReason: "unchanged",
5579
- previousChecksum: record.checksum
5580
- };
5618
+ return [latest, null];
5581
5619
  }
5582
5620
  /**
5583
5621
  * Create a new operation record.
@@ -5613,35 +5651,6 @@ var Tracker = class {
5613
5651
  }
5614
5652
  return id;
5615
5653
  }
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
5654
  /**
5646
5655
  * Finalize an operation.
5647
5656
  *
@@ -5689,10 +5698,10 @@ var Tracker = class {
5689
5698
  // Batch File Operations (Shared by Runner and Changes)
5690
5699
  // ─────────────────────────────────────────────────────────
5691
5700
  /**
5692
- * Create pending file records for all files upfront.
5701
+ * Create pending file records for the files about to run.
5693
5702
  *
5694
- * Creates records so the batch is fully visible. On failure,
5695
- * remaining files can be marked as skipped.
5703
+ * The pending rows let `skipRemainingFiles` mark the files a
5704
+ * failure left unreached.
5696
5705
  *
5697
5706
  * @param operationId - Parent operation ID
5698
5707
  * @param files - Files to create records for
@@ -5732,23 +5741,15 @@ var Tracker = class {
5732
5741
  * @param durationMs - Execution time
5733
5742
  * @param errorMessage - Error message if failed
5734
5743
  * @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
5744
  * @returns Error message if update failed, null on success
5743
5745
  */
5744
- async updateFileExecution(operationId, filepath, status, durationMs, errorMessage, skipReason, checksum) {
5746
+ async updateFileExecution(operationId, filepath, status, durationMs, errorMessage, skipReason) {
5745
5747
  const [result, err] = await attempt(
5746
5748
  () => this.#ndb.updateTable(this.#tables.executions).set({
5747
5749
  status,
5748
5750
  duration_ms: Math.round(durationMs),
5749
5751
  error_message: errorMessage ?? "",
5750
- skip_reason: skipReason ?? "",
5751
- ...checksum === void 0 ? {} : { checksum }
5752
+ skip_reason: skipReason ?? ""
5752
5753
  }).where("change_id", "=", operationId).where("filepath", "=", filepath).executeTakeFirst()
5753
5754
  );
5754
5755
  if (err) {
@@ -5842,7 +5843,6 @@ var Tracker = class {
5842
5843
  /**
5843
5844
  * Check if a change needs to run by name.
5844
5845
  *
5845
- * Similar to needsRun but checks by change name instead of filepath.
5846
5846
  * Used for change sets where we track by change name, not individual files.
5847
5847
  *
5848
5848
  * @param name - Change name
@@ -6363,105 +6363,264 @@ var StatementWatcher = class {
6363
6363
  }
6364
6364
  };
6365
6365
 
6366
- // src/core/lock/types.ts
6367
- var DEFAULT_LOCK_OPTIONS = {
6368
- dialect: "postgres",
6369
- timeout: 5 * 60 * 1e3,
6370
- // 5 minutes
6371
- wait: false,
6372
- waitTimeout: 30 * 1e3,
6373
- // 30 seconds
6374
- pollInterval: 1e3
6375
- // 1 second
6376
- };
6377
-
6378
- // src/core/lock/errors.ts
6379
- var LockAcquireError = class extends Error {
6380
- constructor(configName, holder, heldSince, expiresAt, reason) {
6381
- const since = heldSince.toISOString();
6382
- const expires = expiresAt.toISOString();
6383
- const reasonSuffix = reason ? ` (${reason})` : "";
6384
- super(
6385
- `Lock for '${configName}' held by ${holder} since ${since}, expires ${expires}${reasonSuffix}`
6386
- );
6387
- this.configName = configName;
6388
- this.holder = holder;
6389
- this.heldSince = heldSince;
6390
- this.expiresAt = expiresAt;
6391
- this.reason = reason;
6392
- }
6393
- name = "LockAcquireError";
6394
- };
6395
- var LockExpiredError = class extends Error {
6396
- constructor(configName, identity, expiredAt) {
6397
- super(`Lock for '${configName}' expired at ${expiredAt.toISOString()}`);
6398
- this.configName = configName;
6399
- this.identity = identity;
6400
- this.expiredAt = expiredAt;
6401
- }
6402
- name = "LockExpiredError";
6403
- };
6404
- var LockNotFoundError = class extends Error {
6405
- constructor(configName, identity) {
6406
- super(`No lock found for '${configName}' held by ${identity}`);
6407
- this.configName = configName;
6408
- this.identity = identity;
6409
- }
6410
- name = "LockNotFoundError";
6411
- };
6412
- var LockOwnershipError = class extends Error {
6413
- constructor(configName, requestedBy, actualHolder) {
6414
- super(
6415
- `Cannot release lock for '${configName}': held by ${actualHolder}, not ${requestedBy}`
6416
- );
6417
- this.configName = configName;
6418
- this.requestedBy = requestedBy;
6419
- this.actualHolder = actualHolder;
6420
- }
6421
- name = "LockOwnershipError";
6366
+ // src/core/runner/sqlite-statements.ts
6367
+ var QUOTE_PAIRS = {
6368
+ "'": "'",
6369
+ '"': '"',
6370
+ "`": "`",
6371
+ "[": "]"
6422
6372
  };
6423
-
6424
- // src/core/lock/manager.ts
6425
- function formatDateForDialect(date, dialect) {
6426
- if (dialect === "sqlite") {
6427
- return date.toISOString();
6428
- }
6429
- if (dialect === "mssql") {
6430
- return date;
6431
- }
6432
- return date.toISOString().replace("T", " ").replace("Z", "");
6433
- }
6434
- function parseDateFromDialect(value, dialect) {
6435
- if (dialect === "sqlite" || dialect === "mssql") {
6436
- return new Date(value);
6437
- }
6438
- if (value instanceof Date) {
6439
- return new Date(Date.UTC(
6440
- value.getFullYear(),
6441
- value.getMonth(),
6442
- value.getDate(),
6443
- value.getHours(),
6444
- value.getMinutes(),
6445
- value.getSeconds(),
6446
- value.getMilliseconds()
6447
- ));
6448
- }
6449
- const normalized = String(value).replace(" ", "T");
6450
- return new Date(normalized.endsWith("Z") ? normalized : `${normalized}Z`);
6373
+ function keywordAt(content, index, keyword) {
6374
+ const end = index + keyword.length;
6375
+ if (content.slice(index, end).toUpperCase() !== keyword) return false;
6376
+ const before = index > 0 ? content[index - 1] : " ";
6377
+ const after = end < content.length ? content[end] : " ";
6378
+ return !/[A-Za-z0-9_$]/.test(before) && !/[A-Za-z0-9_$]/.test(after);
6451
6379
  }
6452
- var LockManager = class {
6453
- /**
6454
- * Acquire a lock for a config.
6455
- *
6456
- * If the lock is held by another identity and wait=false, throws LockAcquireError.
6457
- * If wait=true, polls until lock is available or waitTimeout is exceeded.
6458
- *
6459
- * @param db - Kysely database instance
6460
- * @param configName - Config/database scope for the lock
6461
- * @param identity - Identity string of the requester
6462
- * @param options - Lock options
6463
- * @returns Lock state on success
6464
- * @throws LockAcquireError if lock cannot be acquired
6380
+ function splitSqliteStatements(content) {
6381
+ const statements = [];
6382
+ let current = "";
6383
+ let closingQuote = null;
6384
+ let inLineComment = false;
6385
+ let inBlockComment = false;
6386
+ let inTrigger = false;
6387
+ let blockDepth = 0;
6388
+ let i = 0;
6389
+ const flush = () => {
6390
+ const trimmed = current.trim();
6391
+ if (trimmed.length > 0 && !isCommentOnly(trimmed)) {
6392
+ statements.push(trimmed);
6393
+ }
6394
+ current = "";
6395
+ };
6396
+ while (i < content.length) {
6397
+ const char = content[i];
6398
+ if (inLineComment) {
6399
+ current += char;
6400
+ if (char === "\n") inLineComment = false;
6401
+ i++;
6402
+ continue;
6403
+ }
6404
+ if (inBlockComment) {
6405
+ current += char;
6406
+ if (char === "*" && content[i + 1] === "/") {
6407
+ current += "/";
6408
+ inBlockComment = false;
6409
+ i += 2;
6410
+ continue;
6411
+ }
6412
+ i++;
6413
+ continue;
6414
+ }
6415
+ if (closingQuote !== null) {
6416
+ current += char;
6417
+ if (char === closingQuote) {
6418
+ if (closingQuote !== "]" && content[i + 1] === closingQuote) {
6419
+ current += closingQuote;
6420
+ i += 2;
6421
+ continue;
6422
+ }
6423
+ closingQuote = null;
6424
+ }
6425
+ i++;
6426
+ continue;
6427
+ }
6428
+ if (char === "-" && content[i + 1] === "-") {
6429
+ inLineComment = true;
6430
+ current += "--";
6431
+ i += 2;
6432
+ continue;
6433
+ }
6434
+ if (char === "/" && content[i + 1] === "*") {
6435
+ inBlockComment = true;
6436
+ current += "/*";
6437
+ i += 2;
6438
+ continue;
6439
+ }
6440
+ if (QUOTE_PAIRS[char]) {
6441
+ closingQuote = QUOTE_PAIRS[char];
6442
+ current += char;
6443
+ i++;
6444
+ continue;
6445
+ }
6446
+ if (!inTrigger && keywordAt(content, i, "TRIGGER")) {
6447
+ inTrigger = true;
6448
+ current += content.slice(i, i + 7);
6449
+ i += 7;
6450
+ continue;
6451
+ }
6452
+ if (inTrigger && (keywordAt(content, i, "BEGIN") || keywordAt(content, i, "CASE"))) {
6453
+ const length = keywordAt(content, i, "BEGIN") ? 5 : 4;
6454
+ blockDepth++;
6455
+ current += content.slice(i, i + length);
6456
+ i += length;
6457
+ continue;
6458
+ }
6459
+ if (inTrigger && blockDepth > 0 && keywordAt(content, i, "END")) {
6460
+ blockDepth--;
6461
+ if (blockDepth === 0) inTrigger = false;
6462
+ current += content.slice(i, i + 3);
6463
+ i += 3;
6464
+ continue;
6465
+ }
6466
+ if (char === ";" && blockDepth === 0) {
6467
+ current += char;
6468
+ inTrigger = false;
6469
+ flush();
6470
+ i++;
6471
+ continue;
6472
+ }
6473
+ current += char;
6474
+ i++;
6475
+ }
6476
+ flush();
6477
+ return statements;
6478
+ }
6479
+ function isCommentOnly(fragment) {
6480
+ const withoutBlockComments = fragment.replace(/\/\*[\s\S]*?\*\//g, "");
6481
+ return withoutBlockComments.split("\n").every((line) => {
6482
+ const trimmed = line.trim();
6483
+ return trimmed.length === 0 || trimmed.startsWith("--");
6484
+ });
6485
+ }
6486
+
6487
+ // src/core/runner/mssql-batches.ts
6488
+ function splitMssqlBatches(content) {
6489
+ const batches = content.split(/^\s*GO\s*$/mi);
6490
+ return batches.map((b) => b.trim()).filter((b) => b.length > 0 && !isCommentOnly2(b));
6491
+ }
6492
+ function isCommentOnly2(batch) {
6493
+ for (const line of batch.split("\n")) {
6494
+ const trimmed = line.trim();
6495
+ if (trimmed.length > 0 && !trimmed.startsWith("--")) {
6496
+ return false;
6497
+ }
6498
+ }
6499
+ return true;
6500
+ }
6501
+ async function executeSqlBody(context, sqlContent) {
6502
+ if (context.dialect === "mssql") {
6503
+ return executeFragments(context, splitMssqlBatches(sqlContent), "batch");
6504
+ }
6505
+ if (context.dialect === "sqlite") {
6506
+ return executeFragments(context, splitSqliteStatements(sqlContent), "statement");
6507
+ }
6508
+ const [, execErr] = await attempt(() => sql.raw(sqlContent).execute(context.db));
6509
+ if (execErr) {
6510
+ return getSqlErrorMessage(execErr);
6511
+ }
6512
+ return null;
6513
+ }
6514
+ async function executeFragments(context, fragments, label) {
6515
+ for (let i = 0; i < fragments.length; i++) {
6516
+ const [, execErr] = await attempt(() => sql.raw(fragments[i]).execute(context.db));
6517
+ if (execErr) {
6518
+ const message = getSqlErrorMessage(execErr);
6519
+ return fragments.length === 1 ? message : `[${label} ${i + 1} of ${fragments.length}] ${message}`;
6520
+ }
6521
+ }
6522
+ return null;
6523
+ }
6524
+
6525
+ // src/core/lock/types.ts
6526
+ var DEFAULT_LOCK_OPTIONS = {
6527
+ dialect: "postgres",
6528
+ timeout: 5 * 60 * 1e3,
6529
+ // 5 minutes
6530
+ wait: false,
6531
+ waitTimeout: 30 * 1e3,
6532
+ // 30 seconds
6533
+ pollInterval: 1e3
6534
+ // 1 second
6535
+ };
6536
+
6537
+ // src/core/lock/errors.ts
6538
+ var LockAcquireError = class extends Error {
6539
+ constructor(configName, holder, heldSince, expiresAt, reason) {
6540
+ const since = heldSince.toISOString();
6541
+ const expires = expiresAt.toISOString();
6542
+ const reasonSuffix = reason ? ` (${reason})` : "";
6543
+ super(
6544
+ `Lock for '${configName}' held by ${holder} since ${since}, expires ${expires}${reasonSuffix}`
6545
+ );
6546
+ this.configName = configName;
6547
+ this.holder = holder;
6548
+ this.heldSince = heldSince;
6549
+ this.expiresAt = expiresAt;
6550
+ this.reason = reason;
6551
+ }
6552
+ name = "LockAcquireError";
6553
+ };
6554
+ var LockExpiredError = class extends Error {
6555
+ constructor(configName, identity, expiredAt) {
6556
+ super(`Lock for '${configName}' expired at ${expiredAt.toISOString()}`);
6557
+ this.configName = configName;
6558
+ this.identity = identity;
6559
+ this.expiredAt = expiredAt;
6560
+ }
6561
+ name = "LockExpiredError";
6562
+ };
6563
+ var LockNotFoundError = class extends Error {
6564
+ constructor(configName, identity) {
6565
+ super(`No lock found for '${configName}' held by ${identity}`);
6566
+ this.configName = configName;
6567
+ this.identity = identity;
6568
+ }
6569
+ name = "LockNotFoundError";
6570
+ };
6571
+ var LockOwnershipError = class extends Error {
6572
+ constructor(configName, requestedBy, actualHolder) {
6573
+ super(
6574
+ `Cannot release lock for '${configName}': held by ${actualHolder}, not ${requestedBy}`
6575
+ );
6576
+ this.configName = configName;
6577
+ this.requestedBy = requestedBy;
6578
+ this.actualHolder = actualHolder;
6579
+ }
6580
+ name = "LockOwnershipError";
6581
+ };
6582
+
6583
+ // src/core/lock/manager.ts
6584
+ function formatDateForDialect(date, dialect) {
6585
+ if (dialect === "sqlite") {
6586
+ return date.toISOString();
6587
+ }
6588
+ if (dialect === "mssql") {
6589
+ return date;
6590
+ }
6591
+ return date.toISOString().replace("T", " ").replace("Z", "");
6592
+ }
6593
+ function parseDateFromDialect(value, dialect) {
6594
+ if (dialect === "sqlite" || dialect === "mssql") {
6595
+ return new Date(value);
6596
+ }
6597
+ if (value instanceof Date) {
6598
+ return new Date(Date.UTC(
6599
+ value.getFullYear(),
6600
+ value.getMonth(),
6601
+ value.getDate(),
6602
+ value.getHours(),
6603
+ value.getMinutes(),
6604
+ value.getSeconds(),
6605
+ value.getMilliseconds()
6606
+ ));
6607
+ }
6608
+ const normalized = String(value).replace(" ", "T");
6609
+ return new Date(normalized.endsWith("Z") ? normalized : `${normalized}Z`);
6610
+ }
6611
+ var LockManager = class {
6612
+ /**
6613
+ * Acquire a lock for a config.
6614
+ *
6615
+ * If the lock is held by another identity and wait=false, throws LockAcquireError.
6616
+ * If wait=true, polls until lock is available or waitTimeout is exceeded.
6617
+ *
6618
+ * @param db - Kysely database instance
6619
+ * @param configName - Config/database scope for the lock
6620
+ * @param identity - Identity string of the requester
6621
+ * @param options - Lock options
6622
+ * @returns Lock state on success
6623
+ * @throws LockAcquireError if lock cannot be acquired
6465
6624
  */
6466
6625
  async acquire(db, configName, identity, options = {}) {
6467
6626
  const opts = { ...DEFAULT_LOCK_OPTIONS, ...options };
@@ -7007,6 +7166,12 @@ async function executeFiles(context, change, files, direction, checksum, force,
7007
7166
  return result;
7008
7167
  }
7009
7168
  async function runFileBatch(context, change, expandedFiles, direction, checksum, force, history, executor, startTime, watcher) {
7169
+ const fileChecksums = /* @__PURE__ */ new Map();
7170
+ for (const file of expandedFiles) {
7171
+ const [cs] = await attempt(() => computeChecksum(file.path));
7172
+ fileChecksums.set(file.path, cs ?? "");
7173
+ }
7174
+ const skipReasons = await findSkippedFiles(context, change.name, direction, expandedFiles, fileChecksums, force, history);
7010
7175
  const [operationId, createErr] = await attempt(
7011
7176
  () => history.createOperation({
7012
7177
  name: change.name,
@@ -7022,14 +7187,9 @@ async function runFileBatch(context, change, expandedFiles, direction, checksum,
7022
7187
  });
7023
7188
  return createFailedResult(change.name, direction, createErr.message, startTime);
7024
7189
  }
7025
- const fileChecksums = /* @__PURE__ */ new Map();
7026
- for (const file of expandedFiles) {
7027
- const [cs] = await attempt(() => computeChecksum(file.path));
7028
- fileChecksums.set(file.path, cs ?? "");
7029
- }
7030
7190
  const createRecordsErr = await history.createFileRecords(
7031
7191
  operationId,
7032
- expandedFiles.map((f) => ({
7192
+ expandedFiles.filter((_, i) => !skipReasons[i]).map((f) => ({
7033
7193
  filepath: path.relative(context.projectRoot, f.path),
7034
7194
  fileType: f.type,
7035
7195
  checksum: fileChecksums.get(f.path) ?? ""
@@ -7069,36 +7229,15 @@ async function runFileBatch(context, change, expandedFiles, direction, checksum,
7069
7229
  const fileStart = performance.now();
7070
7230
  const relPath = path.relative(context.projectRoot, file.path);
7071
7231
  const fileChecksum = fileChecksums.get(file.path) ?? "";
7072
- const needsRunFileResult = await history.needsRunFile(
7073
- change.name,
7074
- direction,
7075
- relPath,
7076
- fileChecksum,
7077
- force
7078
- );
7079
- if (!needsRunFileResult.needsRun) {
7232
+ const skipReason = skipReasons[i];
7233
+ if (skipReason) {
7080
7234
  results.push({
7081
7235
  filepath: file.path,
7082
7236
  checksum: fileChecksum,
7083
7237
  status: "skipped",
7084
- skipReason: needsRunFileResult.skipReason,
7238
+ skipReason,
7085
7239
  durationMs: 0
7086
7240
  });
7087
- const skipUpdateErr = await history.updateFileExecution(
7088
- operationId,
7089
- relPath,
7090
- "skipped",
7091
- 0,
7092
- void 0,
7093
- needsRunFileResult.skipReason
7094
- );
7095
- if (skipUpdateErr) {
7096
- observer.emit("error", {
7097
- source: "change",
7098
- error: new Error(skipUpdateErr),
7099
- context: { filepath: relPath, operation: "update-skipped-record" }
7100
- });
7101
- }
7102
7241
  continue;
7103
7242
  }
7104
7243
  const [sqlContent, loadErr] = await attempt(() => loadAndRenderFile(context, file.path));
@@ -7130,12 +7269,12 @@ async function runFileBatch(context, change, expandedFiles, direction, checksum,
7130
7269
  }
7131
7270
  break;
7132
7271
  }
7133
- const [, execErr] = await attempt(
7134
- () => watcher.run(file.path, executor, (conn) => sql.raw(sqlContent).execute(conn))
7272
+ const [execMessage, execErr] = await attempt(
7273
+ () => watcher.run(file.path, executor, (conn) => executeSqlBody({ db: conn, dialect: context.dialect }, sqlContent))
7135
7274
  );
7136
7275
  const durationMs = performance.now() - fileStart;
7137
- if (execErr) {
7138
- const errorMessage2 = getSqlErrorMessage(execErr);
7276
+ const errorMessage2 = execErr ? getSqlErrorMessage(execErr) : execMessage;
7277
+ if (errorMessage2) {
7139
7278
  failed = true;
7140
7279
  failedFile = file.path;
7141
7280
  failureError = errorMessage2;
@@ -7233,6 +7372,22 @@ async function runFileBatch(context, change, expandedFiles, direction, checksum,
7233
7372
  operationId
7234
7373
  };
7235
7374
  }
7375
+ async function findSkippedFiles(context, changeName, direction, files, fileChecksums, force, history) {
7376
+ const skipReasons = [];
7377
+ const seen = /* @__PURE__ */ new Set();
7378
+ const [latest, latestErr] = force ? [null, null] : await history.latestFileExecutions(changeName, direction);
7379
+ for (const file of files) {
7380
+ const relPath = path.relative(context.projectRoot, file.path);
7381
+ if (seen.has(relPath)) {
7382
+ skipReasons.push("already applied");
7383
+ continue;
7384
+ }
7385
+ seen.add(relPath);
7386
+ const decision = decideNeedsRunFile(latestErr ?? latest?.get(relPath), fileChecksums.get(file.path) ?? "", force);
7387
+ skipReasons.push(decision.needsRun ? void 0 : decision.skipReason);
7388
+ }
7389
+ return skipReasons;
7390
+ }
7236
7391
  async function executePreview(context, change, files, direction, output, startTime) {
7237
7392
  const expandedFiles = await expandFiles(files, context.sqlDir);
7238
7393
  const results = [];
@@ -7457,17 +7612,31 @@ var ChangeManager = class {
7457
7612
  * List all changes with status.
7458
7613
  *
7459
7614
  * Merges changes from disk with status from database.
7460
- * Includes orphaned changes (in DB but not on disk).
7615
+ * Includes orphaned changes (in DB but not on disk), and folders on disk
7616
+ * that cannot be parsed: `failed` with the parse error when they never
7617
+ * ran, otherwise their recorded status.
7461
7618
  *
7462
7619
  * @returns Array of changes with merged status
7463
7620
  */
7464
7621
  async list() {
7465
- const diskChanges = await discoverChanges(
7622
+ const { items } = await this.#scan();
7623
+ return items;
7624
+ }
7625
+ /**
7626
+ * Merge disk and database state, and report the folders that cannot be read.
7627
+ *
7628
+ * An unreadable folder that a batch would run (never run, or `reverted` or
7629
+ * `stale`) is pending work: the batch fails at its position rather than
7630
+ * skipping it and running later changes out of order. `unreadable` holds the
7631
+ * applied ones; they keep their status and only warrant a warning.
7632
+ */
7633
+ async #scan() {
7634
+ const { changes: diskChanges, failures } = await scanChanges(
7466
7635
  this.#context.changesDir,
7467
7636
  this.#context.sqlDir
7468
7637
  );
7469
7638
  const statuses = await this.#history.getAllStatuses();
7470
- const diskNames = new Set(diskChanges.map((cs) => cs.name));
7639
+ const diskNames = new Set([...diskChanges, ...failures].map((cs) => cs.name));
7471
7640
  const orphanedNames = await this.#history.getOrphaned(diskNames);
7472
7641
  const result = [];
7473
7642
  for (const cs of diskChanges) {
@@ -7485,6 +7654,15 @@ var ChangeManager = class {
7485
7654
  orphaned: false
7486
7655
  });
7487
7656
  }
7657
+ const unreadable = [];
7658
+ for (const failure of failures) {
7659
+ const status = statuses.get(failure.name);
7660
+ const item = failureListItem(failure, status);
7661
+ if (!isPendingChange(item)) {
7662
+ unreadable.push(failure);
7663
+ }
7664
+ result.push(item);
7665
+ }
7488
7666
  for (const name of orphanedNames) {
7489
7667
  const status = statuses.get(name);
7490
7668
  result.push({
@@ -7506,7 +7684,10 @@ var ChangeManager = class {
7506
7684
  hasChangelog: void 0
7507
7685
  });
7508
7686
  }
7509
- return result.sort((a, b) => a.name.localeCompare(b.name));
7687
+ return {
7688
+ items: result.sort((a, b) => a.name.localeCompare(b.name)),
7689
+ unreadable
7690
+ };
7510
7691
  }
7511
7692
  // ─────────────────────────────────────────────────────────
7512
7693
  // Run Single Change
@@ -7561,9 +7742,13 @@ var ChangeManager = class {
7561
7742
  async next(count = 1, options = {}) {
7562
7743
  const opts = { ...DEFAULT_BATCH, ...options };
7563
7744
  const start = performance.now();
7564
- const warnings = await this.#collectWarnings();
7565
- const list = await this.list();
7566
- const pending = list.filter(isPendingChange).slice(0, count);
7745
+ const { items, unreadable } = await this.#scan();
7746
+ const messages = [
7747
+ ...await this.#collectWarnings() ?? [],
7748
+ ...unreadable.map((f) => `Change '${f.name}' is applied but its files can no longer be read: ${f.message}`)
7749
+ ];
7750
+ const warnings = messages.length > 0 ? messages : void 0;
7751
+ const pending = items.filter(isPendingChange).slice(0, count);
7567
7752
  if (pending.length === 0) {
7568
7753
  return {
7569
7754
  status: "success",
@@ -7631,9 +7816,7 @@ var ChangeManager = class {
7631
7816
  * @returns Batch result
7632
7817
  */
7633
7818
  async ff(options = {}) {
7634
- const list = await this.list();
7635
- const pendingCount = list.filter(isPendingChange).length;
7636
- return this.next(pendingCount, options);
7819
+ return this.next(Number.POSITIVE_INFINITY, options);
7637
7820
  }
7638
7821
  /**
7639
7822
  * Rewind: revert changes in reverse order.
@@ -7802,6 +7985,8 @@ var ChangeManager = class {
7802
7985
  () => parseChange(changePath, this.#context.sqlDir)
7803
7986
  );
7804
7987
  if (err) {
7988
+ const [onDisk] = await attempt(() => stat(changePath));
7989
+ if (onDisk) throw err;
7805
7990
  const status = await this.#history.getStatus(name);
7806
7991
  if (status) {
7807
7992
  throw new ChangeOrphanedError(name);
@@ -7903,7 +8088,7 @@ async function resolveVaultKey(db, dialect) {
7903
8088
  async function buildSecretsContext(stateManager, configName, db, vaultKey, dialect) {
7904
8089
  const secrets = {};
7905
8090
  if (db && vaultKey) {
7906
- const { getAllVaultSecrets: getAllVaultSecrets2 } = await import('./storage-FBDOY3MC.js');
8091
+ const { getAllVaultSecrets: getAllVaultSecrets2 } = await import('./storage-NQA4H7PK.js');
7907
8092
  const vaultSecrets = await getAllVaultSecrets2(db, vaultKey, dialect ?? "postgres");
7908
8093
  for (const [key, secret] of Object.entries(vaultSecrets)) {
7909
8094
  secrets[key] = secret.value;
@@ -8427,13 +8612,13 @@ var ChangesNamespace = class {
8427
8612
  get #changesDir() {
8428
8613
  return path.join(
8429
8614
  this.#state.projectRoot,
8430
- this.#state.settings.paths?.changes ?? "changes"
8615
+ this.#state.settings.paths?.changes ?? DEFAULT_CHANGES_PATH
8431
8616
  );
8432
8617
  }
8433
8618
  get #sqlDir() {
8434
8619
  return path.join(
8435
8620
  this.#state.projectRoot,
8436
- this.#state.settings.paths?.sql ?? "sql"
8621
+ this.#state.settings.paths?.sql ?? DEFAULT_SQL_PATH
8437
8622
  );
8438
8623
  }
8439
8624
  get #kysely() {
@@ -8466,167 +8651,6 @@ var ChangesNamespace = class {
8466
8651
  return this.#manager;
8467
8652
  }
8468
8653
  };
8469
-
8470
- // src/core/runner/sqlite-statements.ts
8471
- var QUOTE_PAIRS = {
8472
- "'": "'",
8473
- '"': '"',
8474
- "`": "`",
8475
- "[": "]"
8476
- };
8477
- function keywordAt(content, index, keyword) {
8478
- const end = index + keyword.length;
8479
- if (content.slice(index, end).toUpperCase() !== keyword) return false;
8480
- const before = index > 0 ? content[index - 1] : " ";
8481
- const after = end < content.length ? content[end] : " ";
8482
- return !/[A-Za-z0-9_$]/.test(before) && !/[A-Za-z0-9_$]/.test(after);
8483
- }
8484
- function splitSqliteStatements(content) {
8485
- const statements = [];
8486
- let current = "";
8487
- let closingQuote = null;
8488
- let inLineComment = false;
8489
- let inBlockComment = false;
8490
- let inTrigger = false;
8491
- let blockDepth = 0;
8492
- let i = 0;
8493
- const flush = () => {
8494
- const trimmed = current.trim();
8495
- if (trimmed.length > 0 && !isCommentOnly(trimmed)) {
8496
- statements.push(trimmed);
8497
- }
8498
- current = "";
8499
- };
8500
- while (i < content.length) {
8501
- const char = content[i];
8502
- if (inLineComment) {
8503
- current += char;
8504
- if (char === "\n") inLineComment = false;
8505
- i++;
8506
- continue;
8507
- }
8508
- if (inBlockComment) {
8509
- current += char;
8510
- if (char === "*" && content[i + 1] === "/") {
8511
- current += "/";
8512
- inBlockComment = false;
8513
- i += 2;
8514
- continue;
8515
- }
8516
- i++;
8517
- continue;
8518
- }
8519
- if (closingQuote !== null) {
8520
- current += char;
8521
- if (char === closingQuote) {
8522
- if (closingQuote !== "]" && content[i + 1] === closingQuote) {
8523
- current += closingQuote;
8524
- i += 2;
8525
- continue;
8526
- }
8527
- closingQuote = null;
8528
- }
8529
- i++;
8530
- continue;
8531
- }
8532
- if (char === "-" && content[i + 1] === "-") {
8533
- inLineComment = true;
8534
- current += "--";
8535
- i += 2;
8536
- continue;
8537
- }
8538
- if (char === "/" && content[i + 1] === "*") {
8539
- inBlockComment = true;
8540
- current += "/*";
8541
- i += 2;
8542
- continue;
8543
- }
8544
- if (QUOTE_PAIRS[char]) {
8545
- closingQuote = QUOTE_PAIRS[char];
8546
- current += char;
8547
- i++;
8548
- continue;
8549
- }
8550
- if (!inTrigger && keywordAt(content, i, "TRIGGER")) {
8551
- inTrigger = true;
8552
- current += content.slice(i, i + 7);
8553
- i += 7;
8554
- continue;
8555
- }
8556
- if (inTrigger && (keywordAt(content, i, "BEGIN") || keywordAt(content, i, "CASE"))) {
8557
- const length = keywordAt(content, i, "BEGIN") ? 5 : 4;
8558
- blockDepth++;
8559
- current += content.slice(i, i + length);
8560
- i += length;
8561
- continue;
8562
- }
8563
- if (inTrigger && blockDepth > 0 && keywordAt(content, i, "END")) {
8564
- blockDepth--;
8565
- if (blockDepth === 0) inTrigger = false;
8566
- current += content.slice(i, i + 3);
8567
- i += 3;
8568
- continue;
8569
- }
8570
- if (char === ";" && blockDepth === 0) {
8571
- current += char;
8572
- inTrigger = false;
8573
- flush();
8574
- i++;
8575
- continue;
8576
- }
8577
- current += char;
8578
- i++;
8579
- }
8580
- flush();
8581
- return statements;
8582
- }
8583
- function isCommentOnly(fragment) {
8584
- const withoutBlockComments = fragment.replace(/\/\*[\s\S]*?\*\//g, "");
8585
- return withoutBlockComments.split("\n").every((line) => {
8586
- const trimmed = line.trim();
8587
- return trimmed.length === 0 || trimmed.startsWith("--");
8588
- });
8589
- }
8590
-
8591
- // src/core/runner/mssql-batches.ts
8592
- function splitMssqlBatches(content) {
8593
- const batches = content.split(/^\s*GO\s*$/mi);
8594
- return batches.map((b) => b.trim()).filter((b) => b.length > 0 && !isCommentOnly2(b));
8595
- }
8596
- function isCommentOnly2(batch) {
8597
- for (const line of batch.split("\n")) {
8598
- const trimmed = line.trim();
8599
- if (trimmed.length > 0 && !trimmed.startsWith("--")) {
8600
- return false;
8601
- }
8602
- }
8603
- return true;
8604
- }
8605
- async function executeSqlBody(context, sqlContent) {
8606
- if (context.dialect === "mssql") {
8607
- return executeFragments(context, splitMssqlBatches(sqlContent), "batch");
8608
- }
8609
- if (context.dialect === "sqlite") {
8610
- return executeFragments(context, splitSqliteStatements(sqlContent), "statement");
8611
- }
8612
- const [, execErr] = await attempt(() => sql.raw(sqlContent).execute(context.db));
8613
- if (execErr) {
8614
- return getSqlErrorMessage(execErr);
8615
- }
8616
- return null;
8617
- }
8618
- async function executeFragments(context, fragments, label) {
8619
- for (let i = 0; i < fragments.length; i++) {
8620
- const [, execErr] = await attempt(() => sql.raw(fragments[i]).execute(context.db));
8621
- if (execErr) {
8622
- const message = getSqlErrorMessage(execErr);
8623
- return fragments.length === 1 ? message : `[${label} ${i + 1} of ${fragments.length}] ${message}`;
8624
- }
8625
- }
8626
- return null;
8627
- }
8628
-
8629
- // src/core/runner/runner.ts
8630
8654
  var SQL_EXTENSIONS2 = [".sql", ".sql.tmpl"];
8631
8655
  var FILE_HEADER_TEMPLATE = `-- ============================================================
8632
8656
  -- File: %FILE%
@@ -8675,48 +8699,20 @@ async function runBuild(context, sqlPath, options = {}, preFilteredFiles) {
8675
8699
  }
8676
8700
  async function runFile(context, filepath, options = {}) {
8677
8701
  assertRunPolicy(context, "run:file");
8678
- const opts = { ...DEFAULT_RUN_OPTIONS_INTERNAL, ...options };
8679
8702
  observer.emit("run:file", {
8680
8703
  filepath,
8681
8704
  configName: context.configName
8682
8705
  });
8683
- if (opts.dryRun) {
8684
- const results = await executeDryRun2(context, [filepath]);
8685
- return results[0];
8686
- }
8687
- const tracker = new Tracker(context.db, context.configName, context.dialect ?? "postgres");
8688
- const operationName = `run:${(/* @__PURE__ */ new Date()).toISOString()}`;
8689
- const [operationId, createErr] = await attempt(
8690
- () => tracker.createOperation({
8691
- name: operationName,
8692
- changeType: "run",
8693
- configName: context.configName,
8694
- executedBy: formatIdentity(context.identity)
8695
- })
8696
- );
8697
- if (createErr) {
8698
- observer.emit("error", {
8699
- source: "runner",
8700
- error: createErr,
8701
- context: { filepath, operation: "create-operation" }
8702
- });
8703
- return {
8704
- filepath,
8705
- checksum: "",
8706
- status: "failed",
8707
- error: createErr.message
8708
- };
8709
- }
8710
- const watcher = createWatcher(context);
8711
- const result = await executeSingleFile(context, filepath, opts, tracker, operationId, watcher).finally(() => watcher.close());
8712
- await tracker.finalizeOperation(
8713
- operationId,
8714
- result.status === "failed" || result.error ? "failed" : "success",
8715
- Math.round(result.durationMs ?? 0),
8716
- result.checksum,
8717
- result.error
8718
- );
8719
- return result;
8706
+ const batch = await executeFiles2(context, [{ path: filepath, type: "sql" }], options, {
8707
+ changeType: "run",
8708
+ operationName: `run:${(/* @__PURE__ */ new Date()).toISOString()}`
8709
+ });
8710
+ return batch.files[0] ?? {
8711
+ filepath,
8712
+ checksum: "",
8713
+ status: batch.error === RUN_CANCELLED ? "skipped" : "failed",
8714
+ error: batch.error
8715
+ };
8720
8716
  }
8721
8717
  async function runDir(context, dirpath, options = {}) {
8722
8718
  assertRunPolicy(context, "run:dir");
@@ -8859,6 +8855,19 @@ async function executeFiles2(context, files, runOptions, execOptions) {
8859
8855
  };
8860
8856
  }
8861
8857
  const tracker = execOptions.tracker ?? new Tracker(context.db, context.configName, context.dialect ?? "postgres");
8858
+ const isChange = execOptions.changeType === "change";
8859
+ const prepared = await prepareFiles(context, files);
8860
+ const gated = isChange ? prepared : await applyRunGate(tracker, prepared, opts.force);
8861
+ if (!isChange && gated.every((entry) => entry.skipReason !== void 0)) {
8862
+ return {
8863
+ status: "success",
8864
+ files: gated.map(skipFile),
8865
+ filesRun: 0,
8866
+ filesSkipped: gated.length,
8867
+ filesFailed: 0,
8868
+ durationMs: performance.now() - start
8869
+ };
8870
+ }
8862
8871
  const [operationId, createErr] = await attempt(
8863
8872
  () => tracker.createOperation({
8864
8873
  name: execOptions.operationName,
@@ -8879,20 +8888,11 @@ async function executeFiles2(context, files, runOptions, execOptions) {
8879
8888
  performance.now() - start
8880
8889
  );
8881
8890
  }
8882
- const fileRecords = [];
8883
- for (const file of files) {
8884
- let checksum = file.checksum;
8885
- if (!checksum) {
8886
- const [computed, err] = await attempt(() => computeChecksum(file.path));
8887
- checksum = err ? "" : computed;
8888
- }
8889
- fileRecords.push({
8890
- filepath: path.relative(context.projectRoot, file.path),
8891
- fileType: file.type,
8892
- checksum
8893
- });
8894
- }
8895
- const createRecordsErr = await tracker.createFileRecords(operationId, fileRecords);
8891
+ const runSet = gated.filter((entry) => !entry.skipReason);
8892
+ const createRecordsErr = await tracker.createFileRecords(
8893
+ operationId,
8894
+ runSet.map((entry) => ({ filepath: entry.relFilepath, fileType: entry.file.type, checksum: entry.checksum }))
8895
+ );
8896
8896
  if (createRecordsErr) {
8897
8897
  observer.emit("error", {
8898
8898
  source: "runner:create-file-records",
@@ -8909,26 +8909,21 @@ async function executeFiles2(context, files, runOptions, execOptions) {
8909
8909
  let failed = false;
8910
8910
  let cancelled = false;
8911
8911
  const watcher = createWatcher(context);
8912
+ const lastRunIndex = gated.map((entry) => !entry.skipReason).lastIndexOf(true);
8912
8913
  try {
8913
- for (let i = 0; i < files.length; i++) {
8914
- const file = files[i];
8915
- const fileRecord = fileRecords[i];
8914
+ for (let i = 0; i < gated.length; i++) {
8915
+ const entry = gated[i];
8916
+ if (entry.skipReason) {
8917
+ results.push(skipFile(entry));
8918
+ continue;
8919
+ }
8916
8920
  if (context.signal?.aborted) {
8917
8921
  failed = true;
8918
8922
  cancelled = true;
8919
8923
  await skipRemaining(tracker, operationId, RUN_CANCELLED);
8920
8924
  break;
8921
8925
  }
8922
- const result = await executeSingleFileWithUpdate(
8923
- context,
8924
- file.path,
8925
- fileRecord.checksum,
8926
- opts,
8927
- tracker,
8928
- operationId,
8929
- execOptions.changeType,
8930
- watcher
8931
- );
8926
+ const result = await executeSingleFileWithUpdate(context, entry, tracker, operationId, watcher);
8932
8927
  if (!result) {
8933
8928
  failed = true;
8934
8929
  cancelled = true;
@@ -8936,13 +8931,13 @@ async function executeFiles2(context, files, runOptions, execOptions) {
8936
8931
  break;
8937
8932
  }
8938
8933
  results.push(result);
8939
- cancelled = (context.signal?.aborted ?? false) && (i < files.length - 1 || result.status === "failed");
8934
+ cancelled = (context.signal?.aborted ?? false) && (i < lastRunIndex || result.status === "failed");
8940
8935
  if (cancelled || result.status === "failed" && opts.abortOnError) {
8941
8936
  failed = true;
8942
8937
  await skipRemaining(
8943
8938
  tracker,
8944
8939
  operationId,
8945
- cancelled ? RUN_CANCELLED : `Skipped: failure in ${path.basename(file.path)}`
8940
+ cancelled ? RUN_CANCELLED : `Skipped: failure in ${path.basename(entry.file.path)}`
8946
8941
  );
8947
8942
  break;
8948
8943
  }
@@ -8963,7 +8958,7 @@ async function executeFiles2(context, files, runOptions, execOptions) {
8963
8958
  if (filesFailed > 0 || failed) {
8964
8959
  status = filesRun > 0 ? "partial" : "failed";
8965
8960
  }
8966
- const combinedChecksum = execOptions.checksum ?? computeCombinedChecksum(fileRecords.map((f) => f.checksum));
8961
+ const combinedChecksum = execOptions.checksum ?? computeCombinedChecksum(gated.map((entry) => entry.checksum));
8967
8962
  const finalStatus = status === "success" ? "success" : "failed";
8968
8963
  const finalizeErr = await tracker.finalizeOperation(
8969
8964
  operationId,
@@ -8990,6 +8985,43 @@ async function executeFiles2(context, files, runOptions, execOptions) {
8990
8985
  error: cancelled ? RUN_CANCELLED : void 0
8991
8986
  };
8992
8987
  }
8988
+ async function prepareFiles(context, files) {
8989
+ const prepared = [];
8990
+ for (const file of files) {
8991
+ const relFilepath = path.relative(context.projectRoot, file.path);
8992
+ const [loaded, loadError] = await attempt(async () => {
8993
+ const sql27 = await loadAndRenderFile2(context, file.path);
8994
+ return { sql: sql27, checksum: computeChecksumFromContent(sql27) };
8995
+ });
8996
+ if (loadError) {
8997
+ prepared.push({ file, relFilepath, checksum: "", sql: "", loadError });
8998
+ continue;
8999
+ }
9000
+ prepared.push({ file, relFilepath, ...loaded });
9001
+ }
9002
+ return prepared;
9003
+ }
9004
+ async function applyRunGate(tracker, prepared, force) {
9005
+ if (force) return prepared;
9006
+ const [latest, latestErr] = await tracker.latestExecutions();
9007
+ return prepared.map((entry) => {
9008
+ if (entry.loadError) return entry;
9009
+ const decision = decideNeedsRun(latestErr ?? latest.get(entry.relFilepath), entry.checksum);
9010
+ return decision.skipReason ? { ...entry, skipReason: decision.skipReason } : entry;
9011
+ });
9012
+ }
9013
+ function skipFile(entry) {
9014
+ observer.emit("file:skip", {
9015
+ filepath: entry.file.path,
9016
+ reason: entry.skipReason
9017
+ });
9018
+ return {
9019
+ filepath: entry.file.path,
9020
+ checksum: entry.checksum,
9021
+ status: "skipped",
9022
+ skipReason: entry.skipReason
9023
+ };
9024
+ }
8993
9025
  async function executeFilesInternal(context, files, options, changeType, sqlPath) {
8994
9026
  const fileInputs = files.map((f) => ({
8995
9027
  path: f,
@@ -9003,17 +9035,16 @@ async function executeFilesInternal(context, files, options, changeType, sqlPath
9003
9035
  };
9004
9036
  return executeFiles2(context, fileInputs, options, execOptions);
9005
9037
  }
9006
- async function executeSingleFileWithUpdate(context, filepath, checksum, options, tracker, operationId, changeType, watcher) {
9038
+ async function executeSingleFileWithUpdate(context, entry, tracker, operationId, watcher) {
9007
9039
  const start = performance.now();
9008
- const relFilepath = path.relative(context.projectRoot, filepath);
9009
- const [sqlContent, loadErr] = await attempt(() => loadAndRenderFile2(context, filepath));
9010
- if (loadErr) {
9040
+ const { file: { path: filepath }, relFilepath, checksum } = entry;
9041
+ if (entry.loadError) {
9011
9042
  const durationMs2 = performance.now() - start;
9012
9043
  const result2 = {
9013
9044
  filepath,
9014
- checksum: checksum || "",
9045
+ checksum,
9015
9046
  status: "failed",
9016
- error: loadErr.message,
9047
+ error: entry.loadError.message,
9017
9048
  durationMs: durationMs2
9018
9049
  };
9019
9050
  await tracker.updateFileExecution(
@@ -9021,56 +9052,29 @@ async function executeSingleFileWithUpdate(context, filepath, checksum, options,
9021
9052
  relFilepath,
9022
9053
  "failed",
9023
9054
  Math.round(durationMs2),
9024
- loadErr.message
9055
+ entry.loadError.message
9025
9056
  );
9026
9057
  observer.emit("file:after", {
9027
9058
  filepath,
9028
9059
  status: "failed",
9029
9060
  durationMs: durationMs2,
9030
- error: loadErr.message
9061
+ error: entry.loadError.message
9031
9062
  });
9032
9063
  return result2;
9033
9064
  }
9034
- const [renderedChecksum, checksumErr] = attemptSync(() => computeChecksumFromContent(sqlContent));
9035
- const finalChecksum = checksumErr ? checksum : renderedChecksum;
9036
9065
  observer.emit("file:before", {
9037
9066
  filepath,
9038
- checksum: finalChecksum,
9067
+ checksum,
9039
9068
  configName: context.configName
9040
9069
  });
9041
- if (changeType !== "change") {
9042
- const needsRunResult = await tracker.needsRun(relFilepath, finalChecksum, options.force, operationId);
9043
- if (!needsRunResult.needsRun) {
9044
- const result2 = {
9045
- filepath,
9046
- checksum: finalChecksum,
9047
- status: "skipped",
9048
- skipReason: needsRunResult.skipReason
9049
- };
9050
- await tracker.updateFileExecution(
9051
- operationId,
9052
- relFilepath,
9053
- "skipped",
9054
- 0,
9055
- void 0,
9056
- needsRunResult.skipReason,
9057
- finalChecksum
9058
- );
9059
- observer.emit("file:skip", {
9060
- filepath,
9061
- reason: needsRunResult.skipReason
9062
- });
9063
- return result2;
9064
- }
9065
- }
9066
- const execErrMsg = await runWatched(context, watcher, filepath, sqlContent);
9070
+ const execErrMsg = await runWatched(context, watcher, filepath, entry.sql);
9067
9071
  if (execErrMsg === NOT_STARTED) return null;
9068
9072
  const durationMs = performance.now() - start;
9069
9073
  if (execErrMsg) {
9070
9074
  const error = execErrMsg + await describePriorSuccesses(tracker, relFilepath, operationId);
9071
9075
  const result2 = {
9072
9076
  filepath,
9073
- checksum: finalChecksum,
9077
+ checksum,
9074
9078
  status: "failed",
9075
9079
  error,
9076
9080
  durationMs
@@ -9080,9 +9084,7 @@ async function executeSingleFileWithUpdate(context, filepath, checksum, options,
9080
9084
  relFilepath,
9081
9085
  "failed",
9082
9086
  Math.round(durationMs),
9083
- error,
9084
- void 0,
9085
- finalChecksum
9087
+ error
9086
9088
  );
9087
9089
  observer.emit("file:after", {
9088
9090
  filepath,
@@ -9094,7 +9096,7 @@ async function executeSingleFileWithUpdate(context, filepath, checksum, options,
9094
9096
  }
9095
9097
  const result = {
9096
9098
  filepath,
9097
- checksum: finalChecksum,
9099
+ checksum,
9098
9100
  status: "success",
9099
9101
  durationMs
9100
9102
  };
@@ -9102,10 +9104,7 @@ async function executeSingleFileWithUpdate(context, filepath, checksum, options,
9102
9104
  operationId,
9103
9105
  relFilepath,
9104
9106
  "success",
9105
- Math.round(durationMs),
9106
- void 0,
9107
- void 0,
9108
- finalChecksum
9107
+ Math.round(durationMs)
9109
9108
  );
9110
9109
  observer.emit("file:after", {
9111
9110
  filepath,
@@ -9114,143 +9113,6 @@ async function executeSingleFileWithUpdate(context, filepath, checksum, options,
9114
9113
  });
9115
9114
  return result;
9116
9115
  }
9117
- async function executeSingleFile(context, filepath, options, tracker, operationId, watcher) {
9118
- const start = performance.now();
9119
- const relFilepath = path.relative(context.projectRoot, filepath);
9120
- const [sqlContent, loadErr] = await attempt(() => loadAndRenderFile2(context, filepath));
9121
- if (loadErr) {
9122
- const durationMs2 = performance.now() - start;
9123
- const result2 = {
9124
- filepath,
9125
- checksum: "",
9126
- status: "failed",
9127
- error: loadErr.message,
9128
- durationMs: durationMs2
9129
- };
9130
- await tracker.recordExecution({
9131
- changeId: operationId,
9132
- filepath: relFilepath,
9133
- checksum: "",
9134
- status: "failed",
9135
- errorMessage: loadErr.message,
9136
- durationMs: Math.round(durationMs2)
9137
- });
9138
- observer.emit("file:after", {
9139
- filepath,
9140
- status: "failed",
9141
- durationMs: durationMs2,
9142
- error: loadErr.message
9143
- });
9144
- return result2;
9145
- }
9146
- const [checksum, checksumErr] = attemptSync(() => computeChecksumFromContent(sqlContent));
9147
- if (checksumErr) {
9148
- const result2 = {
9149
- filepath,
9150
- checksum: "",
9151
- status: "failed",
9152
- error: checksumErr.message,
9153
- durationMs: performance.now() - start
9154
- };
9155
- await tracker.recordExecution({
9156
- changeId: operationId,
9157
- filepath: relFilepath,
9158
- checksum: "",
9159
- status: "failed",
9160
- errorMessage: checksumErr.message,
9161
- durationMs: Math.round(result2.durationMs ?? 0)
9162
- });
9163
- observer.emit("file:after", {
9164
- filepath,
9165
- status: "failed",
9166
- durationMs: result2.durationMs ?? 0,
9167
- error: checksumErr.message
9168
- });
9169
- return result2;
9170
- }
9171
- observer.emit("file:before", {
9172
- filepath,
9173
- checksum,
9174
- configName: context.configName
9175
- });
9176
- const needsRunResult = await tracker.needsRun(relFilepath, checksum, options.force);
9177
- if (!needsRunResult.needsRun) {
9178
- const result2 = {
9179
- filepath,
9180
- checksum,
9181
- status: "skipped",
9182
- skipReason: needsRunResult.skipReason
9183
- };
9184
- await tracker.recordExecution({
9185
- changeId: operationId,
9186
- filepath: relFilepath,
9187
- checksum,
9188
- status: "skipped",
9189
- skipReason: needsRunResult.skipReason
9190
- });
9191
- observer.emit("file:skip", {
9192
- filepath,
9193
- reason: needsRunResult.skipReason
9194
- });
9195
- return result2;
9196
- }
9197
- const execErrMsg = await runWatched(context, watcher, filepath, sqlContent);
9198
- const durationMs = performance.now() - start;
9199
- if (execErrMsg === NOT_STARTED) {
9200
- await tracker.recordExecution({
9201
- changeId: operationId,
9202
- filepath: relFilepath,
9203
- checksum,
9204
- status: "skipped",
9205
- skipReason: RUN_CANCELLED
9206
- });
9207
- return { filepath, checksum, status: "skipped", error: RUN_CANCELLED };
9208
- }
9209
- if (execErrMsg) {
9210
- const error = execErrMsg + await describePriorSuccesses(tracker, relFilepath, operationId);
9211
- const result2 = {
9212
- filepath,
9213
- checksum,
9214
- status: "failed",
9215
- error,
9216
- durationMs
9217
- };
9218
- await tracker.recordExecution({
9219
- changeId: operationId,
9220
- filepath: relFilepath,
9221
- checksum,
9222
- status: "failed",
9223
- errorMessage: error,
9224
- durationMs: Math.round(durationMs)
9225
- });
9226
- observer.emit("file:after", {
9227
- filepath,
9228
- status: "failed",
9229
- durationMs,
9230
- error
9231
- });
9232
- return result2;
9233
- }
9234
- const result = {
9235
- filepath,
9236
- checksum,
9237
- status: "success",
9238
- durationMs
9239
- };
9240
- await tracker.recordExecution({
9241
- changeId: operationId,
9242
- filepath: relFilepath,
9243
- checksum,
9244
- status: "success",
9245
- durationMs: Math.round(durationMs)
9246
- });
9247
- observer.emit("file:after", {
9248
- filepath,
9249
- status: "success",
9250
- durationMs
9251
- });
9252
- return result;
9253
- }
9254
9116
  async function loadAndRenderFile2(context, filepath) {
9255
9117
  if (isTemplate(filepath)) {
9256
9118
  const result = await processFile(filepath, {
@@ -9415,12 +9277,12 @@ var RunNamespace = class {
9415
9277
  *
9416
9278
  * @example
9417
9279
  * ```typescript
9418
- * const files = await ctx.noorm.run.discover('sql/')
9280
+ * const files = await ctx.noorm.run.discover('db/sql/')
9419
9281
  * ```
9420
9282
  */
9421
9283
  async discover(dirpath) {
9422
9284
  const absolutePath = this.#resolvePath(
9423
- dirpath ?? this.#state.settings.paths?.sql ?? "sql"
9285
+ dirpath ?? this.#state.settings.paths?.sql ?? DEFAULT_SQL_PATH
9424
9286
  );
9425
9287
  return discoverFiles(absolutePath);
9426
9288
  }
@@ -9432,7 +9294,7 @@ var RunNamespace = class {
9432
9294
  *
9433
9295
  * @example
9434
9296
  * ```typescript
9435
- * const results = await ctx.noorm.run.preview(['sql/001.sql', 'sql/002.sql'])
9297
+ * const results = await ctx.noorm.run.preview(['db/sql/001.sql', 'db/sql/002.sql'])
9436
9298
  * ```
9437
9299
  */
9438
9300
  async preview(filepaths, output) {
@@ -9509,7 +9371,7 @@ var RunNamespace = class {
9509
9371
  const context = await this.#createRunContext();
9510
9372
  const sqlPath = path.join(
9511
9373
  this.#state.projectRoot,
9512
- this.#state.settings.paths?.sql ?? "sql"
9374
+ this.#state.settings.paths?.sql ?? DEFAULT_SQL_PATH
9513
9375
  );
9514
9376
  const configForMatch = {
9515
9377
  name: this.#state.config.name,