diff --git a/changelog.d/debugbar-externalized-assets.changed.md b/changelog.d/debugbar-externalized-assets.changed.md
new file mode 100644
index 0000000000..b6b7dc00dd
--- /dev/null
+++ b/changelog.d/debugbar-externalized-assets.changed.md
@@ -0,0 +1 @@
+- The development debug bar's static CSS and JavaScript are now maintained as standalone files (`vendor/wheels/public/assets/css/debugbar.css`, `vendor/wheels/public/assets/js/debugbar.js`) and included into the bar, eliminating the CFML `##`-escaped inline blocks (a documented "unescaped `#` crashes the suite" hazard) and trimming the per-response debug payload via inter-tag whitespace collapse. The bar remains development-only and unchanged in production (#3210)
diff --git a/changelog.d/deploy-warmup-up-endpoint.added.md b/changelog.d/deploy-warmup-up-endpoint.added.md
new file mode 100644
index 0000000000..6f5a35f855
--- /dev/null
+++ b/changelog.d/deploy-warmup-up-endpoint.added.md
@@ -0,0 +1 @@
+- New apps scaffolded with `wheels new` now ship a `/up` liveness/warm-up endpoint (`app/controllers/Up.cfc` + route). `wheels deploy`'s proxy healthcheck already probes `/up` before traffic cutover, so the dispatch → controller → render path is compiled on a freshly deployed node before the first real visitor — moving the one-time cold-start compile (the bulk of first-request latency) off user traffic. The production-config guide documents the warm-up recipe and recommends setting the engine's template-inspection mode to `never` in production (#3210)
diff --git a/changelog.d/protected-methods-o1-lookup.performance.md b/changelog.d/protected-methods-o1-lookup.performance.md
new file mode 100644
index 0000000000..520c67c693
--- /dev/null
+++ b/changelog.d/protected-methods-o1-lookup.performance.md
@@ -0,0 +1 @@
+- The per-request action-dispatch gate now tests the protected-helper list with an O(1) `StructKeyExists` lookup instead of an O(n) `ListFindNoCase` scan over the ~100-250 framework helper names. A companion `application.wheels.protectedControllerMethodsLookup` struct-as-set is built once at application start alongside the existing comma-list (which is retained); case-insensitive matching is unchanged (#3210)
diff --git a/changelog.d/schema-column-cache.performance.md b/changelog.d/schema-column-cache.performance.md
new file mode 100644
index 0000000000..4f27aae46f
--- /dev/null
+++ b/changelog.d/schema-column-cache.performance.md
@@ -0,0 +1 @@
+- Database column metadata is now memoized per datasource+table in `application.wheels.schemaColumnCache` when `cacheDatabaseSchema` is on (the default). Previously every model class init issued a fresh `cfdbinfo type="columns"` JDBC catalog round-trip — a significant first-request cost on remote or wide-schema databases, re-paid on every reload and for every model sharing a table. The cache is rebuilt on reload, so schema changes are still picked up on reload (the same contract as the model and controller config caches) (#3210)
diff --git a/cli/lucli/templates/app/app/controllers/Up.cfc b/cli/lucli/templates/app/app/controllers/Up.cfc
new file mode 100644
index 0000000000..8547cb3069
--- /dev/null
+++ b/cli/lucli/templates/app/app/controllers/Up.cfc
@@ -0,0 +1,25 @@
+component extends="Controller" {
+
+ /**
+ * Liveness / warm-up endpoint.
+ *
+ * `wheels deploy`'s proxy healthcheck probes `/up` before flipping traffic
+ * to a freshly deployed node (the default healthcheck path), and load
+ * balancers can use it as a readiness check. Returning 200 here also
+ * compiles the dispatch -> controller -> render path on the new host, so the
+ * first real visitor sees warm latency instead of the one-time first-request
+ * compile (which is otherwise the bulk of cold-start time).
+ *
+ * To warm your hottest ORM metadata too, touch your key models here before
+ * cutover, e.g.:
+ *
+ * model("Post").count();
+ *
+ * Keep this action read-only, cheap, and free of authentication so the probe
+ * is never blocked.
+ */
+ function index() {
+ renderText("OK");
+ }
+
+}
diff --git a/cli/lucli/templates/app/config/routes.cfm b/cli/lucli/templates/app/config/routes.cfm
index 542c3768b8..283b3af077 100644
--- a/cli/lucli/templates/app/config/routes.cfm
+++ b/cli/lucli/templates/app/config/routes.cfm
@@ -7,6 +7,11 @@
mapper()
// CLI-Appends-Here
+ // Liveness / warm-up endpoint. `wheels deploy`'s proxy healthcheck probes
+ // `/up` before traffic cutover; a 200 here also compiles the request path
+ // so the first real visitor gets warm latency. See app/controllers/Up.cfc.
+ .get(name="up", to="up##index")
+
// The "wildcard" call below enables automatic mapping of "controller/action" type routes.
// This way you don't need to explicitly add a route every time you create a new action in a controller.
.wildcard()
diff --git a/vendor/wheels/Global.cfc b/vendor/wheels/Global.cfc
index 4b68558991..5e0cca6a8c 100644
--- a/vendor/wheels/Global.cfc
+++ b/vendor/wheels/Global.cfc
@@ -4389,6 +4389,24 @@ return local.$wheels;
return protectedMethods;
}
+ /**
+ * Convert the comma-list returned by `$buildProtectedControllerMethods()`
+ * into a struct-as-set so `$callAction()` can perform an O(1)
+ * `StructKeyExists` membership test on the per-request dispatch hot path
+ * instead of an O(n) `ListFindNoCase` scan over ~100-250 helper names.
+ * CFML struct keys are case-insensitive by default, preserving the prior
+ * `ListFindNoCase` semantics (an action named `ENV` is still rejected like
+ * `env`). Stored on `application.wheels.protectedControllerMethodsLookup`
+ * alongside the list, which is retained for callers expecting that shape.
+ */
+ public struct function $protectedControllerMethodsLookup(required string methods) {
+ var lookup = {};
+ for (var name in ListToArray(arguments.methods)) {
+ lookup[name] = true;
+ }
+ return lookup;
+ }
+
/**
* Re-evaluate the given global-includes file into `application.wo`'s
* variables/this scope. Invoked from the bare `?reload=true` soft-reload
diff --git a/vendor/wheels/controller/processing.cfc b/vendor/wheels/controller/processing.cfc
index 2e0fdd8fcc..7cb842d917 100644
--- a/vendor/wheels/controller/processing.cfc
+++ b/vendor/wheels/controller/processing.cfc
@@ -135,7 +135,7 @@ component {
* Internal function.
*/
public void function $callAction(required string action) {
- if (Left(arguments.action, 1) == "$" || ListFindNoCase(application.wheels.protectedControllerMethods, arguments.action)) {
+ if (Left(arguments.action, 1) == "$" || StructKeyExists(application.wheels.protectedControllerMethodsLookup, arguments.action)) {
// A helper-named or $-prefixed action is treated exactly like a
// missing action: it 404s (see #2845 and CLAUDE.md Anti-Pattern 8).
// Route through $throwErrorOrShow404Page — mirroring RecordNotFound /
diff --git a/vendor/wheels/databaseAdapters/Base.cfc b/vendor/wheels/databaseAdapters/Base.cfc
index 6daa3e40ab..1f102c4fa4 100755
--- a/vendor/wheels/databaseAdapters/Base.cfc
+++ b/vendor/wheels/databaseAdapters/Base.cfc
@@ -417,6 +417,32 @@ component output=false extends="wheels.Global"{
local.args.username = variables.username;
local.args.password = variables.password;
local.args.table = arguments.tableName;
+
+ // Column metadata is a JDBC catalog round-trip (cfdbinfo type="columns")
+ // fetched once per model class. On remote / wide-schema databases that
+ // round-trip dominates first-request latency, and it is otherwise re-paid
+ // on every reload and for every model sharing a table. When
+ // cacheDatabaseSchema is on, memoize the result per datasource+table in
+ // application.wheels.schemaColumnCache. A reload rebuilds the application
+ // scope, so schema changes are still picked up on reload — the same
+ // contract as the model and controller config caches. The cache lives
+ // OUTSIDE application.wheels.cache.* on purpose: those categories hold
+ // {value, expiresAt} envelopes walked by the time-based cull, which would
+ // throw on a raw query. The read mirrors $getFromCache (try/catch +
+ // Duplicate) so a concurrent struct read can't surface a partial value and
+ // a caller can't mutate the cached query in place. (perf)
+ local.cacheSchema = $get("cacheDatabaseSchema");
+ if (local.cacheSchema) {
+ local.cacheKey = Hash(variables.dataSource & Chr(31) & arguments.tableName);
+ try {
+ if (StructKeyExists(application.wheels.schemaColumnCache, local.cacheKey)) {
+ return Duplicate(application.wheels.schemaColumnCache[local.cacheKey]);
+ }
+ } catch (any e) {
+ // fall through to a fresh catalog lookup on any concurrent-read hiccup
+ }
+ }
+
if ($get("showErrorInformation")) {
try {
local.rv = $getColumnInfo(argumentCollection = local.args);
@@ -430,6 +456,14 @@ component output=false extends="wheels.Global"{
} else {
local.rv = $getColumnInfo(argumentCollection = local.args);
}
+
+ if (local.cacheSchema) {
+ // Store an isolated copy so the cached entry can never be mutated via a
+ // reference handed to an earlier caller.
+ lock name="wheels.schemaColumnCache" type="exclusive" timeout="10" {
+ application.wheels.schemaColumnCache[local.cacheKey] = Duplicate(local.rv);
+ }
+ }
return local.rv;
}
diff --git a/vendor/wheels/events/onapplicationstart.cfc b/vendor/wheels/events/onapplicationstart.cfc
index 2aa07d0751..30195be304 100644
--- a/vendor/wheels/events/onapplicationstart.cfc
+++ b/vendor/wheels/events/onapplicationstart.cfc
@@ -109,6 +109,13 @@ component {
}
application.$wheels.controllers = {};
application.$wheels.models = {};
+ // Per-app column-metadata cache (see databaseAdapters/Base.cfc $getColumns).
+ // Deliberately a SIBLING of `cache`, not a `cache.*` category: it stores raw
+ // query objects that live for the application lifetime, whereas every
+ // `cache.*` category holds {value, expiresAt} envelopes that the cull/count
+ // machinery ($addToCache / $cacheCount) walks and dereferences `.expiresAt`
+ // on. Putting schema queries under `cache.*` makes the cull throw.
+ application.$wheels.schemaColumnCache = {};
application.$wheels.helperFileCache = {};
application.$wheels.layoutFileCache = {};
application.$wheels.existingObjectFiles = {};
@@ -400,6 +407,13 @@ component {
// helpers like env(), model(), redirectTo() are never URL-invokable.
application.$wheels.protectedControllerMethods = application.wo.$buildProtectedControllerMethods();
+ // Companion struct-as-set for O(1) membership checks on the dispatch hot
+ // path (see $callAction()); the comma-list above is kept for callers that
+ // expect that shape.
+ application.$wheels.protectedControllerMethodsLookup = application.wo.$protectedControllerMethodsLookup(
+ application.$wheels.protectedControllerMethods
+ );
+
// Enable the main GUI Component
if (application.$wheels.enablePublicComponent) {
application.$wheels.public = application.wo.$createObjectFromRoot(path = "wheels", fileName = "Public", method = "$init");
diff --git a/vendor/wheels/events/onrequestend/debug.cfm b/vendor/wheels/events/onrequestend/debug.cfm
index 00c262b70e..d08fab33e6 100644
--- a/vendor/wheels/events/onrequestend/debug.cfm
+++ b/vendor/wheels/events/onrequestend/debug.cfm
@@ -83,51 +83,9 @@ OR (StructKeyExists(url, "format") AND ListFindNoCase("json,xml,csv,pdf", url.fo
-
+
-
+
@@ -517,53 +475,7 @@ OR (StructKeyExists(url, "format") AND ListFindNoCase("json,xml,csv,pdf", url.fo
-
+
-
+#ReReplace(local.wdbHtml, "(?m)>\s+<", "><", "all")#
diff --git a/vendor/wheels/public/assets/css/debugbar.css b/vendor/wheels/public/assets/css/debugbar.css
new file mode 100644
index 0000000000..0c34410bb6
--- /dev/null
+++ b/vendor/wheels/public/assets/css/debugbar.css
@@ -0,0 +1,41 @@
+#wheels-debugbar *{box-sizing:border-box;margin:0;padding:0;}
+#wheels-debugbar{position:fixed;bottom:0;left:0;right:0;z-index:99999;font-size:13px;line-height:1.4;}
+#wheels-debugbar .wdb-bar{display:flex;align-items:center;background:#1e1e2e;color:#cdd6f4;height:36px;padding:0 8px;gap:2px;border-top:1px solid #45475a;user-select:none;}
+#wheels-debugbar .wdb-bar a,#wheels-debugbar .wdb-bar span{color:#cdd6f4;text-decoration:none;}
+#wheels-debugbar .wdb-tab{display:flex;align-items:center;gap:5px;padding:0 10px;height:36px;cursor:pointer;border:none;background:none;color:#cdd6f4;font-size:12px;font-family:inherit;white-space:nowrap;transition:background .15s;}
+#wheels-debugbar .wdb-tab:hover{background:#313244;}
+#wheels-debugbar .wdb-tab.active{background:#313244;color:#89b4fa;border-top:2px solid #89b4fa;padding-top:2px;}
+#wheels-debugbar .wdb-tab svg{width:14px;height:14px;fill:currentColor;flex-shrink:0;}
+#wheels-debugbar .wdb-badge{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 5px;border-radius:9px;font-size:10px;font-weight:700;line-height:1;}
+#wheels-debugbar .wdb-badge-green{background:#28a74533;color:#a6e3a1;}
+#wheels-debugbar .wdb-badge-yellow{background:#ffc10733;color:#f9e2af;}
+#wheels-debugbar .wdb-badge-red{background:#dc354533;color:#f38ba8;}
+#wheels-debugbar .wdb-badge-blue{background:#89b4fa33;color:#89b4fa;}
+#wheels-debugbar .wdb-sep{width:1px;height:20px;background:#45475a;margin:0 4px;}
+#wheels-debugbar .wdb-spacer{flex:1;}
+#wheels-debugbar .wdb-panel{display:none;position:fixed;bottom:36px;left:0;right:0;max-height:50vh;background:#1e1e2e;border-top:1px solid #45475a;overflow-y:auto;color:#cdd6f4;padding:0;}
+#wheels-debugbar .wdb-panel.open{display:block;}
+#wheels-debugbar .wdb-panel-header{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-bottom:1px solid #313244;position:sticky;top:0;background:#1e1e2e;z-index:1;}
+#wheels-debugbar .wdb-panel-header h3{font-size:14px;font-weight:600;color:#cdd6f4;}
+#wheels-debugbar .wdb-panel-body{padding:12px 16px;}
+#wheels-debugbar .wdb-table{width:100%;border-collapse:collapse;}
+#wheels-debugbar .wdb-table th{text-align:left;padding:6px 12px;font-size:11px;font-weight:600;color:#a6adc8;text-transform:uppercase;letter-spacing:.5px;background:#181825;border-bottom:1px solid #313244;}
+#wheels-debugbar .wdb-table td{padding:6px 12px;border-bottom:1px solid #313244;font-size:12px;vertical-align:top;}
+#wheels-debugbar .wdb-table tr:hover td{background:#31324433;}
+#wheels-debugbar .wdb-kv{display:grid;grid-template-columns:180px 1fr;gap:0;}
+#wheels-debugbar .wdb-kv dt{padding:6px 12px;font-weight:600;color:#a6adc8;font-size:12px;border-bottom:1px solid #313244;}
+#wheels-debugbar .wdb-kv dd{padding:6px 12px;font-size:12px;border-bottom:1px solid #313244;word-break:break-word;}
+#wheels-debugbar .wdb-kv dd code{font-family:'SF Mono',SFMono-Regular,Menlo,Consolas,monospace;background:#313244;padding:1px 5px;border-radius:3px;font-size:11px;}
+#wheels-debugbar .wdb-timing-row{display:flex;align-items:center;gap:8px;margin-bottom:6px;}
+#wheels-debugbar .wdb-timing-label{width:100px;font-size:12px;color:#a6adc8;text-align:right;}
+#wheels-debugbar .wdb-timing-bar-bg{flex:1;height:20px;background:#313244;border-radius:3px;overflow:hidden;position:relative;}
+#wheels-debugbar .wdb-timing-bar{height:100%;border-radius:3px;display:flex;align-items:center;padding-left:6px;font-size:10px;font-weight:600;color:#1e1e2e;min-width:30px;}
+#wheels-debugbar .wdb-close-btn{background:none;border:none;color:#a6adc8;cursor:pointer;font-size:18px;padding:4px 8px;border-radius:4px;line-height:1;}
+#wheels-debugbar .wdb-close-btn:hover{background:#45475a;color:#cdd6f4;}
+#wheels-debugbar .wdb-link-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px;padding:4px 0;}
+#wheels-debugbar .wdb-link-card{display:flex;align-items:center;gap:8px;padding:10px 12px;background:#313244;border-radius:6px;color:#cdd6f4;text-decoration:none;font-size:12px;font-weight:500;transition:background .15s;}
+#wheels-debugbar .wdb-link-card:hover{background:#45475a;}
+#wheels-debugbar .wdb-link-card svg{width:16px;height:16px;fill:#89b4fa;flex-shrink:0;}
+#wheels-debugbar .wdb-env-dot{width:8px;height:8px;border-radius:50%;display:inline-block;}
+#wheels-debugbar .wdb-section{margin-bottom:16px;}
+#wheels-debugbar .wdb-section-title{font-size:11px;font-weight:700;color:#89b4fa;text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px;padding-bottom:4px;border-bottom:1px solid #313244;}
diff --git a/vendor/wheels/public/assets/js/debugbar.js b/vendor/wheels/public/assets/js/debugbar.js
new file mode 100644
index 0000000000..382881954d
--- /dev/null
+++ b/vendor/wheels/public/assets/js/debugbar.js
@@ -0,0 +1,45 @@
+(function () {
+ var activePanel = null;
+ window.wdbToggle = function (name) {
+ var panels = document.querySelectorAll('#wheels-debugbar .wdb-panel');
+ var tabs = document.querySelectorAll('#wheels-debugbar .wdb-tab');
+ if (activePanel === name) {
+ wdbClosePanel();
+ return;
+ }
+ for (var i = 0; i < panels.length; i++) panels[i].classList.remove('open');
+ var p = document.getElementById('wdb-panel-' + name);
+ if (p) p.classList.add('open');
+ for (var j = 0; j < tabs.length; j++) tabs[j].classList.remove('active');
+ var t = document.getElementById('wdb-tab-' + name);
+ if (t) t.classList.add('active');
+ activePanel = name;
+ };
+ window.wdbClosePanel = function () {
+ var panels = document.querySelectorAll('#wheels-debugbar .wdb-panel');
+ var tabs = document.querySelectorAll('#wheels-debugbar .wdb-tab');
+ for (var i = 0; i < panels.length; i++) panels[i].classList.remove('open');
+ for (var j = 0; j < tabs.length; j++) tabs[j].classList.remove('active');
+ activePanel = null;
+ };
+ window.wdbMinimize = function () {
+ wdbClosePanel();
+ document.getElementById('wheels-debugbar').style.display = 'none';
+ document.getElementById('wdb-minimized').style.display = 'block';
+ try { sessionStorage.setItem('wdb-hidden', '1'); } catch (e) {}
+ };
+ window.wdbRestore = function () {
+ document.getElementById('wheels-debugbar').style.display = '';
+ document.getElementById('wdb-minimized').style.display = 'none';
+ try { sessionStorage.removeItem('wdb-hidden'); } catch (e) {}
+ };
+ window.wdbEnvSwitch = function (el) {
+ var target = el.getAttribute('data-wdb-reload');
+ if (!target) return false;
+ var pw = window.prompt('Enter the reload password to switch environments:');
+ if (pw === null || pw === '') return false;
+ window.location.href = target + '&password=' + encodeURIComponent(pw);
+ return false;
+ };
+ try { if (sessionStorage.getItem('wdb-hidden') === '1') wdbMinimize(); } catch (e) {}
+})();
diff --git a/vendor/wheels/tests/specs/controller/protectedMethodsSpec.cfc b/vendor/wheels/tests/specs/controller/protectedMethodsSpec.cfc
index fd6502746f..f710d58b2b 100644
--- a/vendor/wheels/tests/specs/controller/protectedMethodsSpec.cfc
+++ b/vendor/wheels/tests/specs/controller/protectedMethodsSpec.cfc
@@ -35,6 +35,39 @@ component extends="wheels.WheelsTest" {
})
})
+ // The dispatch hot path checks membership via an O(1) struct-as-set
+ // (perf) instead of an O(n) ListFindNoCase over the comma-list. The set
+ // must stay in lockstep with the list it is derived from.
+ describe("application.wheels.protectedControllerMethodsLookup", () => {
+
+ it("is populated at application start", () => {
+ expect(StructCount(application.wheels.protectedControllerMethodsLookup)).toBeGT(0)
+ })
+
+ it("has one key per entry in the comma-list (case-insensitive)", () => {
+ for (var item in ListToArray(application.wheels.protectedControllerMethods)) {
+ expect(StructKeyExists(application.wheels.protectedControllerMethodsLookup, item)).toBeTrue()
+ }
+ })
+
+ it("matches helper names case-insensitively, like the prior ListFindNoCase", () => {
+ expect(StructKeyExists(application.wheels.protectedControllerMethodsLookup, "model")).toBeTrue()
+ expect(StructKeyExists(application.wheels.protectedControllerMethodsLookup, "MODEL")).toBeTrue()
+ expect(StructKeyExists(application.wheels.protectedControllerMethodsLookup, "redirectTo")).toBeTrue()
+ })
+
+ it("does not contain entries the list lacks", () => {
+ expect(StructCount(application.wheels.protectedControllerMethodsLookup))
+ .toBe(ListLen(application.wheels.protectedControllerMethods))
+ })
+
+ it("builds an equivalent set from a known list via the helper", () => {
+ var set = application.wo.$protectedControllerMethodsLookup("env,model,redirectTo")
+ expect(StructCount(set)).toBe(3)
+ expect(StructKeyExists(set, "ENV")).toBeTrue()
+ })
+ })
+
describe("$callAction action-dispatch gate", () => {
beforeEach(() => {
diff --git a/vendor/wheels/tests/specs/model/schemaColumnCacheSpec.cfc b/vendor/wheels/tests/specs/model/schemaColumnCacheSpec.cfc
new file mode 100644
index 0000000000..6e1f2276a1
--- /dev/null
+++ b/vendor/wheels/tests/specs/model/schemaColumnCacheSpec.cfc
@@ -0,0 +1,70 @@
+/**
+ * Coverage for the column-metadata cache (perf). `$getColumns()` issues a
+ * cfdbinfo type="columns" JDBC catalog round-trip — once per model class and
+ * re-paid on every reload and for every model sharing a table. On
+ * remote / wide-schema databases that round-trip dominates first-request
+ * latency. When `cacheDatabaseSchema` is on, the resolved column query is now
+ * memoized per datasource+table in `application.wheels.schemaColumnCache` (rebuilt
+ * on framework reload, so schema changes are still picked up on reload — the
+ * same contract as the model/controller config caches).
+ */
+component extends="wheels.WheelsTest" {
+
+ function beforeAll() {
+ // Snapshot the shared caches/settings so a mid-spec failure can't leave
+ // the rest of the suite running against a polluted cache.
+ if (StructKeyExists(application.wheels, "schemaColumnCache")) {
+ variables.$priorSchemaCache = Duplicate(application.wheels.schemaColumnCache);
+ }
+ variables.$priorCacheDatabaseSchema = application.wheels.cacheDatabaseSchema;
+ }
+
+ function afterAll() {
+ application.wheels.schemaColumnCache = StructKeyExists(variables, "$priorSchemaCache")
+ ? variables.$priorSchemaCache
+ : {};
+ application.wheels.cacheDatabaseSchema = variables.$priorCacheDatabaseSchema;
+ }
+
+ function run() {
+
+ g = application.wo;
+
+ describe("$getColumns column-metadata cache", () => {
+
+ it("memoizes columns per datasource+table when cacheDatabaseSchema is on", () => {
+ application.wheels.cacheDatabaseSchema = true;
+ application.wheels.schemaColumnCache = {};
+ var m = g.model("author");
+ var adapter = m.$assignAdapter();
+ var first = adapter.$getColumns(m.tableName());
+ expect(StructCount(application.wheels.schemaColumnCache)).toBeGT(0);
+ });
+
+ it("returns identical column metadata on the cached path", () => {
+ application.wheels.cacheDatabaseSchema = true;
+ application.wheels.schemaColumnCache = {};
+ var m = g.model("author");
+ var adapter = m.$assignAdapter();
+ var fresh = adapter.$getColumns(m.tableName());
+ var cached = adapter.$getColumns(m.tableName());
+ expect(cached.recordCount).toBe(fresh.recordCount);
+ expect(cached.columnList).toBe(fresh.columnList);
+ });
+
+ it("does not populate the cache when cacheDatabaseSchema is off", () => {
+ application.wheels.cacheDatabaseSchema = false;
+ application.wheels.schemaColumnCache = {};
+ var m = g.model("author");
+ var adapter = m.$assignAdapter();
+ var cols = adapter.$getColumns(m.tableName());
+ expect(StructCount(application.wheels.schemaColumnCache)).toBe(0);
+ // columns still resolve correctly with caching disabled
+ expect(cols.recordCount).toBeGT(0);
+ });
+
+ });
+
+ }
+
+}
diff --git a/web/sites/guides/src/content/docs/v4-0-0/deployment/production-config.mdx b/web/sites/guides/src/content/docs/v4-0-0/deployment/production-config.mdx
index e9cf0cee48..1f4f72c0b0 100644
--- a/web/sites/guides/src/content/docs/v4-0-0/deployment/production-config.mdx
+++ b/web/sites/guides/src/content/docs/v4-0-0/deployment/production-config.mdx
@@ -161,7 +161,10 @@ Run through this before the first production request. Each item maps to a framew
7. **Error email is wired.** `sendEmailOnError` is auto-enabled in production (`debugging.cfm:22-25`). If `errorEmailToAddress` is empty, Wheels still tries to send — `events/EventMethods.cfc:5-13` reads the address at error time — and the mail bounces silently. Set it.
8. **URL rewriting matches the web server.** `set(URLRewriting="On")` only works if nginx/Apache/Tuckey rewrites `/posts/1` to `/index.cfm/posts/1`. If rewrites aren't configured, leave it at `"Partial"` or `"Off"`, or every link breaks.
9. **`allowEnvironmentSwitchViaUrl` is not re-enabled.** Confirm by grepping `config/production/settings.cfm` — don't set it to `true`. If you need to toggle environments, redeploy.
-10. **Caches are warmed or warm fast.** First request after deploy compiles every controller/model. For latency-sensitive endpoints, hit them from the deploy script before flipping the load balancer. `cacheControllerConfig`, `cacheModelConfig`, `cacheDatabaseSchema`, and `cacheFileChecking` are always-on in every environment (`events/init/caching.cfm:3-7`), so a single warm-up request per controller is usually enough.
+10. **Caches are warmed before traffic cutover.** The very first request to a freshly started node is dominated by one-time CFML-to-bytecode compilation — profiling a stock app puts roughly **85% of a ~1.2s cold start in the engine's compiler**, versus sub-millisecond once warm. That cost is paid once per process, so the goal is to move it off the first *real* visitor and onto the deploy sequence:
+ - Apps scaffolded with `wheels new` ship a `/up` route (`app/controllers/Up.cfc`). `wheels deploy`'s proxy healthcheck probes `/up` before flipping traffic, so the dispatch → controller → render path is compiled on the new node before it serves anyone. Touch your hottest models in that action (e.g. `model("Post").count()`) to warm their ORM column metadata too — the first query against a model is the other notable one-time cost.
+ - Not using `wheels deploy`? Curl your latency-sensitive endpoints from the deploy script before enabling the node at the load balancer. A single warm-up request per controller is usually enough, since `cacheControllerConfig`, `cacheModelConfig`, `cacheDatabaseSchema`, and `cacheFileChecking` are always-on in every environment (`events/init/caching.cfm:3-7`).
+11. **Template inspection is set to `never` (engine-level).** In development the CFML engine re-checks each template's source timestamp on every request so edits are picked up live; in production that filesystem stat is pure per-request overhead. Set the engine's template-inspection mode to `never` so it trusts the compiled-class cache and skips the check — Lucee: admin → *Performance/Caching* → "Inspect Templates" = `Never` (or `inspectTemplate="never"` in `.CFConfig.json`); Adobe ColdFusion: enable "Trusted Cache". This is engine configuration, not a Wheels setting, so it isn't flipped by `environment="production"`. Pair it with a warm-up (item 10) and a cache-clear/reload step in your deploy so a new release's templates still recompile once.
## What production does not change
diff --git a/web/tests/visual-baselines/api.png b/web/tests/visual-baselines/api.png
index 4b65188349..507f04ce9e 100644
Binary files a/web/tests/visual-baselines/api.png and b/web/tests/visual-baselines/api.png differ
diff --git a/web/tests/visual-baselines/guides.png b/web/tests/visual-baselines/guides.png
index e3ec16100e..75ae73d563 100644
Binary files a/web/tests/visual-baselines/guides.png and b/web/tests/visual-baselines/guides.png differ