diff --git a/.ai/wheels/testing/browser-testing.md b/.ai/wheels/testing/browser-testing.md index 8de36ff461..b1bfd2acaf 100644 --- a/.ai/wheels/testing/browser-testing.md +++ b/.ai/wheels/testing/browser-testing.md @@ -59,10 +59,11 @@ bash tools/test-local.sh # skips browser specs if JARs missin ## Key gotchas +- **`this.browser` is not wired in plain `describe()` blocks.** Calling any DSL method on `this.browser` outside a `browserDescribe()` block throws `Wheels.BrowserTest.NotWired` (message names `browserDescribe()` as the fix; `detail` names the method that was called). The sentinel `UnwiredBrowserGuard` is installed at construction and after each `$endBrowserContext()` teardown. - **`##` in selectors** — CFML requires `##` to emit literal `#`. `"##email"` → `"#email"` at runtime. - **`client` is a Lucee reserved scope.** `var client = ...` in a closure throws "client scope is not enabled". Use `var c = ...` or `var bc = ...`. (Generalized rule: see CLAUDE.md anti-pattern #11.) - **Data URLs work for most tests** — no server needed for ~95% of DSL coverage. Full HTTP integration (cookies, form submits, redirects) needs a running fixture app; that wiring is the same as Wheels Web app bootstrap (separate server + baseUrl). - **`this.browserTestSkipped`** — when Playwright JARs aren't installed (fresh CI, clean machine), `beforeAll` sets this flag and `browserDescribe`'s hooks short-circuit. All `it`s should check `if (this.browserTestSkipped) return;` to stay green on CI. -- **CI runs browser tests** — `pr.yml` and `snapshot.yml` install Playwright JARs + Chromium (cached via `browser-manifest.json` hash). Browser specs run as part of the normal test suite. `WHEELS_BROWSER_TEST_BASE_URL=http://localhost:60007` is set automatically. +- **CI runs browser tests** — `pr.yml` and `snapshot.yml` install Playwright JARs + Chromium (cached via `browser-manifest.json` hash). Browser specs run as part of the normal test suite. `WHEELS_BROWSER_TEST_BASE_URL=http://localhost:60007` is set automatically. The base URL is resolved at instance time through a layered lookup (`this.baseUrl` → Wheels setting → JVM property `wheels.browserTest.baseUrl` → env var → CGI auto-detect → `http://localhost:8080`); per-spec `this.baseUrl` takes priority over the env var. Set `this.baseUrl` in the component pseudo-constructor (outside any function), not inside `beforeAll()` — `super.beforeAll()` calls `$resolveBaseUrl()` and caches the result, so a `this.baseUrl =` assignment that runs after `super.beforeAll()` is silently ignored. - **Fixture routes** — `/_browser/login-as` and `/_browser/logout` are mounted automatically in test mode. They must come before `.wildcard()` in routes.cfm. In the Routes UI (`/wheels/routes`) all `/_browser/*` routes appear under the **Internal** tab, not Application. - **Dialogs are Lucee-only** — `acceptDialog`, `dismissDialog`, `dialogMessage` use `createDynamicProxy` which is Lucee-specific. Specs skip gracefully on other engines. diff --git a/.ai/wheels/troubleshooting/common-errors.md b/.ai/wheels/troubleshooting/common-errors.md index 7f2a24c1e2..6cac4ced64 100755 --- a/.ai/wheels/troubleshooting/common-errors.md +++ b/.ai/wheels/troubleshooting/common-errors.md @@ -9,6 +9,26 @@ Frequent issues encountered when developing Wheels applications and their soluti - Association syntax has specific Wheels conventions - Migration parameter binding can be unreliable +## Startup / Initialization Errors + +### "Application Error — Wheels failed to initialize" +**Error (on-page):** +``` +Application Error +Wheels failed to initialize. Check the server log for details. +
could not find component or class with name [wheels.Injector]
+``` + +**Cause:** `onApplicationStart` threw before `application.wo` was assigned — typically because the `/wheels` CFML mapping points to a directory that doesn't contain `Injector.cfc`. The `onError` handler now surfaces the original exception text instead of cascading into "The key [WO] does not exist" (fixed in [#2774](https://github.com/wheels-dev/wheels/pull/2774)). + +**Resolution:** +1. Read the `
` block — it contains the original error (missing class, path mismatch, etc.).
+2. Check the server log for the full stack trace from `onApplicationStart`.
+3. Verify the `/wheels` mapping resolves: on a fresh install, `vendor/wheels/Injector.cfc` must exist. If it doesn't, re-run `wheels new` or copy the framework files manually.
+4. Run `wheels reload` (or stop/start the server) to pick up the corrected mapping.
+
+**Note:** Before the #2774 fix, this failure cascaded into a second `[WO] does not exist` exception that hid the real cause. If you see the old cascade on a version that predates this fix (i.e. 4.0.1 or earlier), the underlying cause is always a failed `onApplicationStart` — see above.
+
 ## Common Association Errors
 
 ### "Missing argument name" in hasMany()
diff --git a/.ai/wheels/troubleshooting/shared-dev-databases.md b/.ai/wheels/troubleshooting/shared-dev-databases.md
new file mode 100644
index 0000000000..67de4eff22
--- /dev/null
+++ b/.ai/wheels/troubleshooting/shared-dev-databases.md
@@ -0,0 +1,103 @@
+# Shared Development Databases
+
+Short reference for the orphan-migration case. User-facing version lives at
+`web/sites/guides/src/content/docs/v4-0-0/basics/shared-development-databases.mdx`.
+
+## What's an orphan?
+
+A row in `wheels_migrator_versions` whose `version` timestamp has no matching
+file in `app/migrator/migrations/`. Common cause: shared dev DB, peer ran
+their migration first against the shared DB before their file was merged.
+
+## Detection
+
+`Migrator.cfc::$getOrphanVersions()` — returns an array of orphan version
+strings, sorted ascending. Excludes the sentinel `"0"` returned when the
+tracking table is empty.
+
+## Display
+
+`wheels migrate info` marks orphan rows with `[?]` and the literal
+`********** NO FILE **********` (Rails-style). Includes a footer
+explaining the cause. Rendering logic lives in
+`Migrator.cfc::$buildInfoOutput()` so it's unit-testable without the HTTP
+dispatcher.
+
+## Behavior in `migrateTo()`
+
+If `currentVersion > target` ONLY because of orphans (no local file with
+version > target marked migrated), the down branch is skipped. Either:
+
+- Pending local migrations exist → fall through to up branch with a
+  warning naming the orphans
+- Nothing pending → emit "Nothing to do" naming current vs target and
+  return immediately
+
+If SOME DB versions > target are orphans and SOME have local files, the
+down branch runs as usual but emits a warning naming the orphans (they
+get skipped by the existing loop because it iterates files only).
+
+## Schema enrichment (Plan 3)
+
+`wheels_migrator_versions` carries two extra columns added in 4.0.x:
+`name VARCHAR(255) NULL` and `applied_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP`
+(`TEXT` on SQLite — DEFAULT not supported on existing-table ADD COLUMN).
+Added automatically on first migrator call via `$ensureTrackingColumns()`,
+gated by `application[appKey].$trackingColumnsEnsured` cache so the ALTER
+runs once per app process. Failure is non-fatal (legacy schema still works).
+
+Populated by `$setVersionAsMigrated(version, migrationName)` when:
+- The enriched columns flag is set, AND
+- The caller passes a non-empty `migrationName`
+
+Read by `$getOrphanVersionsWithMeta()` → array of `{version, name, appliedAt}`
+structs. Falls back to bare-version structs when columns aren't ensured
+or the SELECT fails (e.g. concurrent connection hasn't committed the ALTER).
+
+Display via `$buildInfoOutput()` and `cli.cfm`'s `doctor` case render:
+- `[x]  ` for applied local-file rows (timestamps are only
+  surfaced on orphan rows because `getAvailableMigrations()` doesn't
+  re-query the tracking table for applied_at on local files)
+- `[?]  ********** NO FILE **********` for legacy NULL orphans
+- `[?]   (applied )` for enriched orphans
+
+Existing rows (pre-enrichment) get NULL for both columns. Going-forward-only;
+no backfill at bootstrap time.
+
+## Reconciliation commands
+
+Three CLI subcommands for manual reconciliation against the tracking
+table (Flyway `validate` / `repair` / `SkipExecutingMigrations`
+analogues):
+
+- `wheels migrate doctor` — comprehensive health report. Reads
+  `Migrator.doctor()`. Pure read; no mutation. Reports orphans,
+  pending, and applied counts with a human-readable summary.
+- `wheels migrate forget  --yes` — removes a single row
+  from `wheels_migrator_versions`. Requires `--yes`. Refuses if the
+  version has a matching local file (use `migrate down` instead) or
+  if it's not in the tracking table.
+- `wheels migrate pretend  --yes` — inserts a row without
+  running `up()`. Requires `--yes`. Refuses if already applied or
+  if no local file matches.
+
+Implementation:
+- `Migrator.cfc::doctor()`, `forgetVersion()`, `pretendVersion()`
+- `cli.cfm` cases: `doctor`, `forgetVersion`, `pretendVersion`
+- `Module.cfc::runForgetOrPretend()` handles `--yes` gating
+- Tests: `vendor/wheels/tests/specs/migrator/MigratorReconciliationSpec.cfc`
+
+## Related
+
+- Issue #2780 (the original report)
+- PR #2798 (orphan detection + info display + docs, merged 2026-05-22)
+- `vendor/wheels/Migrator.cfc::$getOrphanVersions()`
+- `vendor/wheels/Migrator.cfc::$buildInfoOutput()`
+- `vendor/wheels/Migrator.cfc::doctor()`
+- `vendor/wheels/Migrator.cfc::forgetVersion()`
+- `vendor/wheels/Migrator.cfc::pretendVersion()`
+- `vendor/wheels/tests/specs/migrator/OrphanDetectionSpec.cfc`
+- `vendor/wheels/tests/specs/migrator/MigratorInfoSpec.cfc`
+- `vendor/wheels/tests/specs/migrator/MigratorReconciliationSpec.cfc`
+- Follow-up work (separate PR):
+  - Schema enrichment of `wheels_migrator_versions` (add `name` and `applied_at` columns)
diff --git a/.github/RELEASE_PLAYBOOK.md b/.github/RELEASE_PLAYBOOK.md
index 42d7cac455..2584ab9a0d 100644
--- a/.github/RELEASE_PLAYBOOK.md
+++ b/.github/RELEASE_PLAYBOOK.md
@@ -29,25 +29,45 @@ on them is green. ~5 minutes/day max.
 
 1. **Cherry-pick or merge stabilization commits to `release/X.Y.Z`** if using a
    release-branch workflow. Skip this if cutting directly from develop.
-2. **Update `CHANGELOG.md`** with the release date (replace `=> TBD` for the
-   target version). `release.yml` will refuse to publish if it sees TBD on the
-   target version.
-3. **Verify `box.json` version** matches what you want to release. After the
-   last GA, the `bump-develop-version.yml` workflow set it to next-patch; if
-   you're shipping a bigger bump, update it manually.
+2. **Update `CHANGELOG.md`**: rename `# [Unreleased]` to
+   `# [X.Y.Z](...) => YYYY-MM-DD` with the real release date (replace any
+   `=> TBD`). Then confirm the separator line directly below the renamed
+   section is exactly `---` (three dashes), NOT `----`. `release.yml` refuses
+   to publish if it sees TBD on the target version, and also fails if a `----`
+   separator would make the release-notes awk bleed into the previous version's
+   notes (recurring footgun — see #2606, #2768; now guarded in `release.yml`).
+3. **Verify `wheels.json` version** matches what you want to release.
+   `wheels.json` is the canonical version source since the 4.0 rebrand (the
+   workflows read it; `box.json` is legacy and may be absent or empty). After
+   the last GA, the `bump-develop-version.yml` workflow set it to next-patch;
+   if you're shipping a bigger bump, update it manually.
 
 ### Release day
 
-```bash
-# 1. On main, fast-forward from the release branch.
-git checkout main
-git merge --ff-only release/X.Y.Z   # (or develop, if no release branch)
+`main` has a **"PRs only" ruleset** — `git push origin main` is rejected with
+`GH013`. You also never tag manually: `release.yml` auto-creates the tag via
+`softprops/action-gh-release` once the merge lands on main.
 
-# 2. Tag and push. release.yml fires automatically.
-git tag -a vX.Y.Z -m "Release X.Y.Z"
-git push origin main --tags
+```bash
+# 1. Open a PR into main from a release branch. (Cutting from develop directly
+#    is fine — just branch off develop so the PR has a head ref to merge.)
+git checkout develop && git pull
+git checkout -b release/X.Y.Z-to-main
+git push -u origin release/X.Y.Z-to-main
+gh pr create --base main --head release/X.Y.Z-to-main \
+  --title "Release X.Y.Z" --body "Cut X.Y.Z. See CHANGELOG."
+
+# 2. Merge with "Create a merge commit" (NOT squash — preserves develop
+#    history on main).
+gh pr merge --merge --delete-branch
 ```
 
+The push-to-main from that merge triggers `release.yml`, which builds the
+artifacts and calls `softprops/action-gh-release` with `tag_name: vX.Y.Z`,
+creating the tag at main HEAD automatically. Do **not** run `git tag` /
+`git push --tags` — the legacy `git push origin main --tags` flow is rejected
+by the ruleset anyway.
+
 The tag push triggers:
 - `release.yml` builds artifacts, publishes to `wheels-dev/wheels/releases`
 - `release.yml`'s "Dispatch downstream package managers" step then fires
@@ -149,7 +169,8 @@ If any pin to `_amd64.deb` and the apt-wheels-dev / yum-wheels-dev
+        # ships `wheels__amd64.deb` and the apt-wheels / yum-wheels
         # receiver workflow 404s on its `wheels-be__amd64.deb` fetch.
         # RC builds keep CHANNEL=stable — RCs aren't fanned out to apt/yum
         # (see release.yml § "Dispatch Linux repo buckets").
@@ -588,8 +605,8 @@ jobs:
       #############################################
       # Dispatch Linux repo buckets (apt.wheels.dev / yum.wheels.dev)
       #
-      # Fires repository_dispatch on wheels-dev/apt-wheels-dev and
-      # wheels-dev/yum-wheels-dev so their metadata-regen workflows pick up
+      # Fires repository_dispatch on wheels-dev/apt-wheels and
+      # wheels-dev/yum-wheels so their metadata-regen workflows pick up
       # the new .deb / .rpm asset and republish apt.wheels.dev /
       # yum.wheels.dev within minutes. See issue #2605.
       #
@@ -616,7 +633,7 @@ jobs:
           fi
 
           # Same channel derivation as the homebrew/scoop dispatch.
-          # apt-wheels-dev / yum-wheels-dev route on `channel` in the payload
+          # apt-wheels / yum-wheels route on `channel` in the payload
           # to slot the .deb/.rpm into the right pool (stable vs bleeding-edge).
           # RC releases aren't currently published to apt/yum, so we skip them.
           if echo "${WHEELS_RELEASE_VERSION}" | grep -qiE '\-snapshot[\.\+]'; then
@@ -632,7 +649,7 @@ jobs:
           # Static event type, fixed repo list — no untrusted input flows into
           # the curl arguments. WHEELS_RELEASE_VERSION is generated earlier in
           # the same workflow from wheels.json + run number.
-          for repo in apt-wheels-dev yum-wheels-dev; do
+          for repo in apt-wheels yum-wheels; do
             echo "Dispatching wheels-released to wheels-dev/${repo}..."
             http_status=$(curl -sS -o /tmp/linux-dispatch-resp.txt -w "%{http_code}" \
               -X POST \
diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml
index 35ec475128..5d5d913222 100644
--- a/.github/workflows/snapshot.yml
+++ b/.github/workflows/snapshot.yml
@@ -215,7 +215,7 @@ jobs:
       FORGEBOX_PASS: ${{ secrets.FORGEBOX_PASS }}
       DOWNSTREAM_DISPATCH_TOKEN: ${{ secrets.DOWNSTREAM_DISPATCH_TOKEN }}
       # Optional: when present, the build job's "Dispatch Linux repo buckets"
-      # step fires repository_dispatch on apt-wheels-dev / yum-wheels-dev so
+      # step fires repository_dispatch on apt-wheels / yum-wheels so
       # the bleeding-edge channel of apt.wheels.dev / yum.wheels.dev tracks
       # every develop push. When absent, the step skips silently. See #2605.
       LINUX_REPO_DISPATCH_TOKEN: ${{ secrets.LINUX_REPO_DISPATCH_TOKEN }}
diff --git a/.github/workflows/web-deploy.yml b/.github/workflows/web-deploy.yml
index c65ddc4997..ef1a8ea07d 100644
--- a/.github/workflows/web-deploy.yml
+++ b/.github/workflows/web-deploy.yml
@@ -52,6 +52,14 @@ jobs:
 
       - name: Build ${{ matrix.site }}
         working-directory: web
+        env:
+          # `sites/packages` calls the GitHub REST API at build time to
+          # enumerate the package registry. Without auth that's capped at
+          # 60 req/hour per runner IP, and rapid CI activity on a single
+          # branch trips the limit (failure mode: 403 from the contents API,
+          # build aborts mid-prerender). Passing GITHUB_TOKEN raises the cap
+          # to 5000/hour. Other site builds ignore the var.
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
         run: pnpm --filter @wheels-dev/site-${{ matrix.site }} build
 
       - name: Deploy to Cloudflare Pages
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 219c217a87..f0607a5b39 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,46 @@ All historical references to "CFWheels" in this changelog have been preserved fo
 
 ----
 
+# [4.0.2](https://github.com/wheels-dev/wheels/releases/tag/v4.0.2) => 2026-05-27
+
+> **Wheels 4.0.2** — second patch on the 4.0 line. Adds shared-development-database migrator reconciliation (`wheels migrate doctor` / `forget` / `pretend`, orphan-version auto-detection, and `name` / `applied_at` enrichment of the `wheels_migrator_versions` tracking table) plus `columnNames` aliases across `t.references()`, `t.primaryKey()`, and the `Migration.cfc` command helpers; ships native GPG-signed Linux package repositories at `apt.wheels.dev` and `yum.wheels.dev` (Cloudflare R2); resolves `BrowserTest` base URLs through a layered instance-time lookup; and greens the compatibility matrix across BoxLang and Adobe ColdFusion 2023/2025. ~30 PRs since the 4.0.1 GA (2026-05-20).
+
+### Added
+
+- Native Linux package repositories are now live at `apt.wheels.dev` and `yum.wheels.dev`, GPG-signed and served from Cloudflare R2. Debian/Ubuntu installs fetch the key with `curl -fsSL https://apt.wheels.dev/wheels.gpg | sudo tee /usr/share/keyrings/wheels.gpg`, add a `deb [signed-by=/usr/share/keyrings/wheels.gpg] https://apt.wheels.dev stable main` source, then `sudo apt install wheels`; Fedora/RHEL installs add the repo via `dnf config-manager --add-repo https://yum.wheels.dev/wheels.repo` then `dnf install wheels`, and upgrades collapse to a single `apt upgrade wheels` / `dnf upgrade wheels` with no version pinning. The buckets are backed by R2 rather than Cloudflare Pages because the `.deb` (80 MB) and `.rpm` (81 MB) artifacts exceed Pages' 25 MiB per-file limit, while R2 has no object-size limit and still supports custom-domain serving. The install and release-channel guides now lead with the native sources, keeping the one-off GitHub-Release download behind an aside for air-gapped use (#2814)
+- `t.primaryKey()` in the migrator now accepts `columnName` and `columnNames` as aliases for the legacy `name` parameter, matching the argument-naming convention of every other column helper in `TableDefinition.cfc`. The legacy `name=` form keeps working (it's still what `init()` passes when adding the conventional `id` primary key). Plural `columnNames` wins when both aliases are supplied, mirroring `addReference()` / `dropReference()` precedence semantics. Unlike sibling helpers, `columnNames` here does NOT accept a comma-separated list — `primaryKey()` always creates one PK column, so `columnNames="a,b"` produces a single column literally named `a,b`; call `t.primaryKey()` multiple times for composite PKs (#2812)
+- `t.references()` in the migrator now accepts `columnNames` as an alias for the legacy `referenceNames` argument, matching every sibling column helper (`t.string`, `t.integer`, …) that uses `$combineArguments` to take both the plural and singular forms. The new `useUnderscoreReferenceColumns` setting (boolean, framework default `false`, `wheels new` template default `true`) controls whether `t.references(columnNames="user")` produces `user_id` (matching Wheels model `belongsTo` defaults) or the legacy `userid` (no underscore) suffix; polymorphic references follow the same flag for the `_type` / `type` column. `Migration.cfc::addReference()` and `removeColumn(referenceName=)` respect the flag too. Existing apps keep working unchanged since the framework default is `false`; only new apps generated by `wheels new` opt into the underscore form (#2802)
+- The command-version migrator helpers in `Migration.cfc` now accept the same plural/singular column-name aliases as the `TableDefinition` helpers fixed in #2802, via `$combineArguments`: `addColumn` / `changeColumn` / `removeColumn` take `columnNames` as an alias for `columnName`, `addReference` / `dropReference` take `columnName` / `columnNames` as aliases for `referenceName`, and `addForeignKey` takes `columnName` as an alias for `column`. Legacy parameter names keep working. Two hard-coded `& "id"` concatenations (in `removeColumn` and `addReference`) now route through `useUnderscoreReferenceColumns`, so an app that opted into the underscore convention and created `user_id` via `t.references()` can also drop or constrain that column through the command-version helpers. `changeColumn`'s original required-argument enforcement is preserved for non-reference column types via a conditional `required` on `$combineArguments` (#2804)
+- Two new advisory checks in `wheels upgrade check`: a `t.references()` opt-in suggestion (fires when an app uses `t.references(` in migrations without `set(useUnderscoreReferenceColumns=true)`) and a mixed-convention warning (fires when the flag is set so users audit legacy migrations for `id` columns that pre-date the opt-in). Both surface in the "Recommended Improvements" section introduced by the upgrade-tier scaffolding — advisory severity, never gates CI. The opt-in advisory is suppressed when the flag is already set in `config/settings.cfm` to avoid contradicting the mixed-convention warning. The shared grep loop now strips CFML comments before pattern matching (Anti-Pattern #14), so commented-out code can't trip any check — a framework-wide improvement that benefits every existing breaking-change check too (#2807)
+- Three new `wheels migrate` subcommands for manual reconciliation against the tracking table — Flyway `validate` / `repair` / `SkipExecutingMigrations` analogues. `wheels migrate doctor` prints a single-command health report covering applied/pending/orphan versions plus a human-readable summary; pure read, never mutates. `wheels migrate forget  --yes` removes a single orphan row from `wheels_migrator_versions` (refuses if a matching local file exists — use `migrate down` for legitimate rollbacks — and refuses if the version isn't in the table). `wheels migrate pretend  --yes` records a version as applied without running its `up()` (refuses if already applied or if no local file matches, so future `down()` calls still work). Both `forget` and `pretend` require explicit `--yes` to mutate; without it they print what would happen and exit. Implementation lives in `Migrator.cfc::doctor()`, `forgetVersion()`, `pretendVersion()`. Covers the shared-dev-DB pattern surfaced in #2780 beyond what the orphan auto-detection in #2798 could resolve automatically (#2799)
+
+### Changed
+
+- `wheels upgrade check` output now distinguishes opt-in recommendations from breaking changes. Each check struct in `cli/lucli/Module.cfc::runUpgradeCheck()` accepts a new optional `severity` field (default `"breaking"`, the existing behavior); matches with `severity: "advisory"` bucket into a separate `Recommended Improvements (N found):` section that renders alongside `Breaking Changes (N found):` and `All Clear (N checks):`. The same-major short-circuit was also removed so advisories can fire on point-release upgrades — the "no known breaking changes" message still prints, but execution continues into the checks loop. Scaffolding-only; advisory-annotated entries land in a follow-up PR (#2805)
+- `wheels_migrator_versions` gains two additive nullable columns: `name VARCHAR(255)` (the migration's human-readable name, e.g. `create_users`) and `applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP` (when the migration was applied). Added automatically on the first migrator call after upgrade via the new `Migrator.$ensureTrackingColumns()` helper — idempotent, gated by `application[appKey].$trackingColumnsEnsured` so the ALTER runs once per app process, and non-fatal (legacy schema continues to work if the ALTER fails). Newly applied migrations populate both columns; existing rows pre-dating the enrichment stay NULL. `wheels migrate info` and `wheels migrate doctor` now show `[?]   (applied )` for orphan rows when the columns are populated, instead of just the literal `********** NO FILE **********` — letting you see *what* a peer applied and *when* even though the file isn't in your branch yet. SQLite skips the column DEFAULT (not supported on existing-table ADD COLUMN) and gets explicit timestamps from CFML on insert. Per-engine SQL covers MySQL, PostgreSQL, SQLite, MSSQL, Oracle, H2, and CockroachDB (#2800)
+
+### Fixed
+
+- The Wheels Compatibility Matrix is green again for BoxLang and Adobe CF 2023/2025 ahead of the v4.0.2 cut. **BoxLang** (17 fail / 72 error on every database) traced to a single root cause: `Global.cfc`'s pseudo-constructor seeded `local.varKey = ""` for its include-injected-UDF promotion loop, and BoxLang materializes that as `variables.local`, which then shadows the function-local `local` scope of every mixed-in `$`-helper — so `Migrator`/`Model` calls like `local.appKey = $appKey()` resolved against `{varKey}` and threw `KeyNotFoundException: The key [APPKEY] was not found in the struct. Valid keys are ([VARKEY])`. Lucee/Adobe keep `local` reserved to the function scope so they never saw it; the loop now lives in a real function (`$promoteIncludedGlobalsToThis()`). **Adobe CF 2023/2025** were crashing the whole suite (HTTP 404 + ~1MB HTML prefix corrupting the result JSON) because `InvokeMethodSpec` invoked `Public.index()`, rendering the congratulations welcome page into the test-runner response buffer, which Adobe commits mid-run — now captured with `cfsavecontent`. Five further Adobe-specific bugs: `RequestId` middleware wrote `request.wheels.requestId` through a `request` parameter that shadows the scope on Adobe (Anti-Pattern #11, now a `request`-less helper); `TestClient` emitted a `cfhttp` POST with no `cfhttpparam` for empty bodies (Adobe requires at least one); `ParallelRunner.$collectFailures` relied on array-by-reference mutation that Adobe passes by value (returns the array now); `$reincludeGlobals` re-`include`d an already-bound file, tripping "Routines cannot be declared more than once" (now evaluated in a throwaway `GlobalIncludeLoader`); `RewriteConfigInstallerSpec` compared file content to a literal string where Adobe 2025's `fileWrite`/`fileRead` round-trip appends a newline (compares to a normalized baseline now); and `OuterTransactionSignalSpec`'s cleanup `queryExecute` bound the row id as `cf_sql_integer`, which overflows on Adobe when CockroachDB's default `unique_rowid()` PK returns a ~60-bit value (`cf_sql_bigint` now). Verified on the full matrix CI across all engines × databases (#2817)
+- `model().create()` / `.update()` / `.deleteAll()` inside a migration's `up()` or `down()` no longer silently roll back. The Migrator wraps every `up()` / `down()` invocation in its own outer `cftransaction`, and Wheels Model's default `transaction="commit"` opened a nested `cftransaction` whose JDBC nested-transaction semantics differ per adapter — most acutely on MSSQL, where the inner commit didn't release the row and the eventual outer commit dropped it, leaving the user with orphan FK rows referencing IDs that never persisted. `Migrator.cfc` now sets `request.$wheelsTransactionWrapper = true` around each `up()` / `down()` / `migrateIndividual()` invocation and `Model::invokeWithTransaction()` reads that signal, treating the call as `alreadyopen` and skipping the nested `cftransaction` entirely. The flag is request-scope and cleared in both the success and error paths of every migrator code path, so it never leaks past the migration's outer transaction (#2810)
+- `migrateIndividual()` no longer issues a spurious `transaction action="commit"` on the error path after its `catch` block has already issued `transaction action="rollback"`. Unlike `migrateTo()`'s loops there was no enclosing `for` to `break` out of, so control always fell through to the commit; the fix `return`s from inside the `catch` after the rollback, mirroring `migrateTo()`'s early-exit. On Lucee the second action against a closed transaction is a silent no-op, but on Adobe CF 2023/2025 (and potentially BoxLang) the JDBC driver could throw a "transaction not active" error that masked the real migration failure. Follow-up to #2810 (#2813)
+- `wheels upgrade check` underscore-references opt-in advisory no longer contradicts the mixed-convention warning when `set(useUnderscoreReferenceColumns=true)` lives in an environment override file (e.g. `config/production/settings.cfm`) rather than the root `config/settings.cfm`. The pre-check guard in `cli/lucli/Module.cfc::runUpgradeCheck()` now walks all of `config/` recursively (matching advisory #2's `scanDir: "config"` scope) instead of reading only `config/settings.cfm`, so the opt-in advisory is suppressed whenever the flag is set anywhere in the config tree. Comment-stripping still runs per file so a commented-out `set(useUnderscoreReferenceColumns=true);` cannot satisfy the guard (Anti-Pattern #14) (#2809)
+- `wheels migrate latest` no longer takes a misleading "down" branch and silently no-ops when `wheels_migrator_versions` records a version whose migration file isn't in the current checkout (shared dev DB / peer migration not yet pulled). `Migrator.migrateTo()` now diffs the tracking table against `app/migrator/migrations/` via the new `$getOrphanVersions()` helper and branches on orphan-at-top before the existing direction check — applying pending local migrations with a clear warning when all DB versions above target are orphans, emitting "Nothing to do" naming target vs current when none are pending, and warning + letting the existing down loop continue in mixed cases (orphan rows skip naturally because the loop iterates files). `wheels migrate info` also now shows orphan rows with `[?]  ********** NO FILE **********` (Rails-style) and a footer explaining the cause (#2798)
+- `tools/test-local.sh` silently aborted with `EXIT=1` (no `/tmp/wheels-test-server.log` written, no diagnostic printed) on every install since the `lucli` → `wheels` rebrand window closed — i.e. anyone whose `~/.lucli/express/` directory never existed. Line 81 ran `LUCEE_LIB=$(find ~/.wheels/express ~/.lucli/express -path "*/lib/ext" -type d 2>/dev/null | head -1)` under `set -euo pipefail`; `find` exits non-zero whenever any path argument doesn't exist (stderr suppressed via `2>/dev/null`, but the exit status survives), `pipefail` propagated it through `head -1`, and the assignment tripped `set -e`. The cleanup trap then fired with no server to clean up, leaving the user staring at "Starting Wheels CLI server on port 8080…" with no further output. Dropped the now-dead `~/.lucli/express` fallback (the rename landed in 3.0 and recent CLI releases extract Lucee Express to `~/.wheels/express/` only) and added `|| true` for defense in depth so a missing directory (e.g. a truly fresh install before `wheels start` has ever run) leaves `LUCEE_LIB` empty and the downstream `[ -n "$LUCEE_LIB" ]` guard skips the JDBC pre-install cleanly (#2796)
+- Routes registered inside `.namespace("foo")` (or equivalent `.scope()` / `.package()`) with a redundant namespace prefix in the controller path — e.g. `to="foo/dashboard##index"` instead of `to="dashboard##index"` — previously silently produced a `foo.foo/dashboard` lookup that downstream flattened to a `Foodashboard`-style class name with an opaque `Wheels.ViewNotFound` error. The Mapper now rejects this at route-registration time with `Wheels.MapperArgumentInvalid`, naming the namespace and the offending value and pointing at the correct shorter form, so users can find the bad route definition instead of chasing the symptom (#2794)
+- `WheelsTest` auto-bind missed user-defined global helpers added via `include` in `app/global/functions.cfm`. The pseudo-constructor used `getMetaData(application.wo).functions`, which only enumerates methods declared directly on the CFC and skips symbols merged in via `cfinclude`. Specs that called custom helpers (e.g. `can()`, `hasRole()`) had to manually rebind each one in `beforeAll()`. The auto-bind now iterates `application.wo` as a struct and binds every UDF via `isCustomFunction()`, preserving the existing public-only filter for declared methods (#2793)
+- Model layer SELECT clause builder now routes column identifiers through the adapter's `$quoteIdentifier`, so reserved-word column names (e.g. `key`, `order`, `group`) survive on every supported dialect instead of breaking `findAll` / `findOne` / dynamic finders with cryptic SQL syntax errors. The WHERE / ORDER BY paths already quoted columns; `$createSQLFieldList` and the empty-pagination column-list extraction in `read.cfc` now match (#2787)
+- `wheels packages install ` is now a transparent alias for `wheels packages add ` on every caller path that actually reaches `Module.cfc` (stdio MCP server, scripted in-process clients, spec suite). Previously the dispatch layer's `case "install":` branch printed a yellow warning to stdout and returned `""` without installing anything — so even though `PackagesMainCli.install()` itself had been a true alias for `add()` since #2729, any caller routing through the CLI module's verb dispatch silently no-op'd. The shell-facing `wheels packages install ` form is still intercepted by LuCLI's built-in extension installer upstream of module dispatch and remains broken on that path (and is documented as such in the module-owned help text), but MCP tool calls and programmatic callers now behave identically to `add`. Both branches now share a single fall-through body so the validation, error shape, and install behavior cannot drift apart again (#2786)
+- `wheels.wheelstest.BrowserTest` now throws a clear `Wheels.BrowserTest.NotWired` error — naming `browserDescribe()` as the fix — when a spec calls a DSL method on `this.browser` from a plain `describe()` block. Previously the uninitialized `this.browser` was an empty string, producing the misleading `function [visitUrl] does not exist in the String` on every newcomer's first BrowserTest spec. A sentinel `UnwiredBrowserGuard` is now installed at `this.browser` before `browserDescribe()` wires the real `BrowserClient` and after `$endBrowserContext()` tears it down (#2782)
+- `BrowserTest`'s default base URL is no longer hardcoded to `http://localhost:8080`. `$resolveBaseUrl()` now consults a layered lookup at instance time: `this.baseUrl` (per-spec override, set in the component pseudo-constructor) → `get("browserTestBaseUrl")` (Wheels setting) → `-Dwheels.browserTest.baseUrl=...` (JVM system property) → `WHEELS_BROWSER_TEST_BASE_URL` env var → `cgi.server_name`/`cgi.server_port` auto-detect → `http://localhost:8080` default. Specs running against a non-8080 server (Titan on 60050, `wheels new` scaffolds on 60080) can set `this.baseUrl` in the pseudo-constructor or rely on the CGI auto-detect instead of comparing `getBaseUrl()` against a sentinel string. The bare-env-var approach still works for CI but is no longer the only escape hatch (the JVM caches env vars at process start, so post-launch `export` had no effect). Regression spec at `vendor/wheels/tests/specs/wheelstest/BrowserTestBaseUrlResolutionSpec.cfc` (#2783)
+- Linux `.deb` / `.rpm` packages double-nested the framework at `/opt/wheels/module/vendor/wheels/wheels/` instead of `/opt/wheels/module/vendor/wheels/`. `wheels-core-VER.zip` carries a top-level `wheels/` directory that `unzip` preserves; the nfpm `type: tree` rule then copied the entire `build/framework/` tree (wrapper and all) into the destination, leaving `Injector.cfc` one level too deep. Every fresh `wheels new` install on Ubuntu/Fedora then crashed on first request with `could not find component or class with name [wheels.Injector]`, cascading into the cryptic `The key [WO] does not exist.` error in `onError`. The brew formula handles this correctly via `(share/"wheels/framework/wheels").install Dir["*"]`; the Linux nfpm configs now pin `src` at `./build/framework/wheels/` to match. Regression spec at `vendor/wheels/tests/specs/cli/LinuxPackageStagingSpec.cfc` (#2776)
+- `onError` in the generated app template and demo `public/Application.cfc` now guards `application.wo` with `StructKeyExists(application, "wo")` after the recovery try/catch. When `new wheels.Injector(...)` fails during `onApplicationStart` (e.g. a stale `/wheels` mapping under Lucee Express 7), the original error is preserved via a minimal HTML fallback instead of cascading into the cryptic "The key [WO] does not exist" exception that hit "Your First 15 Minutes" tutorial users on fresh installs (#2774)
+- Bare `?reload=true` now auto-detects changes to `app/global/*.cfm` in development and re-evaluates `app/global/functions.cfm` (plus any files it ``s) when any tracked file's mtime has changed, so new helpers added to `app/global/functions.cfm` (or any file it includes) are picked up without the password-gated `applicationStop()` path. Wheels snapshots the include directory on application start and re-evaluates the include via a new `application.wo.$reincludeGlobals()` hook when `$globalIncludesChanged()` detects an added/removed/touched file. Opt out with `set(reloadOnGlobalChange=false)` in `config/settings.cfm` (#2795)
+- The documented Linux bleeding-edge install commands in the guides 404'd after #2759 renamed the snapshot artifacts from `wheels_*` to `wheels-be_*` (deb) / `wheels-be-*` (rpm) to differentiate the channel by package name. All six affected pages — three unique (`start-here/installing`, `start-here/release-channels`, `command-line-tools/installation`) mirrored across the v4-0-0 and v4-0-1-snapshot doc versions — now point at the correct `wheels-be_*` assets, and the "switching channels" snippets reflect the real `Conflicts: wheels` package metadata instead of assuming same-package-name `--allow-downgrades` / `dnf downgrade` transitions (#2777)
+- Stale `schema_migrations` table-name references in the v4 migration and seeding guides (`v4-0-0/basics/seeding`, `v4-0-1-snapshot/basics/migrations`, `v4-0-1-snapshot/basics/seeding`) now read `wheels_migrator_versions`, matching the on-disk table name since the `c_o_r_e_*` → `wheels_*` rename — no `schema_migrations` table exists anywhere in `vendor/wheels/`, `cli/`, or `app/`. Carryover from #2799 (#2801)
+
+---
+
 # [4.0.1](https://github.com/wheels-dev/wheels/releases/tag/v4.0.1) => 2026-05-20
 
 > **Wheels 4.0.1** — first patch on the 4.0 line. Hardens Adobe ColdFusion 2023/2025 compatibility (Adobe-specific `cfheader` attributeCollection rejection, `env()` reserved-word parameter, Vite asset-walk array-by-value), fixes the Windows Scoop install regressions (`wheels.cmd` cmd.exe pre-parser, `.zip.sha512` sidecar layout), and adds `viewStyle` framework presets to `paginationNav()` plus plural `mappings` aliases to `package.json`. ~100 PRs since the 4.0.0 GA (2026-05-12).
diff --git a/CLAUDE.md b/CLAUDE.md
index 93f1266760..3b41faa55d 100755
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -221,6 +221,25 @@ Any validator, analyzer, scanner, or upgrade-check that does substring-matching
 - `cli/lucli/Module.cfc::stripCfmlComments()`
 - `cli/lucli/services/Doctor.cfc::$stripCfmlBlockComments()`
 
+### 15. Migrator helpers accept singular AND plural column names — prefer the plural
+**Source:** [#2781](https://github.com/wheels-dev/wheels/issues/2781) (`t.references()`) + [#2803](https://github.com/wheels-dev/wheels/issues/2803) (`t.primaryKey()`) — these two helpers were the last outliers in `TableDefinition.cfc`. Every sibling helper accepted `columnNames` / `columnName` via `$combineArguments`, but `references` required `referenceNames` and `primaryKey` required `name`. AI agents and humans both kept reaching for the consistent form and hitting "argument required" errors. Now resolved: both accept `columnNames` as an alias, and that's the preferred form going forward.
+
+```cfm
+// RIGHT — modern, matches every other column helper
+t.string(columnNames="name");
+t.integer(columnNames="age");
+t.references(columnNames="user");
+t.primaryKey(columnNames="userId", autoIncrement=true);
+
+// LEGACY — still works, but the new code path uses columnNames
+t.references(referenceNames="user");
+t.primaryKey(name="userId", autoIncrement=true);
+```
+
+For new migrator helpers or anywhere you accept a column-name argument: declare `string columnNames` (NOT `required`), and call `$combineArguments(args=arguments, combine="columnNames,columnName", required=true)` at the top of the body. The pattern is documented in [vendor/wheels/migrator/CLAUDE.md](vendor/wheels/migrator/CLAUDE.md). Boolean nullable flag is `allowNull` everywhere — never `null`.
+
+`t.references()` also respects `useUnderscoreReferenceColumns` (boolean, framework default `false`, `wheels new` template default `true`) — when true it produces `_id` / `_type` columns matching Wheels model `belongsTo` defaults.
+
 ## Wheels Conventions
 
 - **config()**: All model associations/validations/callbacks and controller filters/verifies go in `config()`.
@@ -540,6 +559,20 @@ Specs extend `wheels.wheelstest.BrowserTest`. Install Playwright once: `wheels b
 
 ## Migrations & Seeding
 
+### Shared Dev DB Reconciliation
+
+`wheels_migrator_versions` can drift from on-disk files when several developers share a single dev database (peer applied a migration whose file isn't yet in your branch). Detected and surfaced automatically; reconciliation is explicit:
+
+- `wheels migrate latest` — when a peer's tracked version sits above your latest local file, it now applies pending local migrations with a warning instead of silently no-op'ing on a "down" branch.
+- `wheels migrate info` — orphan rows render as `[?]   (applied )` when the enriched `wheels_migrator_versions.name` / `.applied_at` columns are populated, or `[?]  ********** NO FILE **********` (Rails-style) for legacy rows.
+- `wheels migrate doctor` — single-command health report. Lists orphans + pending; pure read.
+- `wheels migrate forget  --yes` — delete a stale tracking row (refuses if a matching local file exists, refuses if version not in table).
+- `wheels migrate pretend  --yes` — record a version as applied without running `up()` (refuses if already applied or no matching file).
+
+Tracking-table schema: `wheels_migrator_versions(version, core_level, name, applied_at)`. The `name` and `applied_at` columns are additive (NULL for legacy rows) and added automatically via `$ensureTrackingColumns()` on first migrator call after upgrade. Both columns are populated by `$setVersionAsMigrated(version, migrationName)` going forward; existing rows stay NULL and display version-only.
+
+Both `forget` and `pretend` are dry-run by default; `--yes` is required to mutate. Helpers live on `Migrator.cfc`: `$getOrphanVersions()`, `$getOrphanVersionsWithMeta()`, `doctor()`, `forgetVersion()`, `pretendVersion()`, `$buildInfoOutput()`, `$ensureTrackingColumns()`. Deep reference: [.ai/wheels/troubleshooting/shared-dev-databases.md](.ai/wheels/troubleshooting/shared-dev-databases.md). User-facing guide: `web/sites/guides/src/content/docs/v4-0-0/basics/shared-development-databases.mdx`. Shipped across #2798, #2799, and the schema enrichment PR.
+
 ### Auto-Migration
 
 Generate migrations from model/DB schema diffs. Rename detection via explicit hints (authoritative) + heuristic suggestions (normalized-token + Levenshtein).
@@ -688,7 +721,8 @@ Prefer MCP tools when the Wheels MCP server is available. Fall back to CLI other
 | Task | MCP | CLI |
 |------|-----|-----|
 | Generate | `wheels_generate(type, name, attributes)` | `wheels g model/controller/scaffold Name attrs` |
-| Migrate | `wheels_migrate(action="latest\|up\|down\|info")` | `wheels migrate latest\|up\|down\|info` |
+| Migrate | `wheels_migrate(action="latest\|up\|down\|info\|doctor")` | `wheels migrate latest\|up\|down\|info\|doctor` |
+| Migrator reconciliation | — | `wheels migrate forget\|pretend  --yes` (shared dev DB orphan cleanup; see #2780) |
 | Test | `wheels_test()` | `wheels test run` |
 | Reload | `wheels_reload()` | `?reload=true&password=...` |
 | Server | `wheels_server(action="status")` | `wheels start\|stop\|status` |
@@ -711,6 +745,7 @@ Search `.ai/` for deeper documentation:
 - [.ai/wheels/channels/channels.md](.ai/wheels/channels/channels.md)
 - [.ai/wheels/snippets/model-snippets.md](.ai/wheels/snippets/model-snippets.md), [controller-snippets.md](.ai/wheels/snippets/controller-snippets.md)
 - [.ai/wheels/troubleshooting/common-errors.md](.ai/wheels/troubleshooting/common-errors.md), [form-helper-errors.md](.ai/wheels/troubleshooting/form-helper-errors.md)
+- [.ai/wheels/troubleshooting/shared-dev-databases.md](.ai/wheels/troubleshooting/shared-dev-databases.md) — Orphan-version handling + `migrate doctor` / `forget` / `pretend` reconciliation commands (#2780)
 - [.ai/cfml/](.ai/cfml/) — CFML language reference (syntax, components, control flow)
 
 **External:** user-facing guides at `web/sites/guides/src/content/docs/v4-0-0/` (deployment, command-line-tools/mcp-integration, etc.) — these ship to guides.wheels.dev. Use when you need the version Wheels users read.
diff --git a/cli/lucli/Module.cfc b/cli/lucli/Module.cfc
index b4849313b0..9c00b6255a 100644
--- a/cli/lucli/Module.cfc
+++ b/cli/lucli/Module.cfc
@@ -347,6 +347,17 @@ component extends="modules.BaseModule" {
 					out("Migration failed: #e.message#", "red");
 					return "";
 				}
+			case "doctor":
+				try {
+					return runMigration("doctor");
+				} catch (MigrationError e) {
+					out("Doctor failed: #e.message#", "red");
+					return "";
+				}
+			case "forget":
+				return runForgetOrPretend("forgetVersion", args);
+			case "pretend":
+				return runForgetOrPretend("pretendVersion", args);
 			case "rename-system-tables":
 				// F15 Phase 2: opt-in one-shot rename of legacy c_o_r_e_*
 				// system tables to wheels_*. Idempotent (no-op when nothing
@@ -363,7 +374,7 @@ component extends="modules.BaseModule" {
 				}
 			default:
 				out("Unknown migration action: #action#", "red");
-				out("Usage: wheels migrate [latest|up|down|info|rename-system-tables]");
+				out("Usage: wheels migrate [latest|up|down|info|doctor|forget|pretend|rename-system-tables]");
 				return "";
 		}
 	}
@@ -2089,6 +2100,20 @@ component extends="modules.BaseModule" {
 				opts.name = positional[2];
 				var mainCli = new modules.wheels.services.packages.PackagesMainCli();
 				return mainCli.show(opts);
+			case "install":
+				// LuCLI's built-in extension installer intercepts the
+				// literal verb `install` on the user-facing CLI surface
+				// — same trap that bit `wheels browser install` (renamed
+				// to `wheels browser setup` in #2345). But every other
+				// caller path reaches this dispatch directly: the
+				// stdio MCP server (`wheels mcp wheels`), scripted
+				// in-process clients, and the bundle's own spec suite.
+				// `PackagesMainCli.install()` has been a transparent
+				// alias for `add()` since #2729, so the dispatch layer
+				// must match — otherwise `install ` silently
+				// no-ops on the only paths LuCLI does NOT intercept.
+				// Fall through to the `add` branch (same validation,
+				// same error shape, same install behavior).
 			case "add":
 				if (arrayLen(positional) < 2) {
 					throw(message="add requires a name: wheels packages add [@]");
@@ -2096,20 +2121,6 @@ component extends="modules.BaseModule" {
 				opts.target = positional[2];
 				var mainCli = new modules.wheels.services.packages.PackagesMainCli();
 				return mainCli.add(opts);
-			case "install":
-				// Dead code on the CLI surface: LuCLI's built-in extension
-				// installer intercepts the literal verb `install` across
-				// all modules before dispatch reaches Module.cfc — the
-				// same trap that bit `wheels browser install` (renamed
-				// to `wheels browser setup` in #2345). Kept as a
-				// documentation marker for future maintainers; if LuCLI
-				// ever stops intercepting, this case keeps a friendly
-				// redirect for users still typing the historic verb.
-				out("'wheels packages install' is intercepted by LuCLI's", "yellow");
-				out("built-in extension installer and won't reach this module.", "yellow");
-				out("Use:", "yellow");
-				out("  wheels packages add " & (arrayLen(positional) >= 2 ? positional[2] : ""), "bold");
-				return "";
 			case "update":
 				opts.target = arrayLen(positional) >= 2 ? positional[2] : "";
 				var mainCli = new modules.wheels.services.packages.PackagesMainCli();
@@ -3336,6 +3347,7 @@ component extends="modules.BaseModule" {
 			case "up":     command = "migrateUp"; break;
 			case "down":   command = "migrateDown"; break;
 			case "info":   command = "info"; break;
+			case "doctor": command = "doctor"; break;
 		}
 
 		var migrateUrl = "http://localhost:#serverPort#/wheels/cli?command=#command#&format=json";
@@ -3355,12 +3367,88 @@ component extends="modules.BaseModule" {
 		// the previous code silently treated it as success. See issue #2315.
 		var result = parseCliResponse(httpResult, "Migration #action#");
 
+		// For `doctor`, switch the output color to yellow when the report
+		// signals unhealthy state (orphans or pending migrations). Green
+		// on an unhealthy result reads as "everything's fine" when it
+		// isn't. Other actions stay green on success.
+		var color = "green";
+		if (arguments.action == "doctor" && structKeyExists(result, "healthy") && !result.healthy) {
+			color = "yellow";
+		}
+
 		if (structKeyExists(result, "message") && len(result.message)) {
-			out(result.message, "green");
+			out(result.message, color);
 		} else {
-			out("Migration #action# completed.", "green");
+			out("Migration #action# completed.", color);
+		}
+
+		return "";
+	}
+
+	private string function runForgetOrPretend(required string command, required array args) {
+		// `forget` and `pretend` require an explicit  arg plus
+		// `--yes` to confirm. Default behavior is to print what would
+		// happen and refuse without the flag. See issue #2780.
+		var version = "";
+		var yes = false;
+		for (var i = 2; i <= arrayLen(arguments.args); i++) {
+			var a = arguments.args[i];
+			if (a == "--yes" || a == "-y") {
+				yes = true;
+			} else if (!a.startsWith("--")) {
+				version = a;
+			}
+		}
+
+		var verb = arguments.command == "forgetVersion" ? "forget" : "pretend";
+
+		if (!Len(version)) {
+			out("Missing required argument: ", "red");
+			out("Usage:");
+			out("  wheels migrate #verb#  --yes");
+			return "";
+		}
+
+		if (!yes) {
+			out("This will modify wheels_migrator_versions.", "yellow");
+			out("Re-run with --yes to confirm:", "yellow");
+			out("  wheels migrate #verb# #version# --yes");
+			return "";
+		}
+
+		var serverPort = $requireRunningServer([
+			"Migration reconciliation requires a running server.",
+			"Start one with: wheels start"
+		]);
+
+		out("Running #verb# for version #version#...", "cyan");
+
+		// URL-encode version: $sanitiseVersion() on the server side strips
+		// non-digits before SQL use (no injection path), but raw URL-special
+		// characters (&, =, %) in the CLI argument could still inject
+		// spurious query parameters before reaching that point.
+		var reconcileUrl = "http://localhost:#serverPort#/wheels/cli?command=#arguments.command#&version=#URLEncodedFormat(version)#&format=json";
+
+		var httpResult = "";
+		try {
+			httpResult = makeHttpRequest(reconcileUrl);
+		} catch (any httpErr) {
+			throw(
+				type    = "MigrationError",
+				message = "#verb# failed (connection error): #httpErr.message#",
+				detail  = httpErr.detail ?: ""
+			);
 		}
 
+		var parsed = isJSON(httpResult) ? deserializeJSON(httpResult) : {success: false, message: "Invalid response"};
+		var success = parsed.success ?: false;
+		var msg = parsed.message ?: "";
+
+		if (success) {
+			out(msg, "green");
+		} else {
+			out(msg, "red");
+		}
 		return "";
 	}
 
@@ -3651,14 +3739,18 @@ component extends="modules.BaseModule" {
 		// Compare major versions
 		var currentMajor = val(listFirst(currentVersion, "."));
 		var targetMajor = val(listFirst(target, "."));
+		var sameMajor = (currentMajor == targetMajor);
 
-		if (currentMajor == targetMajor) {
+		if (sameMajor) {
 			out("Same major version — no known breaking changes.", "green");
-			out("Upgrade with: brew upgrade wheels");
-			return "";
+			out("Scanning for opt-in recommendations...", "green");
+			out("");
 		}
 
-		// Breaking changes database
+		// Check database. Each entry may set `severity` to either "breaking"
+		// (the default — flagged in red, gated by major-version-bump scenarios)
+		// or "advisory" (cyan, runs regardless of version-jump — for opt-in
+		// convention changes the user can adopt at their convenience).
 		var checks = [];
 
 		// 2.x -> 3.x
@@ -3723,6 +3815,7 @@ component extends="modules.BaseModule" {
 			// reminder to re-verify, not a false positive.
 			arrayAppend(checks, {
 				description: "RateLimiter middleware — defaults changed in 4.0 (advisory: review config)",
+				severity: "advisory",
 				pattern: "new\s+wheels\.middleware\.RateLimiter",
 				checkType: "grep",
 				scanDir: "config",
@@ -3802,22 +3895,95 @@ component extends="modules.BaseModule" {
 			});
 		}
 
-		// Run checks
+		// ─── Advisory entries — run regardless of version-jump ──────────
+		// These are opt-in convention recommendations, not breaking changes.
+		// They appear in the "Recommended Improvements" section of the
+		// scanner output and never fail CI (exit code 0).
+
+		// Suggest opt-in to _id reference columns when t.references()
+		// is used and the underscore flag is not yet set. The flag flips
+		// the suffix produced by t.references() from `id` to `_id`,
+		// matching Wheels model `belongsTo` defaults. See #2781 + #2802.
+		//
+		// Pre-check the flag across all of config/ before appending — the
+		// check-struct schema doesn't support multi-condition AND logic, so
+		// emitting the advisory unconditionally would fire on every app that
+		// has already opted in (where advisory #2 is the relevant one) and
+		// contradict reality. Walk config/ recursively to match advisory #2's
+		// `scanDir: "config"` scope — users may set the flag in an
+		// environment override file (e.g. config/production/settings.cfm) and
+		// reading only config/settings.cfm would miss it (#2808). Comment-
+		// strip each file first so a commented-out
+		// `// set(useUnderscoreReferenceColumns=true);` doesn't satisfy the
+		// guard (Anti-Pattern #14 — same shape as line 970).
+		var underscoreFlagAlreadySet = false;
+		var configDir = variables.projectRoot & "/config";
+		if (directoryExists(configDir)) {
+			var configFiles = [];
+			for (var ext in ["cfm", "cfc"]) {
+				var found = directoryList(configDir, true, "path", "*." & ext);
+				for (var f in found) arrayAppend(configFiles, f);
+			}
+			for (var configFile in configFiles) {
+				// `reFindNoCase()` returns the 1-based match position (0 = no
+				// match). DO NOT wrap with `len()` — len() coerces the int to
+				// a string and measures digit count, so len(0)=1 and len(25)=2
+				// are both truthy. Use `> 0` for an unambiguous boolean.
+				if (reFindNoCase(
+					"useUnderscoreReferenceColumns\s*=\s*true",
+					stripCfmlComments(fileRead(configFile))
+				) > 0) {
+					underscoreFlagAlreadySet = true;
+					break;
+				}
+			}
+		}
+		if (!underscoreFlagAlreadySet) {
+			arrayAppend(checks, {
+				description: "t.references() produces legacy `id` columns (opt in to `_id` for `belongsTo` defaults)",
+				severity: "advisory",
+				pattern: "t\.references\s*\(",
+				checkType: "grep",
+				scanDir: "app/migrator/migrations",
+				extensions: "cfc",
+				fix: "Opt into _id naming via `set(useUnderscoreReferenceColumns=true)` in config/settings.cfm. Existing applied migrations are unaffected — only NEW migrations get the new suffix. Apps generated by `wheels new` already opt in by default. See ##2781."
+			});
+		}
+
+		// Mixed-convention warning: fires when the flag is set, alerting
+		// users that legacy migrations (pre-flag) may have left `id`
+		// columns in the DB while new migrations will produce `_id`.
+		// Informational — the user reads it once and decides if a data
+		// migration is needed.
+		arrayAppend(checks, {
+			description: "useUnderscoreReferenceColumns=true is set — confirm legacy migrations don't conflict",
+			severity: "advisory",
+			pattern: "useUnderscoreReferenceColumns\s*=\s*true",
+			checkType: "grep",
+			scanDir: "config",
+			extensions: "cfm,cfc",
+			fix: "If migrations under app/migrator/migrations/ were applied before this flag was set, the database still has `id` columns. New migrations will create `_id`. For full consistency, write a data migration to rename old reference columns."
+		});
+
+		// Run checks. Matched checks land in `issues` (severity=breaking) or
+		// `advisories` (severity=advisory); unmatched land in `passed`.
 		var issues = [];
+		var advisories = [];
 		var passed = [];
 
 		for (var check in checks) {
+			var severity = structKeyExists(check, "severity") ? check.severity : "breaking";
+			var matched = false;
+			var matchEntry = {};
+
 			if (check.checkType == "directory") {
 				var dirPath = variables.projectRoot & "/" & check.path;
 				if (directoryExists(dirPath)) {
 					var contents = directoryList(dirPath, false, "name");
 					if (arrayLen(contents)) {
-						arrayAppend(issues, {description: check.description, fix: check.fix, matches: [check.path & "/"]});
-					} else {
-						arrayAppend(passed, check.description);
+						matched = true;
+						matchEntry = {description: check.description, fix: check.fix, matches: [check.path & "/"]};
 					}
-				} else {
-					arrayAppend(passed, check.description);
 				}
 			} else if (check.checkType == "grep") {
 				// Build the file set to scan. Checks may use `scanDir` +
@@ -3862,7 +4028,13 @@ component extends="modules.BaseModule" {
 
 				var matches = [];
 				for (var filePath in filesToScan) {
-					var content = fileRead(filePath);
+					// Strip CFML comments before grepping (Anti-Pattern #14):
+					// a commented-out `// t.references(...)` or
+					// `/* set(...) */` must not satisfy the pattern. Multi-line
+					// block comments collapse and may shift reported line
+					// numbers — same tradeoff `stripCfmlComments` callers at
+					// lines 970 and 5532 already accept.
+					var content = stripCfmlComments(fileRead(filePath));
 					var lines = listToArray(content, chr(10), true);
 					for (var lineNum = 1; lineNum <= arrayLen(lines); lineNum++) {
 						if (reFindNoCase(check.pattern, lines[lineNum])) {
@@ -3879,25 +4051,35 @@ component extends="modules.BaseModule" {
 				// treat as pass to avoid noisy false positives.
 				var isAbsent = structKeyExists(check, "absent") && check.absent;
 				if (isAbsent) {
-					if (!arrayLen(filesToScan) || arrayLen(matches)) {
-						arrayAppend(passed, check.description);
-					} else {
+					if (arrayLen(filesToScan) && !arrayLen(matches)) {
+						matched = true;
 						var hint = structKeyExists(check, "scanDir") && len(check.scanDir)
 							? check.scanDir & "/ (no occurrences found)"
 							: "(no occurrences found)";
-						arrayAppend(issues, {description: check.description, fix: check.fix, matches: [hint]});
+						matchEntry = {description: check.description, fix: check.fix, matches: [hint]};
 					}
 				} else {
 					if (arrayLen(matches)) {
-						arrayAppend(issues, {description: check.description, fix: check.fix, matches: matches});
-					} else {
-						arrayAppend(passed, check.description);
+						matched = true;
+						matchEntry = {description: check.description, fix: check.fix, matches: matches};
 					}
 				}
 			}
+
+			// Bucket the result by severity. Advisories surface as opt-in
+			// recommendations alongside (but distinct from) breaking changes.
+			if (matched) {
+				if (severity == "advisory") {
+					arrayAppend(advisories, matchEntry);
+				} else {
+					arrayAppend(issues, matchEntry);
+				}
+			} else {
+				arrayAppend(passed, check.description);
+			}
 		}
 
-		// Output
+		// Output — three sections in priority order: Breaking → Recommended → All Clear
 		if (arrayLen(issues)) {
 			out("Breaking Changes (#arrayLen(issues)# found):", "yellow");
 			for (var issue in issues) {
@@ -3910,6 +4092,22 @@ component extends="modules.BaseModule" {
 			}
 		}
 
+		if (arrayLen(advisories)) {
+			out("Recommended Improvements (#arrayLen(advisories)# found):", "cyan");
+			for (var advisory in advisories) {
+				out("  ~ #advisory.description#", "cyan");
+				for (var match in advisory.matches) {
+					out("    #match#");
+				}
+				// Advisory fix lines are intentionally uncolored so the
+				// section header and description carry the cyan accent and
+				// opt-in items read lighter than breaking-change fixes
+				// (which use cyan on the fix line for stronger emphasis).
+				out("    -> #advisory.fix#");
+				out("");
+			}
+		}
+
 		if (arrayLen(passed)) {
 			out("All Clear (#arrayLen(passed)# checks):", "green");
 			for (var p in passed) {
diff --git a/cli/lucli/templates/app/config/settings.cfm b/cli/lucli/templates/app/config/settings.cfm
index 367e17c3d3..1fe35e52f0 100644
--- a/cli/lucli/templates/app/config/settings.cfm
+++ b/cli/lucli/templates/app/config/settings.cfm
@@ -24,5 +24,14 @@
 	// Reload your application with ?reload=true&password={{reloadPassword}}
 	set(reloadPassword="{{reloadPassword}}");
 
+	/*
+		Migrator: produce `_id` columns from t.references() and friends
+		(matching Wheels model `belongsTo` defaults). Default in the framework
+		is `false` for backwards compatibility with existing apps; new apps
+		generated by `wheels new` opt in here. Flip to false if you need the
+		legacy `id` (no underscore) suffix.
+	*/
+	set(useUnderscoreReferenceColumns=true);
+
 	// CLI-Appends-Here
 
diff --git a/cli/lucli/templates/app/public/Application.cfc b/cli/lucli/templates/app/public/Application.cfc
index 6c55edc97c..8fa013b179 100644
--- a/cli/lucli/templates/app/public/Application.cfc
+++ b/cli/lucli/templates/app/public/Application.cfc
@@ -274,6 +274,34 @@ component output="false" {
 			// Must never break error handling
 		}
 
+		// If the Wheels global never came up (e.g. the /wheels mapping is
+		// stale or Injector.cfc can't be resolved), the original error is
+		// already lost — fall back to a minimal HTML response rather than
+		// cascading into "The key [WO] does not exist." (issue ##2773).
+		if (!StructKeyExists(application, "wo")) {
+			setting requestTimeout=30;
+			// Surface a real 5xx so monitoring tools and CDNs don't cache this
+			// failure as a successful response. Use a plain struct for
+			// attributeCollection — Adobe CF 2023/2025 reject the `arguments`
+			// scope on built-in tags (CLAUDE.md cross-engine invariant ##10).
+			try {
+				local.statusArgs = {statusCode: 500, statusText: "Internal Server Error"};
+				cfheader(attributeCollection=local.statusArgs);
+			} catch (any headerErr) {
+				// Header may already have been written; the body still renders.
+			}
+			WriteOutput("

Application Error

"); + WriteOutput("

Wheels failed to initialize. Check the server log for details.

"); + try { + if (isStruct(arguments.Exception) && StructKeyExists(arguments.Exception, "message")) { + WriteOutput("
" & encodeForHTML(arguments.Exception.message) & "
"); + } + } catch (any fallbackErr) { + // Last-ditch render must never throw. + } + return; + } + local.requestTimeout = application.wo.$getRequestTimeout() + 30; if (StructKeyExists(application, "wheels") && StructKeyExists(application.wheels, "onErrorRequestTimeout")) { local.requestTimeout = application.wheels.onErrorRequestTimeout; diff --git a/cli/lucli/tests/specs/commands/PackagesCommandSpec.cfc b/cli/lucli/tests/specs/commands/PackagesCommandSpec.cfc index ba23958570..e03ccccf34 100644 --- a/cli/lucli/tests/specs/commands/PackagesCommandSpec.cfc +++ b/cli/lucli/tests/specs/commands/PackagesCommandSpec.cfc @@ -76,5 +76,69 @@ component extends="wheels.wheelstest.system.BaseSpec" { expect(out).toInclude("registry"); }); }); + + describe("wheels packages install — alias for add", () => { + + // Issue #2785: prior implementation made `case "install":` in + // Module.cfc a friendly-redirect dead branch that printed a + // warning to stdout and returned "" without installing anything. + // That meant any caller that reached Module.cfc via a path that + // is not the user-facing CLI (MCP tools, scripted clients, specs) + // silently got nothing back when typing `install` — even though + // `PackagesMainCli.install()` itself has always been a true alias + // for `add()`. The alias must be wired through the dispatch + // layer too, so that the only place `install` ever no-ops is the + // LuCLI extension-installer intercept (which we cannot patch), + // and every in-process caller gets the same behavior as `add`. + it("throws the same BadInput error as `add` when name is missing", () => { + mod.__arguments = ["install"]; + var threw = {flag: false, message: ""}; + try { + mod.packages(); + } catch (any e) { + threw.flag = true; + threw.message = e.message; + } + expect(threw.flag).toBeTrue(); + // The error must point users at the canonical `add` verb so + // programmatic callers (MCP, scripts) see the right shape. + expect(threw.message).toInclude("add"); + }); + + it("dispatches `install ` to the same code path as `add `", () => { + // Both verbs must reach PackagesMainCli — meaning neither + // short-circuits with a warning before instantiation. A + // bogus package name still throws (registry lookup fails), + // but it must throw the SAME way for both verbs. The prior + // behavior was that `install` silently returned "" while + // `add` threw — a divergence that broke any caller that + // expected the alias to be transparent. + var captureThrow = (verb) => { + var localMod = new cli.lucli.Module(cwd = variables.tempRoot); + localMod.__arguments = [verb, "wheels-this-package-does-not-exist-#CreateUUID()#"]; + var threw = {flag: false, type: ""}; + try { + localMod.packages(); + } catch (any e) { + threw.flag = true; + threw.type = e.type; + } + return threw; + }; + var addResult = captureThrow("add"); + var installResult = captureThrow("install"); + expect(addResult.flag).toBeTrue(); + expect(installResult.flag).toBeTrue(); + // Both must throw, and both must throw with a non-empty type + // — proving they reached the same registry-lookup code path + // rather than `install` being intercepted by a different branch. + expect(installResult.type).notToBe(""); + // And both must throw the SAME exception type — a future + // regression that made `install` throw at argument validation + // (before the registry call) would still satisfy the non-empty + // check above, so pin the equivalence explicitly. + expect(installResult.type).toBe(addResult.type); + }); + }); } } diff --git a/cli/lucli/tests/specs/commands/UpgradeAdvisorySpec.cfc b/cli/lucli/tests/specs/commands/UpgradeAdvisorySpec.cfc new file mode 100644 index 0000000000..6c1be71993 --- /dev/null +++ b/cli/lucli/tests/specs/commands/UpgradeAdvisorySpec.cfc @@ -0,0 +1,103 @@ +/** + * Source-level coverage for the underscore-references advisory entries + * shipped in PR4 of the #2781 follow-up series. These advisories surface in + * the "Recommended Improvements" section of `wheels upgrade check` output + * (severity=advisory — runs regardless of major-version-jump, doesn't gate + * CI exit codes). + * + * Like UpgradeCommandSpec, source-level inspection — Module.cfc can't be + * instantiated under TestBox. + */ +component extends="wheels.wheelstest.system.BaseSpec" { + + function beforeAll() { + variables.moduleSource = fileRead(expandPath("/cli/lucli/Module.cfc")); + } + + function run() { + + describe("wheels upgrade — t.references() opt-in advisory", () => { + + it("declares an advisory check that greps app/migrator/migrations for t.references(", () => { + expect(variables.moduleSource).toInclude("t\.references\s*\("); + expect(variables.moduleSource).toInclude("severity: ""advisory"""); + expect(variables.moduleSource).toInclude("app/migrator/migrations"); + }); + + it("mentions useUnderscoreReferenceColumns in the fix message", () => { + expect(variables.moduleSource).toInclude("useUnderscoreReferenceColumns=true"); + }); + + it("notes that applied migrations are unaffected (avoids alarming users)", () => { + expect(variables.moduleSource).toInclude("Existing applied migrations are unaffected"); + }); + + }); + + describe("wheels upgrade — mixed-convention warning", () => { + + it("declares an advisory check that greps config/ for useUnderscoreReferenceColumns=true", () => { + expect(variables.moduleSource).toInclude("useUnderscoreReferenceColumns\s*=\s*true"); + }); + + it("warns about legacy migrations possibly leaving id columns", () => { + expect(variables.moduleSource).toInclude("legacy migrations"); + expect(variables.moduleSource).toInclude("data migration"); + }); + + }); + + describe("wheels upgrade — false-positive guards", () => { + + it("suppresses the opt-in advisory when the flag is already set anywhere in config/", () => { + // Source-level assertion on the guard variable: the t.references() + // advisory must only fire when the flag isn't already set, else + // new apps (which ship with the flag on by default) would see + // advisory #1 contradicting advisory #2. + expect(variables.moduleSource).toInclude("underscoreFlagAlreadySet"); + expect(variables.moduleSource).toInclude("if (!underscoreFlagAlreadySet)"); + }); + + it("strips CFML comments before grepping (Anti-Pattern ##14)", () => { + // The grep loop must run input through stripCfmlComments so a + // commented-out `// t.references(...)` or + // `// set(useUnderscoreReferenceColumns=true);` doesn't trip + // the advisory. The fix lives in the shared grep loop, so this + // is also a framework-wide benefit for every check struct. + expect(variables.moduleSource).toInclude("stripCfmlComments(fileRead(filePath))"); + }); + + it("scans config/ recursively for the underscore flag pre-check (matches advisory ##2 scope)", () => { + // Fix for ##2808: the pre-check originally read only + // `config/settings.cfm`, but advisory ##2 below scans all of + // `config/` recursively. If a user sets the flag in an + // environment override file (e.g. + // `config/production/settings.cfm`), the pre-check missed it + // and both advisories fired contradicting each other. + // Scope-symmetry: walk the same tree the warning scans. + expect(variables.moduleSource).toInclude("directoryExists(configDir)"); + expect(variables.moduleSource).toInclude("directoryList(configDir, true"); + }); + + it("strips CFML comments on every config/ file scanned by the guard", () => { + // The flag-already-set guard must still strip comments so a + // commented-out `// set(useUnderscoreReferenceColumns=true);` + // in any env-override file doesn't satisfy the guard and + // suppress a real advisory (Anti-Pattern ##14). + expect(variables.moduleSource).toInclude("stripCfmlComments(fileRead(configFile))"); + }); + + it("does not wrap reFindNoCase with len() — that pattern is always truthy", () => { + // `reFindNoCase()` returns an integer position (0 = no match). + // `len(0)` returns 1 (digit count of "0"), `len(25)` returns 2, + // so any reFindNoCase result wrapped in len() is truthy. The + // guard would be dead in every app whose config/settings.cfm + // exists. Use direct `> 0` comparison instead. + expect(variables.moduleSource).notToInclude("len(reFindNoCase"); + }); + + }); + + } + +} diff --git a/cli/lucli/tests/specs/commands/UpgradeCommandSpec.cfc b/cli/lucli/tests/specs/commands/UpgradeCommandSpec.cfc new file mode 100644 index 0000000000..790bb527d6 --- /dev/null +++ b/cli/lucli/tests/specs/commands/UpgradeCommandSpec.cfc @@ -0,0 +1,64 @@ +/** + * Source-level regression for `wheels upgrade check` scanner structure. + * + * PR3 of the #2781 follow-up series introduces a `severity` field on each + * check ("breaking" by default; "advisory" for opt-in recommendations) and + * adds a "Recommended Improvements" output section. Same-major upgrades no + * longer short-circuit before the checks loop — advisories must run + * regardless of major-version-bump. + * + * Like other CLI command specs (ConsoleCommandSpec, ReloadCommandSpec), we + * inspect Module.cfc source rather than instantiating it, because Module + * extends `modules.BaseModule` which is only resolvable at LuCLI runtime, + * not in TestBox. + */ +component extends="wheels.wheelstest.system.BaseSpec" { + + function beforeAll() { + variables.moduleSource = fileRead(expandPath("/cli/lucli/Module.cfc")); + } + + function run() { + + describe("wheels upgrade — scanner structure (PR3)", () => { + + it("declares the severity field convention in a comment", () => { + // The check-database comment must document the severity field + // so future contributors know the alias exists. Failure here + // means the convention wasn't recorded in code. + expect(variables.moduleSource).toInclude("severity"); + expect(variables.moduleSource).toInclude("advisory"); + }); + + it("does not short-circuit same-major upgrades before running checks", () => { + // Pre-PR3 behavior: `if (currentMajor == targetMajor) { ...; return ""; }` + // halted execution before checks could run. PR3 removes that + // early return so advisories fire on same-major upgrades too. + // Source check: ensure the `sameMajor` boolean is set and used, + // indicating the new flow that doesn't early-return. + expect(variables.moduleSource).toInclude("sameMajor"); + }); + + it("buckets matched checks into issues OR advisories by severity", () => { + // The iteration loop must split matched checks into two buckets. + expect(variables.moduleSource).toInclude("advisories"); + expect(variables.moduleSource).toInclude("matchEntry"); + }); + + it("renders a 'Recommended Improvements' output section", () => { + expect(variables.moduleSource).toInclude("Recommended Improvements"); + }); + + it("preserves the 'Breaking Changes' output section", () => { + expect(variables.moduleSource).toInclude("Breaking Changes"); + }); + + it("preserves the 'All Clear' output section", () => { + expect(variables.moduleSource).toInclude("All Clear"); + }); + + }); + + } + +} diff --git a/public/Application.cfc b/public/Application.cfc index 6534f6aa49..32946246e5 100644 --- a/public/Application.cfc +++ b/public/Application.cfc @@ -288,6 +288,34 @@ component output="false" { // Must never break error handling } + // If the Wheels global never came up (e.g. the /wheels mapping is + // stale or Injector.cfc can't be resolved), the original error is + // already lost — fall back to a minimal HTML response rather than + // cascading into "The key [WO] does not exist." (issue ##2773). + if (!StructKeyExists(application, "wo")) { + setting requestTimeout=30; + // Surface a real 5xx so monitoring tools and CDNs don't cache this + // failure as a successful response. Use a plain struct for + // attributeCollection — Adobe CF 2023/2025 reject the `arguments` + // scope on built-in tags (CLAUDE.md cross-engine invariant ##10). + try { + local.statusArgs = {statusCode: 500, statusText: "Internal Server Error"}; + cfheader(attributeCollection=local.statusArgs); + } catch (any headerErr) { + // Header may already have been written; the body still renders. + } + WriteOutput("

Application Error

"); + WriteOutput("

Wheels failed to initialize. Check the server log for details.

"); + try { + if (isStruct(arguments.Exception) && StructKeyExists(arguments.Exception, "message")) { + WriteOutput("
" & encodeForHTML(arguments.Exception.message) & "
"); + } + } catch (any fallbackErr) { + // Last-ditch render must never throw. + } + return; + } + // In case the error was caused by a timeout we have to add extra time for error handling. // We have to check if onErrorRequestTimeout exists since errors can be triggered before the application.wheels struct has been created. local.requestTimeout = application.wo.$getRequestTimeout() + 30; diff --git a/tools/distribution-drafts/apt-repo/README.md b/tools/distribution-drafts/apt-repo/README.md index 1be8706d2e..de4e3d07d2 100644 --- a/tools/distribution-drafts/apt-repo/README.md +++ b/tools/distribution-drafts/apt-repo/README.md @@ -1,9 +1,15 @@ -# `apt-wheels-dev` bucket repo template +# `apt-wheels` bucket repo template -This directory is the **template** for the standalone `wheels-dev/apt-wheels-dev` -repository that backs `https://apt.wheels.dev`. The bucket repo holds the static -apt metadata tree plus the pooled `.deb` artifacts, and is auto-deployed to -Cloudflare Pages on every push. +This directory is the **template** for the standalone `wheels-dev/apt-wheels` +repository that backs `https://apt.wheels.dev`. The bucket repo holds the +**workflow + scripts + landing page + signing key**. The apt metadata tree +(`dists/`) and the `.deb` pool (`pool/`) live in **Cloudflare R2** (bucket +`wheels-apt`) and are served via R2's custom-domain feature. + +> **Note on Pages vs R2:** The original Phase 2 design called for Cloudflare +> Pages serving the bucket repo directly. Pages has a hard **25 MiB per-file** +> limit; the `.deb` is ~80 MB. The architecture pivoted to R2 (no per-object +> size limit) during initial rollout. Copy these files into the new repo when it's created — they are designed to work out of the box once the Phase 2 operational prerequisites (GPG key, @@ -101,13 +107,15 @@ Before this bucket repo will function: into 1Password under `op://Wheels/wheels-linux-repo-signing/` (Wheels project vault on `my.1password.com`). Public key (ASCII-armored) overwrites `wheels.gpg` at the bucket-repo root. -2. **Cloudflare Pages** — create a Pages project pointing at this repo, bind - the apex domain `apt.wheels.dev`. The build command is empty (the repo - *is* the static site); the output dir is `./`. +2. **R2 bucket** — create a Cloudflare R2 bucket named `wheels-apt` and + attach the `apt.wheels.dev` custom domain. R2 has no per-object size + limit (unlike Pages' 25 MiB), which is why binaries are stored here. 3. **CI secrets** (set on the bucket repo at - `https://github.com/wheels-dev/apt-wheels-dev/settings/secrets/actions`): + `https://github.com/wheels-dev/apt-wheels/settings/secrets/actions`): - `WHEELS_REPO_GPG_PRIVATE_KEY` — ASCII-armored private key - `WHEELS_REPO_GPG_PASSPHRASE` — passphrase + - `CLOUDFLARE_API_TOKEN` — token with `Workers R2 Storage:Edit` on the + account that owns the `wheels-apt` bucket 4. **Upstream dispatch** — the release workflow in `wheels-dev/wheels` fires a `repository_dispatch` (`wheels-released`) at this repo when a new `.deb` is published to the GitHub Release. The token used by the diff --git a/tools/distribution-drafts/apt-repo/templates/index.html b/tools/distribution-drafts/apt-repo/templates/index.html index f01cb9d5dd..afb48c2203 100644 --- a/tools/distribution-drafts/apt-repo/templates/index.html +++ b/tools/distribution-drafts/apt-repo/templates/index.html @@ -79,7 +79,7 @@

Upgrading

sudo apt update && sudo apt upgrade wheels        # or wheels-be

Signing key

-

The repository is signed with the Wheels project key. The public half is published at /wheels.gpg. The fingerprint is committed to the source repo in FINGERPRINT for offline verification.

+

The repository is signed with the Wheels project key. The public half is published at /wheels.gpg. The fingerprint is committed to the source repo in FINGERPRINT for offline verification.

Other platforms

    @@ -89,7 +89,7 @@

    Other platforms

diff --git a/tools/distribution-drafts/apt-repo/workflows/wheels-released.yml b/tools/distribution-drafts/apt-repo/workflows/wheels-released.yml index aff21a7143..f52f2489fb 100644 --- a/tools/distribution-drafts/apt-repo/workflows/wheels-released.yml +++ b/tools/distribution-drafts/apt-repo/workflows/wheels-released.yml @@ -1,19 +1,23 @@ -# Receiver workflow for `wheels-dev/apt-wheels-dev`. +# Receiver workflow for `wheels-dev/apt-wheels` (R2-backed architecture). # # Listens for `repository_dispatch` from `wheels-dev/wheels`'s release workflow, -# downloads the new `.deb` asset from the upstream GitHub Release, slots it -# into `pool//w//`, regenerates the apt metadata via -# `apt-ftparchive`, signs `Release` + `InRelease` with GPG, and commits the -# whole tree back to this repo. Cloudflare Pages auto-deploys on push. +# downloads the new `.deb` asset from the upstream GitHub Release, syncs the +# existing apt repo state from R2 (so apt-ftparchive can see prior versions), +# regenerates `Packages.gz` / `Release` / `InRelease` via `apt-ftparchive`, +# signs with GPG, and uploads the changed tree to the `wheels-apt` R2 bucket +# (which is served at https://apt.wheels.dev via R2 custom-domain). +# +# This repo no longer commits pool/ or dists/ — those are R2-resident only. +# The repo's job is to hold the WORKFLOW + REGEN SCRIPT + LANDING + GPG KEY. # # Trigger payload contract (sender: wheels-dev/wheels release.yml): # event_type: "wheels-released" # client_payload: -# version: "" or "-snapshot." # full SemVer, no leading v -# channel: "stable" | "bleeding-edge" # routes into the pool +# version: "" or "-snapshot." +# channel: "stable" | "bleeding-edge" # -# Manual dispatch is also supported via `workflow_dispatch` for backfill / -# disaster-recovery (re-add a missing version without waiting for a new release). +# Manual dispatch is supported via `workflow_dispatch` for backfill / +# disaster-recovery. name: Publish to apt.wheels.dev @@ -36,14 +40,23 @@ on: - bleeding-edge permissions: - contents: write # the workflow commits the regenerated metadata back to this repo + contents: read concurrency: - # Serialize across both channels — apt-ftparchive scans the whole pool, so - # parallel runs would race on Packages.gz / Release writes. + # Serialize across channels — apt-ftparchive scans the whole pool, so + # parallel runs would race on the regenerated Packages.gz / Release uploads. group: publish-apt cancel-in-progress: false +env: + R2_BUCKET: wheels-apt + # Cloudflare account ID is not a secret (no auth power on its own), but + # keep it as a repo-level Actions variable so the workflow template stays + # account-agnostic and so harvesting a public template doesn't hand an + # attacker a ready-made API target. Set on the bucket repo: + # gh variable set CLOUDFLARE_ACCOUNT_ID --body '' --repo wheels-dev/apt-wheels + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + jobs: publish: name: Publish ${{ github.event.client_payload.version || inputs.version }} (${{ github.event.client_payload.channel || inputs.channel }}) @@ -52,52 +65,53 @@ jobs: steps: - name: Resolve inputs id: inputs + env: + CLIENT_VERSION: ${{ github.event.client_payload.version }} + CLIENT_CHANNEL: ${{ github.event.client_payload.channel }} + INPUT_VERSION: ${{ inputs.version }} + INPUT_CHANNEL: ${{ inputs.channel }} run: | + set -euo pipefail if [ "${{ github.event_name }}" = "repository_dispatch" ]; then - VERSION="${{ github.event.client_payload.version }}" - CHANNEL="${{ github.event.client_payload.channel }}" + VERSION="$CLIENT_VERSION" + CHANNEL="$CLIENT_CHANNEL" else - VERSION="${{ inputs.version }}" - CHANNEL="${{ inputs.channel }}" + VERSION="$INPUT_VERSION" + CHANNEL="$INPUT_CHANNEL" fi if [ -z "$VERSION" ] || [ -z "$CHANNEL" ]; then echo "::error::Both version and channel are required." exit 1 fi - # Reject "release-candidate" and other channels we don't yet plumb. case "$CHANNEL" in stable|bleeding-edge) ;; *) echo "::error::Unsupported channel: $CHANNEL"; exit 1 ;; esac - # Map channel → package name (mirrors nfpm-wheels.yaml / nfpm-wheels-be.yaml). case "$CHANNEL" in - stable) PKG="wheels" ;; - bleeding-edge) PKG="wheels-be" ;; + stable) PKG="wheels"; UPSTREAM_REPO="wheels-dev/wheels" ;; + bleeding-edge) PKG="wheels-be"; UPSTREAM_REPO="wheels-dev/wheels-snapshots" ;; esac - # Map channel → upstream releases repo. Stable releases live in - # wheels-dev/wheels; snapshot/BE releases live in wheels-dev/wheels-snapshots. - case "$CHANNEL" in - stable) UPSTREAM_REPO="wheels-dev/wheels" ;; - bleeding-edge) UPSTREAM_REPO="wheels-dev/wheels-snapshots" ;; - esac - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "channel=$CHANNEL" >> "$GITHUB_OUTPUT" - echo "pkg=$PKG" >> "$GITHUB_OUTPUT" - echo "upstream_repo=$UPSTREAM_REPO" >> "$GITHUB_OUTPUT" + { + echo "version=$VERSION" + echo "channel=$CHANNEL" + echo "pkg=$PKG" + echo "upstream_repo=$UPSTREAM_REPO" + } >> "$GITHUB_OUTPUT" - name: Checkout bucket repo uses: actions/checkout@v4 with: fetch-depth: 1 - # Persist the workflow token so the trailing `git push` works. - persist-credentials: true - - name: Install apt-ftparchive + gpg + - name: Install apt-ftparchive + gpg + wrangler run: | sudo apt-get update - sudo apt-get install -y --no-install-recommends apt-utils gnupg + sudo apt-get install -y --no-install-recommends apt-utils gnupg jq apt-ftparchive --version gpg --version | head -1 + # wrangler ships with Node — runner has it. Pin to a known-good version. + sudo npm install -g wrangler@4.95.0 + wrangler --version - name: Import GPG signing key env: @@ -110,11 +124,9 @@ jobs: exit 1 fi echo "$GPG_PRIVATE_KEY" | gpg --batch --yes --import - # Trust the imported key ultimately so subsequent sign operations don't prompt. - # `import-ownertrust` expects the 40-char fingerprint (`fpr:` line, field 10), - # NOT the 16-char key_id (`sec:` line, field 5). Using the key_id form emits - # a "gpg: error in '[stdin]': invalid fingerprint" warning that's non-fatal - # but noisy in CI logs. + # import-ownertrust needs the 40-char fingerprint (fpr: field 10), + # NOT the 16-char key_id (sec: field 5). Using key_id emits a + # non-fatal "invalid fingerprint" warning that's noisy in CI logs. FINGERPRINT=$(gpg --list-secret-keys --with-colons \ | awk -F: '/^fpr:/ { print $10; exit }') KEY_ID=$(gpg --list-secret-keys --keyid-format=long --with-colons \ @@ -123,19 +135,63 @@ jobs: echo "$FINGERPRINT:6:" | gpg --batch --yes --import-ownertrust echo "GPG_KEY_ID=$KEY_ID" >> "$GITHUB_ENV" - - name: Download .deb from upstream Release + - name: Sync existing pool from R2 + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CHANNEL: ${{ steps.inputs.outputs.channel }} + run: | + set -euo pipefail + # List all R2 objects under pool// and download each. + # apt-ftparchive needs the actual .deb files locally so it can compute + # size + sha256 + read package metadata for the Packages index. + mkdir -p pool/${CHANNEL} + PREFIX="pool/${CHANNEL}/" + CURSOR="" + while :; do + URL="https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/r2/buckets/${R2_BUCKET}/objects?prefix=${PREFIX}&per_page=1000" + [ -n "$CURSOR" ] && URL="${URL}&cursor=${CURSOR}" + RESP=$(curl -sS "$URL" -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}") + # Guard against silent failures: when the token is invalid/expired + # or the API rate-limits, success=false comes back with an empty + # result array. Without this check, jq emits nothing, the loop + # never runs, regen produces a single-package index, and the + # upload step overwrites Packages.gz on R2 with that trimmed + # index. Every apt update afterward sees the historical packages + # vanish from the repo. Fail loud here instead. + if ! echo "$RESP" | jq -e '.success == true' >/dev/null; then + echo "::error::Cloudflare R2 list API returned success=false (or invalid JSON). Response:" + echo "$RESP" + exit 1 + fi + # Process substitution (not a pipe) so the while body runs in the + # parent shell — pipe-RHS runs in a subshell where set -e in the + # parent does not see wrangler's non-zero exit, masking download + # failures. Same reasoning: wrangler's stderr is NOT suppressed + # so a real failure surfaces in the CI log. + while IFS= read -r key; do + [ -z "$key" ] && continue + mkdir -p "$(dirname "$key")" + echo " pulling $key" + wrangler r2 object get "${R2_BUCKET}/${key}" --file="$key" --remote + done < <(echo "$RESP" | jq -r '.result[].key') + CURSOR=$(echo "$RESP" | jq -r '.result_info.cursor // empty') + [ -z "$CURSOR" ] && break + done + echo "Local pool/${CHANNEL}/ contents after sync:" + find pool/${CHANNEL}/ -type f | head -20 + + - name: Download new .deb from upstream Release env: GH_TOKEN: ${{ github.token }} VERSION: ${{ steps.inputs.outputs.version }} - CHANNEL: ${{ steps.inputs.outputs.channel }} PKG: ${{ steps.inputs.outputs.pkg }} UPSTREAM_REPO: ${{ steps.inputs.outputs.upstream_repo }} run: | set -euo pipefail mkdir -p incoming # GitHub Release URLs rewrite `~` to `.` at upload time. The version - # passed in `client_payload.version` uses `-snapshot.N` (SemVer), but - # the on-URL filename uses `.snapshot.N`. Translate. + # in client_payload uses SemVer hyphen (-snapshot.N); the on-URL form + # uses dot (.snapshot.N). Translate before fetch. URL_VERSION="${VERSION/-snapshot./.snapshot.}" ASSET="${PKG}_${URL_VERSION}_amd64.deb" TAG="v${VERSION}" @@ -144,25 +200,22 @@ jobs: --repo "$UPSTREAM_REPO" \ --pattern "$ASSET" \ --dir incoming/ - ls -l incoming/ + ls -lh incoming/ - - name: Slot .deb into pool/ + - name: Slot new .deb into local pool env: VERSION: ${{ steps.inputs.outputs.version }} CHANNEL: ${{ steps.inputs.outputs.channel }} PKG: ${{ steps.inputs.outputs.pkg }} run: | set -euo pipefail - # Canonical pool form uses `~` (SemVer pre-release separator). + # Canonical pool path uses ~-form (SemVer pre-release separator). POOL_DIR="pool/${CHANNEL}/${PKG:0:1}/${PKG}" POOL_FILE="${POOL_DIR}/${PKG}_${VERSION}_amd64.deb" mkdir -p "$POOL_DIR" - # The downloaded file uses `.` in the snapshot separator; rename to `~`. URL_VERSION="${VERSION/-snapshot./.snapshot.}" mv "incoming/${PKG}_${URL_VERSION}_amd64.deb" "$POOL_FILE" - echo "Placed: $POOL_FILE" - # Idempotency: if the pool already had this version, the mv overwrote - # it. Re-runs of the same dispatch are safe (last-writer-wins). + echo "Placed: $POOL_FILE ($(du -h "$POOL_FILE" | cut -f1))" - name: Regenerate apt metadata + sign env: @@ -173,19 +226,40 @@ jobs: chmod +x scripts/regenerate-apt-metadata.sh ./scripts/regenerate-apt-metadata.sh - - name: Commit + push + - name: Upload pool + dists to R2 env: - VERSION: ${{ steps.inputs.outputs.version }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CHANNEL: ${{ steps.inputs.outputs.channel }} - PKG: ${{ steps.inputs.outputs.pkg }} run: | set -euo pipefail - git config user.name "wheels-release-bot" - git config user.email "hello@wheels.dev" - git add pool/ dists/ - if git diff --cached --quiet; then - echo "No changes — dispatch must have been a no-op (same version re-published?)." - exit 0 - fi - git commit -m "publish: ${PKG} ${VERSION} (${CHANNEL})" - git push origin HEAD:main + upload_one() { + local local_path="$1" + local ct="$2" + local key="$local_path" + echo " uploading $key (ct=${ct})" + # Do not suppress wrangler output — a silent upload failure would + # leave R2 with a stale Packages.gz / Release while the job exits + # green. + wrangler r2 object put "${R2_BUCKET}/${key}" --file="$local_path" --content-type="$ct" --remote + } + # Upload pool files (.deb). Only the one we just added is new, but + # uploading all is idempotent (overwrites with same content). + # Process substitution (not pipe) so set -e fires on upload_one + # failures — pipe-RHS subshell semantics would swallow them. + while read -r f; do + upload_one "$f" "application/vnd.debian.binary-package" + done < <(find pool/${CHANNEL} -type f -name '*.deb') + # Upload regenerated dists tree. apt-ftparchive rewrites these on + # every run; upload all files in dists/ regardless of channel. + while read -r f; do + # Pick a sensible content-type per file + case "$f" in + *.gz) ct="application/gzip" ;; + *.gpg) ct="application/pgp-signature" ;; + *InRelease|*Release) ct="text/plain" ;; + *Packages) ct="text/plain" ;; + *) ct="application/octet-stream" ;; + esac + upload_one "$f" "$ct" + done < <(find dists -type f) + echo "R2 upload complete for channel=${CHANNEL}." diff --git a/tools/distribution-drafts/linux-packages/README.md b/tools/distribution-drafts/linux-packages/README.md index 5f52d50fb6..8c00a43c90 100644 --- a/tools/distribution-drafts/linux-packages/README.md +++ b/tools/distribution-drafts/linux-packages/README.md @@ -62,17 +62,24 @@ Practical impact for this directory: ## Phase 2: apt.wheels.dev / yum.wheels.dev (post-Tuesday) -Two new repos (separate from the source monorepo, mirroring the snapshots-repo -pattern): +Two-repo pattern (one bucket-control repo per package format): -- `wheels-dev/apt-wheels-dev` — Cloudflare Pages site at `apt.wheels.dev` -- `wheels-dev/yum-wheels-dev` — Cloudflare Pages site at `yum.wheels.dev` +- `wheels-dev/apt-wheels` — workflow + scripts + signing key + landing page; + the apt metadata + `.deb` pool live in **Cloudflare R2 bucket `wheels-apt`**, + served at `apt.wheels.dev` via R2's custom-domain feature. +- `wheels-dev/yum-wheels` — same pattern; R2 bucket `wheels-yum` serves + `yum.wheels.dev`. -Each repo holds the static metadata tree (Packages.gz, repodata/, etc.) plus -the `.deb` / `.rpm` files in a `pool/` directory. A CI workflow listens for -`repository_dispatch` from the source repo's release workflows, fetches new -artifacts, regenerates metadata, signs with GPG, commits and pushes — CF Pages -auto-deploys on push. +A CI workflow on each bucket repo listens for `repository_dispatch` from +the source repo's release workflows, fetches new artifacts, syncs the +existing pool from R2, regenerates metadata, signs with GPG, and uploads +the changes back to R2. R2's edge serves the new files within seconds. + +> **Why R2 and not Pages:** Cloudflare Pages enforces a hard 25 MiB +> per-file limit. The Wheels `.deb` and `.rpm` are ~80 MB each. The +> original Phase 2 sketch used Pages; the architecture pivoted to R2 +> during rollout. End-user URLs are unchanged — both `apt.wheels.dev` +> and `yum.wheels.dev` still serve at the same paths. **Filename gotcha for the metadata generator**: when fetching snapshot artifacts from the source repo's GitHub Release, the URL filenames have `.` @@ -129,27 +136,31 @@ The remaining work is operational: `wheels.gpg` (template placeholders live at `/templates/wheels.gpg.placeholder`). 2. **Create the two bucket repos** under `wheels-dev`: - - `wheels-dev/apt-wheels-dev` — copy contents of `apt-repo/` template - - `wheels-dev/yum-wheels-dev` — copy contents of `yum-repo/` template -3. **Create two CF Pages projects** pointing at the new repos, binding the - apex domains: - - `wheels-dev/apt-wheels-dev` → `apt.wheels.dev` - - `wheels-dev/yum-wheels-dev` → `yum.wheels.dev` + - `wheels-dev/apt-wheels` — copy contents of `apt-repo/` template + - `wheels-dev/yum-wheels` — copy contents of `yum-repo/` template +3. **Create two Cloudflare R2 buckets** and attach apex domain bindings: + - bucket `wheels-apt` → custom domain `apt.wheels.dev` + - bucket `wheels-yum` → custom domain `yum.wheels.dev` + + R2 has no per-object size limit (unlike Pages' 25 MiB) which is why we + serve from R2. End-user URLs are identical. 4. **Add CI secrets** to `wheels-dev/wheels` (for the dispatch sender) and to - each bucket repo (for the signing receiver): + each bucket repo (for the signing receiver + R2 upload): - On `wheels-dev/wheels`: - `LINUX_REPO_DISPATCH_TOKEN` — fine-grained PAT with `actions: write` on both bucket repos. The dispatch step in `release.yml` skips silently when this secret is unset, so it's safe to land the wiring before the bucket repos exist. - - On each bucket repo (`apt-wheels-dev`, `yum-wheels-dev`): + - On each bucket repo (`apt-wheels`, `yum-wheels`): - `WHEELS_REPO_GPG_PRIVATE_KEY` — ASCII-armored private key - `WHEELS_REPO_GPG_PASSPHRASE` — passphrase + - `CLOUDFLARE_API_TOKEN` — token with `Workers R2 Storage:Edit` on the + account that owns the bucket 5. **Smoke-test** by running the bucket-repo workflows manually (each supports `workflow_dispatch` for backfill). For the apt bucket: ``` gh workflow run wheels-released.yml \ - --repo wheels-dev/apt-wheels-dev \ + --repo wheels-dev/apt-wheels \ -f version=4.0.0 -f channel=stable ``` then verify the published tree on a fresh Debian/Ubuntu host. Do the same diff --git a/tools/distribution-drafts/linux-packages/nfpm-wheels-be.yaml b/tools/distribution-drafts/linux-packages/nfpm-wheels-be.yaml index 7239b33ccd..2e19d64fe3 100644 --- a/tools/distribution-drafts/linux-packages/nfpm-wheels-be.yaml +++ b/tools/distribution-drafts/linux-packages/nfpm-wheels-be.yaml @@ -34,7 +34,14 @@ contents: - src: ./build/module/ dst: /opt/wheels/module/ type: tree - - src: ./build/framework/ + # Framework source — see nfpm-wheels.yaml for the full explainer of why src + # MUST be ./build/framework/wheels/ (with trailing /wheels/), not bare + # ./build/framework/. Short version: nfpm `type: tree` copies src contents + # into dst, and the wheels-core-VER.zip preserves a top-level wheels/ dir, + # so without the inner /wheels/ we get a double-nested + # /opt/wheels/module/vendor/wheels/wheels/ that breaks runtime resolution + # of `wheels.Injector`. See issue #2773. + - src: ./build/framework/wheels/ dst: /opt/wheels/module/vendor/wheels/ type: tree - src: ./build/sqlite-jdbc.jar diff --git a/tools/distribution-drafts/linux-packages/nfpm-wheels.yaml b/tools/distribution-drafts/linux-packages/nfpm-wheels.yaml index 8239859838..331c4dc859 100644 --- a/tools/distribution-drafts/linux-packages/nfpm-wheels.yaml +++ b/tools/distribution-drafts/linux-packages/nfpm-wheels.yaml @@ -38,7 +38,16 @@ contents: dst: /opt/wheels/module/ type: tree # Framework source — staged into vendor/wheels/ in scaffolded apps. - - src: ./build/framework/ + # IMPORTANT: src points at ./build/framework/wheels/ (the inner directory), + # NOT ./build/framework/. wheels-core-VER.zip has a top-level wheels/ dir + # that `unzip` preserves, leaving the framework at ./build/framework/wheels/. + # nfpm's `type: tree` copies the *contents* of src into dst, so without the + # trailing /wheels/ on src, the framework double-nests as + # /opt/wheels/module/vendor/wheels/wheels/Injector.cfc and Lucee can't + # resolve `wheels.Injector` at app startup. See issue #2773. The brew + # formula handles this the equivalent way — see homebrew-wheels + # Formula/wheels.rb's `(share/"wheels/framework/wheels").install Dir["*"]`. + - src: ./build/framework/wheels/ dst: /opt/wheels/module/vendor/wheels/ type: tree # SQLite JDBC — required at first run by the wrapper script (cliff fix from diff --git a/tools/distribution-drafts/yum-repo/README.md b/tools/distribution-drafts/yum-repo/README.md index 9733fed9e5..5ebb08ce51 100644 --- a/tools/distribution-drafts/yum-repo/README.md +++ b/tools/distribution-drafts/yum-repo/README.md @@ -1,9 +1,15 @@ -# `yum-wheels-dev` bucket repo template +# `yum-wheels` bucket repo template -This directory is the **template** for the standalone `wheels-dev/yum-wheels-dev` +This directory is the **template** for the standalone `wheels-dev/yum-wheels` repository that backs `https://yum.wheels.dev`. The bucket repo holds the -static yum metadata tree plus the pooled `.rpm` artifacts, and is auto-deployed -to Cloudflare Pages on every push. +**workflow + scripts + landing page + .repo files + signing key**. The yum +metadata (`/repodata/`) and `.rpm` pool (`/packages/`) +live in **Cloudflare R2** (bucket `wheels-yum`) and are served via R2's +custom-domain feature. + +> **Note on Pages vs R2:** The original Phase 2 design called for Cloudflare +> Pages. Pages has a hard 25 MiB per-file limit; the `.rpm` is ~81 MB. +> Architecture pivoted to R2 during initial rollout. Copy these files into the new repo when it's created — they are designed to work out of the box once the Phase 2 operational prerequisites (GPG key, @@ -79,9 +85,12 @@ so `rpmvercmp` orders snapshot releases below the next GA correctly. Same GPG key as the apt repo (one key for both, importable on both clients via `https://apt.wheels.dev/wheels.gpg` or `https://yum.wheels.dev/wheels.gpg`). -CI secrets on `https://github.com/wheels-dev/yum-wheels-dev/settings/secrets/actions`: +R2 bucket: create `wheels-yum`, attach `yum.wheels.dev` as a custom domain. + +CI secrets on `https://github.com/wheels-dev/yum-wheels/settings/secrets/actions`: - `WHEELS_REPO_GPG_PRIVATE_KEY` — ASCII-armored private key - `WHEELS_REPO_GPG_PASSPHRASE` — passphrase +- `CLOUDFLARE_API_TOKEN` — token with `Workers R2 Storage:Edit` on the account The upstream dispatch token (`LINUX_REPO_DISPATCH_TOKEN` on `wheels-dev/wheels`) must have `actions: write` on this repo. diff --git a/tools/distribution-drafts/yum-repo/scripts/regenerate-yum-metadata.sh b/tools/distribution-drafts/yum-repo/scripts/regenerate-yum-metadata.sh index 7403e2d8bd..05b5a0b7c9 100755 --- a/tools/distribution-drafts/yum-repo/scripts/regenerate-yum-metadata.sh +++ b/tools/distribution-drafts/yum-repo/scripts/regenerate-yum-metadata.sh @@ -29,15 +29,29 @@ CHANNELS="stable bleeding-edge" # any existing signature). # # Requires rpm-sign (Fedora/RHEL) for the rpm command itself. +# +# Why a custom %__gpg_sign_cmd: in CI there is no TTY, so the default +# rpm-build sign command (`gpg ... --pinentry-mode loopback --passphrase-fd 3 ...`) +# either fails to open /dev/tty or has no fd 3 wired up. Override with an +# explicit `--passphrase-file` pointing at a chmod-600 file we control. The +# file lives under $RUNNER_TEMP (or /tmp as a fallback), is unreadable by +# other users, and is wiped at the end of the script. +PASS_FILE="${RUNNER_TEMP:-/tmp}/wheels-rpm-pass.$$" +umask 077 +printf '%s' "${GPG_PASSPHRASE:-}" > "$PASS_FILE" +trap 'rm -f "$PASS_FILE"' EXIT + cat > ~/.rpmmacros < "${GNUPGHOME:-${HOME}/.gnupg}/gpg-agent.conf" </dev/null` only suppresses rpm's stdout progress chatter; stderr + # is intentionally left open so a signing failure (bad passphrase, missing + # macro, gpg error) still surfaces in the CI log. Do NOT broaden this to + # `>/dev/null 2>&1` — silent signing failures would corrupt the repo. rpm --addsign "$rpm_file" >/dev/null echo " ✓ signed $(basename "$rpm_file")" done diff --git a/tools/distribution-drafts/yum-repo/templates/index.html b/tools/distribution-drafts/yum-repo/templates/index.html index 8cd699d23e..47b4740db1 100644 --- a/tools/distribution-drafts/yum-repo/templates/index.html +++ b/tools/distribution-drafts/yum-repo/templates/index.html @@ -72,7 +72,7 @@

Upgrading

sudo dnf upgrade wheels        # or wheels-be

Signing key

-

The repository is signed with the Wheels project key. The public half is published at /wheels.gpg. Both .repo files reference it via gpgkey=, so dnf imports it automatically on first refresh. The fingerprint is committed to the source repo in FINGERPRINT for offline verification.

+

The repository is signed with the Wheels project key. The public half is published at /wheels.gpg. Both .repo files reference it via gpgkey=, so dnf imports it automatically on first refresh. The fingerprint is committed to the source repo in FINGERPRINT for offline verification.

Other platforms

    @@ -82,7 +82,7 @@

    Other platforms

diff --git a/tools/distribution-drafts/yum-repo/workflows/wheels-released.yml b/tools/distribution-drafts/yum-repo/workflows/wheels-released.yml index 32d98cfe8e..2a8027099e 100644 --- a/tools/distribution-drafts/yum-repo/workflows/wheels-released.yml +++ b/tools/distribution-drafts/yum-repo/workflows/wheels-released.yml @@ -1,18 +1,19 @@ -# Receiver workflow for `wheels-dev/yum-wheels-dev`. +# Receiver workflow for `wheels-dev/yum-wheels` (R2-backed architecture). # # Listens for `repository_dispatch` from `wheels-dev/wheels`'s release workflow, -# downloads the new `.rpm` asset from the upstream GitHub Release, slots it -# into `/packages/`, regenerates the repodata via `createrepo_c`, -# signs `repomd.xml` with GPG (detached → repomd.xml.asc), and commits the -# whole tree back to this repo. Cloudflare Pages auto-deploys on push. +# downloads the new `.rpm` asset from the upstream GitHub Release, syncs the +# existing yum tree from R2 (so createrepo_c can re-scan prior versions), +# signs each .rpm with `rpm --addsign`, regenerates the channel's repodata +# via `createrepo_c`, gpg-signs `repomd.xml`, and uploads the changes to the +# `wheels-yum` R2 bucket (served at https://yum.wheels.dev). +# +# This repo no longer commits stable/ or bleeding-edge/ — those are R2-only. # # Trigger payload contract (sender: wheels-dev/wheels release.yml): # event_type: "wheels-released" # client_payload: -# version: "" or "-snapshot." # full SemVer, no leading v -# channel: "stable" | "bleeding-edge" # routes into the pool -# -# Manual dispatch is also supported via `workflow_dispatch`. +# version: "" or "-snapshot." +# channel: "stable" | "bleeding-edge" name: Publish to yum.wheels.dev @@ -35,12 +36,21 @@ on: - bleeding-edge permissions: - contents: write + contents: read concurrency: group: publish-yum cancel-in-progress: false +env: + R2_BUCKET: wheels-yum + # Cloudflare account ID is not a secret (no auth power on its own), but + # keep it as a repo-level Actions variable so the workflow template stays + # account-agnostic and so harvesting a public template doesn't hand an + # attacker a ready-made API target. Set on the bucket repo: + # gh variable set CLOUDFLARE_ACCOUNT_ID --body '' --repo wheels-dev/yum-wheels + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + jobs: publish: name: Publish ${{ github.event.client_payload.version || inputs.version }} (${{ github.event.client_payload.channel || inputs.channel }}) @@ -49,13 +59,19 @@ jobs: steps: - name: Resolve inputs id: inputs + env: + CLIENT_VERSION: ${{ github.event.client_payload.version }} + CLIENT_CHANNEL: ${{ github.event.client_payload.channel }} + INPUT_VERSION: ${{ inputs.version }} + INPUT_CHANNEL: ${{ inputs.channel }} run: | + set -euo pipefail if [ "${{ github.event_name }}" = "repository_dispatch" ]; then - VERSION="${{ github.event.client_payload.version }}" - CHANNEL="${{ github.event.client_payload.channel }}" + VERSION="$CLIENT_VERSION" + CHANNEL="$CLIENT_CHANNEL" else - VERSION="${{ inputs.version }}" - CHANNEL="${{ inputs.channel }}" + VERSION="$INPUT_VERSION" + CHANNEL="$INPUT_CHANNEL" fi if [ -z "$VERSION" ] || [ -z "$CHANNEL" ]; then echo "::error::Both version and channel are required." @@ -66,35 +82,30 @@ jobs: *) echo "::error::Unsupported channel: $CHANNEL"; exit 1 ;; esac case "$CHANNEL" in - stable) PKG="wheels" ;; - bleeding-edge) PKG="wheels-be" ;; - esac - case "$CHANNEL" in - stable) UPSTREAM_REPO="wheels-dev/wheels" ;; - bleeding-edge) UPSTREAM_REPO="wheels-dev/wheels-snapshots" ;; + stable) PKG="wheels"; UPSTREAM_REPO="wheels-dev/wheels" ;; + bleeding-edge) PKG="wheels-be"; UPSTREAM_REPO="wheels-dev/wheels-snapshots" ;; esac - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "channel=$CHANNEL" >> "$GITHUB_OUTPUT" - echo "pkg=$PKG" >> "$GITHUB_OUTPUT" - echo "upstream_repo=$UPSTREAM_REPO" >> "$GITHUB_OUTPUT" + { + echo "version=$VERSION" + echo "channel=$CHANNEL" + echo "pkg=$PKG" + echo "upstream_repo=$UPSTREAM_REPO" + } >> "$GITHUB_OUTPUT" - name: Checkout bucket repo uses: actions/checkout@v4 with: fetch-depth: 1 - persist-credentials: true - - name: Install createrepo_c + gpg + rpm - # `rpm` brings the `rpm --addsign` tool, used by - # regenerate-yum-metadata.sh to sign each .rpm before createrepo_c - # records the hash chain. Without it, dnf clients refuse to install - # because the wheels.repo files set gpgcheck=1. + - name: Install createrepo_c + gpg + rpm + wrangler run: | sudo apt-get update - sudo apt-get install -y --no-install-recommends createrepo-c gnupg rpm + sudo apt-get install -y --no-install-recommends createrepo-c gnupg rpm jq createrepo_c --version gpg --version | head -1 rpm --version + sudo npm install -g wrangler@4.95.0 + wrangler --version - name: Import GPG signing key env: @@ -107,9 +118,6 @@ jobs: exit 1 fi echo "$GPG_PRIVATE_KEY" | gpg --batch --yes --import - # See apt-repo/workflows/wheels-released.yml for the fingerprint-vs-key_id - # rationale — import-ownertrust needs the 40-char fingerprint, not the - # 16-char key_id (otherwise: "gpg: error in '[stdin]': invalid fingerprint"). FINGERPRINT=$(gpg --list-secret-keys --with-colons \ | awk -F: '/^fpr:/ { print $10; exit }') KEY_ID=$(gpg --list-secret-keys --keyid-format=long --with-colons \ @@ -118,11 +126,55 @@ jobs: echo "$FINGERPRINT:6:" | gpg --batch --yes --import-ownertrust echo "GPG_KEY_ID=$KEY_ID" >> "$GITHUB_ENV" - - name: Download .rpm from upstream Release + - name: Sync existing channel from R2 + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CHANNEL: ${{ steps.inputs.outputs.channel }} + run: | + set -euo pipefail + # createrepo_c rescans /packages/ on each run, so we need + # ALL previously-published .rpm files locally to keep them in the + # repodata. Pull the whole channel down before adding the new one. + mkdir -p "${CHANNEL}/packages" + PREFIX="${CHANNEL}/" + CURSOR="" + while :; do + URL="https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/r2/buckets/${R2_BUCKET}/objects?prefix=${PREFIX}&per_page=1000" + [ -n "$CURSOR" ] && URL="${URL}&cursor=${CURSOR}" + RESP=$(curl -sS "$URL" -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}") + # Guard against silent failures: when the token is invalid/expired + # or the API rate-limits, success=false comes back with an empty + # result array. Without this check, jq emits nothing, the loop + # never runs, createrepo_c regenerates over a single-package + # channel, and the upload step overwrites repomd.xml on R2 with + # that trimmed metadata. Every dnf update afterward sees the + # historical packages vanish. Fail loud here instead. + if ! echo "$RESP" | jq -e '.success == true' >/dev/null; then + echo "::error::Cloudflare R2 list API returned success=false (or invalid JSON). Response:" + echo "$RESP" + exit 1 + fi + # Process substitution (not a pipe) so the while body runs in the + # parent shell — pipe-RHS runs in a subshell where set -e in the + # parent does not see wrangler's non-zero exit, masking download + # failures. Same reasoning: wrangler's stderr is NOT suppressed + # so a real failure surfaces in the CI log. + while IFS= read -r key; do + [ -z "$key" ] && continue + mkdir -p "$(dirname "$key")" + echo " pulling $key" + wrangler r2 object get "${R2_BUCKET}/${key}" --file="$key" --remote + done < <(echo "$RESP" | jq -r '.result[].key') + CURSOR=$(echo "$RESP" | jq -r '.result_info.cursor // empty') + [ -z "$CURSOR" ] && break + done + echo "Local ${CHANNEL}/ contents after sync:" + find "${CHANNEL}/" -type f | head -20 + + - name: Download new .rpm from upstream Release env: GH_TOKEN: ${{ github.token }} VERSION: ${{ steps.inputs.outputs.version }} - CHANNEL: ${{ steps.inputs.outputs.channel }} PKG: ${{ steps.inputs.outputs.pkg }} UPSTREAM_REPO: ${{ steps.inputs.outputs.upstream_repo }} run: | @@ -136,9 +188,9 @@ jobs: --repo "$UPSTREAM_REPO" \ --pattern "$ASSET" \ --dir incoming/ - ls -l incoming/ + ls -lh incoming/ - - name: Slot .rpm into packages/ + - name: Slot new .rpm into local channel env: VERSION: ${{ steps.inputs.outputs.version }} CHANNEL: ${{ steps.inputs.outputs.channel }} @@ -148,7 +200,6 @@ jobs: PKG_DIR="${CHANNEL}/packages" mkdir -p "$PKG_DIR" URL_VERSION="${VERSION/-snapshot./.snapshot.}" - # Rename to the canonical `~`-form for pool storage. mv "incoming/${PKG}-${URL_VERSION}.x86_64.rpm" \ "${PKG_DIR}/${PKG}-${VERSION}.x86_64.rpm" echo "Placed: ${PKG_DIR}/${PKG}-${VERSION}.x86_64.rpm" @@ -163,19 +214,37 @@ jobs: chmod +x scripts/regenerate-yum-metadata.sh ./scripts/regenerate-yum-metadata.sh - - name: Commit + push + - name: Upload channel tree to R2 env: - VERSION: ${{ steps.inputs.outputs.version }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CHANNEL: ${{ steps.inputs.outputs.channel }} - PKG: ${{ steps.inputs.outputs.pkg }} run: | set -euo pipefail - git config user.name "wheels-release-bot" - git config user.email "hello@wheels.dev" - git add stable/ bleeding-edge/ - if git diff --cached --quiet; then - echo "No changes — dispatch must have been a no-op." - exit 0 - fi - git commit -m "publish: ${PKG} ${VERSION} (${CHANNEL})" - git push origin HEAD:main + upload_one() { + local local_path="$1" + local ct="$2" + local key="$local_path" + echo " uploading $key (ct=${ct})" + # Do not suppress wrangler output — a silent upload failure would + # leave R2 with stale repomd.xml / repodata while the job exits + # green. + wrangler r2 object put "${R2_BUCKET}/${key}" --file="$local_path" --content-type="$ct" --remote + } + # Walk the channel tree and upload every file. createrepo_c is + # idempotent — files unchanged from prior runs will overwrite + # with the same bytes. New .rpm + regenerated repodata/ are the + # only meaningful diffs. + # Process substitution (not pipe) so set -e fires on upload_one + # failures — pipe-RHS subshell semantics would swallow them. + while read -r f; do + case "$f" in + *.rpm) ct="application/x-rpm" ;; + *.xml) ct="application/xml" ;; + *.xml.gz) ct="application/gzip" ;; + *.asc) ct="application/pgp-signature" ;; + *.key) ct="application/pgp-keys" ;; + *) ct="application/octet-stream" ;; + esac + upload_one "$f" "$ct" + done < <(find "${CHANNEL}" -type f) + echo "R2 upload complete for channel=${CHANNEL}." diff --git a/tools/test-local.sh b/tools/test-local.sh index 8a3ca52223..b4fad747cf 100755 --- a/tools/test-local.sh +++ b/tools/test-local.sh @@ -6,8 +6,8 @@ # Wheels is built on the LuCLI runtime; we ship the runtime under the # `wheels` brand. There is no separate `lucli` binary on a normal install. # - Java 21+ installed -# - SQLite JDBC driver in ~/.wheels/express/*/lib/ext/ (auto-installed by recent -# Wheels CLI releases; older releases may use ~/.lucli/express/*/lib/ext/) +# - SQLite JDBC driver in ~/.wheels/express/*/lib/ext/ (auto-installed by +# recent Wheels CLI releases) # # Usage: # bash tools/test-local.sh # run all core tests @@ -75,10 +75,11 @@ if curl -s -o /dev/null --connect-timeout 2 --max-time 3 "http://localhost:${POR else echo "Starting Wheels CLI server on port ${PORT}..." - # Ensure SQLite JDBC is installed. Recent Wheels CLI releases extract - # Lucee Express to ~/.wheels/express/; older releases used ~/.lucli/express/. - # Search both so this script keeps working through the rename. - LUCEE_LIB=$(find ~/.wheels/express ~/.lucli/express -path "*/lib/ext" -type d 2>/dev/null | head -1) + # Locate Lucee Express's lib/ext so we can drop the SQLite JDBC there. + # `|| true` keeps `set -e` from killing the script when the directory is + # missing — `find` exits non-zero on missing path args (stderr suppressed + # via 2>/dev/null but the exit status survives pipefail). + LUCEE_LIB=$(find ~/.wheels/express -path "*/lib/ext" -type d 2>/dev/null | head -1 || true) if [ -n "$LUCEE_LIB" ] && ! ls "$LUCEE_LIB"/sqlite-jdbc*.jar 1>/dev/null 2>&1; then echo "Downloading SQLite JDBC driver..." curl -sL "https://repo1.maven.org/maven2/org/xerial/sqlite-jdbc/3.49.1.0/sqlite-jdbc-3.49.1.0.jar" \ diff --git a/vendor/wheels/Global.cfc b/vendor/wheels/Global.cfc index 96cb870485..ab39770359 100644 --- a/vendor/wheels/Global.cfc +++ b/vendor/wheels/Global.cfc @@ -2427,7 +2427,25 @@ return local.$wheels; } /** - * Internal function. + * Internal function. Wheels's canonical plural-or-singular argument alias + * helper. If `args.` is set, copy it to `args.` and delete + * the original — so the function body can read `args.` uniformly + * regardless of which name the caller used. With `required=true`, throws + * `Wheels.IncorrectArguments` when neither name is provided. + * + * Canonical examples: + * - `combine = "columnNames,columnName"` — migrator column helpers in + * vendor/wheels/migrator/TableDefinition.cfc + * - `combine = "properties,property"` — model validations in + * vendor/wheels/model/validations.cfc + * - `combine = "formats,format"` — controller provides() in + * vendor/wheels/controller/provides.cfc + * - `combine = "referenceNames,columnNames"` — t.references() per #2781 + * + * When adding a new helper that takes a list-or-single argument, follow + * this pattern: declare the plural form on the signature (NOT required), + * then call $combineArguments(required=true) at the top of the body so the + * alias works AND the required-ness is enforced at runtime. */ public void function $combineArguments( required struct args, @@ -3849,7 +3867,116 @@ return local.$wheels; } } + /** + * Snapshot mtimes of all .cfm files under the app's global include directory. + * + * Used by the bare `?reload=true` path so a developer adding a helper to + * `app/global/*.cfm` does not have to remember the password-gated full reload + * (issue ##2792). + */ + public struct function $snapshotGlobalIncludes(string directory = ExpandPath("/app/global")) { + var snapshot = {}; + if (!DirectoryExists(arguments.directory)) { + return snapshot; + } + var files = DirectoryList(arguments.directory, true, "query", "*.cfm"); + for (var row in files) { + snapshot[row.directory & "/" & row.name] = row.dateLastModified; + } + return snapshot; + } + + /** + * Compare a prior `$snapshotGlobalIncludes` result against the current + * filesystem state and return true if any tracked .cfm file was added, + * removed, or modified. + * + * Paired with `$snapshotGlobalIncludes` to drive the bare `?reload=true` + * soft-reload path in development (issue ##2792). + */ + public boolean function $globalIncludesChanged( + required struct snapshot, + string directory = ExpandPath("/app/global") + ) { + var current = $snapshotGlobalIncludes(directory = arguments.directory); + for (var key in current) { + if (!StructKeyExists(arguments.snapshot, key)) { + return true; + } + if (DateCompare(arguments.snapshot[key], current[key]) != 0) { + return true; + } + } + for (var key in arguments.snapshot) { + if (!StructKeyExists(current, key)) { + return true; + } + } + return false; + } + + /** + * Re-evaluate the given global-includes file into `application.wo`'s + * variables/this scope. Invoked from the bare `?reload=true` soft-reload + * when `$globalIncludesChanged` reports drift (issue ##2792). + * + * `include` inside a method body adds function declarations to the + * method's local scope, not the component's outer scope, so we walk + * local for any user-defined functions and copy them onto variables + * and this so they remain callable on `application.wo` across requests. + */ + public void function $reincludeGlobals(string file = "/app/global/functions.cfm") { + // Evaluate the file in a throwaway instance and bind the functions it + // declares onto variables + this. Done via a separate instance (not a + // bare `include` here) because Adobe CF throws "Routines cannot be + // declared more than once" when a `?reload=true` re-includes a file + // whose UDFs are already bound to application.wo — the prior copy in + // our own scope collides with the re-declaration. A fresh scope per + // call sidesteps that; rebinding here is a plain struct assignment, so + // the updated version replaces the old one on every engine. + var reloaded = new wheels.GlobalIncludeLoader().loadFunctions(arguments.file); + for (var key in reloaded) { + variables[key] = reloaded[key]; + this[key] = reloaded[key]; + } + } + // User-defined global functions include "/app/global/functions.cfm"; + // Promote include-injected UDFs from `variables` to `this` so they're + // discoverable via struct-iteration on engines (Adobe CF) where only + // `this`-scope members are reliably enumerable. Declared methods on + // Global.cfc are already in `this` via their `access` modifier and are + // not clobbered by the `structKeyExists(this, ...)` guard. See #2790 + // and the auto-bind loop in `vendor/wheels/WheelsTest.cfc`. + // + // Delegated to `$promoteIncludedGlobalsToThis()` so the loop iterator + // lives in a real function-local scope. Inlining a `local.X` iterator in + // the pseudo-constructor materializes `variables.local` on the Global + // instance — harmless on Lucee/Adobe (where `local` is reserved to the + // function scope) but on BoxLang it shadows the method-local `local` of + // every mixed-in `$`-helper (Migrator/Model `local.appKey`, …), throwing + // "The key [...] was not found in the struct. Valid keys are ([VARKEY])". + $promoteIncludedGlobalsToThis(); + + /** + * Copy include-injected user functions from `variables` onto `this` so + * they remain enumerable on engines (Adobe CF) where struct-iteration + * only reliably surfaces `this`-scope members. Must stay a function: an + * inline `local.X` iterator in the pseudo-constructor materializes + * `variables.local` and shadows method-local `local` on BoxLang. + */ + public void function $promoteIncludedGlobalsToThis() { + for (var promoteKey in variables) { + if (!isCustomFunction(variables[promoteKey])) { + continue; + } + if (structKeyExists(this, promoteKey)) { + continue; + } + this[promoteKey] = variables[promoteKey]; + } + } + } diff --git a/vendor/wheels/GlobalIncludeLoader.cfc b/vendor/wheels/GlobalIncludeLoader.cfc new file mode 100644 index 0000000000..417a17c56a --- /dev/null +++ b/vendor/wheels/GlobalIncludeLoader.cfc @@ -0,0 +1,49 @@ +/** + * Throwaway helper that evaluates a global-includes `.cfm` file in its OWN + * component scope and returns the user-defined functions it declares. + * + * Used by `Global.cfc::$reincludeGlobals` for the bare `?reload=true` + * soft-reload (issue #2792). Evaluating the include in a fresh instance per + * call is what makes re-including an overwritten helper file safe on Adobe CF: + * including a file that re-declares a function name into a scope that already + * holds that function throws "Routines cannot be declared more than once". A + * brand-new instance has a clean scope, so the declaration never collides with + * the copy already bound to `application.wo`. + * + * [section: Internal] + * [category: Reload] + */ +component output="false" { + + /** + * Evaluate `file` and return a struct of the user-defined functions it + * declared, keyed by function name. + * + * @file Mapping-relative path to the `.cfm` to evaluate (e.g. "/app/global/functions.cfm"). + */ + public struct function loadFunctions(required string file) { + // Snapshot the component's own `variables` members BEFORE the include so + // the post-include diff captures only what the file declared. Robust to + // this component gaining more methods later — a by-name exclusion of + // `loadFunctions` would silently start leaking any new sibling method. + var beforeVarKeys = StructKeyList(variables); + include "#arguments.file#"; + + var fns = {}; + // Lucee adds include-declared functions to `local`; Adobe CF adds them + // to `variables`. Collect from both; on the `variables` side keep only + // keys that appeared after the include (i.e. the file's own functions). + for (var localKey in local) { + if (IsCustomFunction(local[localKey])) { + fns[localKey] = local[localKey]; + } + } + for (var varKey in variables) { + if (!ListFindNoCase(beforeVarKeys, varKey) && IsCustomFunction(variables[varKey])) { + fns[varKey] = variables[varKey]; + } + } + return fns; + } + +} diff --git a/vendor/wheels/Migrator.cfc b/vendor/wheels/Migrator.cfc index b2f689f49e..35558c9472 100644 --- a/vendor/wheels/Migrator.cfc +++ b/vendor/wheels/Migrator.cfc @@ -39,14 +39,71 @@ component output="false" extends="wheels.Global"{ } } + // Issue #2780: detect orphan versions (DB rows whose timestamp has no + // matching local file). Common in shared dev DBs when a peer applied + // a migration whose file isn't yet in this branch. Without this + // check, the directional logic below silently took the "down" branch + // and emitted a misleading "Migrating from X down to Y" output. + local.orphans = $getOrphanVersions(); + local.orphansAboveTarget = []; + for (local.v in local.orphans) { + if (local.v > arguments.version) { + ArrayAppend(local.orphansAboveTarget, local.v); + } + } + local.isOrphanAtTop = ( + local.currentVersion > arguments.version + && ArrayLen(local.orphansAboveTarget) + && !arguments.missingMigFlag + ); + if (local.isOrphanAtTop) { + // Check whether EVERY DB version above target is an orphan. If + // some have local files, the down branch is legitimate (the user + // has files to run down() on) — we still emit a warning naming + // the orphans but otherwise proceed as before. + local.dbVersionsAboveTarget = []; + for (local.v in ListToArray($getVersionsPreviouslyMigrated())) { + if (Len(local.v) && local.v != "0" && local.v > arguments.version) { + ArrayAppend(local.dbVersionsAboveTarget, local.v); + } + } + local.allOrphans = ArrayLen(local.dbVersionsAboveTarget) == ArrayLen(local.orphansAboveTarget); + if (local.allOrphans) { + local.rv = "Note: database tracks version(s) " + & ArrayToList(local.orphansAboveTarget, ", ") + & " with no matching file in app/migrator/migrations/. " + & "This usually means a peer applied a migration whose " + & "file isn't yet in your branch.#Chr(13) & Chr(10)#"; + if (!local.hasPendingMigrations) { + local.rv &= "Nothing to do. Your target version (" + & arguments.version + & ") is below the database's current version (" + & local.currentVersion & ").#Chr(13) & Chr(10)#"; + return local.rv; + } + // Suppress the down branch: rewrite currentVersion so the + // outer conditional falls through to the up branch, which + // applies any actually-pending local migrations. + local.currentVersion = arguments.version; + } else { + // Mixed case: some legitimate down candidates, some orphans. + // Warn but let the existing down branch handle the rest; + // orphan rows are skipped naturally because the loop only + // iterates local files. + local.rv = "Note: database tracks version(s) " + & ArrayToList(local.orphansAboveTarget, ", ") + & " with no matching file. These will be skipped during rollback.#Chr(13) & Chr(10)#"; + } + } + if (local.currentVersion == arguments.version && !local.hasPendingMigrations) { - local.rv = "Database is currently at version #arguments.version#. No migration required.#Chr(13) & Chr(10)#"; + local.rv &= "Database is currently at version #arguments.version#. No migration required.#Chr(13) & Chr(10)#"; } else { if (!DirectoryExists(this.paths.sql) && application[local.appKey].writeMigratorSQLFiles) { DirectoryCreate(this.paths.sql); } if (local.currentVersion > arguments.version && arguments.missingMigFlag == false) { - local.rv = "Migrating from #local.currentVersion# down to #arguments.version#.#Chr(13) & Chr(10)#"; + local.rv &= "Migrating from #local.currentVersion# down to #arguments.version#.#Chr(13) & Chr(10)#"; for (local.i = ArrayLen(local.migrations); local.i >= 1; local.i--) { local.migration = local.migrations[local.i]; if (local.migration.version <= arguments.version) { @@ -65,21 +122,25 @@ component output="false" extends="wheels.Global"{ if (application[local.appKey].writeMigratorSQLFiles) { $writeMigrationFile(request.$wheelsMigrationSQLFile, ""); } + // Issue #2789: skip nested cftransaction when migrator's outer one owns commit/rollback. + request.$wheelsTransactionWrapper = true; local.migration.cfc.down(); local.rv = local.rv & request.$wheelsMigrationOutput; $removeVersionAsMigrated(local.migration.version); } catch (any e) { local.rv = local.rv & "Error migrating to #local.migration.version#.#Chr(13) & Chr(10)##e.message##Chr(13) & Chr(10)##e.detail##Chr(13) & Chr(10)#"; transaction action="rollback"; + StructDelete(request, "$wheelsTransactionWrapper"); break; } + StructDelete(request, "$wheelsTransactionWrapper"); transaction action="commit"; } } } } else { if(arguments.missingMigFlag){ - local.rv = "Migrating remaining migrations till #arguments.version#.#Chr(13) & Chr(10)#"; + local.rv &= "Migrating remaining migrations till #arguments.version#.#Chr(13) & Chr(10)#"; $removeVersionAsMigrated(local.currentVersion); } else if (local.currentVersion gte arguments.version && local.hasPendingMigrations) { // Out-of-order pending migrations: a migration with a @@ -89,9 +150,9 @@ component output="false" extends="wheels.Global"{ // generator's current-day timestamp). The "from N up to N" // framing reads as a no-op even though new migrations are // about to run, so emit a clearer message. Onboarding F16. - local.rv = "Applying pending migration(s) up to #arguments.version#.#Chr(13) & Chr(10)#"; + local.rv &= "Applying pending migration(s) up to #arguments.version#.#Chr(13) & Chr(10)#"; } else { - local.rv = "Migrating from #local.currentVersion# up to #arguments.version#.#Chr(13) & Chr(10)#"; + local.rv &= "Migrating from #local.currentVersion# up to #arguments.version#.#Chr(13) & Chr(10)#"; } for (local.migration in local.migrations) { if (local.migration.version <= arguments.version && local.migration.status != "migrated") { @@ -107,14 +168,18 @@ component output="false" extends="wheels.Global"{ if (application[local.appKey].writeMigratorSQLFiles) { $writeMigrationFile(request.$wheelsMigrationSQLFile, ""); } + // Issue #2789: skip nested cftransaction when migrator's outer one owns commit/rollback. + request.$wheelsTransactionWrapper = true; local.migration.cfc.up(); local.rv = local.rv & request.$wheelsMigrationOutput; - $setVersionAsMigrated(local.migration.version); + $setVersionAsMigrated(local.migration.version, local.migration.name); } catch (any e) { local.rv = local.rv & "Error migrating to #local.migration.version#.#Chr(13) & Chr(10)##e.message##Chr(13) & Chr(10)##e.detail##Chr(13) & Chr(10)#"; transaction action="rollback"; + StructDelete(request, "$wheelsTransactionWrapper"); break; } + StructDelete(request, "$wheelsTransactionWrapper"); transaction action="commit"; } } else if (local.migration.version > arguments.version) { @@ -168,13 +233,21 @@ component output="false" extends="wheels.Global"{ if (application[local.appKey].writeMigratorSQLFiles) { $writeMigrationFile(request.$wheelsMigrationSQLFile, ""); } + // Issue #2789: skip nested cftransaction when migrator's outer one owns commit/rollback. + request.$wheelsTransactionWrapper = true; local.migration.cfc.up(); local.rv = local.rv & request.$wheelsMigrationOutput; - $setVersionAsMigrated(local.migration.version); + $setVersionAsMigrated(local.migration.version, local.migration.name); } catch (any e) { local.rv = local.rv & "Error migrating #local.migration.version#.#Chr(13) & Chr(10)##e.message##Chr(13) & Chr(10)##e.detail##Chr(13) & Chr(10)#"; transaction action="rollback"; + StructDelete(request, "$wheelsTransactionWrapper"); + // Skip the commit below — rollback already closed the transaction. + // Mirrors the `break` in migrateTo()'s catch (no enclosing loop + // here, so we return instead). + return local.rv; } + StructDelete(request, "$wheelsTransactionWrapper"); transaction action="commit"; } return local.rv; @@ -316,15 +389,61 @@ component output="false" extends="wheels.Global"{ } /** - * Inserts a record to flag a version as migrated. + * Inserts a record to flag a version as migrated. When the enriched + * tracking schema is in use (name + applied_at columns present, signaled + * by application[appKey].$trackingColumnsEnsured), populates name with + * the supplied migrationName and applied_at with NOW() (for SQLite, + * which can't default a TIMESTAMP column on ADD; other engines use + * their column DEFAULT and we omit applied_at from the INSERT). + * + * @migrationName Human-readable name of the migration (e.g. "create_users"). + * Optional — when empty or when the enriched columns aren't present, + * only version + core_level are written, matching legacy behavior. */ - private void function $setVersionAsMigrated(required string version) { + private void function $setVersionAsMigrated(required string version, string migrationName = "") { local.appKey = $appKey(); - if (!StructKeyExists(request, "$wheelsDebugSQL")) - $query( - datasource = application[local.appKey].dataSourceName, - sql = "INSERT INTO #application[local.appKey].migratorTableName# (version, core_level) VALUES ('#$sanitiseVersion(arguments.version)#', #application[local.appKey].migrationLevel#)" - ); + if (StructKeyExists(request, "$wheelsDebugSQL")) { + return; + } + local.cleanVersion = $sanitiseVersion(arguments.version); + local.cols = "version, core_level"; + local.vals = "'#local.cleanVersion#', #application[local.appKey].migrationLevel#"; + // Only write the enriched columns when they exist on this app's + // schema. The $trackingColumnsEnsured flag is set by + // $maybeEnsureTrackingColumns() after a successful ALTER (or after + // it confirms the columns are already present). + if ( + Len(arguments.migrationName) + && StructKeyExists(application[local.appKey], "$trackingColumnsEnsured") + ) { + // Single-quote escape (CFML standard SQL string literal) to defend + // against accidental quote chars in migration names. The names are + // derived from filenames, which Wheels' generator only allows + // alphanumeric + underscore in, but defending here costs nothing. + local.escapedName = Replace(arguments.migrationName, "'", "''", "all"); + local.cols &= ", name"; + local.vals &= ", '#local.escapedName#'"; + // SQLite can't DEFAULT a TIMESTAMP on ADD COLUMN, so we set the + // value explicitly. Other engines rely on the column's + // CURRENT_TIMESTAMP default and we omit applied_at from the + // INSERT to avoid engine-specific date-literal syntax issues. + // + // IMPORTANT: read engine type from the cached value that + // $ensureTrackingColumns set on app scope. Calling $dbinfo here + // would run JDBC metadata inside the migrator's open transaction, + // which breaks SQLite ("[SQLITE_ERROR] SQL error or missing + // database") and would silently corrupt other engines under + // concurrent load. + local.cachedDbType = application[local.appKey].$migratorDbType ?: ""; + if (FindNoCase("SQLite", local.cachedDbType)) { + local.cols &= ", applied_at"; + local.vals &= ", '#DateTimeFormat(Now(), 'yyyy-mm-dd HH:nn:ss')#'"; + } + } + $query( + datasource = application[local.appKey].dataSourceName, + sql = "INSERT INTO #application[local.appKey].migratorTableName# (#local.cols#) VALUES (#local.vals#)" + ); } /** @@ -454,6 +573,10 @@ component output="false" extends="wheels.Global"{ datasource = application[local.appKey].dataSourceName, sql = "SELECT version FROM #application[local.appKey].migratorTableName# WHERE core_level = #application[local.appKey].migrationLevel# ORDER BY version ASC" ); + // Table exists — ensure the enriched name + applied_at columns are + // present. Cached on app scope so this fires once per app process, + // not on every migrator call. See issue #2780 / Plan 3. + $maybeEnsureTrackingColumns(local.appKey); if (!local.migratedVersions.recordcount) { return 0; } else { @@ -528,10 +651,379 @@ component output="false" extends="wheels.Global"{ } } } + // Tracking table was just bootstrapped — add the enriched + // columns now so subsequent $setVersionAsMigrated calls can + // write the migration name + applied timestamp. + $maybeEnsureTrackingColumns(local.appKey); return 0; } } + /** + * Refreshes the $trackingColumnsEnsured flag from the actual schema + * state on every call. Calling $ensureTrackingColumns() unconditionally + * (not skip-on-flag) is what makes this robust to tests that drop and + * recreate the tracking table, and to manual SQL ops in production + * that might invalidate the schema. $ensureTrackingColumns is itself + * idempotent and cheap — one column probe per call — and short-circuits + * when both columns are already present. + * + * The flag is set true when both columns are confirmed present, and + * explicitly cleared otherwise (table was dropped/recreated, ALTER + * partially failed, or probe errored entirely). $setVersionAsMigrated() + * reads the flag to decide whether to include the enriched columns + * in its INSERT; an out-of-date flag would cause INSERTs against + * missing columns or skip the enriched path when columns are present. + */ + private void function $maybeEnsureTrackingColumns(required string appKey) { + try { + var rv = $ensureTrackingColumns(); + if (rv.hasName && rv.hasAppliedAt) { + application[arguments.appKey].$trackingColumnsEnsured = true; + } else { + // Probe says columns aren't both present (table dropped + + // recreated, ALTER failed, or schema rolled back externally). + // Clear any stale cache so $setVersionAsMigrated falls back + // to the legacy two-column INSERT. + StructDelete(application[arguments.appKey], "$trackingColumnsEnsured"); + } + } catch (any e) { + // Probe failed entirely (table might not exist yet, or + // permission issue). Clear cache and let the legacy schema + // continue to work — the migrator is not blocked. + StructDelete(application[arguments.appKey], "$trackingColumnsEnsured"); + } + } + + /** + * Returns versions recorded in the tracking table that have no matching + * migration file in the current checkout. Used to detect the "shared dev + * database" case where a peer has applied a migration whose file isn't + * yet in the local branch. See issue #2780. + * + * Result is sorted ascending. The sentinel "0" returned by + * $getVersionsPreviouslyMigrated() on an empty tracking table is excluded. + * + * [section: Migrator] + * [category: General Functions] + */ + public array function $getOrphanVersions() { + local.appliedList = ListToArray($getVersionsPreviouslyMigrated()); + local.fileVersions = []; + for (local.m in getAvailableMigrations()) { + ArrayAppend(local.fileVersions, local.m.version); + } + local.orphans = []; + for (local.v in local.appliedList) { + if (Len(local.v) && local.v != "0" && !ArrayFind(local.fileVersions, local.v)) { + ArrayAppend(local.orphans, local.v); + } + } + ArraySort(local.orphans, function(a, b) { + return Compare(a, b); + }); + return local.orphans; + } + + /** + * Returns orphan versions enriched with the `name` and `applied_at` + * columns from the tracking table — when those columns exist (the + * Plan 3 schema enrichment). Falls back to bare-version structs when + * the columns aren't present yet (older installs that haven't bootstrapped + * via $maybeEnsureTrackingColumns). + * + * Each row: {version, name, appliedAt} where name and appliedAt are + * empty strings for legacy rows that pre-date the schema enrichment. + * + * Result is sorted ascending by version, matching $getOrphanVersions(). + * + * [section: Migrator] + * [category: General Functions] + */ + public array function $getOrphanVersionsWithMeta() { + local.bareOrphans = $getOrphanVersions(); + local.rv = []; + if (!ArrayLen(local.bareOrphans)) { + return local.rv; + } + local.appKey = $appKey(); + local.hasEnrichedColumns = StructKeyExists(application[local.appKey], "$trackingColumnsEnsured"); + if (!local.hasEnrichedColumns) { + // Schema enrichment not active — return bare structs so callers + // can render version-only. + for (local.v in local.bareOrphans) { + ArrayAppend(local.rv, {version: local.v, name: "", appliedAt: ""}); + } + return local.rv; + } + // Pull name + applied_at for the orphan versions in one query. + try { + local.versionsQuoted = "'" & ArrayToList(local.bareOrphans, "','") & "'"; + local.rows = $query( + datasource = application[local.appKey].dataSourceName, + sql = "SELECT version, name, applied_at FROM #application[local.appKey].migratorTableName# " + & "WHERE version IN (#local.versionsQuoted#) " + & "AND core_level = #application[local.appKey].migrationLevel# " + & "ORDER BY version ASC" + ); + local.metaByVersion = {}; + for (local.row in local.rows) { + local.metaByVersion[local.row.version] = { + name: local.row.name ?: "", + appliedAt: IsDate(local.row.applied_at ?: "") ? DateTimeFormat(local.row.applied_at, "yyyy-mm-dd HH:nn:ss") : "" + }; + } + for (local.v in local.bareOrphans) { + local.meta = local.metaByVersion[local.v] ?: {name: "", appliedAt: ""}; + ArrayAppend(local.rv, { + version: local.v, + name: local.meta.name, + appliedAt: local.meta.appliedAt + }); + } + } catch (any e) { + // If the enriched query fails (e.g. columns not yet committed on + // a different connection), fall back to bare structs. + for (local.v in local.bareOrphans) { + ArrayAppend(local.rv, {version: local.v, name: "", appliedAt: ""}); + } + } + return local.rv; + } + + /** + * Builds the human-readable info output for `wheels migrate info`. + * Returns an array of lines (caller joins with newlines). Extracted + * from cli.cfm's info handler so the rendering can be unit-tested + * without exercising the HTTP dispatcher. Orphan rows (DB versions + * with no matching local file — see issue #2780) are marked with + * [?] and the literal "********** NO FILE **********", Rails-style. + * + * [section: Migrator] + * [category: General Functions] + */ + public array function $buildInfoOutput() { + local.lines = []; + local.migrations = getAvailableMigrations(); + local.currentVersion = getCurrentMigrationVersion(); + local.orphansWithMeta = $getOrphanVersionsWithMeta(); + local.applied = 0; + local.pending = 0; + for (local.m in local.migrations) { + if (local.m.status == "migrated") { + local.applied++; + } else { + local.pending++; + } + } + ArrayAppend(local.lines, "Current version: " & (Len(local.currentVersion) ? local.currentVersion : "0")); + ArrayAppend(local.lines, "Total migrations: " & ArrayLen(local.migrations)); + if (ArrayLen(local.migrations) || ArrayLen(local.orphansWithMeta)) { + ArrayAppend(local.lines, " applied: " & local.applied); + ArrayAppend(local.lines, " pending: " & local.pending); + if (ArrayLen(local.orphansWithMeta)) { + ArrayAppend(local.lines, " orphan: " & ArrayLen(local.orphansWithMeta)); + } + ArrayAppend(local.lines, ""); + ArrayAppend(local.lines, "Migrations (newest last):"); + // Merge file rows + orphan rows into one chronological list so + // orphans appear in the right position relative to local files. + // Orphans with enriched metadata (Plan 3) show the peer's + // migration name + apply timestamp; legacy orphans (no name + // column) fall back to the literal NO FILE marker. + local.combined = []; + for (local.m in local.migrations) { + ArrayAppend(local.combined, { + version: local.m.version, + name: local.m.name, + appliedAt: "", + marker: local.m.status == "migrated" ? "[x]" : "[ ]" + }); + } + for (local.o in local.orphansWithMeta) { + ArrayAppend(local.combined, { + version: local.o.version, + name: Len(local.o.name) ? local.o.name : "********** NO FILE **********", + appliedAt: local.o.appliedAt, + marker: "[?]" + }); + } + ArraySort(local.combined, function(a, b) { + return Compare(a.version, b.version); + }); + for (local.row in local.combined) { + local.line = " " & local.row.marker & " " & local.row.version & " " & local.row.name; + if (Len(local.row.appliedAt)) { + local.line &= " (applied " & local.row.appliedAt & ")"; + } + ArrayAppend(local.lines, local.line); + } + if (ArrayLen(local.orphansWithMeta)) { + ArrayAppend(local.lines, ""); + ArrayAppend(local.lines, "Orphan versions are recorded in the database but have no"); + ArrayAppend(local.lines, "matching file in app/migrator/migrations/. This usually means"); + ArrayAppend(local.lines, "a peer applied a migration whose file isn't yet in your branch."); + } + } + return local.lines; + } + + /** + * Returns a comprehensive health report on the migrator state. Pure + * read — no mutation. Used by `wheels migrate doctor` to surface + * orphans, gaps, and pending migrations in one pass. + * + * Result struct: + * - healthy: boolean — true iff no orphans AND no pending + * - currentVersion: string — highest applied version (may be orphan) + * - orphans: array — DB versions with no matching file + * - pending: array — local files not yet applied + * - summary: struct with .total, .applied, .pending, .orphan counts + * - message: human-readable one-paragraph summary + * + * See issue #2780 / PR #2798 for the orphan detection foundation. + * + * [section: Migrator] + * [category: General Functions] + */ + public struct function doctor() { + local.migrations = getAvailableMigrations(); + local.orphans = $getOrphanVersions(); + local.orphansWithMeta = $getOrphanVersionsWithMeta(); + local.currentVersion = getCurrentMigrationVersion(); + local.pending = []; + local.applied = 0; + for (local.m in local.migrations) { + if (local.m.status == "migrated") { + local.applied++; + } else { + ArrayAppend(local.pending, local.m.version); + } + } + local.healthy = ArrayLen(local.orphans) == 0 && ArrayLen(local.pending) == 0; + local.rv = { + healthy: local.healthy, + currentVersion: local.currentVersion, + orphans: local.orphans, + orphansWithMeta: local.orphansWithMeta, + pending: local.pending, + summary: { + total: ArrayLen(local.migrations), + applied: local.applied, + pending: ArrayLen(local.pending), + orphan: ArrayLen(local.orphans) + } + }; + if (local.healthy) { + local.rv.message = "Migrator is healthy. " & local.applied & " migration(s) applied, none pending."; + } else { + local.parts = []; + if (ArrayLen(local.pending)) { + ArrayAppend(local.parts, ArrayLen(local.pending) & " pending"); + } + if (ArrayLen(local.orphans)) { + ArrayAppend(local.parts, ArrayLen(local.orphans) & " orphan"); + } + local.rv.message = "Migrator needs attention: " & ArrayToList(local.parts, ", ") & "."; + } + return local.rv; + } + + /** + * Removes a row from `wheels_migrator_versions` without running + * down(). Only orphan versions (those with no matching local file) + * can be forgotten — for legitimate rollbacks, use `migrate down`. + * + * Returns: {success, removed, message} + * + * @version The version string to forget (digits only after sanitisation). + * + * [section: Migrator] + * [category: General Functions] + */ + public struct function forgetVersion(required string version) { + local.rv = {success: false, removed: "", message: ""}; + local.cleanVersion = $sanitiseVersion(arguments.version); + if (!Len(local.cleanVersion)) { + local.rv.message = "Invalid version: must contain at least one digit."; + return local.rv; + } + local.appliedList = ListToArray($getVersionsPreviouslyMigrated()); + if (!ArrayFind(local.appliedList, local.cleanVersion)) { + local.rv.message = "Version " & local.cleanVersion & " was not found in the tracking table."; + return local.rv; + } + // Refuse to forget a version that has a matching local file. The + // user almost certainly wants `migrate down` instead; forgetting + // would leave the schema mutated but the row gone, hiding state. + for (local.m in getAvailableMigrations()) { + if (local.m.version == local.cleanVersion) { + local.rv.message = "Refusing to forget version " & local.cleanVersion + & " because a matching local file exists " + & "(app/migrator/migrations/" & local.m.cfcfile & ".cfc). " + & "Use `wheels migrate down` to roll it back properly."; + return local.rv; + } + } + // Delegate the actual delete to the existing private helper so + // the request.$wheelsDebugSQL guard fires uniformly — matches + // what pretendVersion() does via $setVersionAsMigrated(). + $removeVersionAsMigrated(local.cleanVersion); + local.rv.success = true; + local.rv.removed = local.cleanVersion; + local.rv.message = "Removed version " & local.cleanVersion & " from the tracking table."; + return local.rv; + } + + /** + * Records a version as applied in `wheels_migrator_versions` without + * running its up() method. Useful when a peer applied the migration + * via direct SQL or a different tool and you need the tracking + * table to reflect that. Refuses if the version is already applied, + * or if no local file matches (only known versions can be pretended). + * + * Returns: {success, recorded, message} + * + * @version The version string to record (digits only after sanitisation). + * + * [section: Migrator] + * [category: General Functions] + */ + public struct function pretendVersion(required string version) { + local.rv = {success: false, recorded: "", message: ""}; + local.cleanVersion = $sanitiseVersion(arguments.version); + if (!Len(local.cleanVersion)) { + local.rv.message = "Invalid version: must contain at least one digit."; + return local.rv; + } + local.appliedList = ListToArray($getVersionsPreviouslyMigrated()); + if (ArrayFind(local.appliedList, local.cleanVersion)) { + local.rv.message = "Version " & local.cleanVersion & " is already applied. " + & "Use `wheels migrate forget` if you need to remove the tracking row."; + return local.rv; + } + local.fileExists = false; + local.matchedName = ""; + for (local.m in getAvailableMigrations()) { + if (local.m.version == local.cleanVersion) { + local.fileExists = true; + local.matchedName = local.m.name; + break; + } + } + if (!local.fileExists) { + local.rv.message = "Refusing to pretend version " & local.cleanVersion + & " — no matching file in app/migrator/migrations/. " + & "Create the migration file first, then pretend if it has already been applied externally."; + return local.rv; + } + $setVersionAsMigrated(local.cleanVersion, local.matchedName); + local.rv.success = true; + local.rv.recorded = local.cleanVersion; + local.rv.message = "Recorded version " & local.cleanVersion & " as applied (up() was not run)."; + return local.rv; + } + /** * F15 Phase 1: detect which system-table naming family this app's database * already uses, and flip the configured names if needed. @@ -754,6 +1246,125 @@ component output="false" extends="wheels.Global"{ return rv; } + /** + * Adds `name` (VARCHAR(255) NULL) and `applied_at` (TIMESTAMP NULL DEFAULT + * CURRENT_TIMESTAMP) columns to `wheels_migrator_versions` if they don't + * already exist. Enables `wheels migrate info` and `wheels migrate doctor` + * to show the migration name and apply timestamp for each tracked version + * — including orphan rows where the file isn't in the local checkout. + * + * Idempotent: skips ALTER when columns are already present. Cached on + * `application[appKey].$trackingColumnsEnsured` to avoid repeating the + * column probe on every migrator call within one app process. + * + * Per-engine SQL handled inline (matches the existing $detectSystemTables + * / renameSystemTables patterns). SQLite skips DEFAULT on applied_at — + * the column lands NULL and CFML writes Now() when $setVersionAsMigrated + * is called with a name. Other engines use CURRENT_TIMESTAMP default. + * + * Result: {hasName, hasAppliedAt, added: array of column names, errors: array}. + * + * [section: Migrator] + * [category: General Functions] + */ + public struct function $ensureTrackingColumns() { + var rv = {hasName: false, hasAppliedAt: false, added: [], errors: []}; + var appKey = $appKey(); + var dsn = application[appKey].dataSourceName; + var tableName = application[appKey].migratorTableName; + + try { + var cols = $dbinfo( + datasource = dsn, + type = "columns", + table = tableName + ); + } catch (any e) { + // Table doesn't exist yet; nothing to do. The columns will be + // added the first time someone calls migrateTo() and the bootstrap + // path creates the table. + ArrayAppend(rv.errors, "Could not probe columns on " & tableName & ": " & e.message); + return rv; + } + + var existingCols = ValueList(cols.column_name); + rv.hasName = ListFindNoCase(existingCols, "name") > 0; + rv.hasAppliedAt = ListFindNoCase(existingCols, "applied_at") > 0; + + // Cache the engine type on app scope so $setVersionAsMigrated can + // detect SQLite without a $dbinfo round-trip (which would break + // inside the migrator's open transaction). Populate BEFORE the + // hasName/hasAppliedAt early-return below, so the cache is + // available on every app restart — not just the first start + // after upgrade. Without this, SQLite would write NULL into + // applied_at on every restart because the cache lookup in + // $setVersionAsMigrated would miss and skip the CFML-side Now() + // injection (the column has no DEFAULT on SQLite since SQLite + // can't DEFAULT a TIMESTAMP on ALTER ADD COLUMN). Guarded so it + // fires at most once per app process. + if (!StructKeyExists(application[appKey], "$migratorDbType")) { + var info = $dbinfo( + type = "version", + datasource = dsn, + username = application.wheels.dataSourceUserName, + password = application.wheels.dataSourcePassword + ); + application[appKey].$migratorDbType = info.database_productname; + } + + if (rv.hasName && rv.hasAppliedAt) { + return rv; + } + + var dbType = application[appKey].$migratorDbType; + + // Build per-engine ALTER statements for the missing columns. + // Each ALTER is its own statement so a partial-add state still + // completes on re-run (e.g. name added, applied_at failed → retry + // adds only applied_at). + var statements = []; + if (!rv.hasName) { + if (FindNoCase("Oracle", dbType)) { + ArrayAppend(statements, {col: "name", sql: "ALTER TABLE #tableName# ADD (name VARCHAR2(255))"}); + } else if (FindNoCase("SQLServer", dbType) || FindNoCase("SQL Server", dbType)) { + ArrayAppend(statements, {col: "name", sql: "ALTER TABLE #tableName# ADD name VARCHAR(255) NULL"}); + } else { + // MySQL, PostgreSQL, SQLite, H2, CockroachDB + ArrayAppend(statements, {col: "name", sql: "ALTER TABLE #tableName# ADD COLUMN name VARCHAR(255) NULL"}); + } + } + if (!rv.hasAppliedAt) { + if (FindNoCase("Oracle", dbType)) { + ArrayAppend(statements, {col: "applied_at", sql: "ALTER TABLE #tableName# ADD (applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)"}); + } else if (FindNoCase("SQLServer", dbType) || FindNoCase("SQL Server", dbType)) { + ArrayAppend(statements, {col: "applied_at", sql: "ALTER TABLE #tableName# ADD applied_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP"}); + } else if (FindNoCase("MySQL", dbType)) { + ArrayAppend(statements, {col: "applied_at", sql: "ALTER TABLE #tableName# ADD COLUMN applied_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP"}); + } else if (FindNoCase("SQLite", dbType)) { + // SQLite cannot DEFAULT a TIMESTAMP on existing-table ADD COLUMN + // (only on CREATE TABLE). The column lands NULL; CFML supplies + // Now() at $setVersionAsMigrated time. + ArrayAppend(statements, {col: "applied_at", sql: "ALTER TABLE #tableName# ADD COLUMN applied_at TEXT"}); + } else { + // PostgreSQL, H2, CockroachDB + ArrayAppend(statements, {col: "applied_at", sql: "ALTER TABLE #tableName# ADD COLUMN applied_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP"}); + } + } + + for (var stmt in statements) { + try { + $query(datasource = dsn, sql = stmt.sql); + ArrayAppend(rv.added, stmt.col); + if (stmt.col == "name") rv.hasName = true; + if (stmt.col == "applied_at") rv.hasAppliedAt = true; + } catch (any e) { + ArrayAppend(rv.errors, stmt.col & " ALTER failed: " & e.message); + } + } + + return rv; + } + /** * Ensures a version as user input is numeric. */ diff --git a/vendor/wheels/WheelsTest.cfc b/vendor/wheels/WheelsTest.cfc index 9b261ac67a..b74cd9df53 100644 --- a/vendor/wheels/WheelsTest.cfc +++ b/vendor/wheels/WheelsTest.cfc @@ -10,18 +10,31 @@ component extends="wheels.wheelstest.system.BaseSpec" { // Pseudo-constructor (runs automatically) if (structKeyExists(application, "wo")) { - local.methods = getMetaData(application.wo).functions; - - for (local.method in local.methods) { - // Only add public, non-inherited methods - if (local.method.access eq "public") { - local.methodExists = structKeyExists(variables, local.method.name) || structKeyExists(this, local.method.name); + // Iterate struct keys on application.wo and bind every UDF. This + // catches both methods declared on Global.cfc (visible to + // getMetaData) AND helpers merged in via cfinclude (e.g. + // app/global/functions.cfm), which getMetaData(application.wo).functions + // does NOT enumerate — see #2790. + local.metaIndex = {}; + for (local.fn in getMetaData(application.wo).functions) { + local.metaIndex[local.fn.name] = local.fn.access; + } - if (!local.methodExists) { - variables[local.method.name] = application.wo[local.method.name]; - this[local.method.name] = application.wo[local.method.name]; - } + for (local.key in application.wo) { + if (!isCustomFunction(application.wo[local.key])) { + continue; + } + // For methods present in CFC metadata, keep the existing + // public-only filter; include-injected helpers have no + // access modifier so they're treated as public. + if (structKeyExists(local.metaIndex, local.key) && local.metaIndex[local.key] neq "public") { + continue; + } + if (structKeyExists(variables, local.key) || structKeyExists(this, local.key)) { + continue; } + variables[local.key] = application.wo[local.key]; + this[local.key] = application.wo[local.key]; } } diff --git a/vendor/wheels/events/EventMethods.cfc b/vendor/wheels/events/EventMethods.cfc index 1360524187..e5e80a285b 100644 --- a/vendor/wheels/events/EventMethods.cfc +++ b/vendor/wheels/events/EventMethods.cfc @@ -174,6 +174,37 @@ component extends="wheels.Global" implements="wheels.interfaces.events.EventHand request.$wheelsHeaders = GetHTTPRequestData().headers; } + // Soft-reload of app/global/*.cfm when bare ?reload=true is hit in dev + // and any tracked include has a newer mtime. The password-gated + // applicationStop() path already does a full re-init, so we skip the + // check when url.password is present (issue 2792). + // + // The environment guard is intentional: this soft-reload is a + // development-only convenience. Use the password-gated reload for + // non-dev environments — setting reloadOnGlobalChange=true outside + // development is a deliberate no-op. + if ( + StructKeyExists(url, "reload") + && !StructKeyExists(url, "password") + && StructKeyExists(application.wheels, "reloadOnGlobalChange") + && application.wheels.reloadOnGlobalChange + && application.wheels.environment == "development" + && StructKeyExists(application.wheels, "globalIncludesSnapshot") + && application.wo.$globalIncludesChanged(snapshot = application.wheels.globalIncludesSnapshot) + ) { + // Double-checked locking — two concurrent ?reload=true hits would + // otherwise both pass the outer check and race on $reincludeGlobals + // against the same application.wo instance. Lock name is + // per-application so shared Adobe CF servers running multiple + // apps don't serialize on a single global lock. + lock type="exclusive" name="wheels_reload_globals_#application.applicationName#" timeout="5" { + if (application.wo.$globalIncludesChanged(snapshot = application.wheels.globalIncludesSnapshot)) { + application.wo.$reincludeGlobals(); + application.wheels.globalIncludesSnapshot = application.wo.$snapshotGlobalIncludes(); + } + } + } + // Reload the plugins on each request if cachePlugins is set to false. if (!application.wheels.cachePlugins) { $loadPlugins(); diff --git a/vendor/wheels/events/init/orm.cfm b/vendor/wheels/events/init/orm.cfm index 6250011010..6229b4152f 100644 --- a/vendor/wheels/events/init/orm.cfm +++ b/vendor/wheels/events/init/orm.cfm @@ -24,10 +24,19 @@ application.$wheels.suppressRouteBindingWarnings = false; application.$wheels.reloadPassword = ""; application.$wheels.redirectAfterReload = false; + // Soft-reload of app/global/*.cfm on ?reload=true when files change. + // Defaults true in development so adding a helper to app/global is picked + // up without the password-gated applicationStop() path (issue 2792). + application.$wheels.reloadOnGlobalChange = (application.$wheels.environment == "development"); application.$wheels.softDeleteProperty = "deletedAt"; application.$wheels.timeStampOnCreateProperty = "createdAt"; application.$wheels.timeStampOnUpdateProperty = "updatedAt"; application.$wheels.timeStampMode = "utc"; + // Migrator: when true, t.references() and friends produce `_id` + // columns (matching Wheels model `belongsTo` defaults). False keeps + // the legacy `id` suffix so existing migrations and schemas are + // unaffected. New apps generated by `wheels new` flip this to true. + application.$wheels.useUnderscoreReferenceColumns = false; application.$wheels.ipExceptions = ""; application.$wheels.overwritePlugins = true; application.$wheels.deletePluginDirectories = true; diff --git a/vendor/wheels/events/onapplicationstart.cfc b/vendor/wheels/events/onapplicationstart.cfc index c367c9ba8c..5d34d9c563 100644 --- a/vendor/wheels/events/onapplicationstart.cfc +++ b/vendor/wheels/events/onapplicationstart.cfc @@ -364,6 +364,10 @@ component { // Create the dispatcher that will handle all incoming requests. application.$wheels.dispatch = application.wo.$createObjectFromRoot(path = "wheels", fileName = "Dispatch", method = "$init"); + // Snapshot the app/global/*.cfm mtimes so the per-request soft-reload + // check (in $runOnRequestStart) has a baseline to compare against. + application.$wheels.globalIncludesSnapshot = application.wo.$snapshotGlobalIncludes(); + // Assign it all to the application scope in one atomic call. application.wheels = application.$wheels; StructDelete(application, "$wheels"); diff --git a/vendor/wheels/mapper/matching.cfc b/vendor/wheels/mapper/matching.cfc index cd6e184695..5032c329b4 100644 --- a/vendor/wheels/mapper/matching.cfc +++ b/vendor/wheels/mapper/matching.cfc @@ -303,12 +303,42 @@ component { } // Interpret "to" as "controller##action". + local.fromTo = false; + local.originalTo = ""; if (StructKeyExists(arguments, "to")) { + local.fromTo = true; + local.originalTo = arguments.to; arguments.controller = ListFirst(arguments.to, "##"); arguments.action = ListLast(arguments.to, "##"); StructDelete(arguments, "to"); } + // Guard: reject redundant namespace prefix in to=/controller= (#2791). + if ( + StructKeyExists(arguments, "package") + && Len(arguments.package) > 0 + && StructKeyExists(arguments, "controller") + && Find("/", arguments.controller) + ) { + local.packageAsPath = Replace(arguments.package, ".", "/", "all"); + local.prefix = local.packageAsPath & "/"; + if (Len(arguments.controller) > Len(local.prefix) && Left(arguments.controller, Len(local.prefix)) == local.prefix) { + local.stripped = Mid(arguments.controller, Len(local.prefix) + 1, Len(arguments.controller) - Len(local.prefix)); + local.actionForMsg = StructKeyExists(arguments, "action") ? arguments.action : "action"; + local.hh = "####"; + if (local.fromTo) { + local.detail = "Got controller=""" & arguments.controller & """ (from to=""" & local.originalTo & """). The namespace prefix is added automatically — use to=""" & local.stripped & local.hh & local.actionForMsg & """ instead."; + } else { + local.detail = "Got controller=""" & arguments.controller & """ (passed as controller=). The namespace prefix is added automatically — use controller=""" & local.stripped & """ (or to=""" & local.stripped & local.hh & local.actionForMsg & """) instead."; + } + Throw( + type = "Wheels.MapperArgumentInvalid", + message = "Route inside `.namespace('#arguments.package#')` (or equivalent `.scope()` / `.package()`) uses a redundant namespace prefix in its controller path.", + detail = local.detail + ); + } + } + // Pull route name from arguments if it exists. local.name = ""; if (StructKeyExists(arguments, "name")) { diff --git a/vendor/wheels/middleware/RequestId.cfc b/vendor/wheels/middleware/RequestId.cfc index d662f99928..3159e2510d 100644 --- a/vendor/wheels/middleware/RequestId.cfc +++ b/vendor/wheels/middleware/RequestId.cfc @@ -10,7 +10,7 @@ component implements="wheels.middleware.MiddlewareInterface" output="false" { public string function handle(required struct request, required any next) { // Generate a unique request ID. local.requestId = CreateUUID(); - request.wheels.requestId = local.requestId; + $writeRequestId(local.requestId); // Call the next middleware / controller dispatch. local.response = arguments.next(arguments.request); @@ -25,4 +25,19 @@ component implements="wheels.middleware.MiddlewareInterface" output="false" { return local.response; } + /** + * Store the request ID on the `request` scope so it's available request-wide + * for tracing. Kept in a helper with no `request` parameter on purpose: + * inside `handle()` the `required struct request` parameter shadows the + * `request` scope on Adobe CF, so a bare `request.wheels.requestId = …` + * there writes to the passed struct instead of the scope (CLAUDE.md + * cross-engine anti-pattern ##11). Here `request` is unambiguously the scope. + */ + private void function $writeRequestId(required string requestId) { + if (!StructKeyExists(request, "wheels")) { + request.wheels = {}; + } + request.wheels.requestId = arguments.requestId; + } + } diff --git a/vendor/wheels/migrator/CLAUDE.md b/vendor/wheels/migrator/CLAUDE.md new file mode 100644 index 0000000000..538f28b68a --- /dev/null +++ b/vendor/wheels/migrator/CLAUDE.md @@ -0,0 +1,67 @@ +# Migrator + +CFML migration runtime. `Migrator.cfc` discovers files on disk; `Migration.cfc` is the per-file base class; `TableDefinition.cfc` is the in-memory builder used inside `up()` callbacks; adapters under `databaseAdapters/` translate to engine-specific DDL. + +## Parameter naming conventions + +Every column-adding helper in `TableDefinition.cfc` follows the same shape — match it when adding or modifying helpers here. + +**Column name arguments use `$combineArguments` to accept both plural and singular forms.** The plural is canonical; the singular is the alias. + +```cfm +public any function string(string columnNames, any limit, string default, boolean allowNull) { + $combineArguments(args = arguments, combine = "columnNames,columnName", required = true); + // ... iterate over the list internally +} +``` + +Callers can pass either `t.string(columnNames = "a,b,c")` or `t.string(columnName = "a")` — both resolve to `arguments.columnNames` for the function body. Drop the `required` keyword from the parameter declaration; `$combineArguments(required=true)` enforces it at runtime. + +**`references()` and its command-version siblings carry back-compat aliases.** The legacy parameter names predate the `$combineArguments` convention; the modern ones are accepted as synonyms via the same helper. Each of these accepts the modern form going forward: + +| Function | Legacy param | Modern alias(es) | +|---|---|---| +| `TableDefinition::primaryKey()` | `name` | `columnName`, `columnNames` | +| `TableDefinition::references()` | `referenceNames` | `columnNames` | +| `Migration::addReference()` | `referenceName` | `columnName`, `columnNames` | +| `Migration::dropReference()` | `referenceName` | `columnName`, `columnNames` | +| `Migration::addColumn()` / `changeColumn()` | `columnName` | `columnNames` | +| `Migration::removeColumn()` | `columnName` | `columnNames` | +| `Migration::addForeignKey()` | `column` | `columnName` | + +Example (the `references()` form, [#2781](https://github.com/wheels-dev/wheels/issues/2781)): + +```cfm +$combineArguments(args = arguments, combine = "referenceNames,columnNames", required = true); +``` + +New code should pass `columnNames`. Both keep working. + +**Nullable flag is always `allowNull`** — never `null`. Every column helper agrees on this. + +## Reference-column suffix flag + +`t.references(columnNames="user")` produces either `userid` (legacy) or `user_id` (Rails-style) depending on the `useUnderscoreReferenceColumns` setting: + +| Setting value | `t.references(columnNames="user")` produces | Polymorphic `user` produces | +|---|---|---| +| `false` (framework default) | `userid` | `userid`, `usertype` | +| `true` (new-app template default) | `user_id` | `user_id`, `user_type` | + +The framework default is `false` so existing apps with applied migrations keep matching their database schemas. The `wheels new` template at `cli/lucli/templates/app/config/settings.cfm` opts new apps into `true` so they match Wheels model `belongsTo` defaults out of the box. + +The flag is read via `$get("useUnderscoreReferenceColumns")` inside `references()` at runtime — apps can flip the setting in `config/settings.cfm` without reloading the framework. Migrations already applied to a real database are unaffected; only the column name the *next* migration produces changes. + +## Anti-patterns to watch for in this directory + +1. **Mixing helper-style and standalone-style argument names.** Both `t.references(columnNames=...)` (helper inside `createTable`) and `addReference(table=..., columnName=...)` (standalone Migration.cfc method) now accept the modern `columnNames` / `columnName` aliases via `$combineArguments`, alongside their legacy `referenceNames` / `referenceName` originals. Prefer the modern form in new code; the legacy names keep working. +2. **Hard-coding `& "id"` or `& "type"` concatenations.** All four sites in this directory resolve the reference-column suffix through `$get("useUnderscoreReferenceColumns")` — `TableDefinition.cfc::references()` (id + polymorphic type), `Migration.cfc::removeColumn` (referenceName branch), and `Migration.cfc::addReference`. If you add new code that builds a reference column name, route it through `$get` too rather than hard-coding `& "id"`. +3. **`required` on column-name parameters.** Use `$combineArguments(... required=true)` instead. Declaring CFML-level `required` blocks the alias path because validation runs before the function body. + +## Tests + +Specs live in `vendor/wheels/tests/specs/migrator/`. `referencesSpec.cfc` exercises `TableDefinition::references()` (the `columnNames` alias plus the suffix flag) at the unit layer — inspecting `t.columns` / `t.foreignKeys` directly without `t.create()` so the assertions are adapter-independent. `primaryKeySpec.cfc` mirrors that shape for `TableDefinition::primaryKey()` — the `columnName` / `columnNames` aliases plus precedence semantics (#2803). `migrationSpec.cfc` covers Migration.cfc command-version helpers via real DDL roundtrips — its "Tests addReference" describe block guards the `useUnderscoreReferenceColumns` path on `Migration.cfc::addReference()`. Most FK-related tests in `migrationSpec.cfc` skip on SQLite (which doesn't support altering CONSTRAINTS) but run on every other engine in CI. + +Prefer TableDefinition-layer tests for argument plumbing and reach for `migrationSpec.cfc` patterns only when the assertion requires a real database (FK constraints, column existence after ALTER, etc.). + +Smoke-test cross-adapter SQL via `bash tools/test-local.sh migrator` (Lucee 7 + SQLite) and the full matrix via `tools/test-matrix.sh` when touching the suffix flag or `$combineArguments` calls. diff --git a/vendor/wheels/migrator/Migration.cfc b/vendor/wheels/migrator/Migration.cfc index 1a1a194f68..4bc8743060 100755 --- a/vendor/wheels/migrator/Migration.cfc +++ b/vendor/wheels/migrator/Migration.cfc @@ -153,7 +153,8 @@ component extends="Base" { * @table The Name of the table to add the column to * @columnType The type of the new column * @afterColumn The name of the column which this column should be inserted after - * @columnName THe name of the new column + * @columnName The name of the new column + * @columnNames Modern alias for `columnName` (matches the plural form every TableDefinition column helper accepts). Pass one or the other — not both. * @referenceName Name for new reference column, see documentation for references function, required if columnType is 'reference' * @default Default value for this column * @allowNull Whether to allow NULL values @@ -164,7 +165,8 @@ component extends="Base" { public void function addColumn( required string table, required string columnType, - required string columnName = "", + string columnName, + string columnNames, string afterColumn = "", string referenceName = "", string default, @@ -173,6 +175,12 @@ component extends="Base" { numeric precision, numeric scale ) { + // Resolve the alias here so addColumn is self-contained — a future + // refactor that stops delegating to changeColumn won't silently lose + // the columnNames alias path. Required unless columnType="reference" + // (in that branch the column name is computed from referenceName, so + // columnName/columnNames are not needed). + $combineArguments(args = arguments, combine = "columnName,columnNames", required = (arguments.columnType != "reference")); arguments.addColumns = true; changeColumn(argumentCollection = arguments); } @@ -185,7 +193,8 @@ component extends="Base" { * [category: Migration Functions] * * @table The Name of the table where the column is - * @columnName THe name of the column + * @columnName The name of the column + * @columnNames Modern alias for `columnName` (matches the plural form every TableDefinition column helper accepts). Pass one or the other — not both. * @columnType The type of the column * @afterColumn The name of the column which this column should be inserted after * @referenceName Name for reference column, see documentation for references function, required if columnType is 'reference' @@ -198,7 +207,8 @@ component extends="Base" { */ public void function changeColumn( required string table, - required string columnName, + string columnName, + string columnNames, required string columnType, string afterColumn = "", string referenceName = "", @@ -209,12 +219,20 @@ component extends="Base" { numeric scale, boolean addColumns = "false" ) { + // Accept columnNames as alias for columnName (consistency with + // TableDefinition column helpers — #2781 follow-up). Required unless + // columnType="reference" — that branch ignores columnName and uses + // referenceName instead. Restores the missing-arg enforcement the + // original `required string columnName` parameter provided before + // this PR widened the signature to accept the alias. + $combineArguments(args = arguments, combine = "columnName,columnNames", required = (arguments.columnType != "reference")); + var t = changeTable(arguments.table); if (arguments.columnType == "reference") { arguments.columnType = "references"; arguments.referenceNames = arguments.referenceName; } else { - arguments.columnNames = arguments.columnName; + arguments.columnNames = arguments.columnName ?: ""; } invoke(t, arguments.columnType, arguments); t.change(addColumns = arguments.addColumns); @@ -251,11 +269,26 @@ component extends="Base" { * * @table The table containing the column to remove * @columnName The column name to remove + * @columnNames Modern alias for `columnName` (matches the plural form every TableDefinition column helper accepts). Pass one or the other — not both. * @referenceName optional reference name */ - public void function removeColumn(required string table, string columnName = "", string referenceName = "") { + public void function removeColumn( + required string table, + string columnName, + string columnNames, + string referenceName = "" + ) { + // Accept columnNames as alias for columnName via the standard helper — + // matches every other site in this file. If both are passed, the alias + // (columnNames) wins, consistent with $combineArguments precedence + // across the framework. + $combineArguments(args = arguments, combine = "columnName,columnNames", required = false); + if (!StructKeyExists(arguments, "columnName")) { + arguments.columnName = ""; + } if (arguments.referenceName != "") { - arguments.columnName = arguments.referenceName & "id"; + local.idSuffix = $get("useUnderscoreReferenceColumns") ? "_id" : "id"; + arguments.columnName = arguments.referenceName & local.idSuffix; } $execute(this.adapter.dropColumnFromTable(name = arguments.table, columnName = arguments.columnName)); announce("Removed column #arguments.columnName# from #arguments.table#"); @@ -270,12 +303,26 @@ component extends="Base" { * * @table The table name to perform the operation on * @referenceName The reference table name to perform the operation on + * @columnName Alias for `referenceName` (consistent with the modern migrator surface — `columnName` / `columnNames` are accepted alongside the legacy form). + * @columnNames Plural alias for `referenceName`. When both `columnName` and `columnNames` are supplied, `columnNames` wins. */ - public void function addReference(required string table, required string referenceName) { + public void function addReference( + required string table, + string referenceName, + string columnName, + string columnNames + ) { + // Accept columnName / columnNames as aliases for referenceName. + // Precedence (per $combineArguments semantics): the alias wins. If a + // caller passes both `referenceName` and `columnName`/`columnNames`, + // the alias overwrites — same shape every other helper here uses. + $combineArguments(args = arguments, combine = "referenceName,columnName", required = false); + $combineArguments(args = arguments, combine = "referenceName,columnNames", required = true); + local.idSuffix = $get("useUnderscoreReferenceColumns") ? "_id" : "id"; addForeignKey( table = arguments.table, referenceTable = pluralize(arguments.referenceName), - column = "#arguments.referenceName#id", + column = arguments.referenceName & local.idSuffix, referenceColumn = "id" ); } @@ -290,14 +337,19 @@ component extends="Base" { * @table The table name to perform the operation on * @referenceTable The reference table name to perform the operation on * @column The column name to perform the operation on + * @columnName Modern alias for `column` (consistent with the rest of the migrator surface). * @referenceColumn The reference column name to perform the operation on */ public void function addForeignKey( required string table, required string referenceTable, - required string column, + string column, + string columnName, required string referenceColumn ) { + // Accept columnName as alias for column (consistency with the rest + // of the migrator surface). + $combineArguments(args = arguments, combine = "column,columnName", required = true); var foreignKey = CreateObject("component", "ForeignKeyDefinition").init( adapter = this.adapter, argumentCollection = arguments @@ -315,9 +367,21 @@ component extends="Base" { * * @table The table name to perform the operation on * @referenceName the name of the reference to drop + * @columnName Alias for `referenceName` (consistent with the modern migrator surface — `columnName` / `columnNames` are accepted alongside the legacy form). + * @columnNames Plural alias for `referenceName`. When both `columnName` and `columnNames` are supplied, `columnNames` wins. * */ - public void function dropReference(required string table, required string referenceName) { + public void function dropReference( + required string table, + string referenceName, + string columnName, + string columnNames + ) { + // Accept columnName / columnNames as aliases for referenceName. + // Precedence (per $combineArguments semantics): the alias wins. See + // addReference for the same pattern. + $combineArguments(args = arguments, combine = "referenceName,columnName", required = false); + $combineArguments(args = arguments, combine = "referenceName,columnNames", required = true); dropForeignKey(arguments.table, "FK_#arguments.table#_#pluralize(arguments.referenceName)#"); } diff --git a/vendor/wheels/migrator/TableDefinition.cfc b/vendor/wheels/migrator/TableDefinition.cfc index 7312ebf741..03a4fab125 100644 --- a/vendor/wheels/migrator/TableDefinition.cfc +++ b/vendor/wheels/migrator/TableDefinition.cfc @@ -28,11 +28,23 @@ component extends="Base" { /** * Adds a primary key definition to the table. this method also allows for multiple primary keys. * + * Accepts `columnName` / `columnNames` as aliases for `name` (per #2803) so the + * PK helper matches the argument-naming convention every other column helper + * in this file uses. The legacy `name` parameter keeps working — it is still + * what the body reads and what `init()` passes when adding the conventional + * `id` primary key. + * * [section: Migrator] * [category: Table Definition Functions] + * + * @name Legacy parameter for the primary-key column name. New code should prefer `columnName`. + * @columnName Modern singular alias for `name` (matches sibling column helpers). + * @columnNames Modern plural alias for `name`. Accepted for muscle-memory parity with `t.integer(columnNames=...)` etc. NOTE: unlike sibling helpers, this does NOT accept a comma-separated list — `primaryKey()` always creates one PK column, so `columnNames="a,b"` produces a single column literally named `a,b` (not two PKs). For composite PKs call `t.primaryKey()` multiple times. */ public any function primaryKey( - required string name, + string name, + string columnName, + string columnNames, string type = "integer", boolean autoIncrement = "false", numeric limit, @@ -42,6 +54,12 @@ component extends="Base" { string onUpdate = "", string onDelete = "" ) { + // Accept columnName / columnNames as aliases for name (#2803). Precedence + // (per $combineArguments semantics): the later call wins, so when both + // columnName and columnNames are supplied, columnNames takes priority — + // matching addReference() / dropReference() in Migration.cfc. + $combineArguments(args = arguments, combine = "name,columnName", required = false); + $combineArguments(args = arguments, combine = "name,columnNames", required = true); arguments.allowNull = false; arguments.adapter = this.adapter; @@ -376,13 +394,33 @@ component extends="Base" { } /** - * adds integer reference columns to table definition and creates foreign key constraints + * Adds integer reference columns to the table definition and (unless + * `foreignKey=false` or `polymorphic=true`) registers a matching foreign-key + * constraint. The column suffix depends on the `useUnderscoreReferenceColumns` + * setting: `false` (framework default) → `id`; `true` (default for + * apps generated by `wheels new`) → `_id`, matching Wheels model + * `belongsTo` defaults. With `polymorphic=true`, a `type` / `_type` + * companion column is added and no FK is registered. + * + * Accepts `columnNames` as an alias for `referenceNames` (per #2781) — both + * are list-shaped (single name or comma-delimited). New code should use + * `columnNames` for consistency with every other column helper here. * * [section: Migrator] * [category: Table Definition Functions] + * + * @referenceNames Comma-delimited list of reference base names (e.g. `"user,role"`). Each produces a `_id` (or `id`) column. Legacy parameter — `columnNames` is the modern alias. + * @columnNames Modern alias for `referenceNames`. Pass one or the other — not both. + * @default Default value for the generated integer column(s). + * @allowNull If true, the generated column(s) allow NULL. + * @polymorphic If true, also creates a `type` / `_type` companion column and skips the foreign-key constraint. + * @foreignKey If true (default), registers a foreign key on the generated column. Ignored when `polymorphic=true`. + * @onUpdate Foreign-key ON UPDATE clause. Engine-specific values; common: `"cascade"`, `"null"`, `"none"`. + * @onDelete Foreign-key ON DELETE clause. Same value set as `onUpdate`. */ public any function references( - required string referenceNames, + string referenceNames, + string columnNames, string default, boolean allowNull = "false", boolean polymorphic = "false", @@ -390,6 +428,9 @@ component extends="Base" { string onUpdate = "", string onDelete = "" ) { + $combineArguments(args = arguments, combine = "referenceNames,columnNames", required = true); + local.idSuffix = $get("useUnderscoreReferenceColumns") ? "_id" : "id"; + local.typeSuffix = $get("useUnderscoreReferenceColumns") ? "_type" : "type"; local.referenceNamesArray = ListToArray(arguments.referenceNames); local.iEnd = ArrayLen(local.referenceNamesArray); for (local.i = 1; local.i <= local.iEnd; local.i++) { @@ -403,9 +444,9 @@ component extends="Base" { // default the column to an integer if not provided if (!StructKeyExists(local.columnArgs, "columnType")) local.columnArgs.columnType = "integer"; - column(columnName = local.referenceName & "id", argumentCollection = local.columnArgs); + column(columnName = local.referenceName & local.idSuffix, argumentCollection = local.columnArgs); - if (arguments.polymorphic) column(columnName = local.referenceName & "type", columnType = "string"); + if (arguments.polymorphic) column(columnName = local.referenceName & local.typeSuffix, columnType = "string"); if (arguments.foreignKey && !arguments.polymorphic) { local.referenceTable = pluralize(local.referenceName); @@ -413,7 +454,7 @@ component extends="Base" { adapter = this.adapter, table = this.name, referenceTable = local.referenceTable, - column = "#local.referenceName#id", + column = "#local.referenceName##local.idSuffix#", referenceColumn = "id", onUpdate = arguments.onUpdate, onDelete = arguments.onDelete diff --git a/vendor/wheels/migrator/templates/create-table.cfc b/vendor/wheels/migrator/templates/create-table.cfc index 03b815c9d5..a0eaf29638 100644 --- a/vendor/wheels/migrator/templates/create-table.cfc +++ b/vendor/wheels/migrator/templates/create-table.cfc @@ -22,7 +22,7 @@ t.integer(columnNames='age', default='', allowNull=true, limit='1'); t.time(columnNames='lunchStarts', default='', allowNull=true); t.uniqueidentifier(columnNames='uid', default='newid()', allowNull=false); - t.references(referenceNames="vacation"); + t.references(columnNames="vacation"); t.timestamps(); t.create(); */ diff --git a/vendor/wheels/model/read.cfc b/vendor/wheels/model/read.cfc index 787427986d..8b0b1d53c6 100644 --- a/vendor/wheels/model/read.cfc +++ b/vendor/wheels/model/read.cfc @@ -214,7 +214,9 @@ component { list = arguments.select, returnAs = arguments.returnAs ); - local.columns = ReReplace(local.columns, "[`""\[\]\w]*?\.([\w\s]*?)(,|$)", "\1\2", "all"); + // Strip dialect quotes: $createSQLFieldList now quotes identifiers; the bare-identifier regex below requires unquoted input. + local.columns = variables.wheels.class.adapter.$stripIdentifierQuotes(local.columns); + local.columns = ReReplace(local.columns, "[\w]*?\.([\w\s]*?)(,|$)", "\1\2", "all"); local.columns = ReReplace(local.columns, "\(.*?\)\sAS\s([\w\s]*?)(,|$)", "\1\2", "all"); local.columns = ReReplace(local.columns, "\w*?\sAS\s([\w\s]*?)(,|$)", "\1\2", "all"); local.rv = QueryNew(local.columns); diff --git a/vendor/wheels/model/sql.cfc b/vendor/wheels/model/sql.cfc index c7040bf2aa..f62ec78958 100644 --- a/vendor/wheels/model/sql.cfc +++ b/vendor/wheels/model/sql.cfc @@ -567,9 +567,9 @@ component { if (StructKeyExists(local.classData.propertyStruct, local.iItem)) { local.toAppend &= variables.wheels.class.adapter.$quoteIdentifier(local.classData.tableName) & "."; if (StructKeyExists(local.classData.columnStruct, local.iItem)) { - local.toAppend &= local.iItem; + local.toAppend &= variables.wheels.class.adapter.$quoteIdentifier(local.iItem); } else { - local.toAppend &= local.classData.properties[local.iItem].column; + local.toAppend &= variables.wheels.class.adapter.$quoteIdentifier(local.classData.properties[local.iItem].column); if (arguments.clause == "select") { local.toAppend &= " AS " & local.iItem; } @@ -631,6 +631,9 @@ component { // get the property part, done by taking everything from the end of the string to a . or a space (which would be found when using " AS ") local.property = Reverse(SpanExcluding(Reverse(local.iItem), ". ")); + // Strip dialect quotes added above so alias matching and downstream concatenation work on bare identifiers. + local.property = variables.wheels.class.adapter.$stripIdentifierQuotes(local.property); + // check if this one has been flagged as a duplicate, we get the number of classes to skip and also remove the flagged info from the item local.duplicateCount = 0; local.matches = ReFind("^\[\[duplicate\]\](\d+)(.+)$", local.iItem, 1, true); @@ -668,7 +671,7 @@ component { local.newItem = ReplaceNoCase(local.iItem, " AS " & local.property, " AS " & local.newProperty); } else { if (local.aliasFound) { - local.newItem = local.alias & "." & local.property & " AS " & local.newProperty; + local.newItem = local.alias & "." & variables.wheels.class.adapter.$quoteIdentifier(local.property) & " AS " & local.newProperty; } else { local.newItem = local.iItem & " AS " & local.newProperty; } diff --git a/vendor/wheels/model/transactions.cfc b/vendor/wheels/model/transactions.cfc index 67b1ed00b5..84add8f5ad 100644 --- a/vendor/wheels/model/transactions.cfc +++ b/vendor/wheels/model/transactions.cfc @@ -36,8 +36,15 @@ component { request.wheels.transactions[local.connectionArgs] = false; } + // Issue #2789: skip model-level cftransaction when an outer owner (e.g. migrator) wraps this call. + local.outerTransactionActive = ( + StructKeyExists(request, "$wheelsTransactionWrapper") + && IsBoolean(request.$wheelsTransactionWrapper) + && request.$wheelsTransactionWrapper + ); + // If a transaction is already marked as open, change the mode to "alreadyopen", otherwise open one. - if (request.wheels.transactions[local.connectionArgs]) { + if (local.outerTransactionActive || request.wheels.transactions[local.connectionArgs]) { arguments.transaction = "alreadyopen"; local.closeTransaction = false; } else { diff --git a/vendor/wheels/public/views/cli.cfm b/vendor/wheels/public/views/cli.cfm index c56983efe3..c1c130f036 100644 --- a/vendor/wheels/public/views/cli.cfm +++ b/vendor/wheels/public/views/cli.cfm @@ -157,37 +157,99 @@ try { data.message = migrator.redoMigration(local.redoVersion); break; case "info": - // Build a human-readable status block from the data already - // populated above (currentVersion, migrations, datasource). - // Without this, the CLI's `wheels migrate info` printed an - // empty success message (issue #2474). + // Build a human-readable status block. The migrations list + // is rendered by Migrator.$buildInfoOutput() so the logic + // is unit-testable without exercising the HTTP dispatcher. + // Issue #2780 surfaced orphan versions (DB rows with no + // matching file) — those are rendered with a [?] marker + // and an explanatory footer. local.lines = []; ArrayAppend(local.lines, "Datasource: " & data.datasource); ArrayAppend(local.lines, "Database type: " & data.databaseType); - ArrayAppend(local.lines, "Current version: " & (Len(data.currentVersion) ? data.currentVersion : "0")); - ArrayAppend(local.lines, "Total migrations: " & ArrayLen(data.migrations)); - if (ArrayLen(data.migrations)) { - local.applied = 0; - local.pending = 0; - for (local.m in data.migrations) { - if (local.m.status == "migrated") { - local.applied++; - } else { - local.pending++; - } + for (local.line in migrator.$buildInfoOutput()) { + ArrayAppend(local.lines, local.line); + } + data.message = ArrayToList(local.lines, Chr(10)); + break; + case "doctor": + // Comprehensive migrator health diagnostic. Returns a struct + // describing orphans, pending, and applied counts. See #2780. + // Plan 3: orphansWithMeta exposes the peer's migration name + // + apply timestamp when the schema is enriched. + local.report = migrator.doctor(); + data.healthy = local.report.healthy; + data.currentVersion = local.report.currentVersion; + data.orphans = local.report.orphans; + data.orphansWithMeta = local.report.orphansWithMeta; + data.pending = local.report.pending; + data.summary = local.report.summary; + local.docLines = []; + ArrayAppend(local.docLines, local.report.message); + ArrayAppend(local.docLines, ""); + ArrayAppend(local.docLines, " Datasource: " & data.datasource); + ArrayAppend(local.docLines, " Database type: " & data.databaseType); + ArrayAppend(local.docLines, " Current version: " & (Len(local.report.currentVersion) ? local.report.currentVersion : "0")); + ArrayAppend(local.docLines, " Total migrations: " & local.report.summary.total); + ArrayAppend(local.docLines, " applied: " & local.report.summary.applied); + ArrayAppend(local.docLines, " pending: " & local.report.summary.pending); + if (local.report.summary.orphan > 0) { + ArrayAppend(local.docLines, " orphan: " & local.report.summary.orphan & " (" & ArrayToList(local.report.orphans, ", ") & ")"); + } + if (ArrayLen(local.report.pending) > 0) { + ArrayAppend(local.docLines, ""); + ArrayAppend(local.docLines, "Pending local migrations:"); + for (local.v in local.report.pending) { + ArrayAppend(local.docLines, " [ ] " & local.v); } - ArrayAppend(local.lines, " applied: " & local.applied); - ArrayAppend(local.lines, " pending: " & local.pending); - ArrayAppend(local.lines, ""); - ArrayAppend(local.lines, "Migrations (newest last):"); - for (local.m in data.migrations) { - local.marker = local.m.status == "migrated" ? "[x]" : "[ ]"; - ArrayAppend(local.lines, " " & local.marker & " " & local.m.version & " " & local.m.name); + } + if (ArrayLen(local.report.orphansWithMeta) > 0) { + ArrayAppend(local.docLines, ""); + ArrayAppend(local.docLines, "Orphan versions (no matching file):"); + for (local.o in local.report.orphansWithMeta) { + local.orphanLine = " [?] " & local.o.version; + if (Len(local.o.name)) { + local.orphanLine &= " " & local.o.name; + } + if (Len(local.o.appliedAt)) { + local.orphanLine &= " (applied " & local.o.appliedAt & ")"; + } + ArrayAppend(local.docLines, local.orphanLine); } + ArrayAppend(local.docLines, ""); + ArrayAppend(local.docLines, "Resolve: `wheels migrate forget --yes` to remove an orphan row,"); + ArrayAppend(local.docLines, " or pull the peer's migration file via git."); } - data.message = ArrayToList(local.lines, Chr(10)); + data.message = ArrayToList(local.docLines, Chr(10)); break; - + case "forgetVersion": + // Remove a row from wheels_migrator_versions without running + // down(). Refuses if the version has a matching local file. + local.versionArg = request.wheels.params.version ?: ""; + if (!Len(local.versionArg)) { + data.success = false; + data.message = "Missing required argument: version. Usage: wheels migrate forget "; + break; + } + local.forgetResult = migrator.forgetVersion(local.versionArg); + data.success = local.forgetResult.success; + data.removed = local.forgetResult.removed; + data.message = local.forgetResult.message; + break; + case "pretendVersion": + // Record a version as applied without running up(). Refuses + // if already applied or if no local file matches. + local.pretendArg = request.wheels.params.version ?: ""; + if (!Len(local.pretendArg)) { + data.success = false; + data.message = "Missing required argument: version. Usage: wheels migrate pretend "; + break; + } + local.pretendResult = migrator.pretendVersion(local.pretendArg); + data.success = local.pretendResult.success; + data.recorded = local.pretendResult.recorded; + data.message = local.pretendResult.message; + break; + // Database commands case "dbStatus": // Return migration status diff --git a/vendor/wheels/tests/_assets/migrator-error-path/migrations/004_synthetic_failing_migration.cfc b/vendor/wheels/tests/_assets/migrator-error-path/migrations/004_synthetic_failing_migration.cfc new file mode 100644 index 0000000000..37e69ef20c --- /dev/null +++ b/vendor/wheels/tests/_assets/migrator-error-path/migrations/004_synthetic_failing_migration.cfc @@ -0,0 +1,14 @@ +component extends="wheels.migrator.Migration" hint="Synthetic migration whose up() throws — exercises the migrateIndividual() rollback path." { + + function up() { + throw( + type = "Wheels.Test.SyntheticFailure", + message = "synthetic failure", + detail = "intentional failure to exercise the rollback path in migrateIndividual()" + ); + } + + function down() { + } + +} diff --git a/vendor/wheels/tests/_assets/migrator/migrations_2789/001_seed_tag_via_model_create.cfc b/vendor/wheels/tests/_assets/migrator/migrations_2789/001_seed_tag_via_model_create.cfc new file mode 100644 index 0000000000..97934a1cc8 --- /dev/null +++ b/vendor/wheels/tests/_assets/migrator/migrations_2789/001_seed_tag_via_model_create.cfc @@ -0,0 +1,19 @@ +component extends="wheels.migrator.Migration" hint="issue ##2789 repro — seed a tag via Model.create() inside up()" { + + function up() { + // Issue #2789 repro: model().create() inside up() must persist after the outer transaction commits. + model("Tag").create(name = "issue2789_via_model_create"); + + // Capture the flag's value during up() so the spec can assert the migrator set it. + request.$issue2789FlagDuringUp = ( + StructKeyExists(request, "$wheelsTransactionWrapper") + && request.$wheelsTransactionWrapper + ); + } + + function down() { + // Issue #2789: raw queryExecute avoids nested transaction; safe across all adapters. + removeRecord(table = "c_o_r_e_tags", where = "name = 'issue2789_via_model_create'"); + } + +} diff --git a/vendor/wheels/tests/specs/cli/LinuxPackageStagingSpec.cfc b/vendor/wheels/tests/specs/cli/LinuxPackageStagingSpec.cfc index 225c3b1f8b..9baa66d562 100644 --- a/vendor/wheels/tests/specs/cli/LinuxPackageStagingSpec.cfc +++ b/vendor/wheels/tests/specs/cli/LinuxPackageStagingSpec.cfc @@ -164,6 +164,58 @@ component extends="wheels.WheelsTest" { ); }); + it("stages framework src from ./build/framework/wheels/ so contents flatten under vendor/wheels/", () => { + var src = fileRead(t.path); + // wheels-core-VER.zip has a top-level `wheels/` directory inside it + // (the smoke test asserts this at tools/ci/smoke-test-module.sh:112). + // nfpm `type: tree` copies the *contents* of src into dst, so if src + // points at ./build/framework/ (one level above the inner wheels/), + // the entire wheels/ subdirectory itself lands at dst — producing + // /opt/wheels/module/vendor/wheels/wheels/Injector.cfc instead of + // /opt/wheels/module/vendor/wheels/Injector.cfc. The framework then + // never loads at runtime ("could not find component or class with + // name [wheels.Injector]" — see issue ##2773). + // + // The brew formula handles this by explicitly re-introducing the + // wheels/ wrapper at stage time — see homebrew-wheels Formula/wheels.rb:62 + // — (share/"wheels/framework/wheels").install Dir["*"]. The .deb/.rpm + // equivalent is to point src at the inner wheels/ directory directly. + // + // `[[:space:]]+` matches across the YAML line break between the src + // value and `dst:` — POSIX `[[:space:]]` resolves to Java's `\s` in + // both Lucee and Adobe CF, which includes `\n`. + var hasFixedPair = reFindNoCase( + "src:[[:space:]]+\./build/framework/wheels/[[:space:]]+dst:[[:space:]]+/opt/wheels/module/vendor/wheels/", + src + ) > 0; + expect(hasFixedPair).toBeTrue( + t.label & " must declare `src: ./build/framework/wheels/` (with the " + & "trailing /wheels/) for the framework contents entry. Without the " + & "inner /wheels/ segment, nfpm's `type: tree` double-nests the " + & "framework at /opt/wheels/module/vendor/wheels/wheels/, and Lucee " + & "fails to resolve `wheels.Injector` at app startup. See issue ##2773." + ); + + // Negative guard: the buggy bare-framework form must not coexist + // with the fixed form. A future copy-paste could leave both entries + // in the file, and nfpm would happily stage both — the bare one + // reintroduces the double-nesting. Pairs with the toBeTrue above + // per the dual-assertion pattern already used by the wrapper-routing + // checks at lines 60-68 / 81-106. + var hasBuggyPair = reFindNoCase( + "src:[[:space:]]+\./build/framework/[[:space:]]+dst:[[:space:]]+/opt/wheels/module/vendor/wheels/", + src + ) > 0; + expect(hasBuggyPair).toBeFalse( + t.label & " must NOT declare `src: ./build/framework/` (without " + & "the trailing /wheels/) for any contents entry targeting " + & "/opt/wheels/module/vendor/wheels/. If both the bare and the " + & "/wheels/-suffixed entries coexist, nfpm stages the inner " + & "wheels/ wrapper as a subdirectory and the framework " + & "double-nests. See issue ##2773." + ); + }); + }); })(target); } diff --git a/vendor/wheels/tests/specs/cli/OnErrorFallbackGuardSpec.cfc b/vendor/wheels/tests/specs/cli/OnErrorFallbackGuardSpec.cfc new file mode 100644 index 0000000000..420b51a3f9 --- /dev/null +++ b/vendor/wheels/tests/specs/cli/OnErrorFallbackGuardSpec.cfc @@ -0,0 +1,153 @@ +/** + * Regression for issue ##2773 — "First 15 Minutes tutorial fails. The key + * [WO] does not exist." + * + * When the Wheels Injector fails to load during onApplicationStart (e.g. a + * stale ##wheels mapping under Lucee 7), application.wo is never assigned. + * onError then fires for the original Injector failure, swallows a second + * failure inside its try/catch, and unconditionally calls + * application.wo.$getRequestTimeout() — which throws "The key [WO] does not + * exist." and replaces the real diagnostic with a cryptic cascade. + * + * The defensive fix is in each Application.cfc whose onError uses the + * try/catch + DI-init pattern: after the catch, guard application.wo with + * StructKeyExists(application, "wo") and short-circuit to a minimal error + * response if the global never came up. Without the guard, init failure + * cascades and hides the actual root cause from the user. + */ +component extends="wheels.WheelsTest" { + + function run() { + + describe("Application.cfc onError fallback hardening (issue ##2773)", () => { + + // expandPath("/wheels") resolves to vendor/wheels via the configured + // Lucee mapping; the repo root is two levels above. + var repoRoot = expandPath("/wheels/../.."); + var targets = [ + "cli/lucli/templates/app/public/Application.cfc", + "public/Application.cfc" + ]; + + for (var rel in targets) { + // Capture the loop variable so the closure body binds the + // current value, not the final iteration's value. + (function(relPath) { + it("guards application.wo before dereferencing it in onError() in " & relPath, () => { + var absolute = repoRoot & "/" & relPath; + expect(fileExists(absolute)).toBeTrue("Missing file: " & absolute); + + var raw = fileRead(absolute); + var content = $stripCfmlComments(raw); + + // Extract the onError function body so we don't pick up + // guards from other handlers (e.g. onAbort) that already + // check application.wo. + var onErrorMatch = reFindNoCase( + "(?s)public\s+void\s+function\s+onError\s*\([^\)]*\)\s*\{", + content, + 1, + true + ); + expect(onErrorMatch.len[1] > 0).toBeTrue( + relPath & " should declare a public void onError() function." + ); + + var bodyStart = onErrorMatch.pos[1] + onErrorMatch.len[1]; + var depth = 1; + var bodyEnd = bodyStart; + var iEnd = len(content); + for (var i = bodyStart; i <= iEnd; i++) { + var ch = mid(content, i, 1); + if (ch == "{") { + depth++; + } else if (ch == "}") { + depth--; + if (depth == 0) { + bodyEnd = i - 1; + break; + } + } + } + var onErrorBody = mid(content, bodyStart, bodyEnd - bodyStart + 1); + + // 1. The cascade-guard must exist. + expect( + reFindNoCase( + "StructKeyExists\s*\(\s*application\s*,\s*[""']wo[""']\s*\)", + onErrorBody + ) > 0 + ).toBeTrue( + relPath & " onError() must guard application.wo with " + & "StructKeyExists(application, ""wo"") before dereferencing " + & "it — without the guard a failed Injector init cascades " + & "into ""The key [WO] does not exist."" (issue ##2773)." + ); + + // 2. The guard must short-circuit before the first + // application.wo.* dereference. Find the position of + // the first such call after the catch block closes, + // and assert the guard appears before it. + // + // Assumption: the outer catch body has no nested + // braces. `[^\}]*` only matches catch bodies whose + // contents (after comment stripping) contain no `{` + // or `}`. If a future edit introduces a conditional + // or nested try inside the outer catch, this regex + // will fail to match and `scanFrom` falls back to 1 + // (top of onErrorBody) — the spec still passes as + // long as the guard exists, but the "scan after the + // catch" precision is lost. Widen the pattern (e.g. + // a brace-counter like the one above) if that + // becomes necessary. + var catchClosePattern = "catch\s*\(\s*any\s+\w+\s*\)\s*\{[^\}]*\}"; + var catchMatch = reFindNoCase(catchClosePattern, onErrorBody, 1, true); + + // If the body has a try/catch at the top of onError, the + // guard must land between the close of that catch and + // the first application.wo.* reference. If it doesn't + // (simpler form), the guard still needs to come before + // the first application.wo.* reference. + var scanFrom = (catchMatch.pos[1] > 0) + ? (catchMatch.pos[1] + catchMatch.len[1]) + : 1; + var tail = mid(onErrorBody, scanFrom, len(onErrorBody) - scanFrom + 1); + + var derefPos = reFindNoCase("application\.wo\.", tail); + var guardPos = reFindNoCase( + "StructKeyExists\s*\(\s*application\s*,\s*[""']wo[""']\s*\)", + tail + ); + + if (derefPos > 0) { + expect(guardPos > 0 && guardPos < derefPos).toBeTrue( + relPath & " onError() dereferences application.wo " + & "after the recovery try/catch without first " + & "guarding it. The guard must appear before any " + & "application.wo.* call so a failed Injector " + & "init can short-circuit cleanly (issue ##2773)." + ); + } + }); + })(rel); + } + + }); + + } + + /** + * Strip CFML tag, block, and line comments before scanning. Mirrors + * the helpers under cli/lucli/services (Analysis.cfc, Doctor.cfc) so a + * commented-out access pattern doesn't pollute the structural check + * (CLAUDE.md anti-pattern ##14). + */ + private string function $stripCfmlComments(required string source) { + var stripped = arguments.source; + stripped = reReplace(stripped, "", "", "all"); + stripped = reReplace(stripped, "/\*[\s\S]*?\*/", "", "all"); + stripped = reReplace(stripped, "(?m)//[^\n]*", "", "all"); + return stripped; + } + +} diff --git a/vendor/wheels/tests/specs/cli/RewriteConfigInstallerSpec.cfc b/vendor/wheels/tests/specs/cli/RewriteConfigInstallerSpec.cfc index 3c73dbb6e9..6aa6303905 100644 --- a/vendor/wheels/tests/specs/cli/RewriteConfigInstallerSpec.cfc +++ b/vendor/wheels/tests/specs/cli/RewriteConfigInstallerSpec.cfc @@ -58,6 +58,12 @@ component extends="wheels.WheelsTest" { directoryCreate(projectRoot); try { fileWrite(projectRoot & "/rewrite.config", "## user-customized rules"); + // Baseline read AFTER write: Adobe CF 2025's fileWrite/fileRead + // round-trip appends a trailing newline, so compare the + // post-install content to this normalized baseline rather than + // to the literal write string. The intent is "install() left the + // file untouched", not "the engine round-trips bytes identically". + var before = fileRead(projectRoot & "/rewrite.config"); var result = installer.install(projectRoot=projectRoot, sourceTemplate=ctx.templatePath); @@ -66,7 +72,7 @@ component extends="wheels.WheelsTest" { ); var preserved = fileRead(projectRoot & "/rewrite.config"); expect(preserved).toBe( - "## user-customized rules", + before, "Existing rewrite.config content must be preserved untouched" ); } finally { diff --git a/vendor/wheels/tests/specs/dispatch/InvokeMethodSpec.cfc b/vendor/wheels/tests/specs/dispatch/InvokeMethodSpec.cfc index 282f0db561..cdaafb2485 100644 --- a/vendor/wheels/tests/specs/dispatch/InvokeMethodSpec.cfc +++ b/vendor/wheels/tests/specs/dispatch/InvokeMethodSpec.cfc @@ -60,7 +60,16 @@ component extends="wheels.WheelsTest" { var receiverLossMessage = ""; try { - adapter.invokeMethod(publicCfc, "index"); + // index() renders the congratulations welcome page via + // cfinclude. Capture that output so it doesn't leak into the + // test-runner response buffer: on Adobe CF the leaked HTML + // commits the servlet response (HTTP 404 + ~1MB prefix), + // corrupting the JSON result for the ENTIRE suite. We only + // care that the dispatch invocation survives without a + // receiver-loss error, not what index() prints. + cfsavecontent(variable = "local.discardedIndexOutput") { + adapter.invokeMethod(publicCfc, "index"); + } } catch (any e) { // index() doesn't call $blockInProduction so this should // never throw a "Function [$...] not found" error. Any diff --git a/vendor/wheels/tests/specs/global/reloadGlobalsSpec.cfc b/vendor/wheels/tests/specs/global/reloadGlobalsSpec.cfc new file mode 100644 index 0000000000..898781196e --- /dev/null +++ b/vendor/wheels/tests/specs/global/reloadGlobalsSpec.cfc @@ -0,0 +1,115 @@ +component extends="wheels.WheelsTest" { + + function run() { + + // Shared struct so nested describe / beforeEach / afterEach / it closures + // can read `g` and `baseDir` on Adobe CF 2023/2025. CFML closures cannot + // reach an enclosing function's `local` scope on Adobe CF (CLAUDE.md + // cross-engine invariant ##3); a struct is a reference type, so all + // closures share the same object via `variables.ctx`. + var ctx = { + g: application.wo, + baseDir: ExpandPath("/wheels/tests/_tmp/reloadGlobals") + }; + + describe("Reload — global includes mtime tracking (issue ##2792)", () => { + + beforeEach(() => { + if (DirectoryExists(ctx.baseDir)) { + DirectoryDelete(ctx.baseDir, true); + } + // DirectoryCreate(path, true) is Lucee-only (issue ##2567); + // java.io.File.mkdirs() recurses parents on every engine. + CreateObject("java", "java.io.File").init(ctx.baseDir).mkdirs(); + }); + + afterEach(() => { + if (DirectoryExists(ctx.baseDir)) { + DirectoryDelete(ctx.baseDir, true); + } + }); + + it("$snapshotGlobalIncludes returns a struct keyed by cfm file paths", () => { + FileWrite(ctx.baseDir & "/fixtureA.cfm", "function fxA(){return 1;}"); + FileWrite(ctx.baseDir & "/fixtureB.cfm", "function fxB(){return 2;}"); + var snapshot = ctx.g.$snapshotGlobalIncludes(directory = ctx.baseDir); + expect(snapshot).toBeStruct(); + expect(StructCount(snapshot)).toBe(2); + }); + + it("$snapshotGlobalIncludes returns an empty struct when the directory does not exist", () => { + var missing = ctx.baseDir & "/does-not-exist"; + var snapshot = ctx.g.$snapshotGlobalIncludes(directory = missing); + expect(snapshot).toBeStruct(); + expect(StructCount(snapshot)).toBe(0); + }); + + it("$globalIncludesChanged returns false when no files changed", () => { + FileWrite(ctx.baseDir & "/stable.cfm", "function fxStable(){return 'stable';}"); + var snapshot = ctx.g.$snapshotGlobalIncludes(directory = ctx.baseDir); + expect(ctx.g.$globalIncludesChanged(snapshot = snapshot, directory = ctx.baseDir)).toBeFalse(); + }); + + it("$globalIncludesChanged returns true when a new cfm file appears", () => { + FileWrite(ctx.baseDir & "/one.cfm", "function fxOne(){return 1;}"); + var snapshot = ctx.g.$snapshotGlobalIncludes(directory = ctx.baseDir); + FileWrite(ctx.baseDir & "/two.cfm", "function fxTwo(){return 2;}"); + expect(ctx.g.$globalIncludesChanged(snapshot = snapshot, directory = ctx.baseDir)).toBeTrue(); + }); + + it("$globalIncludesChanged returns true when a tracked cfm file is removed", () => { + FileWrite(ctx.baseDir & "/keep.cfm", "function fxKeep(){return 1;}"); + FileWrite(ctx.baseDir & "/gone.cfm", "function fxGone(){return 2;}"); + var snapshot = ctx.g.$snapshotGlobalIncludes(directory = ctx.baseDir); + FileDelete(ctx.baseDir & "/gone.cfm"); + expect(ctx.g.$globalIncludesChanged(snapshot = snapshot, directory = ctx.baseDir)).toBeTrue(); + }); + + it("$globalIncludesChanged tolerates an empty starting snapshot", () => { + var snapshot = {}; + FileWrite(ctx.baseDir & "/added.cfm", "function fxAdded(){return 1;}"); + expect(ctx.g.$globalIncludesChanged(snapshot = snapshot, directory = ctx.baseDir)).toBeTrue(); + }); + + it("$globalIncludesChanged returns true when a tracked cfm file is modified", () => { + // Exercise the DateCompare != 0 branch — the "developer edited + // an existing helper" path the PR is designed to serve. + // Backdate the snapshot entry rather than sleeping for a fresh + // mtime, so the test is deterministic across filesystems with + // different mtime granularities (ext4 nanosecond vs APFS/HFS+ + // 1-second). + FileWrite(ctx.baseDir & "/modified.cfm", "function fxV1(){return 1;}"); + var snapshot = ctx.g.$snapshotGlobalIncludes(directory = ctx.baseDir); + var key = ListFirst(StructKeyList(snapshot)); + snapshot[key] = DateAdd("s", -60, snapshot[key]); + expect(ctx.g.$globalIncludesChanged(snapshot = snapshot, directory = ctx.baseDir)).toBeTrue(); + }); + + it("$reincludeGlobals re-evaluates the target cfm without throwing", () => { + // CFML's `include` resolves via mappings, not absolute filesystem + // paths — call $reincludeGlobals with the mapping-relative form. + var absPath = ExpandPath("/wheels/tests/_tmp/reloadGlobals/reinclude.cfm"); + FileWrite(absPath, "function fxReinclude(){return 'first';}"); + $assert.notThrows(function() { + application.wo.$reincludeGlobals(file = "/wheels/tests/_tmp/reloadGlobals/reinclude.cfm"); + }); + // The contract: re-including must make the function callable + // on application.wo. Without this assertion, a silent no-op + // on any engine would slip through. + expect(IsDefined("application.wo.fxReinclude")).toBeTrue(); + + // After overwriting the file, re-running the include should also + // succeed — covers the "developer just changed a helper" path + // that the bare ?reload=true workflow targets. Assert the + // *return value* changes so an Adobe-only silent no-op (the + // old version stays bound to `this`) can't slip past CI. + FileWrite(absPath, "function fxReinclude(){return 'second';}"); + $assert.notThrows(function() { + application.wo.$reincludeGlobals(file = "/wheels/tests/_tmp/reloadGlobals/reinclude.cfm"); + }); + expect(application.wo.fxReinclude()).toBe("second"); + }); + + }); + } +} diff --git a/vendor/wheels/tests/specs/mapper/MatchingSpec.cfc b/vendor/wheels/tests/specs/mapper/MatchingSpec.cfc index 70568e7e8e..f900b81abb 100644 --- a/vendor/wheels/tests/specs/mapper/MatchingSpec.cfc +++ b/vendor/wheels/tests/specs/mapper/MatchingSpec.cfc @@ -197,6 +197,77 @@ component extends="wheels.WheelsTest" { expect(r[4]).toHaveKey("redirect") expect(r[5]).toHaveKey("redirect") }); + + // Guard against redundant namespace prefix in to=/controller= (#2791). + it("Rejects to= with redundant namespace prefix", function(){ + expect(function() { + m.$draw() + .namespace("datapai") + .get(name = "datapaiDashboard", pattern = "dashboard", to = "datapai/dashboard##index") + .end() + .end(); + }).toThrow(type = "Wheels.MapperArgumentInvalid"); + }); + it("Rejects controller= with redundant namespace prefix", function(){ + expect(function() { + m.$draw() + .namespace("admin") + .$match(name = "dashboard", method = "get", controller = "admin/dashboard", action = "index") + .end() + .end(); + }).toThrow(type = "Wheels.MapperArgumentInvalid"); + }); + it("Rejects redundant prefix in nested namespaces", function(){ + expect(function() { + m.$draw() + .namespace("admin") + .namespace("users") + .get(name = "edit", pattern = "edit", to = "admin/users/profile##edit") + .end() + .end() + .end(); + }).toThrow(type = "Wheels.MapperArgumentInvalid"); + }); + it("Rejects redundant prefix inside .package() too", function(){ + expect(function() { + m.$draw() + .package("admin") + .get(name = "users", pattern = "users", to = "admin/users##index") + .end() + .end(); + }).toThrow(type = "Wheels.MapperArgumentInvalid"); + }); + it("Allows controllers whose name only happens to share a prefix with the namespace", function(){ + // "foobar/dashboard" inside .namespace("foo") is not a redundant prefix — "foobar" != "foo". + m.$draw() + .namespace("foo") + .get(name = "dashboard", pattern = "dashboard", to = "foobar/dashboard##index") + .end() + .end(); + r = m.getRoutes(); + expect(r).toBeArray(); + expect(ArrayLen(r) >= 1).toBeTrue(); + }); + it("Accepts the correct (non-redundant) form inside a namespace", function(){ + m.$draw() + .namespace("datapai") + .get(name = "datapaiDashboard", pattern = "dashboard", to = "dashboard##index") + .end() + .end(); + r = m.getRoutes(); + expect(r).toBeArray(); + expect(ArrayLen(r) >= 1).toBeTrue(); + expect(r[1].controller).toBe("datapai.dashboard"); + expect(r[1].action).toBe("index"); + }); + it("Does not falsely reject when package is empty", function(){ + m.$draw() + .$match(name = "edge", method = "get", pattern = "edge", controller = "/users", action = "index", package = "") + .end(); + r = m.getRoutes(); + expect(r).toBeArray(); + expect(ArrayLen(r) >= 1).toBeTrue(); + }); }); } diff --git a/vendor/wheels/tests/specs/migrator/MigrateIndividualErrorPathSpec.cfc b/vendor/wheels/tests/specs/migrator/MigrateIndividualErrorPathSpec.cfc new file mode 100644 index 0000000000..20b1ee9dc2 --- /dev/null +++ b/vendor/wheels/tests/specs/migrator/MigrateIndividualErrorPathSpec.cfc @@ -0,0 +1,75 @@ +component extends="wheels.WheelsTest" { + + include "helperFunctions.cfm"; + + function beforeAll() { + migration = CreateObject("component", "wheels.migrator.Migration").init(); + migrator = CreateObject("component", "wheels.Migrator").init( + migratePath = "/wheels/tests/_assets/migrator-error-path/migrations/", + sqlPath = "/wheels/tests/_assets/migrator-error-path/sql/" + ); + } + + function run() { + + var _isCockroachDB = CreateObject("component", "wheels.migrator.Migration").init().adapter.adapterName() == "CockroachDB"; + + // When migrateIndividual()'s up() throws, the catch block must not fall + // through to `transaction action="commit"` after the rollback. On Lucee + // the spurious commit is a silent no-op; on Adobe CF 2023/2025 (and + // potentially BoxLang) it can throw a JDBC "transaction not active" + // error that masks the real migration failure. This spec asserts the + // post-fix contract on every engine. + describe("migrateIndividual() error path", () => { + + beforeEach(() => { + deleteMigratorVersions(2); + try { + queryExecute( + "DELETE FROM #application.wheels.migratorTableName# WHERE version = '004'", + {}, + { datasource = application.wheels.dataSourceName } + ); + } catch (any e) {} + $cleanSqlDirectory(); + }); + + it("returns normally when the migration's up() throws", () => { + if (_isCockroachDB) return; + var rv = ""; + var threw = false; + var thrownMessage = ""; + try { + rv = migrator.migrateIndividual("004"); + } catch (any e) { + threw = true; + thrownMessage = e.message; + } + expect(threw).toBeFalse( + "migrateIndividual() should catch the migration error and return its message, " + & "not propagate an exception. Got: " & thrownMessage + ); + expect(rv).toInclude("Error migrating 004"); + expect(rv).toInclude("synthetic failure"); + }); + + it("does not record a tracking row when the migration throws", () => { + if (_isCockroachDB) return; + migrator.migrateIndividual("004"); + var versions = queryExecute( + "SELECT version FROM #application.wheels.migratorTableName# WHERE version = '004'", + {}, + { datasource = application.wheels.dataSourceName } + ); + expect(versions.recordCount).toBe( + 0, + "The migration's up() threw, so the catch should have rolled back " + & "and returned early. No tracking row should exist for version 004." + ); + }); + + }); + + } + +} diff --git a/vendor/wheels/tests/specs/migrator/MigratorInfoSpec.cfc b/vendor/wheels/tests/specs/migrator/MigratorInfoSpec.cfc new file mode 100644 index 0000000000..49b659ce44 --- /dev/null +++ b/vendor/wheels/tests/specs/migrator/MigratorInfoSpec.cfc @@ -0,0 +1,84 @@ +component extends="wheels.WheelsTest" { + + include "helperFunctions.cfm"; + + function beforeAll() { + migration = CreateObject("component", "wheels.migrator.Migration").init(); + migrator = CreateObject("component", "wheels.Migrator").init( + migratePath = "/wheels/tests/_assets/migrator/migrations/", + sqlPath = "/wheels/tests/_assets/migrator/sql/" + ); + } + + function run() { + + var _isCockroachDB = CreateObject("component", "wheels.migrator.Migration").init().adapter.adapterName() == "CockroachDB"; + + var insertOrphan = function(required string version) { + queryExecute( + "INSERT INTO #application.wheels.migratorTableName# (version, core_level) VALUES ('#arguments.version#', #application.wheels.migrationLevel#)", + {}, + { datasource = application.wheels.dataSourceName } + ); + }; + + describe("Migrator info output", () => { + + beforeEach(() => { + for (local.table in ["c_o_r_e_bunyips", "c_o_r_e_dropbears", "c_o_r_e_hoopsnakes"]) { + try { migration.dropTable(local.table); } catch (any e) {} + } + deleteMigratorVersions(2); + }); + + afterEach(() => { + for (local.table in ["c_o_r_e_bunyips", "c_o_r_e_dropbears", "c_o_r_e_hoopsnakes"]) { + try { migration.dropTable(local.table); } catch (any e) {} + } + deleteMigratorVersions(2); + }); + + it("$buildInfoOutput returns expected lines for a clean state", () => { + if (_isCockroachDB) return; + migrator.migrateTo("002"); + var lines = migrator.$buildInfoOutput(); + expect(lines).toBeArray(); + var joined = ArrayToList(lines, Chr(10)); + expect(joined).toInclude("Current version:"); + expect(joined).toInclude("[x] 001"); + expect(joined).toInclude("[x] 002"); + expect(joined).toInclude("[ ] 003"); + }); + + it("$buildInfoOutput marks orphan versions with [?] and NO FILE", () => { + if (_isCockroachDB) return; + migrator.migrateTo("001"); + insertOrphan("999"); + var lines = migrator.$buildInfoOutput(); + var joined = ArrayToList(lines, Chr(10)); + expect(joined).toInclude("[?] 999"); + expect(joined).toInclude("NO FILE"); + }); + + it("$buildInfoOutput summary counts orphans separately", () => { + if (_isCockroachDB) return; + migrator.migrateTo("001"); + insertOrphan("999"); + var lines = migrator.$buildInfoOutput(); + var joined = ArrayToList(lines, Chr(10)); + expect(joined).toInclude("orphan: 1"); + }); + + it("$buildInfoOutput omits orphan summary line when no orphans exist", () => { + if (_isCockroachDB) return; + migrator.migrateTo("001"); + var lines = migrator.$buildInfoOutput(); + var joined = ArrayToList(lines, Chr(10)); + expect(joined).notToInclude("orphan:"); + }); + + }); + + } + +} diff --git a/vendor/wheels/tests/specs/migrator/MigratorOuterTransactionSpec.cfc b/vendor/wheels/tests/specs/migrator/MigratorOuterTransactionSpec.cfc new file mode 100644 index 0000000000..44fa170ce4 --- /dev/null +++ b/vendor/wheels/tests/specs/migrator/MigratorOuterTransactionSpec.cfc @@ -0,0 +1,83 @@ +component extends="wheels.WheelsTest" { + + include "helperFunctions.cfm"; + + function beforeAll() { + // Required by helperFunctions.cfm engine-detection helpers (e.g. isDbCompatible's adapter probe). + migration = CreateObject("component", "wheels.migrator.Migration").init(); + // Separate migrations dir avoids collision with reconciliation spec's 001/002/003 fixtures. + migrator = CreateObject("component", "wheels.Migrator").init( + migratePath = "/wheels/tests/_assets/migrator/migrations_2789/", + sqlPath = "/wheels/tests/_assets/migrator/sql_2789/" + ); + } + + function run() { + + var ctx = {isCockroachDB: CreateObject("component", "wheels.migrator.Migration").init().adapter.adapterName() == "CockroachDB"}; + + describe("Migrator sets $wheelsTransactionWrapper while up()/down() runs (issue ##2789)", () => { + + beforeEach(() => { + deleteMigratorVersions(2); + $cleanSqlDirectory(); + try { + queryExecute( + "DELETE FROM c_o_r_e_tags WHERE name = 'issue2789_via_model_create'", + {}, + { datasource = application.wheels.dataSourceName } + ); + } catch (any e) {} + StructDelete(request, "$issue2789FlagDuringUp"); + StructDelete(request, "$wheelsTransactionWrapper"); + }); + + afterEach(() => { + deleteMigratorVersions(2); + $cleanSqlDirectory(); + try { + queryExecute( + "DELETE FROM c_o_r_e_tags WHERE name = 'issue2789_via_model_create'", + {}, + { datasource = application.wheels.dataSourceName } + ); + } catch (any e) {} + StructDelete(request, "$issue2789FlagDuringUp"); + StructDelete(request, "$wheelsTransactionWrapper"); + }); + + it("model.create() inside up() persists after the outer transaction commits", () => { + if (ctx.isCockroachDB) return; + migrator.migrateTo("001"); + var found = queryExecute( + "SELECT id FROM c_o_r_e_tags WHERE name = 'issue2789_via_model_create'", + {}, + { datasource = application.wheels.dataSourceName } + ); + expect(found.recordCount).toBe(1); + }); + + it("sets request.$wheelsTransactionWrapper for the duration of up()", () => { + if (ctx.isCockroachDB) return; + migrator.migrateTo("001"); + expect(request.$issue2789FlagDuringUp).toBeTrue(); + }); + + it("clears request.$wheelsTransactionWrapper after up() returns", () => { + if (ctx.isCockroachDB) return; + migrator.migrateTo("001"); + expect(StructKeyExists(request, "$wheelsTransactionWrapper")).toBeFalse(); + }); + + it("clears request.$wheelsTransactionWrapper after down() returns", () => { + if (ctx.isCockroachDB) return; + migrator.migrateTo("001"); + migrator.migrateTo("0"); + expect(StructKeyExists(request, "$wheelsTransactionWrapper")).toBeFalse(); + }); + + }); + + } + +} diff --git a/vendor/wheels/tests/specs/migrator/MigratorReconciliationSpec.cfc b/vendor/wheels/tests/specs/migrator/MigratorReconciliationSpec.cfc new file mode 100644 index 0000000000..8e8131836c --- /dev/null +++ b/vendor/wheels/tests/specs/migrator/MigratorReconciliationSpec.cfc @@ -0,0 +1,195 @@ +component extends="wheels.WheelsTest" { + + include "helperFunctions.cfm"; + + function beforeAll() { + migration = CreateObject("component", "wheels.migrator.Migration").init(); + migrator = CreateObject("component", "wheels.Migrator").init( + migratePath = "/wheels/tests/_assets/migrator/migrations/", + sqlPath = "/wheels/tests/_assets/migrator/sql/" + ); + } + + function run() { + + var _isCockroachDB = CreateObject("component", "wheels.migrator.Migration").init().adapter.adapterName() == "CockroachDB"; + + var insertOrphan = function(required string version) { + queryExecute( + "INSERT INTO #application.wheels.migratorTableName# (version, core_level) VALUES ('#arguments.version#', #application.wheels.migrationLevel#)", + {}, + { datasource = application.wheels.dataSourceName } + ); + }; + + describe("doctor()", () => { + + beforeEach(() => { + for (local.table in ["c_o_r_e_bunyips", "c_o_r_e_dropbears", "c_o_r_e_hoopsnakes"]) { + try { migration.dropTable(local.table); } catch (any e) {} + } + deleteMigratorVersions(2); + $cleanSqlDirectory(); + }); + + afterEach(() => { + for (local.table in ["c_o_r_e_bunyips", "c_o_r_e_dropbears", "c_o_r_e_hoopsnakes"]) { + try { migration.dropTable(local.table); } catch (any e) {} + } + deleteMigratorVersions(2); + $cleanSqlDirectory(); + }); + + it("returns a clean health struct when DB and files match", () => { + if (_isCockroachDB) return; + migrator.migrateTo("003"); + var report = migrator.doctor(); + expect(report).toBeStruct(); + expect(report.healthy).toBeTrue(); + expect(ArrayLen(report.orphans)).toBe(0); + expect(ArrayLen(report.pending)).toBe(0); + expect(report.summary.applied).toBe(3); + expect(report.summary.total).toBe(3); + }); + + it("flags orphans as unhealthy", () => { + if (_isCockroachDB) return; + migrator.migrateTo("001"); + insertOrphan("999"); + var report = migrator.doctor(); + expect(report.healthy).toBeFalse(); + expect(ArrayLen(report.orphans)).toBe(1); + expect(report.orphans[1]).toBe("999"); + }); + + it("flags pending local migrations", () => { + if (_isCockroachDB) return; + migrator.migrateTo("001"); + var report = migrator.doctor(); + expect(report.healthy).toBeFalse(); + expect(ArrayLen(report.pending)).toBe(2); + }); + + it("includes a human-readable summary message", () => { + if (_isCockroachDB) return; + migrator.migrateTo("001"); + insertOrphan("999"); + var report = migrator.doctor(); + expect(report.message).toBeString(); + expect(report.message).toInclude("orphan"); + }); + + }); + + describe("forgetVersion()", () => { + + beforeEach(() => { + for (local.table in ["c_o_r_e_bunyips", "c_o_r_e_dropbears", "c_o_r_e_hoopsnakes"]) { + try { migration.dropTable(local.table); } catch (any e) {} + } + deleteMigratorVersions(2); + $cleanSqlDirectory(); + }); + + afterEach(() => { + for (local.table in ["c_o_r_e_bunyips", "c_o_r_e_dropbears", "c_o_r_e_hoopsnakes"]) { + try { migration.dropTable(local.table); } catch (any e) {} + } + deleteMigratorVersions(2); + $cleanSqlDirectory(); + }); + + it("removes an orphan version from the tracking table", () => { + if (_isCockroachDB) return; + migrator.migrateTo("001"); + insertOrphan("999"); + var result = migrator.forgetVersion("999"); + expect(result.success).toBeTrue(); + expect(result.removed).toBe("999"); + expect(ArrayLen(migrator.$getOrphanVersions())).toBe(0); + }); + + it("refuses to forget a version that has a local file", () => { + if (_isCockroachDB) return; + migrator.migrateTo("002"); + var result = migrator.forgetVersion("002"); + expect(result.success).toBeFalse(); + expect(result.message).toInclude("local file"); + }); + + it("refuses to forget a version that is not in the tracking table", () => { + if (_isCockroachDB) return; + var result = migrator.forgetVersion("999"); + expect(result.success).toBeFalse(); + expect(result.message).toInclude("not found"); + }); + + it("returns a failure for invalid version input", () => { + if (_isCockroachDB) return; + var result = migrator.forgetVersion("abc"); + expect(result.success).toBeFalse(); + expect(result.message).toInclude("Invalid version"); + }); + + }); + + describe("pretendVersion()", () => { + + beforeEach(() => { + for (local.table in ["c_o_r_e_bunyips", "c_o_r_e_dropbears", "c_o_r_e_hoopsnakes"]) { + try { migration.dropTable(local.table); } catch (any e) {} + } + deleteMigratorVersions(2); + $cleanSqlDirectory(); + }); + + afterEach(() => { + for (local.table in ["c_o_r_e_bunyips", "c_o_r_e_dropbears", "c_o_r_e_hoopsnakes"]) { + try { migration.dropTable(local.table); } catch (any e) {} + } + deleteMigratorVersions(2); + $cleanSqlDirectory(); + }); + + it("records a version as applied without running its up()", () => { + if (_isCockroachDB) return; + var result = migrator.pretendVersion("001"); + expect(result.success).toBeTrue(); + expect(result.recorded).toBe("001"); + var versions = queryExecute( + "SELECT version FROM #application.wheels.migratorTableName# WHERE version = '001'", + {}, + {datasource = application.wheels.dataSourceName} + ); + expect(versions.recordCount).toBe(1); + var info = application.wo.$dbinfo(datasource = application.wheels.dataSourceName, type = "tables", pattern = "c_o_r_e_bunyips"); + expect(ListFindNoCase(ValueList(info.table_name), "c_o_r_e_bunyips")).toBeFalse(); + }); + + it("refuses to pretend a version that is already applied", () => { + if (_isCockroachDB) return; + migrator.migrateTo("001"); + var result = migrator.pretendVersion("001"); + expect(result.success).toBeFalse(); + expect(result.message).toInclude("already applied"); + }); + + it("refuses to pretend a version that has no local file", () => { + if (_isCockroachDB) return; + var result = migrator.pretendVersion("999"); + expect(result.success).toBeFalse(); + expect(result.message).toInclude("no matching file"); + }); + + it("returns a failure for invalid version input", () => { + if (_isCockroachDB) return; + var result = migrator.pretendVersion("abc"); + expect(result.success).toBeFalse(); + expect(result.message).toInclude("Invalid version"); + }); + + }); + + } + +} diff --git a/vendor/wheels/tests/specs/migrator/OrphanDetectionSpec.cfc b/vendor/wheels/tests/specs/migrator/OrphanDetectionSpec.cfc new file mode 100644 index 0000000000..8364134ca1 --- /dev/null +++ b/vendor/wheels/tests/specs/migrator/OrphanDetectionSpec.cfc @@ -0,0 +1,160 @@ +component extends="wheels.WheelsTest" { + + include "helperFunctions.cfm"; + + function beforeAll() { + migration = CreateObject("component", "wheels.migrator.Migration").init(); + migrator = CreateObject("component", "wheels.Migrator").init( + migratePath = "/wheels/tests/_assets/migrator/migrations/", + sqlPath = "/wheels/tests/_assets/migrator/sql/" + ); + } + + function run() { + + var _isCockroachDB = CreateObject("component", "wheels.migrator.Migration").init().adapter.adapterName() == "CockroachDB"; + + // Helper for inserting fake orphan versions directly into the + // tracking table. Pulled into a function so each `it` doesn't + // repeat the SQL. + var insertOrphan = function(required string version) { + queryExecute( + "INSERT INTO #application.wheels.migratorTableName# (version, core_level) VALUES ('#arguments.version#', #application.wheels.migrationLevel#)", + {}, + { datasource = application.wheels.dataSourceName } + ); + }; + + describe("$getOrphanVersions", () => { + + beforeEach(() => { + for (local.table in ["c_o_r_e_bunyips", "c_o_r_e_dropbears", "c_o_r_e_hoopsnakes"]) { + try { migration.dropTable(local.table); } catch (any e) {} + } + deleteMigratorVersions(2); + $cleanSqlDirectory(); + }); + + afterEach(() => { + deleteMigratorVersions(2); + $cleanSqlDirectory(); + }); + + it("returns empty array when DB and files match", () => { + if (_isCockroachDB) return; + migrator.migrateTo("001"); + var orphans = migrator.$getOrphanVersions(); + expect(orphans).toBeArray(); + expect(ArrayLen(orphans)).toBe(0); + }); + + it("returns the orphan when DB has a version with no matching file", () => { + if (_isCockroachDB) return; + migrator.migrateTo("001"); + insertOrphan("999"); + var orphans = migrator.$getOrphanVersions(); + expect(ArrayLen(orphans)).toBe(1); + expect(orphans[1]).toBe("999"); + }); + + it("returns multiple orphans sorted ascending", () => { + if (_isCockroachDB) return; + migrator.migrateTo("001"); + insertOrphan("999"); + insertOrphan("998"); + var orphans = migrator.$getOrphanVersions(); + expect(ArrayLen(orphans)).toBe(2); + expect(orphans[1]).toBe("998"); + expect(orphans[2]).toBe("999"); + }); + + it("ignores the sentinel '0' returned by empty tracking table", () => { + if (_isCockroachDB) return; + var orphans = migrator.$getOrphanVersions(); + expect(ArrayLen(orphans)).toBe(0); + }); + + }); + + describe("migrateTo with orphan-at-top", () => { + + beforeEach(() => { + for (local.table in ["c_o_r_e_bunyips", "c_o_r_e_dropbears", "c_o_r_e_hoopsnakes"]) { + try { migration.dropTable(local.table); } catch (any e) {} + } + deleteMigratorVersions(2); + $cleanSqlDirectory(); + }); + + afterEach(() => { + for (local.table in ["c_o_r_e_bunyips", "c_o_r_e_dropbears", "c_o_r_e_hoopsnakes"]) { + try { migration.dropTable(local.table); } catch (any e) {} + } + deleteMigratorVersions(2); + $cleanSqlDirectory(); + }); + + it("does not take the down branch when only orphans separate current from target", () => { + if (_isCockroachDB) return; + migrator.migrateTo("001"); + insertOrphan("999"); + var output = migrator.migrateTo("003"); + expect(output).notToInclude("down to 003"); + }); + + it("applies pending local migrations when only orphans separate current from target", () => { + if (_isCockroachDB) return; + migrator.migrateTo("001"); + insertOrphan("999"); + migrator.migrateTo("003"); + var info = application.wo.$dbinfo(datasource = application.wheels.dataSourceName, type = "tables", pattern = "c_o_r_e_dropbears"); + expect(ListFindNoCase(ValueList(info.table_name), "c_o_r_e_dropbears")).toBeTrue(); + var info2 = application.wo.$dbinfo(datasource = application.wheels.dataSourceName, type = "tables", pattern = "c_o_r_e_hoopsnakes"); + expect(ListFindNoCase(ValueList(info2.table_name), "c_o_r_e_hoopsnakes")).toBeTrue(); + }); + + it("emits a warning naming the orphan version(s)", () => { + if (_isCockroachDB) return; + migrator.migrateTo("001"); + insertOrphan("999"); + var output = migrator.migrateTo("003"); + expect(output).toInclude("999"); + expect(output).toInclude("no matching file"); + }); + + it("prints a clear nothing-to-do message when no pending local migrations exist", () => { + if (_isCockroachDB) return; + migrator.migrateTo("003"); + insertOrphan("999"); + var output = migrator.migrateToLatest(); + expect(output).notToInclude("down to"); + expect(output).toInclude("999"); + expect(output).toInclude("Nothing to do"); + }); + + it("still allows legitimate down-migration when down target has a local file", () => { + if (_isCockroachDB) return; + migrator.migrateTo("002"); + var output = migrator.migrateTo("001"); + expect(output).toInclude("down to 001"); + var info = application.wo.$dbinfo(datasource = application.wheels.dataSourceName, type = "tables", pattern = "c_o_r_e_dropbears"); + expect(ListFindNoCase(ValueList(info.table_name), "c_o_r_e_dropbears")).toBeFalse(); + }); + + it("warns about orphans and still runs the down branch when both are present above target", () => { + if (_isCockroachDB) return; + migrator.migrateTo("002"); + insertOrphan("999"); + var output = migrator.migrateTo("001"); + expect(output).toInclude("999"); + expect(output).toInclude("skipped during rollback"); + expect(output).toInclude("down to 001"); + var info = application.wo.$dbinfo(datasource = application.wheels.dataSourceName, type = "tables", pattern = "c_o_r_e_dropbears"); + expect(ListFindNoCase(ValueList(info.table_name), "c_o_r_e_dropbears")).toBeFalse(); + }); + + }); + + } + +} diff --git a/vendor/wheels/tests/specs/migrator/SchemaEnrichmentSpec.cfc b/vendor/wheels/tests/specs/migrator/SchemaEnrichmentSpec.cfc new file mode 100644 index 0000000000..439e9b32db --- /dev/null +++ b/vendor/wheels/tests/specs/migrator/SchemaEnrichmentSpec.cfc @@ -0,0 +1,161 @@ +component extends="wheels.WheelsTest" { + + include "helperFunctions.cfm"; + + function beforeAll() { + migration = CreateObject("component", "wheels.migrator.Migration").init(); + migrator = CreateObject("component", "wheels.Migrator").init( + migratePath = "/wheels/tests/_assets/migrator/migrations/", + sqlPath = "/wheels/tests/_assets/migrator/sql/" + ); + } + + function run() { + + var _isCockroachDB = CreateObject("component", "wheels.migrator.Migration").init().adapter.adapterName() == "CockroachDB"; + + describe("$ensureTrackingColumns", () => { + + // CockroachDB skip mirrors the pattern in migratorSpec.cfc, + // OrphanDetectionSpec.cfc and MigratorInfoSpec.cfc. The + // numeric-version test fixtures (001/002/003) are exercised + // against CockroachDB in compat-matrix.yml only as soft-fail + // (SOFT_FAIL_DBS includes cockroachdb). Keep the guard so the + // suite stays consistent with the rest of the migrator specs. + beforeEach(() => { + for (local.table in ["c_o_r_e_bunyips", "c_o_r_e_dropbears", "c_o_r_e_hoopsnakes"]) { + try { migration.dropTable(local.table); } catch (any e) {} + } + // Wipe the tracking-columns cache so each test sees a fresh state + StructDelete(application.wheels, "$trackingColumnsEnsured"); + StructDelete(application.wheels, "$migratorDbType"); + deleteMigratorVersions(2); + $cleanSqlDirectory(); + }); + + afterEach(() => { + deleteMigratorVersions(2); + StructDelete(application.wheels, "$trackingColumnsEnsured"); + StructDelete(application.wheels, "$migratorDbType"); + $cleanSqlDirectory(); + }); + + it("adds name and applied_at columns to the tracking table on first call", () => { + if (_isCockroachDB) return; + // Force tracking table to be created via migrateTo + migrator.migrateTo("001"); + // First call should add the columns (the migrator may have already + // added them via the bootstrap path, but the helper is idempotent) + var result = migrator.$ensureTrackingColumns(); + expect(result).toBeStruct(); + expect(result.hasName).toBeTrue(); + expect(result.hasAppliedAt).toBeTrue(); + expect(result.errors).toBeArray(); + expect(ArrayLen(result.errors)).toBe(0); + }); + + it("is idempotent — second call adds nothing", () => { + if (_isCockroachDB) return; + migrator.migrateTo("001"); + migrator.$ensureTrackingColumns(); + var result = migrator.$ensureTrackingColumns(); + expect(result.hasName).toBeTrue(); + expect(result.hasAppliedAt).toBeTrue(); + expect(ArrayLen(result.added)).toBe(0); + }); + + it("populates the name column for newly applied migrations", () => { + if (_isCockroachDB) return; + migrator.migrateTo("001"); + // The name column should now contain "create_bunyips_table" for version 001 + var rows = queryExecute( + "SELECT version, name FROM #application.wheels.migratorTableName# WHERE version = '001'", + {}, + {datasource = application.wheels.dataSourceName} + ); + expect(rows.recordCount).toBe(1); + // Assert directly on the value so a failure shows the actual + // name in the message — not just "Expected [false] to be [true]" + // which would happen if we collapsed Len(...) > 0 to a boolean + // before the matcher saw it. + expect(rows.name).notToBeEmpty(); + }); + + it("populates applied_at for newly applied migrations", () => { + if (_isCockroachDB) return; + migrator.migrateTo("001"); + // applied_at is the column-DEFAULT CURRENT_TIMESTAMP on most + // engines; SQLite gets an explicit CFML-side Now() because it + // can't DEFAULT a column on ADD COLUMN. Either way the value + // should be a parseable date string after migration. + var rows = queryExecute( + "SELECT applied_at FROM #application.wheels.migratorTableName# WHERE version = '001'", + {}, + {datasource = application.wheels.dataSourceName} + ); + expect(rows.recordCount).toBe(1); + expect(IsDate(rows.applied_at)).toBeTrue(); + }); + + it("re-runs ALTER when the tracking table is dropped+recreated (regression)", () => { + if (_isCockroachDB) return; + // First run: applies a migration, schema gets enriched, + // and both app-scope caches are set. + migrator.migrateTo("001"); + expect(StructKeyExists(application.wheels, "$trackingColumnsEnsured")).toBeTrue(); + // Drop both the tracking table AND the migration's target + // table so we can re-apply 001 from a clean schema. + // Cache flag stays set — that's the bug scenario: the stale + // flag caused $maybeEnsureTrackingColumns to skip the ALTER, + // leaving the freshly-recreated tracking table without the + // name/applied_at columns. $setVersionAsMigrated then tried + // to INSERT against missing columns → SQL error → migration + // transaction rolled back. Mirrors migratorSpec.cfc's + // beforeEach pattern and any production scenario where the + // schema is rolled back externally. + migration.dropTable("c_o_r_e_bunyips"); + migration.dropTable(application.wheels.migratorTableName); + // Cache flag is intentionally NOT cleared — that's what + // triggered the bug. With the fix, $maybeEnsureTrackingColumns + // re-runs $ensureTrackingColumns on every call and refreshes + // the flag from the actual schema state. + expect(StructKeyExists(application.wheels, "$trackingColumnsEnsured")).toBeTrue(); + migrator.migrateTo("001"); + var rows = queryExecute( + "SELECT name FROM #application.wheels.migratorTableName# WHERE version = '001'", + {}, + {datasource = application.wheels.dataSourceName} + ); + expect(rows.recordCount).toBe(1); + expect(rows.name).notToBeEmpty(); + }); + + it("populates applied_at across app restarts (regression for round-2 C2)", () => { + if (_isCockroachDB) return; + // First "app run": migrate 001, which adds the enriched + // columns and caches $migratorDbType + $trackingColumnsEnsured. + migrator.migrateTo("001"); + // Simulate an app restart: wipe both app-scope caches but + // leave the schema in place. The columns are already present + // in the DB; the next $ensureTrackingColumns() call must + // repopulate $migratorDbType BEFORE the early-return fires, + // otherwise SQLite would write NULL into applied_at on the + // next $setVersionAsMigrated insert (no DEFAULT on SQLite). + StructDelete(application.wheels, "$trackingColumnsEnsured"); + StructDelete(application.wheels, "$migratorDbType"); + // Second "app run": apply another migration. + migrator.migrateTo("002"); + var rows = queryExecute( + "SELECT applied_at FROM #application.wheels.migratorTableName# WHERE version = '002'", + {}, + {datasource = application.wheels.dataSourceName} + ); + expect(rows.recordCount).toBe(1); + expect(IsDate(rows.applied_at)).toBeTrue(); + }); + + }); + + } + +} diff --git a/vendor/wheels/tests/specs/migrator/migrationCommandsSpec.cfc b/vendor/wheels/tests/specs/migrator/migrationCommandsSpec.cfc new file mode 100644 index 0000000000..56cd006ddf --- /dev/null +++ b/vendor/wheels/tests/specs/migrator/migrationCommandsSpec.cfc @@ -0,0 +1,173 @@ +/** + * Coverage for the consistency-sweep changes to Migration.cfc command-version + * helpers (the standalone counterparts to TableDefinition's t.X() builders). + * + * Follow-up to #2781 (PR #2802). The TableDefinition layer was fixed in PR1 + * and PR1's review iterations also added DB-roundtrip coverage for the + * `Migration.cfc::addReference()` and `Migration.cfc::removeColumn(referenceName=)` + * suffix-flag behavior — those live in `migrationSpec.cfc` and are not + * duplicated here. This spec covers what PR2 uniquely contributes: + * + * - `addColumn` / `changeColumn` / `removeColumn` accept the plural + * `columnNames` as an alias for the legacy singular `columnName`, + * matching the convention every helper in `TableDefinition.cfc` already + * follows via `$combineArguments`. + * + * `addReference` / `dropReference` `columnName`/`columnNames` aliases for + * the legacy `referenceName` are exercised at the unit level by the + * `$combineArguments` calls themselves (any regression would surface as + * IncorrectArguments errors in the existing migrator suite). + */ +component extends="wheels.WheelsTest" { + + include "helperFunctions.cfm"; + + function beforeAll() { + variables.migration = CreateObject("component", "wheels.migrator.Migration").init(); + } + + function run() { + + describe("Migration.cfc — argument aliases via integration", () => { + + it("addColumn accepts columnNames alias and actually adds the column", () => { + if (!isDbCompatible()) return; + var t = variables.migration.createTable(name = "dbm_cmd_addcol_test", force = true); + t.string(columnNames = "placeholder"); + t.create(); + variables.migration.addColumn( + table = "dbm_cmd_addcol_test", + columnNames = "age", + columnType = "integer" + ); + expect(ListFindNoCase(variables.migration.$getColumns("dbm_cmd_addcol_test"), "age")).toBeGT(0); + variables.migration.dropTable("dbm_cmd_addcol_test"); + }); + + it("changeColumn accepts columnNames alias directly (not via addColumn delegation)", () => { + if (!isDbCompatible()) return; + // Direct changeColumn call — guards against a future refactor of + // addColumn that stops delegating and would silently lose the + // columnNames alias path inside changeColumn itself. + var t = variables.migration.createTable(name = "dbm_cmd_changecol_test", force = true); + t.integer(columnNames = "age"); + t.create(); + variables.migration.changeColumn( + table = "dbm_cmd_changecol_test", + columnNames = "age", + columnType = "integer" + ); + expect(ListFindNoCase(variables.migration.$getColumns("dbm_cmd_changecol_test"), "age")).toBeGT(0); + variables.migration.dropTable("dbm_cmd_changecol_test"); + }); + + it("removeColumn accepts columnNames alias and actually drops the column", () => { + if (!isDbCompatible()) return; + var t = variables.migration.createTable(name = "dbm_cmd_rmcol_test", force = true); + t.integer(columnNames = "age"); + t.create(); + expect(ListFindNoCase(variables.migration.$getColumns("dbm_cmd_rmcol_test"), "age")).toBeGT(0); + variables.migration.removeColumn( + table = "dbm_cmd_rmcol_test", + columnNames = "age" + ); + expect(ListFindNoCase(variables.migration.$getColumns("dbm_cmd_rmcol_test"), "age")).toBe(0); + variables.migration.dropTable("dbm_cmd_rmcol_test"); + }); + + it("addForeignKey accepts columnName alias for the legacy column parameter", () => { + // SQLite doesn't support altering CONSTRAINTS, so addForeignKey + // fires a SQL error there. We only need to verify the alias + // resolved (no IncorrectArguments thrown); the downstream SQL + // failure on SQLite is irrelevant to the alias contract. + var t = variables.migration.createTable(name = "dbm_cmd_addfk_alias_test", force = true); + t.integer(columnNames = "userid"); + t.create(); + var aliasAccepted = true; + try { + variables.migration.addForeignKey( + table = "dbm_cmd_addfk_alias_test", + referenceTable = "users", + columnName = "userid", + referenceColumn = "id" + ); + } catch (Wheels.IncorrectArguments e) { + aliasAccepted = false; + } catch (any e) {} + variables.migration.dropTable("dbm_cmd_addfk_alias_test"); + expect(aliasAccepted).toBeTrue(); + }); + + it("dropReference accepts columnName alias for the legacy referenceName parameter", () => { + // dropReference resolves to dropForeignKey by name pattern + // (FK__). If no such FK exists, + // dropForeignKey errors at the SQL layer — that's fine here; + // we only care that the alias resolved before SQL ran. + var aliasAccepted = true; + try { + variables.migration.dropReference( + table = "dbm_cmd_dropref_alias_table", + columnName = "user" + ); + } catch (Wheels.IncorrectArguments e) { + aliasAccepted = false; + } catch (any e) {} + expect(aliasAccepted).toBeTrue(); + }); + + }); + + describe("Migration.cfc — required-arg regression guards", () => { + + it("addColumn throws Wheels.IncorrectArguments when neither columnName nor columnNames is provided (non-reference)", () => { + // Prior to PR2 the original `required string columnName = ""` + // signature enforced param presence at the CFML level. The + // widened signature uses $combineArguments(required=true) to + // restore equivalent enforcement for non-reference column types. + var threwIncorrectArgs = false; + try { + variables.migration.addColumn( + table = "dbm_cmd_reqcheck_test", + columnType = "integer" + ); + } catch (Wheels.IncorrectArguments e) { + threwIncorrectArgs = true; + } catch (any e) {} + expect(threwIncorrectArgs).toBeTrue(); + }); + + it("changeColumn throws Wheels.IncorrectArguments when neither columnName nor columnNames is provided (non-reference)", () => { + var threwIncorrectArgs = false; + try { + variables.migration.changeColumn( + table = "dbm_cmd_reqcheck_test", + columnType = "integer" + ); + } catch (Wheels.IncorrectArguments e) { + threwIncorrectArgs = true; + } catch (any e) {} + expect(threwIncorrectArgs).toBeTrue(); + }); + + it("addColumn does NOT require columnName for columnType='reference' (referenceName takes its place)", () => { + // Reference-type column construction relies on referenceName, + // not columnName. The conditional required check must skip + // this branch. + var threwIncorrectArgs = false; + try { + variables.migration.addColumn( + table = "dbm_cmd_refcheck_test", + columnType = "reference", + referenceName = "user" + ); + } catch (Wheels.IncorrectArguments e) { + threwIncorrectArgs = true; + } catch (any e) {} + expect(threwIncorrectArgs).toBeFalse(); + }); + + }); + + } + +} diff --git a/vendor/wheels/tests/specs/migrator/migrationSpec.cfc b/vendor/wheels/tests/specs/migrator/migrationSpec.cfc index 7c72698b91..93701b9598 100644 --- a/vendor/wheels/tests/specs/migrator/migrationSpec.cfc +++ b/vendor/wheels/tests/specs/migrator/migrationSpec.cfc @@ -5,10 +5,16 @@ component extends="wheels.WheelsTest" { function beforeAll() { migration = CreateObject("component", "wheels.migrator.Migration").init() originalMigratorObjectCase = Duplicate(application.wheels.migratorObjectCase) + // The addReference / removeColumn(referenceName=) flag tests below + // flip useUnderscoreReferenceColumns. Snapshot the original here so + // afterAll can restore it even if an in-test reset is skipped by an + // exception between set and cleanup. + originalUseUnderscoreReferenceColumns = application.wheels.useUnderscoreReferenceColumns ?: false } function afterAll() { application.wheels.migratorObjectCase = originalMigratorObjectCase + application.wheels.useUnderscoreReferenceColumns = originalUseUnderscoreReferenceColumns } function run() { @@ -717,6 +723,74 @@ component extends="wheels.WheelsTest" { }) }) + describe("Tests addReference", () => { + + it("creates a FK on id when useUnderscoreReferenceColumns is false (legacy)", () => { + local.info = g.$dbinfo(datasource = application.wheels.dataSourceName, type = "version") + local.db = LCase(Replace(local.info.database_productname, " ", "", "all")) + if (local.db eq 'sqlite') { + skip("SQLite does not allow altering CONSTRAINTS.") + } + + application.wheels.useUnderscoreReferenceColumns = false + + targetTableName = "dbm_arl_owners" + sourceTableName = "dbm_arl_pets" + + t = migration.createTable(name = targetTableName, force = true) + t.integer(columnNames = "integercolumn") + t.create() + + t = migration.createTable(name = sourceTableName, force = true) + t.integer(columnNames = "dbm_arl_ownerid") + t.create() + + migration.addReference(table = sourceTableName, referenceName = "dbm_arl_owner") + + info = g.$dbinfo(datasource = application.wheels.dataSourceName, table = targetTableName, type = "foreignkeys") + + migration.dropTable(sourceTableName) + migration.dropTable(targetTableName) + + sql = "SELECT * FROM query WHERE LOWER(fkcolumn_name) = 'dbm_arl_ownerid' AND LOWER(fktable_name) = '#sourceTableName#'" + actual = g.$query(query = info, dbtype = "query", sql = sql) + expect(actual.recordcount).toBe(1) + }) + + it("creates a FK on _id when useUnderscoreReferenceColumns is true", () => { + local.info = g.$dbinfo(datasource = application.wheels.dataSourceName, type = "version") + local.db = LCase(Replace(local.info.database_productname, " ", "", "all")) + if (local.db eq 'sqlite') { + skip("SQLite does not allow altering CONSTRAINTS.") + } + + application.wheels.useUnderscoreReferenceColumns = true + + targetTableName = "dbm_aru_owners" + sourceTableName = "dbm_aru_pets" + + t = migration.createTable(name = targetTableName, force = true) + t.integer(columnNames = "integercolumn") + t.create() + + t = migration.createTable(name = sourceTableName, force = true) + t.integer(columnNames = "dbm_aru_owner_id") + t.create() + + migration.addReference(table = sourceTableName, referenceName = "dbm_aru_owner") + + info = g.$dbinfo(datasource = application.wheels.dataSourceName, table = targetTableName, type = "foreignkeys") + + migration.dropTable(sourceTableName) + migration.dropTable(targetTableName) + application.wheels.useUnderscoreReferenceColumns = false + + sql = "SELECT * FROM query WHERE LOWER(fkcolumn_name) = 'dbm_aru_owner_id' AND LOWER(fktable_name) = '#sourceTableName#'" + actual = g.$query(query = info, dbtype = "query", sql = sql) + expect(actual.recordcount).toBe(1) + }) + }) + describe("Tests addIndex", () => { beforeEach(() => { @@ -1118,6 +1192,51 @@ component extends="wheels.WheelsTest" { expect(ListFindNoCase(actual, expected)).toBeFalse() }) + + it("drops id column when referenceName= is used and useUnderscoreReferenceColumns is false (legacy)", () => { + application.wheels.useUnderscoreReferenceColumns = false + tableName = "dbm_rmref_legacy_tests" + t = migration.createTable(name = tableName, force = true) + t.references(columnNames = "dbm_rmref_legacy_owner", foreignKey = false) + t.create() + + // Capture before/after column lists with no mid-test asserts — + // matches the addReference + addForeignKey pattern so cleanup + // runs regardless of which assertion fails. + info = g.$dbinfo(datasource = application.wheels.dataSourceName, table = tableName, type = "columns") + before = ValueList(info.column_name) + + migration.removeColumn(table = tableName, referenceName = "dbm_rmref_legacy_owner") + info = g.$dbinfo(datasource = application.wheels.dataSourceName, table = tableName, type = "columns") + after = ValueList(info.column_name) + migration.dropTable(tableName) + + // Sanity + exercise asserts both after cleanup. + expect(ListFindNoCase(before, "dbm_rmref_legacy_ownerid")).toBeGT(0) + expect(ListFindNoCase(after, "dbm_rmref_legacy_ownerid")).toBe(0) + }) + + it("drops _id column when referenceName= is used and useUnderscoreReferenceColumns is true", () => { + application.wheels.useUnderscoreReferenceColumns = true + tableName = "dbm_rmref_under_tests" + t = migration.createTable(name = tableName, force = true) + t.references(columnNames = "dbm_rmref_under_owner", foreignKey = false) + t.create() + + info = g.$dbinfo(datasource = application.wheels.dataSourceName, table = tableName, type = "columns") + before = ValueList(info.column_name) + + migration.removeColumn(table = tableName, referenceName = "dbm_rmref_under_owner") + info = g.$dbinfo(datasource = application.wheels.dataSourceName, table = tableName, type = "columns") + after = ValueList(info.column_name) + migration.dropTable(tableName) + // In-test reset stays as belt-and-suspenders; afterAll() is the + // guarantee that survives an exception above this line. + application.wheels.useUnderscoreReferenceColumns = false + + expect(ListFindNoCase(before, "dbm_rmref_under_owner_id")).toBeGT(0) + expect(ListFindNoCase(after, "dbm_rmref_under_owner_id")).toBe(0) + }) }) describe("Tests removeIndex", () => { diff --git a/vendor/wheels/tests/specs/migrator/primaryKeySpec.cfc b/vendor/wheels/tests/specs/migrator/primaryKeySpec.cfc new file mode 100644 index 0000000000..0e3f33ce38 --- /dev/null +++ b/vendor/wheels/tests/specs/migrator/primaryKeySpec.cfc @@ -0,0 +1,120 @@ +/** + * Coverage for vendor/wheels/migrator/TableDefinition.cfc::primaryKey() — + * the `columnName` / `columnNames` aliases added in #2803. + * + * The PK helper was the lone outlier among column-shaped helpers in this file: + * every sibling (`integer`, `string`, `references`, etc.) accepts `columnNames` + * via $combineArguments, but `primaryKey()` historically required `name` and + * only `name`. Users and AI tooling learning the convention from one helper + * would consistently mis-call `primaryKey(columnName=...)` and hit + * "required argument missing." Same shape as #2781 for `t.references()`. + * + * These specs run at the TableDefinition layer — they assert on `t.primaryKeys` + * directly and never call `t.create()`. That keeps them fast and adapter- + * independent: the DB roundtrip is already covered by the existing migrator + * suite (the `id` PK added by every `createTable()` call exercises the same + * code path on every adapter in CI). + */ +component extends="wheels.WheelsTest" { + + function beforeAll() { + variables.migration = CreateObject("component", "wheels.migrator.Migration").init(); + } + + function run() { + + describe("TableDefinition.primaryKey() — argument aliases", () => { + + it("accepts the legacy name parameter", () => { + // createTable(id=false) suppresses the conventional `id` PK so we + // can assert against just the one we add explicitly. + var t = variables.migration.createTable(name = "dbm_pk_legacy_name_test", id = false, force = true); + t.primaryKey(name = "userId"); + expect(ArrayLen(t.primaryKeys)).toBe(1); + expect(t.primaryKeys[1].name).toBe("userId"); + }); + + it("accepts columnName as a singular alias for name", () => { + var t = variables.migration.createTable(name = "dbm_pk_column_name_test", id = false, force = true); + t.primaryKey(columnName = "userId"); + expect(ArrayLen(t.primaryKeys)).toBe(1); + expect(t.primaryKeys[1].name).toBe("userId"); + }); + + it("accepts columnNames as a plural alias for name", () => { + var t = variables.migration.createTable(name = "dbm_pk_column_names_test", id = false, force = true); + t.primaryKey(columnNames = "userId"); + expect(ArrayLen(t.primaryKeys)).toBe(1); + expect(t.primaryKeys[1].name).toBe("userId"); + }); + + it("does not iterate a comma-separated columnNames — PK name is the literal string", () => { + // Unlike sibling helpers (t.string, t.integer, …) that ListToArray + // the plural argument and create one column per entry, primaryKey() + // always creates exactly one PK column. A comma-separated value is + // passed through as the literal column name. For composite PKs, + // call t.primaryKey() once per column. + var t = variables.migration.createTable(name = "dbm_pk_no_list_test", id = false, force = true); + t.primaryKey(columnNames = "a,b"); + expect(ArrayLen(t.primaryKeys)).toBe(1); + expect(t.primaryKeys[1].name).toBe("a,b"); + }); + + }); + + describe("TableDefinition.primaryKey() — alias precedence", () => { + + it("plural columnNames wins when both columnName and columnNames are supplied", () => { + // Mirrors the precedence documented on addReference() / + // dropReference() — the later $combineArguments call wins, so + // columnNames overrides columnName. + var t = variables.migration.createTable(name = "dbm_pk_precedence_test", id = false, force = true); + t.primaryKey(columnName = "singularLoses", columnNames = "pluralWins"); + expect(t.primaryKeys[1].name).toBe("pluralWins"); + }); + + it("columnName overrides legacy name when both are supplied", () => { + // Same $combineArguments shape: alias wins over the canonical + // resolved form when both are passed. + var t = variables.migration.createTable(name = "dbm_pk_legacy_vs_alias_test", id = false, force = true); + t.primaryKey(name = "legacyLoses", columnName = "aliasWins"); + expect(t.primaryKeys[1].name).toBe("aliasWins"); + }); + + }); + + describe("TableDefinition.primaryKey() — required enforcement", () => { + + it("throws when none of name, columnName, or columnNames is supplied", () => { + // $combineArguments(required=true) only throws when + // showErrorInformation is on — match the wiring used elsewhere + // in the suite (see paginationHelpersSpec.cfc). + var _origShowErr = application.wheels.showErrorInformation; + application.wheels.showErrorInformation = true; + try { + var t = variables.migration.createTable(name = "dbm_pk_required_test", id = false, force = true); + expect(() => { + t.primaryKey(type = "integer"); + }).toThrow(); + } finally { + application.wheels.showErrorInformation = _origShowErr; + } + }); + + }); + + describe("TableDefinition.primaryKey() — other args still flow through", () => { + + it("preserves type, autoIncrement, and limit when using columnName alias", () => { + var t = variables.migration.createTable(name = "dbm_pk_args_flow_test", id = false, force = true); + t.primaryKey(columnName = "uuidPk", type = "uniqueidentifier", autoIncrement = false); + expect(t.primaryKeys[1].name).toBe("uuidPk"); + expect(t.primaryKeys[1].type).toBe("uniqueidentifier"); + expect(t.primaryKeys[1].autoIncrement).toBe(false); + }); + + }); + + } + +} diff --git a/vendor/wheels/tests/specs/migrator/referencesSpec.cfc b/vendor/wheels/tests/specs/migrator/referencesSpec.cfc new file mode 100644 index 0000000000..d398f4f38b --- /dev/null +++ b/vendor/wheels/tests/specs/migrator/referencesSpec.cfc @@ -0,0 +1,104 @@ +/** + * Coverage for vendor/wheels/migrator/TableDefinition.cfc::references() — the + * `columnNames` alias and the `useUnderscoreReferenceColumns` suffix flag. + * + * Sibling Migration.cfc helpers (`addReference`, `removeColumn(referenceName=)`) + * also honor the same flag; their DB-roundtrip coverage lives in + * migrationSpec.cfc under the "Tests addReference" and "Tests removeColumn" + * describe blocks (added alongside this PR). + * + * Issue #2781 surfaced two long-standing quirks in `t.references()`: + * 1. The argument is `referenceNames` — every sibling column helper accepts + * `columnNames` / `columnName` via $combineArguments. + * 2. The generated column name is `id` (no underscore), which doesn't + * match Wheels model `belongsTo` defaults that expect `_id`. + * + * These specs run at the TableDefinition layer — they assert on `t.columns` + * and `t.foreignKeys` directly and never call `t.create()`. That keeps them + * fast and adapter-independent: the DB roundtrip is already covered by the + * existing migrator suite. + */ +component extends="wheels.WheelsTest" { + + function beforeAll() { + variables.migration = CreateObject("component", "wheels.migrator.Migration").init(); + // Capture the live setting so each test can scope its flip and the suite + // leaves the global in its original state regardless of which order + // TestBox runs the specs in. + variables.originalUseUnderscore = application.wheels.useUnderscoreReferenceColumns ?: false; + } + + function afterAll() { + application.wheels.useUnderscoreReferenceColumns = variables.originalUseUnderscore; + } + + function run() { + + describe("TableDefinition.references() — argument aliases", () => { + + it("accepts columnNames as an alias for referenceNames", () => { + application.wheels.useUnderscoreReferenceColumns = false; + var t = variables.migration.createTable(name = "dbm_refs_alias_test", force = true); + t.references(columnNames = "user"); + expect(ArrayLen(t.columns)).toBe(1); + expect(t.columns[1].name).toBe("userid"); + }); + + it("still accepts the legacy referenceNames parameter", () => { + application.wheels.useUnderscoreReferenceColumns = false; + var t = variables.migration.createTable(name = "dbm_refs_legacy_param_test", force = true); + t.references(referenceNames = "user"); + expect(ArrayLen(t.columns)).toBe(1); + expect(t.columns[1].name).toBe("userid"); + }); + + }); + + describe("TableDefinition.references() — column suffix flag", () => { + + it("produces legacy id suffix when useUnderscoreReferenceColumns is false (default)", () => { + application.wheels.useUnderscoreReferenceColumns = false; + var t = variables.migration.createTable(name = "dbm_refs_legacy_test", force = true); + t.references(columnNames = "user"); + expect(t.columns[1].name).toBe("userid"); + expect(t.foreignKeys[1].column).toBe("userid"); + }); + + it("produces _id suffix when useUnderscoreReferenceColumns is true", () => { + application.wheels.useUnderscoreReferenceColumns = true; + var t = variables.migration.createTable(name = "dbm_refs_underscore_test", force = true); + t.references(columnNames = "user"); + expect(t.columns[1].name).toBe("user_id"); + expect(t.foreignKeys[1].column).toBe("user_id"); + }); + + }); + + describe("TableDefinition.references() — polymorphic suffix follows the flag", () => { + + it("produces id + type when flag is false (legacy)", () => { + application.wheels.useUnderscoreReferenceColumns = false; + var t = variables.migration.createTable(name = "dbm_refs_poly_legacy_test", force = true); + t.references(columnNames = "commentable", polymorphic = true); + expect(ArrayLen(t.columns)).toBe(2); + expect(t.columns[1].name).toBe("commentableid"); + expect(t.columns[2].name).toBe("commentabletype"); + // polymorphic=true skips the foreign-key constraint by design + expect(ArrayLen(t.foreignKeys)).toBe(0); + }); + + it("produces _id + _type when flag is true", () => { + application.wheels.useUnderscoreReferenceColumns = true; + var t = variables.migration.createTable(name = "dbm_refs_poly_underscore_test", force = true); + t.references(columnNames = "commentable", polymorphic = true); + expect(ArrayLen(t.columns)).toBe(2); + expect(t.columns[1].name).toBe("commentable_id"); + expect(t.columns[2].name).toBe("commentable_type"); + expect(ArrayLen(t.foreignKeys)).toBe(0); + }); + + }); + + } + +} diff --git a/vendor/wheels/tests/specs/model/OuterTransactionSignalSpec.cfc b/vendor/wheels/tests/specs/model/OuterTransactionSignalSpec.cfc new file mode 100644 index 0000000000..451f616d72 --- /dev/null +++ b/vendor/wheels/tests/specs/model/OuterTransactionSignalSpec.cfc @@ -0,0 +1,94 @@ +component extends="wheels.WheelsTest" { + + function run() { + + g = application.wo; + + describe("model invokeWithTransaction respects outer-transaction signal (issue ##2789)", () => { + + beforeEach(() => { + // Defensive cleanup in case a prior failing run left the flag set. + if (StructKeyExists(request, "$wheelsTransactionWrapper")) { + StructDelete(request, "$wheelsTransactionWrapper"); + } + queryExecute( + "DELETE FROM c_o_r_e_tags WHERE name IN ('outerSig_control', 'outerSig_signal', 'outerSig_signal_create', 'outerSig_signal_update', 'outerSig_signal_delete')", + {}, + { datasource = application.wheels.dataSourceName } + ); + }); + + afterEach(() => { + if (StructKeyExists(request, "$wheelsTransactionWrapper")) { + StructDelete(request, "$wheelsTransactionWrapper"); + } + queryExecute( + "DELETE FROM c_o_r_e_tags WHERE name IN ('outerSig_control', 'outerSig_signal', 'outerSig_signal_create', 'outerSig_signal_update', 'outerSig_signal_delete')", + {}, + { datasource = application.wheels.dataSourceName } + ); + }); + + it("control: model.create(transaction='rollback') without the flag rolls the row back", () => { + // Baseline: proves the rollback path actually fires in this environment. + var beforeCount = g.model("tag").count(); + g.model("tag").create(name = "outerSig_control", transaction = "rollback"); + var afterCount = g.model("tag").count(); + expect(afterCount).toBe(beforeCount); + }); + + it("treats invokeWithTransaction as 'alreadyopen' when request.$wheelsTransactionWrapper is set", () => { + // Issue #2789: signal set → model bypasses its own wrapper; INSERT persists despite transaction='rollback'. + var beforeCount = g.model("tag").count(); + request.$wheelsTransactionWrapper = true; + try { + g.model("tag").create(name = "outerSig_signal", transaction = "rollback"); + } finally { + StructDelete(request, "$wheelsTransactionWrapper"); + } + var afterCount = g.model("tag").count(); + expect(afterCount).toBe(beforeCount + 1); + }); + + it("also bypasses the wrapper for update via save()", () => { + // Issue #2789: same bypass applies to save()'s UPDATE path. + var tag = g.model("tag").create(name = "outerSig_signal_update"); + request.$wheelsTransactionWrapper = true; + try { + tag.name = "outerSig_signal_update_modified"; + tag.save(transaction = "rollback"); + } finally { + StructDelete(request, "$wheelsTransactionWrapper"); + } + var refetched = g.model("tag").findByKey(tag.id); + expect(refetched.name).toBe("outerSig_signal_update_modified"); + // Cleanup: row name no longer matches the afterEach pattern. + queryExecute( + "DELETE FROM c_o_r_e_tags WHERE id = :id", + // cf_sql_bigint, not cf_sql_integer: CockroachDB's default + // unique_rowid() PK is a ~60-bit value that overflows a 32-bit + // CF_SQL_INTEGER on Adobe CF ("Invalid data for CFSQLTYPE + // CF_SQL_INTEGER"). bigint binds correctly on every engine/DB. + { id = { value = tag.id, cfsqltype = "cf_sql_bigint" } }, + { datasource = application.wheels.dataSourceName } + ); + }); + + it("also bypasses the wrapper for deleteAll", () => { + // Issue #2789: same bypass applies to deleteAll's DELETE path. + g.model("tag").create(name = "outerSig_signal_delete"); + request.$wheelsTransactionWrapper = true; + try { + g.model("tag").deleteAll(where = "name = 'outerSig_signal_delete'", transaction = "rollback"); + } finally { + StructDelete(request, "$wheelsTransactionWrapper"); + } + var remaining = g.model("tag").count(where = "name = 'outerSig_signal_delete'"); + expect(remaining).toBe(0); + }); + + }); + + } + +} diff --git a/vendor/wheels/tests/specs/model/crudSpec.cfc b/vendor/wheels/tests/specs/model/crudSpec.cfc index e58fe597a0..a151074d85 100644 --- a/vendor/wheels/tests/specs/model/crudSpec.cfc +++ b/vendor/wheels/tests/specs/model/crudSpec.cfc @@ -573,7 +573,7 @@ component extends="wheels.WheelsTest" { // trim extra whitespace actual = Trim(actual) - expected = "SELECT #qi('c_o_r_e_authors')#.id FROM #qi('c_o_r_e_authors')#" + expected = "SELECT #qi('c_o_r_e_authors')#.#qi('id')# FROM #qi('c_o_r_e_authors')#" expect(actual).toBe(expected) }) @@ -1671,7 +1671,7 @@ component extends="wheels.WheelsTest" { local.a = qi("c_o_r_e_authors"); local.p = qi("c_o_r_e_posts"); - expect(columnList).toBe("#local.a#.firstname,#local.a#.id,#local.a#.id AS Authorid,#local.a#.lastname,#local.p#.averagerating AS postaveragerating,#local.p#.body AS postbody,#local.p#.createdat AS postcreatedat,#local.p#.deletedat AS postdeletedat,#local.p#.id AS postid,#local.p#.status AS poststatus,#local.p#.title AS posttitle,#local.p#.updatedat AS postupdatedat,#local.p#.views AS postviews") + expect(columnList).toBe("#local.a#.#qi('firstname')#,#local.a#.#qi('id')#,#local.a#.#qi('id')# AS Authorid,#local.a#.#qi('lastname')#,#local.p#.#qi('averagerating')# AS postaveragerating,#local.p#.#qi('body')# AS postbody,#local.p#.#qi('createdat')# AS postcreatedat,#local.p#.#qi('deletedat')# AS postdeletedat,#local.p#.#qi('id')# AS postid,#local.p#.#qi('status')# AS poststatus,#local.p#.#qi('title')# AS posttitle,#local.p#.#qi('updatedat')# AS postupdatedat,#local.p#.#qi('views')# AS postviews") }) it("works with association with expanded aliases disabled", () => { @@ -1688,7 +1688,7 @@ component extends="wheels.WheelsTest" { local.a = qi("c_o_r_e_authors"); local.p = qi("c_o_r_e_posts"); - expect(columnList).toBe("#local.a#.firstname,#local.a#.id,#local.a#.id AS Authorid,#local.a#.lastname,#local.p#.averagerating,#local.p#.body,#local.p#.createdat,#local.p#.deletedat,#local.p#.id AS postid,#local.p#.status,#local.p#.title,#local.p#.updatedat,#local.p#.views") + expect(columnList).toBe("#local.a#.#qi('firstname')#,#local.a#.#qi('id')#,#local.a#.#qi('id')# AS Authorid,#local.a#.#qi('lastname')#,#local.p#.#qi('averagerating')#,#local.p#.#qi('body')#,#local.p#.#qi('createdat')#,#local.p#.#qi('deletedat')#,#local.p#.#qi('id')# AS postid,#local.p#.#qi('status')#,#local.p#.#qi('title')#,#local.p#.#qi('updatedat')#,#local.p#.#qi('views')#") }) it("works on calculated property", () => { diff --git a/vendor/wheels/tests/specs/model/reservedColumnQuotingSpec.cfc b/vendor/wheels/tests/specs/model/reservedColumnQuotingSpec.cfc new file mode 100644 index 0000000000..f9eef9aa04 --- /dev/null +++ b/vendor/wheels/tests/specs/model/reservedColumnQuotingSpec.cfc @@ -0,0 +1,51 @@ +component extends="wheels.WheelsTest" { + + function run() { + + g = application.wo; + + describe("$createSQLFieldList column identifier quoting", () => { + + it("quotes the underlying column when a property aliases a different column name", () => { + // City aliases id -> countyid, so the SELECT column must be quoted in case it is a reserved word. + var m = g.model("city"); + var actual = m.$selectClause(select = "id", include = "", returnAs = "objects"); + + expect(actual).toInclude(m.$quotedTableName() & "." & m.$quoteColumn("countyid")); + }); + + it("quotes the column when the property name matches the column name", () => { + // Author.firstName has no column mapping (property == column); SELECT must still quote the identifier. + var m = g.model("author"); + var actual = m.$selectClause(select = "firstName", include = "", returnAs = "objects"); + + expect(actual).toInclude(m.$quotedTableName() & "." & m.$quoteColumn("firstName")); + }); + + it("quotes column identifiers in the GROUP BY clause too", () => { + // $groupByClause routes through the same $createSQLFieldList builder. + var m = g.model("city"); + var actual = m.$groupByClause( + select = "id", + include = "", + group = "id", + distinct = false, + returnAs = "objects" + ); + + expect(actual).toInclude(m.$quotedTableName() & "." & m.$quoteColumn("countyid")); + }); + + it("returns a well-formed empty query when paginated findAll matches zero rows on an aliased column", () => { + // Without $stripIdentifierQuotes, dialect quote chars from $createSQLFieldList leak into QueryNew column names. + var m = g.model("city"); + var rv = m.findAll(select = "id", where = "id = -1", page = 1, perPage = 25); + + expect(IsQuery(rv)).toBeTrue(); + expect(rv.recordCount).toBe(0); + expect(ListFindNoCase(rv.columnList, "id")).toBeGT(0); + }); + + }); + } +} diff --git a/vendor/wheels/tests/specs/wheelstest/BrowserTestBaseUrlResolutionSpec.cfc b/vendor/wheels/tests/specs/wheelstest/BrowserTestBaseUrlResolutionSpec.cfc new file mode 100644 index 0000000000..e9e035376f --- /dev/null +++ b/vendor/wheels/tests/specs/wheelstest/BrowserTestBaseUrlResolutionSpec.cfc @@ -0,0 +1,92 @@ +/** + * Regression for #2779: BrowserTest's default base URL is hardcoded to + * http://localhost:8080 and the only escape hatch (the JVM-cached + * WHEELS_BROWSER_TEST_BASE_URL env var) cannot be changed after server + * start. Specs running against non-8080 servers must apply a manual + * fallback comparing getBaseUrl() to a sentinel string. + * + * After the fix, BrowserTest resolves the base URL through a layered + * lookup at instance time: + * 1. this.baseUrl (per-spec override) + * 2. get("browserTestBaseUrl") (Wheels setting) + * 3. JVM system property (wheels.browserTest.baseUrl) + * 4. Env var (WHEELS_BROWSER_TEST_BASE_URL) + * 5. CGI auto-detect (cgi.server_name + cgi.server_port) + * 6. Default (http://localhost:8080) + */ +component extends="wheels.WheelsTest" { + + function run() { + describe("BrowserTest base URL layered resolution (issue ##2779)", () => { + + it("honors this.baseUrl as the highest-precedence override", () => { + var bt = new wheels.wheelstest.BrowserTest(); + bt.baseUrl = "http://override.example:9999"; + expect(bt.$resolveBaseUrl()).toBe("http://override.example:9999"); + }); + + it("falls back through layers when this.baseUrl is empty", () => { + // Intentionally weak assertion: JVM env vars are read-only + // from CFML and the Wheels get() setting requires a live + // framework context, so we can't fully isolate which layer + // (2–6) fires here. The JVM-property test below and the + // direct $detectBaseUrlFromCgi tests cover layers 3 and 5 + // in isolation. This case only verifies the chain + // terminates with a valid http(s):// URL — the original bug + // was returning the wrong port, not an invalid value. + var bt = new wheels.wheelstest.BrowserTest(); + bt.baseUrl = ""; + expect(bt.$resolveBaseUrl()).toMatch("^https?://"); + }); + + it("$detectBaseUrlFromCgi returns blank when port is the stale 8080 default", () => { + var bt = new wheels.wheelstest.BrowserTest(); + var fakeCgi = {server_port: "8080", server_name: "localhost", https: "off"}; + expect(bt.$detectBaseUrlFromCgi(fakeCgi)).toBe(""); + }); + + it("$detectBaseUrlFromCgi builds the URL from a non-default port", () => { + var bt = new wheels.wheelstest.BrowserTest(); + var fakeCgi = {server_port: "60050", server_name: "localhost", https: "off"}; + expect(bt.$detectBaseUrlFromCgi(fakeCgi)).toBe("http://localhost:60050"); + }); + + it("$detectBaseUrlFromCgi honors the https scheme when cgi.https is on", () => { + var bt = new wheels.wheelstest.BrowserTest(); + var fakeCgi = {server_port: "443", server_name: "staging.example.com", https: "on"}; + expect(bt.$detectBaseUrlFromCgi(fakeCgi)).toBe("https://staging.example.com"); + }); + + it("$detectBaseUrlFromCgi omits canonical http port 80", () => { + var bt = new wheels.wheelstest.BrowserTest(); + var fakeCgi = {server_port: "80", server_name: "example.com", https: "off"}; + expect(bt.$detectBaseUrlFromCgi(fakeCgi)).toBe("http://example.com"); + }); + + it("$detectBaseUrlFromCgi returns blank when server_port is missing or zero", () => { + var bt = new wheels.wheelstest.BrowserTest(); + expect(bt.$detectBaseUrlFromCgi({server_port: "0"})).toBe(""); + expect(bt.$detectBaseUrlFromCgi({})).toBe(""); + }); + + it("JVM system property is honored when this.baseUrl is empty", () => { + var bt = new wheels.wheelstest.BrowserTest(); + bt.baseUrl = ""; + var sys = createObject("java", "java.lang.System"); + var key = "wheels.browserTest.baseUrl"; + var prior = sys.getProperty(key); + try { + sys.setProperty(key, "http://jvm-prop.example:1234"); + expect(bt.$resolveBaseUrl()).toBe("http://jvm-prop.example:1234"); + } finally { + if (isNull(prior)) { + sys.clearProperty(key); + } else { + sys.setProperty(key, prior); + } + } + }); + + }); + } +} diff --git a/vendor/wheels/tests/specs/wheelstest/BrowserTestNotWiredSpec.cfc b/vendor/wheels/tests/specs/wheelstest/BrowserTestNotWiredSpec.cfc new file mode 100644 index 0000000000..34a127764b --- /dev/null +++ b/vendor/wheels/tests/specs/wheelstest/BrowserTestNotWiredSpec.cfc @@ -0,0 +1,36 @@ +/** + * Self-test for the unwired-this.browser guard. A BrowserTest subclass + * that uses plain describe() (instead of browserDescribe()) must surface + * a clear "use browserDescribe()" error when any method is invoked on + * this.browser — not "function [visitUrl] does not exist in the String". + */ +component extends="wheels.WheelsTest" { + + function run() { + describe("BrowserTest unwired this.browser guard", () => { + + it("throws Wheels.BrowserTest.NotWired when a DSL method is called before browserDescribe wiring", () => { + var spec = new wheels.wheelstest.BrowserTest(); + expect(function() { + spec.browser.visitUrl("data:text/html,

Hi

"); + }).toThrow(type="Wheels.BrowserTest.NotWired"); + }); + + it("error message names browserDescribe() so users see the fix", () => { + var spec = new wheels.wheelstest.BrowserTest(); + var state = {message: "", detail: ""}; + + try { + spec.browser.assertSee("anything"); + } catch (Wheels.BrowserTest.NotWired e) { + state.message = e.message; + state.detail = e.detail; + } + + expect(state.message).toInclude("browserDescribe"); + expect(state.detail).toInclude("assertSee"); + }); + + }); + } +} diff --git a/vendor/wheels/tests/specs/wheelstest/WheelsTestAutoBindIncludesSpec.cfc b/vendor/wheels/tests/specs/wheelstest/WheelsTestAutoBindIncludesSpec.cfc new file mode 100644 index 0000000000..8c4d42f7ac --- /dev/null +++ b/vendor/wheels/tests/specs/wheelstest/WheelsTestAutoBindIncludesSpec.cfc @@ -0,0 +1,112 @@ +/** + * Regression: WheelsTest auto-bind misses include-injected helpers (#2790). + * + * `WheelsTest.cfc` uses `getMetaData(application.wo).functions` to discover + * which Wheels globals to bind into the spec's `variables` / `this` scopes. + * That metadata enumerates only methods defined directly on the CFC body, + * NOT symbols merged in via `cfinclude` / `include` (which is how + * `vendor/wheels/Global.cfc` pulls `/app/global/functions.cfm` at the bottom + * of the file). User-defined global helpers therefore worked in controllers / + * views / models but were invisible to test specs — every spec had to + * manually rebind helpers in `beforeAll()`. + * + * Cross-engine note: a bare `include` inside a CFC body lands UDFs in the + * component's `variables` scope, not `this`. Lucee's struct-iteration over + * a CFC instance surfaces both scopes, but Adobe CF's contract only + * reliably exposes `this`-scope members. To make the auto-bind path + * uniform across engines, `vendor/wheels/Global.cfc` promotes include- + * injected UDFs from `variables` to `this` immediately after the include + * runs. These specs simulate that post-promotion shape by assigning the + * probe UDF directly to `application.wo` (bracket-notation assignment + * from outside writes to `this`), then assert that: + * + * - The probe is invisible to `getMetaData(application.wo).functions` + * (the bug precondition the old code missed). + * - The probe is enumerated by `for (key in application.wo)` — the + * iteration mechanism the new auto-bind loop relies on. Failure here + * on any engine means the auto-bind loop will silently miss the helper. + * - The probe lands on a fresh `wheels.WheelsTest` instance and is + * callable. + */ +component extends="wheels.WheelsTest" { + + function run() { + + describe("WheelsTest auto-bind", () => { + + describe("helpers attached to application.wo outside of CFC metadata (issue ##2790)", () => { + + it("the bug precondition holds: include-style UDFs are invisible to getMetaData", () => { + var probeName = "$bot2790MetaProbe"; + application.wo[probeName] = function() { + return "metadata-probe"; + }; + try { + var meta = getMetaData(application.wo).functions; + var foundInMeta = false; + for (var fn in meta) { + if (fn.name == probeName) { + foundInMeta = true; + break; + } + } + expect(foundInMeta).toBeFalse(); + expect(structKeyExists(application.wo, probeName)).toBeTrue(); + expect(isCustomFunction(application.wo[probeName])).toBeTrue(); + } finally { + structDelete(application.wo, probeName); + } + }); + + it("for-in iteration over application.wo enumerates the probe key", () => { + // Guards the iteration mechanism the auto-bind loop in + // WheelsTest.cfc relies on. If this fails on any engine + // (notably Adobe CF, where struct-iteration over a CFC + // only reliably exposes this-scope members), the bind + // case below will silently pass-but-not-test. + var probeName = "$bot2790IterProbe"; + application.wo[probeName] = function() { + return "iter-probe"; + }; + try { + var seen = false; + for (var key in application.wo) { + if (key == probeName) { + seen = true; + break; + } + } + expect(seen).toBeTrue(); + } finally { + structDelete(application.wo, probeName); + } + }); + + it("auto-binds include-style helpers into a fresh WheelsTest instance", () => { + var probeName = "$bot2790BindProbe"; + application.wo[probeName] = function() { + return "bind-probe"; + }; + try { + var freshSpec = new wheels.WheelsTest(); + expect(structKeyExists(freshSpec, probeName)).toBeTrue(); + var bound = freshSpec[probeName]; + expect(bound()).toBe("bind-probe"); + } finally { + structDelete(application.wo, probeName); + } + }); + + it("still binds methods that ARE in CFC metadata (regression guard for the existing path)", () => { + var freshSpec = new wheels.WheelsTest(); + expect(structKeyExists(freshSpec, "model")).toBeTrue(); + expect(structKeyExists(freshSpec, "urlFor")).toBeTrue(); + }); + + }); + + }); + + } + +} diff --git a/vendor/wheels/wheelstest/BrowserTest.cfc b/vendor/wheels/wheelstest/BrowserTest.cfc index 10d6859eba..36fe75c42d 100644 --- a/vendor/wheels/wheelstest/BrowserTest.cfc +++ b/vendor/wheels/wheelstest/BrowserTest.cfc @@ -38,8 +38,19 @@ component extends="wheels.WheelsTest" { this.browserEngine = "chromium"; this.browserViewport = ""; // empty = Playwright default; "mobile"/"tablet"/"desktop" or {width:N, height:N} this.browserScreenshotOnFailure = true; - this.browser = ""; + // Sentinel: any method call on this.browser before browserDescribe() wires + // a real BrowserClient throws Wheels.BrowserTest.NotWired with a helpful + // message naming browserDescribe(), instead of the misleading + // "function [X] does not exist in the String". + this.browser = new wheels.wheelstest.UnwiredBrowserGuard(); this.browserTestSkipped = false; + // Per-spec base-URL override (issue #2779). Highest-precedence layer in + // $resolveBaseUrl(). Set in the component pseudo-constructor (the + // `this.baseUrl = "..."` line outside any function in the subclass), + // NOT inside beforeAll() — super.beforeAll() calls $resolveBaseUrl() and + // caches the resolved URL before any beforeAll-level assignment can take + // effect, so a late override is silently ignored. + this.baseUrl = ""; variables.$launcher = ""; variables.$browser = ""; @@ -178,7 +189,7 @@ component extends="wheels.WheelsTest" { } variables.$context = ""; variables.$page = ""; - this.browser = ""; + this.browser = new wheels.wheelstest.UnwiredBrowserGuard(); } } @@ -228,26 +239,73 @@ component extends="wheels.WheelsTest" { } /** - * Base URL for `client.visit("/path")`. Defaults to localhost:8080 - * (the default LuCLI port); override via WHEELS_BROWSER_TEST_BASE_URL env. + * Base URL for `client.visit("/path")`. Resolved through a layered lookup + * at instance time so post-launch overrides take effect (the JVM caches + * env vars at process start, so `export`-ing after `wheels start` is too + * late for the bare-env-var approach). Precedence, highest first: + * + * 1. this.baseUrl — per-spec override + * 2. get("browserTestBaseUrl") — Wheels setting + * 3. -Dwheels.browserTest.baseUrl=... — JVM system property + * 4. WHEELS_BROWSER_TEST_BASE_URL env — CI / shell + * 5. $detectBaseUrlFromCgi(cgi) — derived from the in-flight + * test-runner request + * 6. "http://localhost:8080" default — bare LuCLI port + * * For specs that use only `visitUrl(absolute)` (e.g. data:/file:), the * baseUrl is effectively unused. */ - private string function $resolveBaseUrl() { + public string function $resolveBaseUrl() { + if (len(this.baseUrl ?: "")) return this.baseUrl; + + try { + var setting = get(name="browserTestBaseUrl"); + if (len(setting ?: "")) return setting; + } catch (any e) { + // Setting not registered — fall through to next layer. + } + try { - var env = createObject("java", "java.lang.System") - .getenv("WHEELS_BROWSER_TEST_BASE_URL"); + var sys = createObject("java", "java.lang.System"); + var prop = sys.getProperty("wheels.browserTest.baseUrl"); + if (!isNull(prop) && len(prop)) return prop; + var env = sys.getenv("WHEELS_BROWSER_TEST_BASE_URL"); if (!isNull(env) && len(env)) return env; } catch (any e) { - // Best-effort: SecurityManager could deny env access. Falling - // back to the localhost default is correct — if the user actually - // configured a different URL but we can't read it, their tests - // will fail with connection-refused, surfacing the problem - // clearly rather than silently using the wrong URL. + // Best-effort: a SecurityManager could deny system access. + // Falling through to CGI detection / default is correct. } + + try { + var detected = $detectBaseUrlFromCgi(cgi); + if (len(detected)) return detected; + } catch (any e) { + // cgi scope unavailable (rare; e.g. background thread) — fall + // through to the hardcoded default. + } + return "http://localhost:8080"; } + /** + * Derive base URL from the in-flight test-runner request. The core test + * suite runs over HTTP at /wheels/core/tests, so the cgi scope already + * names the correct host:port for the running server. Returns "" when + * cgi looks like the bare LuCLI default (localhost:8080) so downstream + * layers can supply the real value. + */ + public string function $detectBaseUrlFromCgi(required any cgiScope) { + if (!structKeyExists(arguments.cgiScope, "server_port") || !val(arguments.cgiScope.server_port ?: 0)) { + return ""; + } + var port = val(arguments.cgiScope.server_port); + var host = len(arguments.cgiScope.server_name ?: "") ? arguments.cgiScope.server_name : "localhost"; + var scheme = (arguments.cgiScope.https ?: "off") == "on" ? "https" : "http"; + if (host == "localhost" && port == 8080) return ""; + var isCanonicalPort = (scheme == "http" && port == 80) || (scheme == "https" && port == 443); + return scheme & "://" & host & (isCanonicalPort ? "" : ":" & port); + } + /** * Best-effort capture of screenshot + HTML on test failure. * Called from aroundEach catch block. Swallows all errors to avoid @@ -256,6 +314,7 @@ component extends="wheels.WheelsTest" { public void function $captureFailureArtifacts(required any spec) { if (!(this.browserScreenshotOnFailure ?: true)) return; if (!isObject(this.browser) || this.browserTestSkipped) return; + if (structKeyExists(this.browser, "$isUnwiredBrowserGuard")) return; try { var artifactDir = this.browserArtifactPath diff --git a/vendor/wheels/wheelstest/ParallelRunner.cfc b/vendor/wheels/wheelstest/ParallelRunner.cfc index bfccbd4d41..9df082a6af 100644 --- a/vendor/wheels/wheelstest/ParallelRunner.cfc +++ b/vendor/wheels/wheelstest/ParallelRunner.cfc @@ -270,13 +270,18 @@ component { for (var bs in d.bundleStats) { arrayAppend(aggregated.bundleStats, bs); - // Collect failures from bundle suiteStats + // Collect failures from bundle suiteStats. $collectFailures + // returns the list (Adobe CF passes arrays to functions by + // value, so it can't append into aggregated.failures through + // an argument — the merge happens here in the caller scope). if (structKeyExists(bs, "suiteStats") && isArray(bs.suiteStats)) { - $collectFailures( + var bundleFailures = $collectFailures( suiteStats = bs.suiteStats, - bundleName = structKeyExists(bs, "name") ? bs.name : "unknown", - failures = aggregated.failures + bundleName = structKeyExists(bs, "name") ? bs.name : "unknown" ); + if (!arrayIsEmpty(bundleFailures)) { + arrayAppend(aggregated.failures, bundleFailures, true); + } } } } @@ -348,19 +353,24 @@ component { } /** - * Recursively collect failure/error specs from suiteStats. + * Recursively collect failure/error specs from suiteStats and RETURN them. + * + * Returns the failures rather than mutating a passed-in array: Adobe CF + * passes arrays to functions by value (Lucee/BoxLang pass by reference), + * so an `arrayAppend(arguments.failures, …)` here would never reach the + * caller's array on Adobe — the aggregated failure list came back empty. */ - private void function $collectFailures( + private array function $collectFailures( required array suiteStats, - required string bundleName, - required array failures + required string bundleName ) { + var collected = []; for (var suite in arguments.suiteStats) { if (structKeyExists(suite, "specStats") && isArray(suite.specStats)) { for (var spec in suite.specStats) { var status = structKeyExists(spec, "status") ? spec.status : ""; if (status == "Failed" || status == "Error") { - arrayAppend(arguments.failures, { + arrayAppend(collected, { bundle = arguments.bundleName, spec = structKeyExists(spec, "name") ? spec.name : "unknown", status = status, @@ -369,15 +379,18 @@ component { } } } - // Recurse into nested suites + // Recurse into nested suites and merge their failures in. if (structKeyExists(suite, "suiteStats") && isArray(suite.suiteStats)) { - $collectFailures( + var nested = $collectFailures( suiteStats = suite.suiteStats, - bundleName = arguments.bundleName, - failures = arguments.failures + bundleName = arguments.bundleName ); + if (!arrayIsEmpty(nested)) { + arrayAppend(collected, nested, true); + } } } + return collected; } } diff --git a/vendor/wheels/wheelstest/TestClient.cfc b/vendor/wheels/wheelstest/TestClient.cfc index 8fc1531c49..5f062332fd 100644 --- a/vendor/wheels/wheelstest/TestClient.cfc +++ b/vendor/wheels/wheelstest/TestClient.cfc @@ -502,14 +502,21 @@ component { cfhttpparam(type = "cookie", name = cName, value = variables.cookies[cName]); } - // Add body for POST/PUT/PATCH - if (ListFindNoCase("POST,PUT,PATCH", arguments.method) && !StructIsEmpty(arguments.body)) { - if (variables.sendAsJson) { - cfhttpparam(type = "body", value = SerializeJSON(arguments.body)); - } else { + // Add body for POST/PUT/PATCH. Adobe CF rejects a POST/PUT/PATCH + // cfhttp with zero cfhttpparam tags ("requires at least one + // cfhttpparam tag for a POST operation"), so always emit a body + // param for these methods — an empty body is valid — instead of + // skipping when the body struct is empty. + if (ListFindNoCase("POST,PUT,PATCH", arguments.method)) { + if (!StructIsEmpty(arguments.body) && !variables.sendAsJson) { for (var fName in arguments.body) { cfhttpparam(type = "formfield", name = fName, value = arguments.body[fName]); } + } else { + // This branch covers both JSON posts (any body) and empty-body + // form posts — the latter still needs a body param so the POST + // isn't left with zero cfhttpparam tags. + cfhttpparam(type = "body", value = StructIsEmpty(arguments.body) ? "" : SerializeJSON(arguments.body)); } } } diff --git a/vendor/wheels/wheelstest/UnwiredBrowserGuard.cfc b/vendor/wheels/wheelstest/UnwiredBrowserGuard.cfc new file mode 100644 index 0000000000..b6a9b48094 --- /dev/null +++ b/vendor/wheels/wheelstest/UnwiredBrowserGuard.cfc @@ -0,0 +1,26 @@ +/** + * Sentinel installed at `this.browser` before browserDescribe() wires a real + * BrowserClient. Any DSL method call (visitUrl, click, assertSee, ...) raises + * Wheels.BrowserTest.NotWired with a message that names the actual cause — + * the spec is using plain describe() inside a BrowserTest subclass — instead + * of the original misleading "function [X] does not exist in the String". + * + * Carries a flag `this.$isUnwiredBrowserGuard = true` so callers that need to + * distinguish the guard from a real BrowserClient can use structKeyExists() + * without invoking onMissingMethod. + */ +component { + + this.$isUnwiredBrowserGuard = true; + + public any function onMissingMethod( + required string missingMethodName, + required struct missingMethodArguments + ) { + throw( + type="Wheels.BrowserTest.NotWired", + message="this.browser is not wired. BrowserTest specs must use browserDescribe() blocks instead of describe() — the framework only populates this.browser inside browserDescribe() callbacks.", + detail="Attempted to call this.browser." & arguments.missingMethodName & "() outside a browserDescribe() block. Change describe(...) to browserDescribe(...) so the browser context is set up per `it` block, or do not access this.browser from this block." + ); + } +} diff --git a/web/content/blog/posts/wheels-4-0-1-released.md b/web/content/blog/posts/wheels-4-0-1-released.md new file mode 100644 index 0000000000..bfe7e1e17e --- /dev/null +++ b/web/content/blog/posts/wheels-4-0-1-released.md @@ -0,0 +1,162 @@ +--- +title: 'Wheels 4.0.1: Adobe CF hardening, Windows Scoop fixes, and the post-GA shakeout' +slug: wheels-4-0-1-released +publishedAt: '2026-05-20T23:00:00.000Z' +updatedAt: '2026-05-21T03:51:31.000Z' +author: Peter Amiri +tags: + - wheels-4 + - release-notes + - frameworks +categories: + - Releases +excerpt: >- + Wheels 4.0.1 is out — the first patch on the 4.0 line. It hardens Adobe + ColdFusion 2023/2025 compatibility, fixes the Windows Scoop install + regressions reported after GA, adds CSS-framework presets to + paginationNav(), short-circuits whereIn([]) so empty filters stop emitting + invalid SQL, and threads about a hundred smaller fixes through the rest of + the framework. +coverImage: null +--- + + +Wheels 4.0.1 ships today, eight days after the 4.0.0 GA. It is a patch release in the SemVer sense — no breaking changes, no new public APIs you have to learn — but it is the first patch on a brand-new major, and the post-GA week surfaced a longer list of "this only matters at scale" issues than a normal point release would carry. Roughly a hundred PRs landed between [4.0.0](https://github.com/wheels-dev/wheels/releases/tag/v4.0.0) and [4.0.1](https://github.com/wheels-dev/wheels/releases/tag/v4.0.1). + +This post walks through what changed, organized by who hit it. + +## Adobe ColdFusion 2023 and 2025: the compat-matrix gauntlet + +The headline story is Adobe CF. The 4.0 release verified clean on Lucee 5/6/7 and BoxLang, but the Adobe 2023 and 2025 legs of the compatibility matrix were reporting `0 pass / 0 fail / 0 err` — every request was failing before any test could complete. The root cause was a cascade of three Adobe-specific behaviors stacked on top of each other, each one masking the next. + +**`attributeCollection = arguments` is rejected on Adobe CF 2023/2025** ([#2741](https://github.com/wheels-dev/wheels/pull/2741), [#2750](https://github.com/wheels-dev/wheels/pull/2750)). Lucee, BoxLang, and Adobe 2018/2021 all accept the `arguments` scope passed directly to a built-in CFML tag's `attributeCollection`. Adobe 2023 tightened that — it now demands a plain struct, and throws `InvalidHeaderException: Failed to add HTML header` on `cfheader` (and equivalents on `cfcache`, `cfcontent`, `cfmail`, `cfdirectory`, `cffile`, `cflocation`, `cfhtmlhead`, `cfimage`, `cfdbinfo`, `cfinvoke`, `cfwddx`, `cfzip`). The fix is a uniform copy-arguments-to-struct shim across thirteen sites in `vendor/wheels/Global.cfc`. `$header()` is the dispatch-path blocker, but every other helper that wraps a built-in tag needed the same treatment. + +**`$header()` masks the original exception when called from `onError`** ([#2756](https://github.com/wheels-dev/wheels/pull/2756)). Even after the `attributeCollection` fix, Adobe CF still rejects `cfheader` calls when the response buffer has already been committed — and CF commits the buffer aggressively when any view content flushes mid-render. The error handler at `EventMethods.cfc:113` calls `$header("Content-Type", "application/json")`, which then threw `InvalidHeaderException` and replaced the upstream exception in the stack. `$header()` now probes `response.isCommitted()` and short-circuits with a best-effort write when the buffer has flushed. A new companion helper, `$responseCommitted()`, lets other tag wrappers adopt the same short-circuit incrementally. + +**`env("KEY", "fallback")` silently returned `""` on Adobe CF** (also in [#2756](https://github.com/wheels-dev/wheels/pull/2756)). The second parameter was named `default` — a CFML reserved word (`switch`/`case`/`default`) — and Adobe refuses to bind a parameter with that name at all. Neither the signature default nor a caller-supplied value populated `arguments.default`. Lucee and BoxLang bind it correctly. The fix renames the parameter to `defaultValue` and adds a back-compat shim for the legacy named-arg form `env(name="X", default="Y")` by checking the `arguments` scope for the literal `default` key first. Positional callers — the framework's own pattern — are unaffected. + +**Vite asset-walk lost transitive imports on Adobe CF** ([#2756](https://github.com/wheels-dev/wheels/pull/2756)). `viteScriptTag`, `viteStyleTag`, `vitePreloadTag`, and `$viteHtmlHead` were passing arrays from a struct literal to a recursive walker — and Adobe CF copies arrays by value in struct literals, while Lucee and BoxLang share the reference. Every `ArrayAppend()` inside the recursion was writing to a garbage copy. The fix is to pass the parent struct by reference and mutate `arguments.rv.preloads` / `arguments.rv.styles` instead. This is Cross-Engine Invariant #6 in [CLAUDE.md](https://github.com/wheels-dev/wheels/blob/develop/CLAUDE.md) — the kind of thing that bites once per release cycle. + +Two more in the same chain: the vendored TestBox `BaseReporter.resetHTMLResponse()` was throwing `IllegalStateException` on already-committed responses, and the test runner's bare `cfheader`/`cfcontent` calls in `runner.cfm` needed to route through the defensive `$header()`/`$content()` helpers so the runner could finish its own end-of-suite reporting. Both shipped in [#2756](https://github.com/wheels-dev/wheels/pull/2756) and [#2745](https://github.com/wheels-dev/wheels/pull/2745). + +The Adobe CF 2023/2025 legs are now green. + +## Windows: Scoop install actually works + +The second user-visible cliff was Windows install. Scoop is the canonical Windows install path for 4.0 (Chocolatey is no longer maintained), and the GA Scoop wrapper was failing on a clean Windows 11 install before LuCLI could even run. + +**The `wheels.cmd` wrapper tripped cmd.exe's pre-parser** ([#2766](https://github.com/wheels-dev/wheels/pull/2766), [#2767](https://github.com/wheels-dev/wheels/pull/2767)). The wrapper had been doing `call "%~dp0lucli-.bat" %*`, which makes cmd.exe pre-parse the entire bat-jar concatenation looking for labels. The bat preamble is ~915 KB, then a `:JAR_BOUNDARY` marker, then raw JAR ZIP bytes. The pre-parser tripped on byte sequences in the ZIP tail and printed `The filename, directory name, or volume label syntax is incorrect.` before exiting. The fix dispatches LuCLI via `"%JAVA_HOME%\bin\java.exe" -client -jar "%~dp0lucli-.bat" %*` directly — `java` reads the JAR via stream and skips the bat preamble, bypassing cmd's pre-parser entirely. A one-line fallback handles the case where Scoop's extraction lands the inlined JDK at a different path than expected. + +**Release artifacts now ship `.zip.sha512` sidecars instead of `.sha512`** ([#2761](https://github.com/wheels-dev/wheels/pull/2761)). The scoop-wheels `autoupdate` config uses `$url.sha512` substitution and expects the `.zip.sha512` shape; the GA artifacts shipped `*.sha512` and `*.md5`, so every non-module artifact 404'd the autoupdater. Four release artifacts and three workflows now produce the correct shape. + +**The Scoop install docs now mention the `java` bucket prerequisite**. Scoop's `depends:` declaration doesn't add the dependency bucket on the user's behalf, so users hit `Couldn't find manifest for 'openjdk21' from 'java' bucket` before they could even attempt install. `start-here/installing.mdx` and `command-line-tools/installation.mdx` now lead with `scoop bucket add java`. + +If you tried to install on Windows after 4.0.0 dropped and hit any of the above, this is the release that fixes it. + +## Pagination view helpers: CSS-framework presets + +This is the only meaningfully new user-facing surface in 4.0.1, and it lands because the like-for-like 3.x → 4.0 swap from `paginationLinks()` to `paginationNav()` was forcing Bootstrap apps to do a `Replace()` regex hack to move the `active` class from the anchor to the wrapping `
  • `. `paginationNav()` and `pageNumberLinks()` now accept a `viewStyle` argument with named presets ([#2718](https://github.com/wheels-dev/wheels/pull/2718)): + +```cfm +#paginationNav(viewStyle="bootstrap5")# +#paginationNav(viewStyle="bootstrap4")# +#paginationNav(viewStyle="tailwind")# +#paginationNav(viewStyle="plain")# +``` + +The Bootstrap presets emit the canonical `