Files

591 lines
28 KiB
JavaScript
Raw Permalink Normal View History

2022-10-11 16:56:51 -04:00
// @ts-check
import { withAxiom } from '@civitai/next-axiom';
import bundlAnalyzer from '@next/bundle-analyzer';
2025-05-29 16:27:30 -06:00
import CircularDependencyPlugin from 'circular-dependency-plugin';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const packageJson = require('./package.json');
2024-11-14 17:53:53 -07:00
2025-02-19 16:14:04 -07:00
const isProd = process.env.NODE_ENV === 'production';
const isDev = process.env.NODE_ENV === 'development';
const analyze = process.env.ANALYZE === 'true';
2025-05-29 16:27:30 -06:00
const includeCircularDependencyPlugin = process.env.CIRCULAR_DEPENDENCY_PLUGIN === 'true';
2025-02-19 16:14:04 -07:00
2024-11-14 17:53:53 -07:00
const withBundleAnalyzer = bundlAnalyzer({
enabled: analyze,
});
2022-10-11 16:56:51 -04:00
fix(deps): Next 16.3.0 → 16.3.1, and make the compiled-branch gate hard (#3983) (#4075) * fix(deps): Next 16.3.0 -> 16.3.1, and make the compiled-branch gate hard (#3983) This is the fix for #3983. The defect was in the bundler, not in our source. Turbopack's value analyzer in 16.3.0 models a bare `return someAsyncFn()` tail call as `Promise<Promise<T>>`. That is always truthy, so a caller that `await`s it is analysed as always-true and every statement after the resulting conditional is eliminated as dead code. `isAppListingsEnabled` ends in exactly that shape, which is why `resolveStoreVisibilityScopeUninstrumented` lost two of its three returns, fell off the end, and produced `undefined` for every non-privileged caller — served as the whole catalog on one read path (`?? 'full'`) and as an empty store on the other (`?? 'none'`). Upstream: vercel/next.js#96601 "[turbopack] Collapse nested promises in the analyzer", backported as #96675, shipped in 16.3.1. MEASURED, not inferred. Two production builds of THIS commit on one machine, same Node 24.19.0, differing only in the pinned Next: 16.3.0 async function S(e){if(await p(e))return"full"} 16.3.1 async function w(e){return await c(e)?"full":await y(e)?"public-external":"none"} Both read out of the emitted `.next/server` chunks by source-map attribution and identified by their source neighbour `STORE_SCOPE_FLAGS`, never by minified name. Note the fixed form is a TERNARY — `grep 'return"public-external"'` returns zero on the FIXED build too, which is why the gate reads source maps. `package.json` already allowed 16.3.1 (`^16.3.0`); only the lockfile pinned 16.3.0, so the substance here is the lockfile. The floor is raised to `^16.3.1` so a fresh resolution cannot land back on the broken compiler. `patches/next@…` is renamed and its `patchedDependencies` key updated — that patch is the unrelated libvips/SVG one-liner (vercel/next.js#96681), it still applies cleanly, and 16.3.1 still does not carry the loader entry upstream, so it stays. `--warn-only` is removed from `scripts/assert-compiled-branches.mjs` in the same commit. It existed only because the 16.3.0 build genuinely violated the gate, and a permanently-red gate trains everyone to click through. Keeping the bump and the strictness atomic means the gate's strictness always matches the toolchain: a revert of the bump turns it red instead of silently passing. Verified on this commit: - gate exit 0 (hard, no --warn-only) against the 16.3.1 build - gate exit 1 against a 16.3.0 build of the same tree — watched red - `scripts/ci/assert-next-svg-patch-applied.mjs` OK on both installed copies - unit suite 1149 files / 18,123 tests passed, 0 failed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(build): ship @swc/helpers' module-sync branch into the standalone image (#3983) The bump built clean, passed every source-level gate — unit suites, typecheck, ESLint + Prettier, schema drift, the event-engine pin, and the now-hard compiled-branch gate — and the container could not boot: Error: Cannot find module '.../@swc/helpers/esm/_interop_require_default.js' at ... next/dist/server/require-hook.js code: 'MODULE_NOT_FOUND' Same shape as the defect this PR exists to fix: a correct source tree producing a broken artefact, invisible to everything that reads source. ROOT CAUSE, measured on the published artefact rather than inferred. `output: 'standalone'` does not ship node_modules; it ships the subset @vercel/nft traced. nft resolves a bare specifier under the `require`/`default` conditions. Node (>= 22.10) additionally honours `module-sync` for a CJS `require`. When a package's `exports` map points those two at different files, the build traces one and the running process asks for the other. next/dist/shared/lib/constants.js does `require('@swc/helpers/_/_interop_require_default')`, reached from the generated server.js via `next` -> config.js -> constants.js, i.e. before any application code. The relevant delta is not next itself but next's own dependency: next 16.3.0 next 16.3.1 @swc/helpers 0.5.15 0.5.23 ./_/_interop_require_default {import,default} {module-sync,webpack,import,default} require.resolve() under CJS cjs/...cjs esm/...js Both resolutions were RUN, not reasoned about. nft still traced the cjs file, so the published image carried that package as exactly cjs/_interop_require_default.cjs, cjs/_interop_require_wildcard.cjs and package.json — no esm/ directory at all. Adding only the missing esm/ directory to that exact image, nothing else changed, boots it: "Next.js 16.3.1 ... Ready". FIX. `outputFileTracingIncludes` force-includes BOTH condition branches of EVERY installed @swc/helpers copy — not the one file missing today, because which helper Next requires and which branch each resolver picks are upstream details that move. Globs are version- and hash-agnostic (`@swc+helpers@*`), plus a flat form for a hoisted layout. ~950 KB per copy. Verified on a local production build of this commit: both copies land in .next/standalone with complete esm/ (108 and 105 files) and cjs/, and next's virtual store links the 0.5.23 copy. Attached to three existing API-route keys rather than a `'**'` key. copyTracedFiles unions every entry's traced set into the single .next/standalone node_modules, so one entry carrying it is enough, while `'**'` would make all 572 entries read/parse/rewrite their .nft.json concurrently — 826 MB of JSON in one Promise.all — on a build already tuned against OOM. GATE, because a glob is a silent no-op once it stops matching. scripts/ci/assert-standalone-boot-graph.mjs runs in the Dockerfile's RUNNER stage: the first gate in that file to run against the runtime filesystem rather than the build tree, and the only one that can see this class of defect. It reads the GENERATED server.js for the specifiers that process requires at module scope and loads them in a child rooted at the shipped tree — no package, version, virtual-store path or patch hash hardcoded, so it keeps covering this after the next bump. Exit 2, never 0, when it cannot observe its input. It must run in the runner and not the builder: /app there is byte-for-byte what ships, whereas the builder's complete node_modules sits above .next/standalone on the resolution path and can satisfy a require the image cannot. Watched red and green on real artefacts, not only fixtures: - exit 1 with this exact MODULE_NOT_FOUND against the published broken image; - exit 0 against the same image with only the esm/ directory added; - exit 0 against the local production build of this commit, isolated from any parent node_modules; - exit 1 again after deleting exactly esm/_interop_require_default.js from that same local build. src/tests/build/standalone-boot-graph.test.ts pins the MECHANISM (a module-sync/default split with only the default branch present) rather than the package, and all 5 of its cases were watched to fail against a neutered gate. NOT VERIFIED. Nothing about production: this is only true of production once it merges, is promoted main -> release, is built and is serving. The gate covers the ENTRYPOINT's require graph; route chunks load lazily, so a condition mismatch reachable only from a route would still surface at request time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore: retrigger preview The previous preview run (pr-preview-4075-b2wnw) never scheduled: its build-image and typecheck pods sat Pending with ExceededNodeResources for 82 minutes and the run hit the 1h30m PipelineRunTimeout. No verdict was produced — this was build-pool capacity contention, not a code failure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 15:07:05 -05:00
/**
* Runtime files of every installed `@swc/helpers`, force-included into the standalone output.
*
* WHY. `output: 'standalone'` ships the subset @vercel/nft traced, not `node_modules`. nft
* resolves a bare specifier under the `require`/`default` conditions; Node (>= 22.10)
* additionally honours `module-sync` for a CJS `require`. When a package's `exports` map points
* those two at different files, the build traces one and the running process asks for the other.
*
* Next's own `dist/shared/lib/constants.js` does
* `require('@swc/helpers/_/_interop_require_default')`, reached from the generated `server.js`
* via `next` -> `config.js` -> `constants.js` — i.e. before any application code. On
* @swc/helpers 0.5.15 (next 16.3.0) that subpath exported only `{ import, default }` and both
* resolvers landed on `cjs/_interop_require_default.cjs`. 0.5.17+ added `module-sync` ->
* `esm/_interop_require_default.js`, and next 16.3.1 bumped its dependency to 0.5.23 — so the
* image shipped `cjs/` only and every pod crash-looped on
* `MODULE_NOT_FOUND .../@swc/helpers/esm/_interop_require_default.js` (civitai#4075) with the
* build, the unit suite, typecheck, ESLint and the compiled-branch gate all green.
*
* BOTH condition branches of EVERY copy, not the one file missing today: which helper Next
* requires, and which branch each resolver picks, are upstream details that move. ~950 KB per
* copy (426 files across the two copies installed today). Version- and hash-agnostic globs —
* `@swc+helpers@*` covers whatever the next bump resolves to, and the flat form covers a hoisted
* (non-pnpm) layout. A non-matching glob is a silent no-op, which is exactly why this is NOT the
* guard: the guard is
* `scripts/ci/assert-standalone-boot-graph.mjs`, run against the runtime filesystem in the
* Dockerfile's runner stage, which fails the build if this ever stops landing the files.
*
* ATTACHED TO EXISTING ROUTE KEYS ON PURPOSE. This is a process-wide boot dependency, not a
* route's, and `copyTracedFiles` unions every entry's traced set into the single
* `.next/standalone` node_modules — so any one entry carrying it is enough. A `'**'` key does
* match every route (keys are picomatch'd with `contains: true`), but it would make all 572
* entries read/parse/rewrite their `.nft.json` concurrently — 826 MB of JSON in one
* `Promise.all` — on a build already tuned against OOM. These keys are API routes: always
* present, never statically prerendered (an entry in `staticPages` has its includes skipped),
* and already include-keyed, so they cost no additional entry. Three of them for redundancy: if
* one route is ever renamed or removed the files still ship, and if all three go the boot gate
* turns the build red rather than letting a broken image out.
*/
const swcHelpersRuntimeFiles = [
'./node_modules/@swc/helpers/esm/**/*',
'./node_modules/@swc/helpers/cjs/**/*',
'./node_modules/.pnpm/@swc+helpers@*/node_modules/@swc/helpers/esm/**/*',
'./node_modules/.pnpm/@swc+helpers@*/node_modules/@swc/helpers/cjs/**/*',
];
2022-10-11 16:56:51 -04:00
/**
* Don't be scared of the generics here.
* All they do is to give us autocompletion when using this.
*
* @template {import('next').NextConfig} T
* @param {T} config - A generic parameter that flows through to the return type
* @constraint {{import('next').NextConfig}}
*/
function defineNextConfig(config) {
2024-11-15 09:45:26 -07:00
return withBundleAnalyzer(config);
2022-10-11 16:56:51 -04:00
}
export default defineNextConfig(
withAxiom({
env: {
version: packageJson.version,
// The client login helpers need the hub origin. Reuse AUTH_JWT_ISSUER (the server's single hub-URL source)
// by exposing it to the client bundle as NEXT_PUBLIC_AUTH_HUB_URL — so there's no separate var to set. An
// explicit NEXT_PUBLIC_AUTH_HUB_URL still wins if provided. (AUTH_JWT_ISSUER is public: the JWT `iss` /
// JWKS origin.)
NEXT_PUBLIC_AUTH_HUB_URL: process.env.NEXT_PUBLIC_AUTH_HUB_URL ?? process.env.AUTH_JWT_ISSUER,
},
// webpack: (config, options) => {
// if (isDev && !options.isServer) {
// config.plugins.push(
// new CircularDependencyPlugin({
// exclude: /node_modules|\.d\.ts/, // Ignore types and external modules
// failOnError: true, // Fail build on cycle
// allowAsyncCycles: false, // Disallow lazy cycles (recommended)
// cwd: process.cwd(), // Base path for clearer output
// // `onStart` is called before the cycle detection starts
// // onStart({ compilation }) {
// // console.log('start detecting webpack modules cycles');
// // },
// // `onDetected` is called for each module that is cyclical
// onDetected({ module: webpackModuleRecord, paths, compilation }) {
// // `paths` will be an Array of the relative module paths that make up the cycle
// // `module` will be the module record generated by webpack that caused the cycle
// compilation.errors.push(new Error(paths.join(' -> ')));
// },
// // `onEnd` is called before the cycle detection ends
// // onEnd({ compilation }) {
// // console.log('end detecting webpack modules cycles');
// // },
// })
// );
// }
2025-05-29 16:27:30 -06:00
// return config;
// },
// Turbopack is the default bundler as of Next 16. The OpenTelemetry packages
// that produced the `require-in-the-middle` webpack warnings are listed in
// `serverExternalPackages` below, so Turbopack externalizes them and never
// emits those warnings — an empty config just acknowledges we're on Turbopack
// and silences Next's "webpack config with no turbopack config" build error.
turbopack: {},
chore(dev-server): spoke apps, branch-switch survival, and per-worktree env (#3741) * feat(dev-server): run the SvelteKit spoke apps as daemon sidecars The apps under apps/ each need `vite dev` in their own directory so vite picks up that app's .env, plus readiness parsing, crash handling and a log buffer. AuthHub already encodes all of that, so a spoke is an AuthHub with a label. Ports are fixed per app rather than auto-assigned so a redirect between two of them (moderator -> auth) always lands in the same place, and --strictPort fails loudly on a collision instead of drifting to the next free port. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(dev-server): keep the dev server alive across branch switches Switching branches in place meant deleting .next and reinstalling, or the server would hang. The daemon now watches HEAD and handles the switch. It does NOT kill the dev server to do it. Measured on this repo, same routes, same machine: cold start after a kill ......... /models 42.6s left running across a switch .... /models 7.7-9.2s, /images 1.0-1.6s The running process keeps its in-memory module graph, so only what actually changed recompiles, and routes it isn't asked for stay warm. A restart is forced only when pnpm-lock.yaml or schema.full.prisma changes, since node_modules and the generated Prisma client can't be swapped under a live process. KILL_ON_BRANCH_SWITCH=true restores the old behaviour. Also adds: - PREWARM_ROUTES, compiled in the background on start and after each switch, so the ~45s first-route compile lands on the daemon rather than on your next click. Detail pages are separate routes from their list pages and need their own entry. - PER_BRANCH_DIST_DIR (default off) via distDir in next.config.mjs, with LRU eviction against both a count and a size budget. Off because a shared .next lets unchanged modules stay valid across branches, and each per-branch dir grows to ~4GB. - TUI session switching (s/Tab) plus a session counter, for running several worktrees side by side. - defender-exclusions.ps1: Windows real-time scanning over ~210k node_modules files is a large chunk of the cost on this machine. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(dev-server): start a session from its own worktree's .env The env path fell back to the daemon's project root, so every session in every worktree ran on whichever tree happened to launch the daemon. A branch that adds a flag or points at a different database silently ran without it, and a NODE_OPTIONS set in a worktree .env never reached the process at all. Prefer the session worktree's .env, falling back to the project root only when the worktree has none. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(dev-server): stop orphaning spokes, and say which .env a session loaded Review findings on this branch. Spoke apps were started but never stopped: /shutdown, SIGINT and SIGTERM each tore down sessions, the rgb proxy and the auth hub, and left the spokes running. Since spokes bind with --strictPort, one survivor makes the next start of that app fail EADDRINUSE against a process the daemon no longer tracks. The env-path change in 04114ab9 picks between two files that are not merged, so it can point a session at a different database with no receipt. Log the chosen path at start and return it as `envPath` in session status. Docs corrected where they described behaviour the code does not have — the worktree/root .env precedence, PREWARM_ROUTES having a default (it is empty, and prewarm-on-start needs HEALTH_CHECK_URL), and skill .env edits reaching a session that a branch switch deliberately does not restart. .env.example gains the ten knobs it was missing, and defender-exclusions.ps1 takes -ReposRoot instead of hardcoding one machine's layout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 17:55:29 -06:00
// Per-branch build dir. Turbopack's dev filesystem cache (~8GB) is invalidated
// wholesale by an in-place branch switch, so the dev daemon points each branch at
// its own dir and keeps them warm instead of purging. Unset -> stock `.next`.
distDir: process.env.NEXT_DIST_DIR || '.next',
allowedDevOrigins: ['civitai-dev.green', 'civitai-dev.blue', 'civitai-dev.red'],
// Retained for the `next build --webpack` fallback path; ignored under Turbopack.
feat: Add paid challenge judging (#2031) * feat: Add paid challenge judging — pay Buzz to guarantee entry review Users can pay Buzz to guarantee their challenge entries get reviewed by the AI judge instead of waiting for random selection. Adds reviewCost field to Challenge model, requestReview mutation for buzz payment + tag assignment, getUserUnjudgedEntries query, guarantee checkbox in submit modal, post-submission review UI on detail page, and removes the per-user scoring cap for paid (reviewMeTagId) entries. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: Add UI preview screenshots for paid challenge judging Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: Add flat-rate review option for paid challenge judging Extends the paid review system to support both per-entry and flat-rate pricing. Flat rate charges once and covers all current + future entries. UI updates include a review cost type selector, segmented progress bar with hover animation, spotlight card effect, and HoverCard→Popover swap. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: Address PR review issues for paid challenge judging - Use pendingCount instead of unreviewedCount for guarantee cost display to avoid inflating the Buzz price with already-queued entries - Fix test assertions to check createBuzzTransactionMany (per-entry transactions) instead of createBuzzTransaction (single transaction) - Invalidate getUserUnjudgedEntries after submit to prevent stale data Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: Revert guaranteeCost to use unreviewedCount (include queued entries) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: Move stories file out of pages/ to fix build error Next.js requires all files in pages/ to have a default React component export. Moved EligibleModels.stories.tsx to src/components/Challenge/. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: manuelurenah <manuel.ureh@hotmail.com>
2026-02-13 12:26:46 -07:00
webpack: (config) => {
config.ignoreWarnings = [
{ module: /require-in-the-middle/ },
{ module: /@opentelemetry\/instrumentation/ },
];
feat: Add paid challenge judging (#2031) * feat: Add paid challenge judging — pay Buzz to guarantee entry review Users can pay Buzz to guarantee their challenge entries get reviewed by the AI judge instead of waiting for random selection. Adds reviewCost field to Challenge model, requestReview mutation for buzz payment + tag assignment, getUserUnjudgedEntries query, guarantee checkbox in submit modal, post-submission review UI on detail page, and removes the per-user scoring cap for paid (reviewMeTagId) entries. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: Add UI preview screenshots for paid challenge judging Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: Add flat-rate review option for paid challenge judging Extends the paid review system to support both per-entry and flat-rate pricing. Flat rate charges once and covers all current + future entries. UI updates include a review cost type selector, segmented progress bar with hover animation, spotlight card effect, and HoverCard→Popover swap. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: Address PR review issues for paid challenge judging - Use pendingCount instead of unreviewedCount for guarantee cost display to avoid inflating the Buzz price with already-queued entries - Fix test assertions to check createBuzzTransactionMany (per-entry transactions) instead of createBuzzTransaction (single transaction) - Invalidate getUserUnjudgedEntries after submit to prevent stale data Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: Revert guaranteeCost to use unreviewedCount (include queued entries) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: Move stories file out of pages/ to fix build error Next.js requires all files in pages/ to have a default React component export. Moved EligibleModels.stories.tsx to src/components/Challenge/. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: manuelurenah <manuel.ureh@hotmail.com>
2026-02-13 12:26:46 -07:00
return config;
},
reactStrictMode: true,
feat(build): ship server source maps for prod CPU-profile de-minification (#2460) * feat(build): ship server source maps for prod CPU-profile de-minification Prod pods capture V8 .cpuprofiles to find event-loop blockers, but the standalone image shipped zero server .js.map files, so frames were minified and unnameable (e.g. `p @ src_17njnbr._.js:0`). Build change (Turbopack / Next 16): - Under Turbopack the only source-map lever is `turbopackSourceMaps`, whose build-time default IS `productionBrowserSourceMaps` (already true). So server chunk maps (.next/server/**/*.js.map) are already emitted at build time; `experimental.serverSourceMaps` is webpack-only and ignored by Turbopack. Reworded next.config.mjs comment to state this accurately. - output:'standalone' traces via @vercel/nft, which follows import/require/fs and does NOT copy sibling .map files, so the maps were dropped from the image. Dockerfile now stages just the server-chunk maps (structure-preserving tar) in the builder and overlays them onto .next/server in the runner. Maps are inert at runtime (loaded only by an inspector/stack resolver) -> no request-path perf cost. Cost is build time + ~order-of-tens-of-MB image size. Resolver tool: - scripts/resolve-cpuprofile.mjs maps each frame's (chunk.js, line, col) back to original {source, line, name} via the `source-map` package, ranks hottest self-time leaves, and reconstructs the longest-synchronous-block stack, named. - Verified end-to-end against a real esbuild-minified bundle + map (synthetic profile frames resolved back to original fn names + .ts locations). Prod proof awaits the next map-enabled deploy + a fresh capture. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(build): publish server maps as on-demand artifact, not in runtime image The previous approach overlaid all server .js.map files into the runtime image, adding ~761 MB to every prod pod (11,305 maps) for a debug aid that is only needed OFFLINE by the cpuprofile resolver. Instead: - Dockerfile: revert the runtime-stage map COPY (runtime image is lean again). Add a `FROM scratch AS maps` target holding ONLY the staged server maps; it shares every builder layer (cache hit) and is published separately. - resolve-cpuprofile.mjs: add `--image <tag-or-ref>` mode that fetches that build's maps from ghcr.io/civitai/civitai-web-maps:<tag> via `crane export` (falls back to `oras pull`), then resolves. Keeps the local `--maps <dir>` mode. Maps are keyed by the exact image tag so a profile from image X resolves against X's maps. The Tekton build pipeline (datapacket-talos) publishes the `maps` target to the sibling repo after the main build+push, reusing the same ghcr creds, as a non-fatal step. Verified end-to-end: pushed a synthetic maps image to a local registry, then `--image` fetched + extracted it and de-minified frames to original src/*.ts functions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(source-maps): correct stale next.config comment + clean up resolver temp dir on error Audit follow-ups on the artifact-based source-maps PR: - next.config.mjs: the comment still described the pre-revision behavior (maps baked into the runtime image). Reworded to reflect the on-demand maps artifact. - resolve-cpuprofile.mjs: wrap the post-fetch body in try/finally so the fetched maps temp dir (hundreds of MB) is always cleaned up, even if resolution throws (previously leaked on the --image error path). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 08:55:04 -05:00
// Source maps for prod CPU-profile de-minification.
//
// Under Turbopack (our prod bundler, Next 16), the ONLY source-map lever is the
// experimental `turbopackSourceMaps` flag, whose build-time default IS
// `productionBrowserSourceMaps`. So setting this `true` turns on map emission for
// BOTH client (`.next/static/**/*.js.map`) and server (`.next/server/**/*.js.map`)
// chunks. Turbopack ignores `experimental.serverSourceMaps` (webpack-only) — that
// flag below only matters for the `next build --webpack` fallback path.
//
// Maps are inert at runtime: the Node server never loads a `.js.map` unless an
// inspector / error-stack resolver reads it, so there is NO
// request-path perf cost.
feat(build): ship server source maps for prod CPU-profile de-minification (#2460) * feat(build): ship server source maps for prod CPU-profile de-minification Prod pods capture V8 .cpuprofiles to find event-loop blockers, but the standalone image shipped zero server .js.map files, so frames were minified and unnameable (e.g. `p @ src_17njnbr._.js:0`). Build change (Turbopack / Next 16): - Under Turbopack the only source-map lever is `turbopackSourceMaps`, whose build-time default IS `productionBrowserSourceMaps` (already true). So server chunk maps (.next/server/**/*.js.map) are already emitted at build time; `experimental.serverSourceMaps` is webpack-only and ignored by Turbopack. Reworded next.config.mjs comment to state this accurately. - output:'standalone' traces via @vercel/nft, which follows import/require/fs and does NOT copy sibling .map files, so the maps were dropped from the image. Dockerfile now stages just the server-chunk maps (structure-preserving tar) in the builder and overlays them onto .next/server in the runner. Maps are inert at runtime (loaded only by an inspector/stack resolver) -> no request-path perf cost. Cost is build time + ~order-of-tens-of-MB image size. Resolver tool: - scripts/resolve-cpuprofile.mjs maps each frame's (chunk.js, line, col) back to original {source, line, name} via the `source-map` package, ranks hottest self-time leaves, and reconstructs the longest-synchronous-block stack, named. - Verified end-to-end against a real esbuild-minified bundle + map (synthetic profile frames resolved back to original fn names + .ts locations). Prod proof awaits the next map-enabled deploy + a fresh capture. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(build): publish server maps as on-demand artifact, not in runtime image The previous approach overlaid all server .js.map files into the runtime image, adding ~761 MB to every prod pod (11,305 maps) for a debug aid that is only needed OFFLINE by the cpuprofile resolver. Instead: - Dockerfile: revert the runtime-stage map COPY (runtime image is lean again). Add a `FROM scratch AS maps` target holding ONLY the staged server maps; it shares every builder layer (cache hit) and is published separately. - resolve-cpuprofile.mjs: add `--image <tag-or-ref>` mode that fetches that build's maps from ghcr.io/civitai/civitai-web-maps:<tag> via `crane export` (falls back to `oras pull`), then resolves. Keeps the local `--maps <dir>` mode. Maps are keyed by the exact image tag so a profile from image X resolves against X's maps. The Tekton build pipeline (datapacket-talos) publishes the `maps` target to the sibling repo after the main build+push, reusing the same ghcr creds, as a non-fatal step. Verified end-to-end: pushed a synthetic maps image to a local registry, then `--image` fetched + extracted it and de-minified frames to original src/*.ts functions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(source-maps): correct stale next.config comment + clean up resolver temp dir on error Audit follow-ups on the artifact-based source-maps PR: - next.config.mjs: the comment still described the pre-revision behavior (maps baked into the runtime image). Reworded to reflect the on-demand maps artifact. - resolve-cpuprofile.mjs: wrap the post-fetch body in try/finally so the fetched maps temp dir (hundreds of MB) is always cleaned up, even if resolution throws (previously leaked on the --image error path). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 08:55:04 -05:00
// They are NOT served to browsers for server chunks (those live in `.next/server`,
// which is not a static-served directory). Cost is build time + image size only.
//
// IMPORTANT: `output:'standalone'` traces required files via @vercel/nft, which
// follows `import`/`require`/`fs` — it does NOT trace sibling `.js.map` files, so
// the server maps are emitted to `.next/server` but DROPPED from `.next/standalone`.
// The runtime image does NOT ship these maps (the RUNNER stage copies only
// standalone + static). The build instead publishes them as a separate
// `civitai-web-maps:<tag>` artifact (Dockerfile `maps` target + the pipeline),
// fetched on demand by `scripts/resolve-cpuprofile.mjs --image <tag>` to
// de-minify a captured profile — keeping the runtime image lean.
productionBrowserSourceMaps: true,
// Next.js i18n docs: https://nextjs.org/docs/advanced-features/i18n-routing
i18n: {
locales: ['en'],
defaultLocale: 'en',
},
generateEtags: false,
compress: false,
images: {
remotePatterns: [
{ hostname: 's3.us-west-1.wasabisys.com' },
{ hostname: 'model-share.s3.us-west-1.wasabisys.com' },
{ hostname: 'civitai-prod.s3.us-west-1.wasabisys.com' },
{ hostname: 'civitai-dev.s3.us-west-1.wasabisys.com' },
{ hostname: 'image.civitai.com' },
],
// domains: [
// 's3.us-west-1.wasabisys.com',
// 'model-share.s3.us-west-1.wasabisys.com',
// 'civitai-prod.s3.us-west-1.wasabisys.com',
// 'civitai-dev.s3.us-west-1.wasabisys.com',
// 'image.civitai.com',
// ],
},
compiler:
process.env.NODE_ENV === 'production'
? {
reactRemoveProperties: { properties: ['^data-testid$'] },
// removeConsole: true,
}
: {},
transpilePackages: [
feat(generation): form-graph client forms, handler lane, and seedance Three lanes on top of the ported graphs, all review-driven this session: CLIENT (`src/components/form-graph/generation/`): - BaseGenerationForm owns the store over the composed root (FormProvider), renders the workflow picker and the submit footer, and switches between the per-output forms on the graph's own `output` computed. - Image/VideoGenerationForm in the generation_v2 GenerationForm idiom: one `<Controller graph={imageHub|videoHub} name=...>` per field — typed from that hub's registry (computeds and branch tags included via the state type) — wired to the REAL generation_v2 inputs: BaseModelInput, ResourceSelect* with a ported VersionGroupSelector, ImageUploadMultipleInput, VideoInput, GenerationTextEditor (snippets + trigger words from editor meta), ControlNetsInput, AspectRatioInput, Priority/OutputFormat, ActiveWildcards with the ported add/remove flow, ResourceAlerts via MultiController, and v2's wan version picker (sets ecosystem; the tag follows). - Demo at `/form-graph` (standalone; mounts ResourceDataProvider itself). `/data-graph-v2` now renders the packaged GenerationFormV2 — its hand-assembled provider stack was missing GenerationProvider and ResourceDataProvider and threw for any signed-in visitor. SERVER (`src/server/services/orchestrator/form-graph/`): - Per-family handlers (ltx, wan, seedance, stable-diffusion, z-image, chroma) transcribed from the data-graph handlers but importing only ported modules, plus a dispatcher (`createFormGraphStepInput`) with v1's seed normalization and enhanced-compatibility engine rewrite; unported ecosystems are a loud error. - The acceptance gate: a differential test feeds BOTH dispatchers the same parsed data and asserts identical @civitai/client steps — 29 cases over draft batching, comfy routing, controlnets, every wan version (both flipt states of 2.2 multi-step), distilled LTX, prompt-enhancer $refs. GRAPHS: - video/seedance.graph.ts (no resources, no negative prompt; per-version resolution/duration ceilings) — unblocked the video matrix's hidden-ecosystem gate coverage. - `resolveCompatibleEcosystem`: v1's workflow→ecosystem sync effect as a pure redirect in both hubs' input transforms (txt2img + WanVideo30 parses as SD1, pinned), with the workflow-group override guard. - LTX gets the model-wins split (`effectiveEcosystem` emit + `checkpointDef({ modelWins: true })`); wan/ltx `shift`/`numFrames` mirror v1's REJECTING inputs; full v1 picker meta everywhere (controlNet groups, sampler presets, gate payloads, editor meta with the editor-vs-registers-target split for ZImage Base). - Family dispatches are switch statements returning member graphs directly (record-less branches). Toolchain: the form-graph override is `file:` (Turbopack cannot read outside the workspace root, which `link:` always violates) and the package is in `transpilePackages`. Requires local form-graph at 066377d — publish + swap the override before any push or PR. Verified: 4,757 differential cases, typecheck and lint clean; full unit suite 29,056 passed with only the 10 pre-existing Windows-portability failures (identical on clean main). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 19:59:26 -06:00
// pnpm link: to the local checkout during the data-graph port — Turbopack
// won't resolve the out-of-root symlink without transpiling it
'form-graph',
'superjson',
'@civitai/db-schema',
'@civitai/db',
'@civitai/db-queries',
'@civitai/shared',
'@civitai/buzz',
'@civitai/redis',
'@civitai/clickhouse',
'@civitai/axiom',
'@civitai/flipt',
'@civitai/telemetry',
'@civitai/auth',
feat(notifications): fold notification domain into apps/notifications + @civitai/notifications Move the notification domain out of the monolith (and the external notification-server repo) into an in-repo app + shared package, and cut the monolith off the notification DB entirely. - @civitai/notifications (package): the stable producer/reader seam — zod schema contracts, NotificationCategory/signal constants (now the single source of truth; src/server/common/enums re-exports it), and an HTTP client (create/bulk/query/count/markRead/exists/cleanup) that POSTs to the app. - apps/notifications (app): Fastify producer + read API (shared-secret auth, internal ingress) and the fan-out poll worker, on the shared @civitai/* clients. Owns the settings-filter, base-row read, unread-count cache, replication-lag routing, and mark-read retry/queue. - Monolith cutover: notification.service, send-notifications, club.service, reaction.controller, and the admin cleanup endpoint call the package directly; deleted notifDb.ts, the notif prom gauges, getNotifDbWithoutLag, and the dead notification-cache; getClient dropped its notification instances; NOTIFICATION_DB_URL/_REPLICA_URL made optional. - @civitai/db: extracted generic createPool/createClients (getClient now wraps them) and a redis-agnostic createLagTracker (monolith db-lag-helpers + the app both build on it). Verified: monolith + app typecheck, 28 unit tests, two code reviews, and a live smoke of the producer/read path scoped to user 5. NOT a merge-and-deploy — this is a hard cutover; follow docs/plans/notifications-predeploy-checklist.md (app must be deployed with the worker gated off and NOTIFICATIONS_ENDPOINT set before the monolith ships). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 17:37:08 -06:00
'@civitai/notifications',
'@civitai/moderation',
],
Migrate to Next.js 15 Bump next 14.2.28 -> 15.5.19 (Pages Router app). Also bumps eslint-config-next and @next/eslint-plugin-next to 15.5.19, and eslint 8.22.0 -> 8.57.1 (Next 15's plugin rules use context.filename, added in ESLint 8.40). Config / API changes required by Next 15: - next.config.mjs: experimental.serverComponentsExternalPackages -> top-level serverExternalPackages; removed the now-stable experimental.instrumentationHook flag. - .eslintrc.js: added settings.next.rootDir so the Next ESLint plugin can locate the pages dir (otherwise the rules crash on load). - bot-detection.middleware.ts: dropped request.ip (NextRequest.ip removed in 15); cf-connecting-ip / x-forwarded-for fallbacks remain. - api/trpc/[trpc].ts: assert the default export as NextApiHandler. Next 15's route-type validator rejected it because withAxiom's NextConfig overload (declared first) structurally matches an arrow function. - Moved api/auth/oauth/__tests__/token.test.ts -> server/oauth/__tests__/token-endpoint.test.ts. Next 15 type-validates every file under pages/api as a route; the colocated test had no default export. Tests still pass (8/8). - Deleted the unused src/app/layout.tsx stub (whole app is Pages Router); removes the spurious "i18n unsupported in App Router" build warning. Build memory + Windows local-build helpers: - package.json build:dev / build:analyze heap 8192 -> 16384 (the app needs >8GB to compile). - scripts/graceful-fs-patch.cjs: optional NODE_OPTIONS --require preload to mitigate Windows EMFILE during local builds (no-op on Linux/CI). Validated: typecheck clean; lint at parity with main; build compiles, type-checks, passes route validation and page-data collection. The full production build's "Collecting build traces" step is FD-bound on Windows (EMFILE) and should be run on CI/Linux. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:08:40 -06:00
// Renamed from experimental.serverComponentsExternalPackages → top-level serverExternalPackages in Next 15
serverExternalPackages: [
'redis',
'@redis/client',
'@redis/bloom',
'@redis/json',
'@redis/search',
'@redis/time-series',
'@opentelemetry/sdk-node',
'@opentelemetry/instrumentation',
'@opentelemetry/instrumentation-http',
'@opentelemetry/instrumentation-redis',
'@prisma/instrumentation',
// Bundling this gives the app layer its own copy of the Prisma runtime while
// `dbRead`/`dbWrite` (reached through the transpiled `@civitai/db-schema`) hold a
// second one. `$queryRaw` identifies its template argument with `instanceof Sql`,
// so a `Prisma.join()` built by the other copy fails that check and is bound as a
// plain value -> `operator does not exist: integer = jsonb`.
'@prisma/client',
feat(telemetry): emit structured logs over OTLP alongside Axiom (dark by default) (#3736) * feat(telemetry): emit structured logs over OTLP alongside Axiom (dark by default) Adds a third sink to the single `logToAxiom` chokepoint: the same record is handed to the OpenTelemetry Logs API in addition to the existing stderr write and the Axiom ingest. Neither existing sink changes behaviour. Ships INERT: `emitOtelLog` no-ops unless `OTEL_LOGS_ENABLED === 'true'`, which nothing sets. Merging this changes nothing observable; enabling it is a config change per workload, not a code deploy. The bridge (`@civitai/telemetry/otel-logs`) - Injected into `createAxiomLogger(overrides, deps)` rather than imported by it, so `@civitai/axiom` keeps no OpenTelemetry dependency and no OpenTelemetry semantics. Its own env.ts header already prescribed this shape. - ONE serialization, N sinks: the line is built once and the OTLP body is that exact string, so a query written against either path matches the other and the hot path pays for one JSON.stringify. This refactors the three-branch stringify catch ladder from three console.error calls to assign-once/write-once; a test pins each branch. - Severity: `type` is a hybrid field (mostly a severity word, sometimes an event name, sometimes absent), so only the four clear cases map. Everything else is UNSPECIFIED(0) with severityText OMITTED — downstream level resolution prefers text over number, so an invented string would outrank a correct number. The raw value always survives as the `civitai.type` attribute, so nothing is lost. A Map, not an object literal: an object lookup with a caller-supplied key fails open on inherited keys like `toString`. - Attributes are a CLOSED set. Spreading the payload would reproduce the schema explosion `safeError` exists to prevent. The payload is already in the body. - The logger is resolved LAZILY per emit and only once a provider is registered. `logs.getLogger()` called before registration returns a proxy that is never delegated and stays silent forever, with no error — a module-scope getLogger is the bug, not a style choice. - Counters make a zero legible: a bare "no records arrived" cannot distinguish a disabled flag from an unregistered provider from a broken emit. Emitted and skipped{disabled|no_provider|emit_threw} separate them. - Emission can never throw to the caller: logToAxiom is awaited on hot paths. SDK correctness - Batch processor: `exportTimeoutMillis` made explicit (env-tunable) because it is the one value coupled to something outside the process — a hung exporter must not hold the shutdown flush past the termination grace period. The default is sized for the shortest grace period any of these workloads runs with. - Shutdown was SIGTERM-only and awaited NEITHER promise, so the final batch raced termination — immaterial at one record per boot, a real loss path once it carries traffic. Now SIGTERM + SIGINT, awaited, idempotent across a second signal, and allSettled so one failing exporter cannot block the other flush. - Provider construction moved into the package so the `traceBased` hazard has one home and a regression test. `traceBased: true` would drop every record emitted inside an unsampled span; with head sampling below 1.0 that is most in-request records, silently. It is inert at SDK defaults and must stay that way. - `serverExternalPackages` gains the four logs-pipeline packages so the Logs API's globalThis provider registry is written and read by one module instance. `@opentelemetry/resources` and `semantic-conventions` are deliberately excluded — the web tracing SDK reaches them from the client graph. Tests 41 tests across the two package suites, using the real SDK — no module mock, which would make the assertions vacuous. Each guard was watched to fail for its own reason: 14 mutations, each killed by the specifically-named test that owns it. The bundling failure is structurally invisible to Vitest (it aliases the package to source and never runs the bundler). The runtime check for it is skipped{reason="no_provider"} being non-zero in a real process; a build-time module-identity scan is the follow-up. * fix(telemetry): drop the serverExternalPackages additions from this PR The four logs-pipeline entries (@opentelemetry/api, /api-logs, /sdk-logs, /exporter-logs-otlp-proto) failed the image build. That change needs a full `next build` as its gate, which makes it its own PR rather than a rider on the bridge. The bridge does not depend on it. It resolves the Logs API lazily, per emit, so a second bundled module copy is survivable rather than fatal, and skipped{reason="no_provider"} is the runtime signal if one ever shows up. The list is now byte-identical to main; only a comment recording this is added. * fix(telemetry): register the OTel sink from the server entry, not the shared shim `src/server/logging/client.ts` is in the CLIENT bundle. It reads as server-only and is not: _app.tsx -> server/services/system-cache -> server/redis/fail-open-log reaches it, so a module-scope import there is bundled for the browser too. Importing the bridge from it pulled prom-client into the browser graph and failed the image build with `Can't resolve 'cluster' / 'fs' / 'v8'`. Nothing local caught it: typecheck, eslint, and both vitest projects were green, because only the bundler walks that edge. The sink is now REGISTERED rather than imported — instrumentation.node.ts, which only ever runs on the server, calls setStructuredLogSink(emitOtelLog) at boot. The shim keeps a type-only import, which is erased and adds no runtime edge. This works because deps.emitLog is read per call rather than captured at construction. That was incidental before and is now load-bearing, so a test pins it: registering a sink AFTER the logger is built must still fire. Mutating the factory to capture at construction fails that test and only that test.
2026-08-10 10:41:56 -05:00
// NOTE: the logs-pipeline packages (@opentelemetry/api, /api-logs, /sdk-logs,
// /exporter-logs-otlp-proto) are deliberately NOT externalized here. Adding them
// fails the image build, so it needs to be its own change with a full build as its
// gate. The logs bridge does not depend on it: it binds to the Logs API lazily, so
// a second bundled module copy is survivable, and `no_provider` on its skip counter
// is the runtime signal if one ever appears.
'@pyroscope/nodejs',
'@datadog/pprof',
Migrate to Next.js 15 Bump next 14.2.28 -> 15.5.19 (Pages Router app). Also bumps eslint-config-next and @next/eslint-plugin-next to 15.5.19, and eslint 8.22.0 -> 8.57.1 (Next 15's plugin rules use context.filename, added in ESLint 8.40). Config / API changes required by Next 15: - next.config.mjs: experimental.serverComponentsExternalPackages -> top-level serverExternalPackages; removed the now-stable experimental.instrumentationHook flag. - .eslintrc.js: added settings.next.rootDir so the Next ESLint plugin can locate the pages dir (otherwise the rules crash on load). - bot-detection.middleware.ts: dropped request.ip (NextRequest.ip removed in 15); cf-connecting-ip / x-forwarded-for fallbacks remain. - api/trpc/[trpc].ts: assert the default export as NextApiHandler. Next 15's route-type validator rejected it because withAxiom's NextConfig overload (declared first) structurally matches an arrow function. - Moved api/auth/oauth/__tests__/token.test.ts -> server/oauth/__tests__/token-endpoint.test.ts. Next 15 type-validates every file under pages/api as a route; the colocated test had no default export. Tests still pass (8/8). - Deleted the unused src/app/layout.tsx stub (whole app is Pages Router); removes the spurious "i18n unsupported in App Router" build warning. Build memory + Windows local-build helpers: - package.json build:dev / build:analyze heap 8192 -> 16384 (the app needs >8GB to compile). - scripts/graceful-fs-patch.cjs: optional NODE_OPTIONS --require preload to mitigate Windows EMFILE during local builds (no-op on Linux/CI). Validated: typecheck clean; lint at parity with main; build compiles, type-checks, passes route validation and page-data collection. The full production build's "Collecting build traces" step is FD-bound on Windows (EMFILE) and should be run on CI/Linux. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:08:40 -06:00
],
// Several entry points read markdown from src/static-content at runtime via fs
// (dynamic string paths that @vercel/nft can't trace). With output:'standalone'
// the build only ships traced files, so without these explicit includes the
// markdown is missing in the deployed image and every read hits ENOENT ->
// 500/404 (works locally because the full source tree is present). Top-level as
// of Next 15 (lived under `experimental` on Next 14). Keyed by each read site.
outputFileTracingIncludes: {
'/safety': ['./src/static-content/**/*'],
'/region-blocked': ['./src/static-content/**/*'],
'/content/[[...slug]]': ['./src/static-content/**/*'],
fix(deps): Next 16.3.0 → 16.3.1, and make the compiled-branch gate hard (#3983) (#4075) * fix(deps): Next 16.3.0 -> 16.3.1, and make the compiled-branch gate hard (#3983) This is the fix for #3983. The defect was in the bundler, not in our source. Turbopack's value analyzer in 16.3.0 models a bare `return someAsyncFn()` tail call as `Promise<Promise<T>>`. That is always truthy, so a caller that `await`s it is analysed as always-true and every statement after the resulting conditional is eliminated as dead code. `isAppListingsEnabled` ends in exactly that shape, which is why `resolveStoreVisibilityScopeUninstrumented` lost two of its three returns, fell off the end, and produced `undefined` for every non-privileged caller — served as the whole catalog on one read path (`?? 'full'`) and as an empty store on the other (`?? 'none'`). Upstream: vercel/next.js#96601 "[turbopack] Collapse nested promises in the analyzer", backported as #96675, shipped in 16.3.1. MEASURED, not inferred. Two production builds of THIS commit on one machine, same Node 24.19.0, differing only in the pinned Next: 16.3.0 async function S(e){if(await p(e))return"full"} 16.3.1 async function w(e){return await c(e)?"full":await y(e)?"public-external":"none"} Both read out of the emitted `.next/server` chunks by source-map attribution and identified by their source neighbour `STORE_SCOPE_FLAGS`, never by minified name. Note the fixed form is a TERNARY — `grep 'return"public-external"'` returns zero on the FIXED build too, which is why the gate reads source maps. `package.json` already allowed 16.3.1 (`^16.3.0`); only the lockfile pinned 16.3.0, so the substance here is the lockfile. The floor is raised to `^16.3.1` so a fresh resolution cannot land back on the broken compiler. `patches/next@…` is renamed and its `patchedDependencies` key updated — that patch is the unrelated libvips/SVG one-liner (vercel/next.js#96681), it still applies cleanly, and 16.3.1 still does not carry the loader entry upstream, so it stays. `--warn-only` is removed from `scripts/assert-compiled-branches.mjs` in the same commit. It existed only because the 16.3.0 build genuinely violated the gate, and a permanently-red gate trains everyone to click through. Keeping the bump and the strictness atomic means the gate's strictness always matches the toolchain: a revert of the bump turns it red instead of silently passing. Verified on this commit: - gate exit 0 (hard, no --warn-only) against the 16.3.1 build - gate exit 1 against a 16.3.0 build of the same tree — watched red - `scripts/ci/assert-next-svg-patch-applied.mjs` OK on both installed copies - unit suite 1149 files / 18,123 tests passed, 0 failed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(build): ship @swc/helpers' module-sync branch into the standalone image (#3983) The bump built clean, passed every source-level gate — unit suites, typecheck, ESLint + Prettier, schema drift, the event-engine pin, and the now-hard compiled-branch gate — and the container could not boot: Error: Cannot find module '.../@swc/helpers/esm/_interop_require_default.js' at ... next/dist/server/require-hook.js code: 'MODULE_NOT_FOUND' Same shape as the defect this PR exists to fix: a correct source tree producing a broken artefact, invisible to everything that reads source. ROOT CAUSE, measured on the published artefact rather than inferred. `output: 'standalone'` does not ship node_modules; it ships the subset @vercel/nft traced. nft resolves a bare specifier under the `require`/`default` conditions. Node (>= 22.10) additionally honours `module-sync` for a CJS `require`. When a package's `exports` map points those two at different files, the build traces one and the running process asks for the other. next/dist/shared/lib/constants.js does `require('@swc/helpers/_/_interop_require_default')`, reached from the generated server.js via `next` -> config.js -> constants.js, i.e. before any application code. The relevant delta is not next itself but next's own dependency: next 16.3.0 next 16.3.1 @swc/helpers 0.5.15 0.5.23 ./_/_interop_require_default {import,default} {module-sync,webpack,import,default} require.resolve() under CJS cjs/...cjs esm/...js Both resolutions were RUN, not reasoned about. nft still traced the cjs file, so the published image carried that package as exactly cjs/_interop_require_default.cjs, cjs/_interop_require_wildcard.cjs and package.json — no esm/ directory at all. Adding only the missing esm/ directory to that exact image, nothing else changed, boots it: "Next.js 16.3.1 ... Ready". FIX. `outputFileTracingIncludes` force-includes BOTH condition branches of EVERY installed @swc/helpers copy — not the one file missing today, because which helper Next requires and which branch each resolver picks are upstream details that move. Globs are version- and hash-agnostic (`@swc+helpers@*`), plus a flat form for a hoisted layout. ~950 KB per copy. Verified on a local production build of this commit: both copies land in .next/standalone with complete esm/ (108 and 105 files) and cjs/, and next's virtual store links the 0.5.23 copy. Attached to three existing API-route keys rather than a `'**'` key. copyTracedFiles unions every entry's traced set into the single .next/standalone node_modules, so one entry carrying it is enough, while `'**'` would make all 572 entries read/parse/rewrite their .nft.json concurrently — 826 MB of JSON in one Promise.all — on a build already tuned against OOM. GATE, because a glob is a silent no-op once it stops matching. scripts/ci/assert-standalone-boot-graph.mjs runs in the Dockerfile's RUNNER stage: the first gate in that file to run against the runtime filesystem rather than the build tree, and the only one that can see this class of defect. It reads the GENERATED server.js for the specifiers that process requires at module scope and loads them in a child rooted at the shipped tree — no package, version, virtual-store path or patch hash hardcoded, so it keeps covering this after the next bump. Exit 2, never 0, when it cannot observe its input. It must run in the runner and not the builder: /app there is byte-for-byte what ships, whereas the builder's complete node_modules sits above .next/standalone on the resolution path and can satisfy a require the image cannot. Watched red and green on real artefacts, not only fixtures: - exit 1 with this exact MODULE_NOT_FOUND against the published broken image; - exit 0 against the same image with only the esm/ directory added; - exit 0 against the local production build of this commit, isolated from any parent node_modules; - exit 1 again after deleting exactly esm/_interop_require_default.js from that same local build. src/tests/build/standalone-boot-graph.test.ts pins the MECHANISM (a module-sync/default split with only the default branch present) rather than the package, and all 5 of its cases were watched to fail against a neutered gate. NOT VERIFIED. Nothing about production: this is only true of production once it merges, is promoted main -> release, is built and is serving. The gate covers the ENTRYPOINT's require graph; route chunks load lazily, so a condition mismatch reachable only from a route would still surface at request time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore: retrigger preview The previous preview run (pr-preview-4075-b2wnw) never scheduled: its build-image and typecheck pods sat Pending with ExceededNodeResources for 82 minutes and the run hit the 1h30m PipelineRunTimeout. No verdict was produced — this was build-pool capacity contention, not a code failure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 15:07:05 -05:00
'/api/trpc/[trpc]': ['./src/static-content/**/*', ...swcHelpersRuntimeFiles],
'/api/v1/content/[[...slug]]': ['./src/static-content/**/*', ...swcHelpersRuntimeFiles],
fix(og): ship @vercel/og in standalone so /api/og stops 500ing (#2509) * fix(og): ship @vercel/og in standalone so /api/og stops 500ing /api/og renders via next/og's `ImageResponse`, which on the nodejs runtime lazily require()s `next/dist/compiled/@vercel/og/index.node.js` (+ its resvg/yoga WASM + fonts). @vercel/nft can't follow that dynamic require, so with output:'standalone' the file is traced OUT of the image — every origin (cache-miss) /api/og render then throws `Cannot find module ... index.node.js` → res.status(500). Impact: the dominant app-emitted 500 source on dp-prod (~1.9/s, ~74% of the app-500 floor) since the Next 16.2.7 upgrade. Cloudflare edge-caching of OG images masked it for popular entities, but cache-bypass returns 500 for every type (verified: model/image/post/article). Degrades social-share/OG previews for any uncached entity. Fix: add `/api/og` to outputFileTracingIncludes with the compiled @vercel/og dir (entry + WASM + fonts). Two version-agnostic globs (no hardcoded next@hash) to be robust to pnpm symlink resolution. Verification: requires a standalone build — confirm on the PR preview that a cache-busted /api/og?type=model&id=<id> returns 200 (currently 500 at origin). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(og): give FallbackCard logo explicit width so the fallback can't 500 Verifying the standalone-packaging fix on a preview exposed a SECOND, pre- existing /api/og 500 that the module-not-found error had masked: the catch-path `FallbackCard` renders the wordmark logo as `<img height={60}>` with NO width. satori then throws "Image size cannot be determined. Please provide the width and height of the image." — so the fallback ImageResponse itself throws and the outer catch returns 500. This means ANY missing entity (e.g. a deleted/invalid id from a social crawler) or any OgCard render error hit FallbackCard and STILL 500'd, even with the module present. Verified on the pr-2509 preview: existing model/image → 200, but missing post/article/bounty/challenge → 500 with this exact error. Fix: logo_dark_mode.png is 142x30 (≈4.733), so set width={284} at height={60} to preserve aspect and satisfy satori. Now the fallback renders instead of throwing. (Other cards already set both dims; only FallbackCard omitted width.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 11:25:02 -05:00
// /api/og uses next/og's `ImageResponse`, which on the nodejs runtime
// lazily require()s `next/dist/compiled/@vercel/og/index.node.js` (plus its
// resvg/yoga WASM + fonts). @vercel/nft cannot follow that dynamic require,
// so with output:'standalone' the file is DROPPED from the image and every
// origin (cache-miss) /api/og render throws `Cannot find module ...
// index.node.js` -> 500. This became the dominant app-500 source (~1.9/s)
// after the Next 16.2.7 upgrade; Cloudflare edge-caching of OG images masks
// it for popular entities. Force-include the whole compiled @vercel/og dir
// (entry + WASM + fonts) for this route. Two version-agnostic globs (no
// hardcoded next@<hash>): the symlinked path, plus the real pnpm path with a
// `next@*` wildcard in case globby doesn't follow the node_modules/next
// symlink. Whichever matches copies the files; a non-matching glob is a no-op.
'/api/og': [
'./node_modules/next/dist/compiled/@vercel/og/**/*',
'./node_modules/.pnpm/next@*/node_modules/next/dist/compiled/@vercel/og/**/*',
fix(deps): Next 16.3.0 → 16.3.1, and make the compiled-branch gate hard (#3983) (#4075) * fix(deps): Next 16.3.0 -> 16.3.1, and make the compiled-branch gate hard (#3983) This is the fix for #3983. The defect was in the bundler, not in our source. Turbopack's value analyzer in 16.3.0 models a bare `return someAsyncFn()` tail call as `Promise<Promise<T>>`. That is always truthy, so a caller that `await`s it is analysed as always-true and every statement after the resulting conditional is eliminated as dead code. `isAppListingsEnabled` ends in exactly that shape, which is why `resolveStoreVisibilityScopeUninstrumented` lost two of its three returns, fell off the end, and produced `undefined` for every non-privileged caller — served as the whole catalog on one read path (`?? 'full'`) and as an empty store on the other (`?? 'none'`). Upstream: vercel/next.js#96601 "[turbopack] Collapse nested promises in the analyzer", backported as #96675, shipped in 16.3.1. MEASURED, not inferred. Two production builds of THIS commit on one machine, same Node 24.19.0, differing only in the pinned Next: 16.3.0 async function S(e){if(await p(e))return"full"} 16.3.1 async function w(e){return await c(e)?"full":await y(e)?"public-external":"none"} Both read out of the emitted `.next/server` chunks by source-map attribution and identified by their source neighbour `STORE_SCOPE_FLAGS`, never by minified name. Note the fixed form is a TERNARY — `grep 'return"public-external"'` returns zero on the FIXED build too, which is why the gate reads source maps. `package.json` already allowed 16.3.1 (`^16.3.0`); only the lockfile pinned 16.3.0, so the substance here is the lockfile. The floor is raised to `^16.3.1` so a fresh resolution cannot land back on the broken compiler. `patches/next@…` is renamed and its `patchedDependencies` key updated — that patch is the unrelated libvips/SVG one-liner (vercel/next.js#96681), it still applies cleanly, and 16.3.1 still does not carry the loader entry upstream, so it stays. `--warn-only` is removed from `scripts/assert-compiled-branches.mjs` in the same commit. It existed only because the 16.3.0 build genuinely violated the gate, and a permanently-red gate trains everyone to click through. Keeping the bump and the strictness atomic means the gate's strictness always matches the toolchain: a revert of the bump turns it red instead of silently passing. Verified on this commit: - gate exit 0 (hard, no --warn-only) against the 16.3.1 build - gate exit 1 against a 16.3.0 build of the same tree — watched red - `scripts/ci/assert-next-svg-patch-applied.mjs` OK on both installed copies - unit suite 1149 files / 18,123 tests passed, 0 failed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(build): ship @swc/helpers' module-sync branch into the standalone image (#3983) The bump built clean, passed every source-level gate — unit suites, typecheck, ESLint + Prettier, schema drift, the event-engine pin, and the now-hard compiled-branch gate — and the container could not boot: Error: Cannot find module '.../@swc/helpers/esm/_interop_require_default.js' at ... next/dist/server/require-hook.js code: 'MODULE_NOT_FOUND' Same shape as the defect this PR exists to fix: a correct source tree producing a broken artefact, invisible to everything that reads source. ROOT CAUSE, measured on the published artefact rather than inferred. `output: 'standalone'` does not ship node_modules; it ships the subset @vercel/nft traced. nft resolves a bare specifier under the `require`/`default` conditions. Node (>= 22.10) additionally honours `module-sync` for a CJS `require`. When a package's `exports` map points those two at different files, the build traces one and the running process asks for the other. next/dist/shared/lib/constants.js does `require('@swc/helpers/_/_interop_require_default')`, reached from the generated server.js via `next` -> config.js -> constants.js, i.e. before any application code. The relevant delta is not next itself but next's own dependency: next 16.3.0 next 16.3.1 @swc/helpers 0.5.15 0.5.23 ./_/_interop_require_default {import,default} {module-sync,webpack,import,default} require.resolve() under CJS cjs/...cjs esm/...js Both resolutions were RUN, not reasoned about. nft still traced the cjs file, so the published image carried that package as exactly cjs/_interop_require_default.cjs, cjs/_interop_require_wildcard.cjs and package.json — no esm/ directory at all. Adding only the missing esm/ directory to that exact image, nothing else changed, boots it: "Next.js 16.3.1 ... Ready". FIX. `outputFileTracingIncludes` force-includes BOTH condition branches of EVERY installed @swc/helpers copy — not the one file missing today, because which helper Next requires and which branch each resolver picks are upstream details that move. Globs are version- and hash-agnostic (`@swc+helpers@*`), plus a flat form for a hoisted layout. ~950 KB per copy. Verified on a local production build of this commit: both copies land in .next/standalone with complete esm/ (108 and 105 files) and cjs/, and next's virtual store links the 0.5.23 copy. Attached to three existing API-route keys rather than a `'**'` key. copyTracedFiles unions every entry's traced set into the single .next/standalone node_modules, so one entry carrying it is enough, while `'**'` would make all 572 entries read/parse/rewrite their .nft.json concurrently — 826 MB of JSON in one Promise.all — on a build already tuned against OOM. GATE, because a glob is a silent no-op once it stops matching. scripts/ci/assert-standalone-boot-graph.mjs runs in the Dockerfile's RUNNER stage: the first gate in that file to run against the runtime filesystem rather than the build tree, and the only one that can see this class of defect. It reads the GENERATED server.js for the specifiers that process requires at module scope and loads them in a child rooted at the shipped tree — no package, version, virtual-store path or patch hash hardcoded, so it keeps covering this after the next bump. Exit 2, never 0, when it cannot observe its input. It must run in the runner and not the builder: /app there is byte-for-byte what ships, whereas the builder's complete node_modules sits above .next/standalone on the resolution path and can satisfy a require the image cannot. Watched red and green on real artefacts, not only fixtures: - exit 1 with this exact MODULE_NOT_FOUND against the published broken image; - exit 0 against the same image with only the esm/ directory added; - exit 0 against the local production build of this commit, isolated from any parent node_modules; - exit 1 again after deleting exactly esm/_interop_require_default.js from that same local build. src/tests/build/standalone-boot-graph.test.ts pins the MECHANISM (a module-sync/default split with only the default branch present) rather than the package, and all 5 of its cases were watched to fail against a neutered gate. NOT VERIFIED. Nothing about production: this is only true of production once it merges, is promoted main -> release, is built and is serving. The gate covers the ENTRYPOINT's require graph; route chunks load lazily, so a condition mismatch reachable only from a route would still surface at request time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore: retrigger preview The previous preview run (pr-preview-4075-b2wnw) never scheduled: its build-image and typecheck pods sat Pending with ExceededNodeResources for 82 minutes and the run hit the 1h30m PipelineRunTimeout. No verdict was produced — this was build-pool capacity contention, not a code failure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 15:07:05 -05:00
...swcHelpersRuntimeFiles,
fix(og): ship @vercel/og in standalone so /api/og stops 500ing (#2509) * fix(og): ship @vercel/og in standalone so /api/og stops 500ing /api/og renders via next/og's `ImageResponse`, which on the nodejs runtime lazily require()s `next/dist/compiled/@vercel/og/index.node.js` (+ its resvg/yoga WASM + fonts). @vercel/nft can't follow that dynamic require, so with output:'standalone' the file is traced OUT of the image — every origin (cache-miss) /api/og render then throws `Cannot find module ... index.node.js` → res.status(500). Impact: the dominant app-emitted 500 source on dp-prod (~1.9/s, ~74% of the app-500 floor) since the Next 16.2.7 upgrade. Cloudflare edge-caching of OG images masked it for popular entities, but cache-bypass returns 500 for every type (verified: model/image/post/article). Degrades social-share/OG previews for any uncached entity. Fix: add `/api/og` to outputFileTracingIncludes with the compiled @vercel/og dir (entry + WASM + fonts). Two version-agnostic globs (no hardcoded next@hash) to be robust to pnpm symlink resolution. Verification: requires a standalone build — confirm on the PR preview that a cache-busted /api/og?type=model&id=<id> returns 200 (currently 500 at origin). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(og): give FallbackCard logo explicit width so the fallback can't 500 Verifying the standalone-packaging fix on a preview exposed a SECOND, pre- existing /api/og 500 that the module-not-found error had masked: the catch-path `FallbackCard` renders the wordmark logo as `<img height={60}>` with NO width. satori then throws "Image size cannot be determined. Please provide the width and height of the image." — so the fallback ImageResponse itself throws and the outer catch returns 500. This means ANY missing entity (e.g. a deleted/invalid id from a social crawler) or any OgCard render error hit FallbackCard and STILL 500'd, even with the module present. Verified on the pr-2509 preview: existing model/image → 200, but missing post/article/bounty/challenge → 500 with this exact error. Fix: logo_dark_mode.png is 142x30 (≈4.733), so set width={284} at height={60} to preserve aspect and satisfy satori. Now the fallback renders instead of throwing. (Other cards already set both dims; only FallbackCard omitted width.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 11:25:02 -05:00
],
},
experimental: {
// scrollRestoration: true,
cpus: 8,
2026-01-12 11:28:11 -07:00
serverSourceMaps: true,
Migrate to Next.js 15 Bump next 14.2.28 -> 15.5.19 (Pages Router app). Also bumps eslint-config-next and @next/eslint-plugin-next to 15.5.19, and eslint 8.22.0 -> 8.57.1 (Next 15's plugin rules use context.filename, added in ESLint 8.40). Config / API changes required by Next 15: - next.config.mjs: experimental.serverComponentsExternalPackages -> top-level serverExternalPackages; removed the now-stable experimental.instrumentationHook flag. - .eslintrc.js: added settings.next.rootDir so the Next ESLint plugin can locate the pages dir (otherwise the rules crash on load). - bot-detection.middleware.ts: dropped request.ip (NextRequest.ip removed in 15); cf-connecting-ip / x-forwarded-for fallbacks remain. - api/trpc/[trpc].ts: assert the default export as NextApiHandler. Next 15's route-type validator rejected it because withAxiom's NextConfig overload (declared first) structurally matches an arrow function. - Moved api/auth/oauth/__tests__/token.test.ts -> server/oauth/__tests__/token-endpoint.test.ts. Next 15 type-validates every file under pages/api as a route; the colocated test had no default export. Tests still pass (8/8). - Deleted the unused src/app/layout.tsx stub (whole app is Pages Router); removes the spurious "i18n unsupported in App Router" build warning. Build memory + Windows local-build helpers: - package.json build:dev / build:analyze heap 8192 -> 16384 (the app needs >8GB to compile). - scripts/graceful-fs-patch.cjs: optional NODE_OPTIONS --require preload to mitigate Windows EMFILE during local builds (no-op on Linux/CI). Validated: typecheck clean; lint at parity with main; build compiles, type-checks, passes route validation and page-data collection. The full production build's "Collecting build traces" step is FD-bound on Windows (EMFILE) and should be run on CI/Linux. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:08:40 -06:00
// instrumentationHook removed in Next 15 — instrumentation.ts is enabled by default now
largePageDataBytes: 512 * 100000,
perf(build): enable Turbopack server-side nested async chunking (-316.7 MiB of server chunk source) (#3458) * perf(build): enable Turbopack server-side nested async chunking Next's own default table has `turbopackClientSideNestedAsyncChunking` defaulting to TRUE in build mode, but `turbopackServerSideNestedAsyncChunking` defaulting to FALSE in both dev AND build. The server build therefore never received the async-chunk dedup the client build already has, so every async chunk group re-emits its entire module graph. Measured on the production server build: emitted chunk files 21,247 chunk source bytes 587 MB distinct modules 7,844 module copies emitted 1,176,346 (~150 copies per module) duplicated 535.8 MB (96.7% of chunk bytes) Enabling the flag on the same commit: chunk files 21,247 -> 5,830 (-72.6%) chunk bytes 587.4 MB -> 255.3 MB (-316.7 MiB, -56.5%) page entries 548 -> 548 (unchanged) build wall time 228s -> 312s (+37%) Runtime risk is low: the Turbopack runtime loader installs a module factory only `if (!moduleFactories.has(id))`, so duplicate copies are already registered-then-discarded today. This changes what is emitted, not module identity - no singleton or `instanceof` semantics change. Eager chunk sets are byte-identical before and after, so process startup work is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(build): trim the chunking comment to the mechanism, drop unverified aggregates An adversarial audit could not reproduce three of the figures baked into the config comment (7,844 distinct modules / 1,176,346 module copies / ~150 copies per module), and the "96.7% of chunk bytes duplicated" claim does not cohere with the other numbers in the same block: 535.8 MB is 91.2% of 587.4 MB, and 96.7% only holds against module-source bytes rather than chunk bytes. It also measured the real cost far lower than the comment claimed. The scoping pass's "+37% build wall time" compared against an anomalously fast local baseline. The actual CI build of this commit took 312.8s against a ~290s baseline median (+7.9%); a cold same-commit local A/B gave +13% wall / +19% compile. Keeping the mechanism and the shape of the trade in the config, and moving the measurement table to the PR, where it can be corrected without a code change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 10:57:49 -05:00
// Nested async chunking for the SERVER build. Next's own defaults table
// (node_modules/next/dist/docs/01-app/03-api-reference/08-turbopack.md) has
// `turbopackClientSideNestedAsyncChunking` defaulting to TRUE in build mode but
// `turbopackServerSideNestedAsyncChunking` defaulting to FALSE in *both* dev and
// build — so the server build never got the async-chunk dedup the client build has,
// and every async chunk group re-emits its whole module graph.
//
// Trade: ~72% fewer emitted server chunks and roughly half the server chunk bytes,
// in exchange for ~+8% CI build time and ~+33% peak builder RSS. Measurements live
// in the PR rather than here, so they don't rot when Next's chunker changes.
chore(deps): upgrade to Next 16.3.0 and stop paying nested-async chunking in dev (#3742) * perf(dev): stop paying server nested-async chunking in dev The flag exists to shrink emitted server chunks in a production build. A dev server never emits those chunks, so the walk over dynamic-import paths is pure cost there, on a module graph `_app` already makes large. Keeps the build-time behaviour from #3458 unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(deps): upgrade next to 16.3.0 16.3.0 is the first release carrying vercel/next.js#93788, which fixes a lock-order inversion in turbo-tasks-backend and removes an incorrect `unsafe impl Send` on dash_map_multi::RefMut that let a caller hold a shard write guard across an await. The symptom is a dev server that pegs most of the cores with flat memory and never finishes compiling a route, recoverable only by restart. Verified by reading the file at each tag: the unsound impl is present through 16.2.12, the last 16.2 release, and gone in 16.3.0. It was never backported, so no 16.2.x can pick it up. The `get_multiple_mut` equal-keys assert still exists in 16.3.0, so this is not expected to silence that panic — only to stop a panicking worker from wedging every other worker behind it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: commit the agent-rules block next dev writes into CLAUDE.md `next dev` on 16.3.0 appends a `nextjs-agent-rules` block to CLAUDE.md and re-adds it every run (next/dist/server/lib/generate-agent-files.js), so the choice is to commit it or carry an uncommitted change forever. It earns its place here: it points at `node_modules/next/dist/docs/`, which is already the authority this repo cites for Turbopack defaults, and the warning is aimed squarely at a pages-router app on Next 16. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 17:55:51 -06:00
//
fix(build): bring release-build peak memory back under the 40Gi builder limit (#3807) * perf(build): stop nested-async chunking the server build The flag trades peak builder memory for smaller server chunks: ~72% fewer emitted server chunks and roughly half the server chunk bytes, in exchange for ~+8% CI build time and ~+33% peak builder RSS (numbers from #3458, recorded in the comment this replaces). That trade was accepted when nothing measured build RSS. It now has to be paid against an enforced 40Gi builder cgroup, and the release build OOMKilled three times at 37-39 GiB. Turning it off is a real regression in emitted chunk size, not a free win, but a build that cannot complete is worth less than smaller chunks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(build): pin turbopackFileSystemCacheForBuild to the pre-16.3.0 default Next 16.3.0 flipped this experimental flag's default to true, so leaving it unset silently changed the production build. `turbopack-build/impl.js` derives `dependencyTracking` from it, which changes what turbo-tasks retains in memory, not only what is written to `.next/cache`. The 16.3.0 upgrade (d1b3241814) was justified solely on a turbo-tasks lock-order fix for the dev server; this build-side default flip came along unevaluated. Setting it explicitly restores the pre-upgrade behaviour. Independent of memory, the same default is reported upstream to cause a correctness bug where a production build serves pre-change renders from restored cache. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 10:31:39 -06:00
// Off because the builder's memory ceiling is now enforced and that ~+33% peak RSS is
// what puts the release build over it. Re-enable only with a measured peak-RSS margin.
//
// SECOND REASON THIS FLAG MATTERS, and the reason to revisit it: the emitted server
// chunk COUNT is what drives the intermittent `Two or more assets with different
// content were emitted to the same output path` build failure. Turbopack names a
// server chunk `<namespace>_<7-char-hash>._.js`, and that hash's first character is
// bounded in practice to {0,1,2} — so the usable space is ~2 x 38^6, not 38^7, and
// the failure is an ordinary birthday collision between two UNRELATED chunks. It is
// deterministic for a given module graph (so a rebuild of the same commit fails
// again), and it moves to a different pair whenever the graph changes at all — which
// is why bisecting finds a commit but never a responsible file.
//
// Turning this flag ON is the only lever here that attacks the mechanism, because
// P(collision) grows with the SQUARE of the chunk count. Measured on one tree:
// 24,552 server chunks with the flag off vs 7,122 with it on (-71%).
// 🔴 DO NOT FLIP IT ANYWAY — measured on 16.3.1, it does not fit the builder's
// memory ceiling. Two blockers were on record here. The first cleared: the flag is
// BROKEN on Next 16.3.0 (19 `__turbopack_context__.a is not a function` PostCSS
// errors) and compiles from 16.3.1 onward, which the repo is now on. The second
// closed the option: a same-commit A/B on 16.3.1 measured +43.0% peak `next-build`
// RSS / +30.3% build-container peak — LARGER than the ~+33% quoted above, not
// smaller. Projected onto the worst observed production build that lands at
// 37-39 GiB against the enforced 40 GiB limit, which is the exact band where the
// release build OOMKilled three times when this flag was last on (#3807).
// Dropping source maps to pay for it is also closed: it works, but server `.js.map`
// has three consumers including the hard `scripts/assert-compiled-branches.mjs`
// gate, and `turbopackSourceMaps` cannot be split client/server.
// Full evidence: claudedocs/turbopack-chunk-hash-collision-2026-08-18.md
// (§Option 1 is closed). The live fix is upstream, not this flag.
fix(build): bring release-build peak memory back under the 40Gi builder limit (#3807) * perf(build): stop nested-async chunking the server build The flag trades peak builder memory for smaller server chunks: ~72% fewer emitted server chunks and roughly half the server chunk bytes, in exchange for ~+8% CI build time and ~+33% peak builder RSS (numbers from #3458, recorded in the comment this replaces). That trade was accepted when nothing measured build RSS. It now has to be paid against an enforced 40Gi builder cgroup, and the release build OOMKilled three times at 37-39 GiB. Turning it off is a real regression in emitted chunk size, not a free win, but a build that cannot complete is worth less than smaller chunks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(build): pin turbopackFileSystemCacheForBuild to the pre-16.3.0 default Next 16.3.0 flipped this experimental flag's default to true, so leaving it unset silently changed the production build. `turbopack-build/impl.js` derives `dependencyTracking` from it, which changes what turbo-tasks retains in memory, not only what is written to `.next/cache`. The 16.3.0 upgrade (d1b3241814) was justified solely on a turbo-tasks lock-order fix for the dev server; this build-side default flip came along unevaluated. Setting it explicitly restores the pre-upgrade behaviour. Independent of memory, the same default is reported upstream to cause a correctness bug where a production build serves pre-change renders from restored cache. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 10:31:39 -06:00
turbopackServerSideNestedAsyncChunking: false,
// Not the same as omitting it: Next 16.3.0 defaults this to true, and turbopack-build
// derives `dependencyTracking` from it, so the flag governs what turbo-tasks retains in
// memory and not just what lands on disk.
turbopackFileSystemCacheForBuild: false,
// NB: `lodash-es`, `@tabler/icons-react` and `@headlessui/react` are already in Next's
// built-in default list (config.js merges ours into it) — kept here only as intent.
//
// 🔴 Do NOT add a package that creates React context — `@mantine/core`, `@mantine/modals`,
// `@mantine/notifications`. This rewrites barrel imports into deep per-component imports,
// which can put the provider and its consumers on DIFFERENT module instances: the provider
// is in the tree, but consumers read a context object created by another copy. Adding
// `@mantine/core` here 500'd every Mantine-heavy route in preview with "MantineProvider was
// not found in component tree" (PR #3802). Nothing local catches it — typecheck, lint and
// both vitest projects stayed green; only a real build renders the provider.
2025-06-12 18:19:16 -04:00
optimizePackageImports: [
'@civitai/client',
2025-07-07 12:01:39 -04:00
'./src/libs/form',
2025-06-12 18:19:16 -04:00
'lodash-es',
2026-03-24 16:52:11 -06:00
'@tabler/icons-react',
2025-06-12 18:19:16 -04:00
'@headlessui/react',
],
},
headers: async () => {
// Add X-Robots-Tag header to all pages matching /sitemap.xml and /sitemap-models.xml /sitemap-articles.xml, etc
const headers = [
{
source: '/sitemap(-\\w+)?.xml',
headers: [
{ key: 'X-Robots-Tag', value: 'noindex' },
{ key: 'Content-Type', value: 'application/xml' },
{ key: 'Cache-Control', value: 'public, max-age=86400, must-revalidate' },
],
},
];
if (process.env.NODE_ENV !== 'production') {
headers.push({
source: '/:path*',
headers: [
{
key: 'X-Robots-Tag',
value: 'noindex',
},
],
});
}
2025-08-28 16:58:11 -04:00
// Allow Kinguin checkout iframe on gift cards page - NO X-Frame-Options header
// Minimal CSP that only restricts frame-src to allow Kinguin
headers.push({
2025-08-28 16:58:11 -04:00
source: '/gift-cards',
headers: [
2026-01-12 11:28:11 -07:00
{
key: 'Content-Security-Policy',
value:
"frame-src 'self' https://www.kinguin.net https://sandbox.kinguin.net https://gateway.kinguin.net https://*.kinguin.net;",
},
2025-08-28 16:58:11 -04:00
// NOTE: Intentionally NO X-Frame-Options header as per Kinguin's documentation
// NOTE: Only setting frame-src, letting other resources use browser defaults
],
});
2026-01-12 11:28:11 -07:00
2025-08-28 16:58:11 -04:00
// Apply X-Frame-Options to all pages EXCEPT gift-cards
headers.push({
2025-08-28 16:58:11 -04:00
source: '/((?!gift-cards).*)',
headers: [{ key: 'X-Frame-Options', value: 'DENY' }],
});
return headers;
},
poweredByHeader: false,
redirects: async () => {
// Note: the .red-host support-portal bounce is implemented as a
// Cloudflare Redirect Rule on the civitai.red zone. Config lives at
// ops/cloudflare/civitai-red-redirects.json. A host-conditional rule
// here would be pruned to nothing at build time anyway since
// SERVER_DOMAIN_* env vars aren't exposed as Docker build ARGs.
return [
feat(apps): consolidate the build funnel into one state-aware /apps/build (#4685) * feat(apps): consolidate the build funnel into one state-aware /apps/build Three sub-nav items and two pages become one. `Build apps` (/apps/get-started), `Create` (/apps/submit) and `My apps` (/apps/mine) were largely one another's content: get-started was 100% static marketing whose every CTA pointed off-platform, and /apps/submit's on-platform branch was the same copy-paste wall a second time. Only /apps/mine did real work. A developer's path through them was three tabs that mostly showed each other. /apps/build replaces them with one route in three states: A pitch - not an author: the recruiting page + an access CTA B first-app - author, nothing yet: quickstart + create C workbench - author with apps or submissions: the app list + New app /apps/get-started and /apps/mine 301 to it (/apps/my-submissions repointed straight there too, so there is no 301 chain). /apps/submit KEEPS its route - getOwnerEditHref deep-links every offsite listing at /apps/submit?edit=<id> from the store card and the listing-detail menus - it simply loses its tab. ONE PREDICATE, TWO CALLERS. canAccessAppsBuild (shared/utils/app-blocks-access) is called by both the SUB_NAV_LINKS row and the page's getServerSideProps. The defect this exists to prevent has shipped twice - a tab offered to a cohort whose page answers notFound (#3899, and again as a deploy-blocking finding on #4668) - and both times the two rules were written separately and drifted. Also closes a third instance of that defect that was documented and deliberately left open: Marketplace was `visible: () => true` while /apps gates on resolveAppsPageAccess. It could not be fixed before because closing it dropped the get-started-only cohort to one tab and the <2 collapse deleted their bar. Build being store-gated removes that objection. Adds the first instrumentation this funnel has ever had (one AppsBuild_Action type carrying its step in details, plus the ClickHouse Enum16 widening it needs, to be applied by hand before deploy). Verified: unit tier 38558 passed; the four AppsSubNav browser suites 93/93 under a PLAYWRIGHT_BROWSERS_PATH shim; typecheck clean on both configs for every touched file. The new sub-nav/page agreement guard was measured RED at origin/main with a byte-identical file and green here. * fix(apps): carry the POST-APPLY tracker-restart marker on the new actions migration Caught by a MERGED-TREE run, not by either side on its own. `main` added `action-type-enum-drift`s marker requirement after this branch was cut; the two changes touch no file in common, so both sides were green and the merge was red. Applying the DDL without restarting civitai-clickhouse-tracker ships a type that collects zero rows while every signal says it worked. * fix(apps): round-2 audit fixes — widen the gate ledger, compare the Build row to its page, correct three voided claims F1 (downgraded to prose-only). The audit called the OWNER_SUBMISSIONS_URL repoint deploy-blocking. It is not: measured in Flipt v2 env `civitai-app`, `app-blocks-author`, `app-listings` and `app-blocks-enabled` all roll out to the SAME two segments (`moderators`, `app-dev-testers`), so `appBlocksAuthor` implies `hasAppsStoreAccess` and the `{isAuthor, no store access}` cohort is empty by construction. Routing unchanged. What WAS wrong is the prose: - comment.notifications.ts: the decision record still argued `/apps/mine` was better because it needed "the developer cohort rather than the narrower store-access one". `/apps/build` requires store access as a hard AND, so that argument is void — and it inverts: on the cohort axis the destination is now NARROWER than the public detail page. Rewritten to say the destination is chosen for CONTENT (state C is the owner's submissions table) rather than reach, and the superseded measurement is kept but labelled as no longer discriminating. - app-listing.notifications.ts: replaced "every App-Blocks flag is staged mod-only and a moderator holds all of them" with the actual segment proof, plus what would break it (widening one flag's segments without the other) and the live counter-example that shows they can diverge (`app-listings-public-external` already uses `testers`). F2. appsBuildGateCallSites SCAN_ROOTS was ['components/Apps', 'pages/apps'] — the sibling ledger's PRE-FIX value — so its "fails when the set GROWS" claim was blind outside two directories. Measured: a third module calling canAccessAppsBuild at components/AppLayout/AppHeader/auditThirdGate.ts SURVIVED the whole node tier (9/9 green). Widened to ['components', 'pages']; the floor moved 40 -> 1200 so narrowing the roots back cannot pass silently, and a new guard names the two blind-spot directories explicitly. F3. subNavRowsMatchPageGates claimed to evaluate EVERY row against its page's gate and evaluated one. The Build row was compared to nothing: both `(_s, c) => c.isAuthor` and `(_s, c) => c.canSeeStore` SURVIVED the blocking tier, the latter being #4668 reintroduced verbatim. Added the Build loop against the real resolveBuildPageAccess across (store x author x getStarted), plus a per-term positive control. Docstring narrowed to the two flag-gated rows it actually covers, with why the other four have no second copy to drift from. F5. feature-flags.service.ts still promised widening `app-blocks-get-started` to ['public'] was "a one-line flag change". Post consolidation the pitch sits behind `canAccessAppsBuild`, so widening it alone gives the new cohort a notFound from /apps/build, no user-menu entry and no sub-nav — the pitch unreachable by every route. Names the new precondition. F7. cliCommands.ts cited apps-my-submissions-redirect.test.ts, which this PR deletes. Repointed to apps-build-redirects.test.ts. F8. CopyableCommand's onCopy doc said "Fired on a successful copy"; handleCopy calls copy() then onCopy?.() unconditionally and Mantine surfaces no success signal. Corrected the doc rather than the code, and said why. Mutation matrix (node `unit` tier, pre-fix tree = 9b5df27961): F2 outside-roots mutant PRE-FIX SURVIVES 9/9 -> POST-FIX DIES on the ledger's own "the set of modules deciding /apps/build access has changed" message F2 in-roots control still DIES post-fix (no regression) F2 components/AppBlocks DIES post-fix (the other named blind spot) F3 Build -> c.isAuthor PRE-FIX SURVIVES 6/6 -> POST-FIX DIES, store=false author=true getStarted=false F3 Build -> c.canSeeStore PRE-FIX SURVIVES 6/6 -> POST-FIX DIES, store=true author=false getStarted=false Each post-fix death is a single failing test carrying that assertion's own message, not a neighbour's. Verification: unit 1663 files / 38562 tests green; typecheck 0 errors (main tsconfig) and 1164 (tsconfig.tests.json) = the recorded baseline, with a planted error moving each count as a positive control; eslint 0 errors; prettier clean, validated against a planted formatting error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(apps): say SUBSET, not SUPERSET — the old phrase named the requirement while the sentence was about the cohort Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(apps): round-3 audit fixes — per-root walk floor, and withdraw seven voided claims H1 — the walked-file floor did not pin what its comment claimed. `appsBuildGateCallSites.test.ts`'s single floor of 1,200 was satisfied by `components` (1,786 non-test modules) alone, so narrowing `SCAN_ROOTS` back to `['components']` passed silently — and both `BUILD_GATE_SITES` entries and both named directories live under `components`, so nothing else caught it either. Measured on this branch: narrowed roots + a planted `canAccessAppsBuild` importer at `src/pages/apps/auditThirdGate.ts` gave 10 passed (10) — invisible. Replaced with a PER-ROOT floor plus an assertion that `SCAN_ROOTS` and `ROOT_FLOORS` name the same roots; the mutant now dies on that assertion's own message. Live counts components 1,786 / pages 575; floors 1,200 / 350, i.e. ~33% and ~39% of margin, so ordinary churn cannot trip them. Comment rewritten to state what the assertion pins. H2 — two stale quotes. (a) `cliCommands.ts` quoted a `feature-flags.service.ts` comment saying the get-started widen was blocked on "the real Request-access link". `f5ad1d6deb` rewrote that comment; the sentence is gone (grep = 0 at head, 1 at 9b5df27961). The citation is marked as no longer existing rather than repointed at a substitute. (b) `GetStartedBody.tsx` still described itself as rendered by a page gated on `appBlocksGetStarted` and widened in "a one-line flag change", pointing at `src/pages/apps/get-started.tsx` — a file this PR deletes. Replaced with what is true: it is mounted by `AppsBuildBody` as state A of `/apps/build`, whose gate is `canAccessAppsBuild`, under which widening the flag alone yields a `notFound`. H3 — the voided cohort argument, swept at every site. Round 2 fixed three and left six; all six are fixed here, and a SEVENTH was found by the sweep and fixed: - app-moderator-message.notifications.test.ts — test RENAMED; it does not assert the recipient can open the URL, and its name said it did. - comment.appListing-owner.test.ts x3 — header, the 2026-08-20 measurement (now dated and labelled as no longer separating the two destinations), and the "all five" total. - AppsSubNav.browser.test.tsx x2 and AppsSubNav.hydration.browser.test.tsx — the named cohorts and the "verified live" claims are withdrawn; the SHAPE under test is kept and is still reachable via a store flag that does not imply authorship. - SEVENTH (not in the report): AppsSubNav.browser.test.tsx's Build-tab block carried the same "verified live on a real tester account" claim. In every case the argument is withdrawn and explicitly NOT replaced. L1 — the two `edit.tsx` pages pointed at a fuller note on the deleted `/apps/get-started`; the note was not relocated, and that is now what they say. L2 — the two "five" totals beside `OWNER_SUBMISSIONS_URL` are removed rather than corrected to seven. The constant's own note already records that a stated total drifted once; readers are pointed at the importers instead. Verification: unit project 27 failed / 38535 passed, all 27 in four files on the known-flake list (localStorage unavailable on this Node, plus a real-deadline timing test) and none touched here. eslint 0 errors (1 pre-existing warning), prettier clean, `typecheck.mjs -p tsconfig.tests.json` at the 1164 baseline. Instruments validated with planted positive controls in each case. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(apps): correct two stale clauses in listMyOrphanedSubmissions, wrong in opposite directions The final audit round reported one factually-wrong sentence here; reading it, there were two, and they were wrong in different directions. The destination clause named /apps/mine, a route this PR deletes — the notification points at OWNER_SUBMISSIONS_URL, now /apps/build state C. The gate clause said "same appBlocksAuthor-only gate as the page". That was true of /apps/mine and is NOT true of the page that replaced it: this procedure still gates on appDeveloperProcedure (appBlocksAuthor only), while /apps/build gates on canAccessAppsBuild, which requires store access on top. So the two are no longer the same gate, and a caller this procedure serves can be refused by the page that displays its rows. That cohort is empty today only because app-blocks-author and app-listings roll out to the same two Flipt segments — a property of civitai/flipt-state that nothing in this repo enforces. The comment now says so and points at the measurement rather than restating it. Sized the residual: 61 files under src/ still mention /apps/mine (positive control: 51 files mention /apps/submit, which still exists; negative control: 0). This commit fixes only the site that misstates behaviour. The rest is route-rename doc-rot with no behavioural consequence and is left open deliberately, recorded on the PR rather than filed as an object nobody closes. Verified: eslint 0 errors on the changed file (6-problem positive control on src/utils/zod-helpers.ts in the same invocation); prettier clean; typecheck 0 errors in 88s. Ends the audit ladder for this PR. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SzWdpMmKwGdvb2K3eSua8h --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 19:51:11 -05:00
// ── The `/apps/build` consolidation ────────────────────────────────────────
// `/apps/get-started` (a static marketing page whose every CTA pointed
// off-platform) and `/apps/mine` (the real author table) merged into ONE
// state-aware `/apps/build`: pitch → first-app → workbench. Both page components
// are DELETED, not emptied — a stub whose only job is to redirect is dead code
// that reads as a live route, and `appsPageWidths`' fs-walk would then demand a
// width classification for a route that never renders.
//
// 🔴 ALL THREE ARE `statusCode: 301`, NOT `permanent: true`. Next maps `permanent`
// to **308**, which preserves the request METHOD — correct in general, but these
// are GET-only pages whose inbound links are bookmarks, notification URLs and
// search results, and 301 is the status those consumers cache and rewrite on. The
// two options are mutually exclusive in Next's schema, so this is `statusCode`
// alone.
//
// 🔴 NO CHAIN. `/apps/my-submissions` used to land on `/apps/mine`; it now lands
// on `/apps/build` DIRECTLY. Repointing only the two new rules and leaving that
// one would have made it a two-hop 301→301 — which costs a round trip, and which
// some link-equity and bookmark-rewriting consumers stop following.
feat(apps): merge /apps/my-submissions into /apps/mine — one author table, nested history, inactive collapse (#4154) * feat(apps): merge /apps/my-submissions into /apps/mine — one author table `/apps/mine` becomes THE author surface: one table over every app the caller owns or holds an accepted collaborator seat on, on-site and off-site, with that app's publish history nested in its row and fetched on expand. `/apps/my-submissions` 301s here; its page component is deleted rather than stubbed. The row set stays `appListings.listMine` (→ `resolveAccessibleListingIds`). Re-deriving it from a submissions read is the regression this page exists to prevent: a publish request is scoped to `submittedByUserId`, so a collaborator (who submitted nothing), a transfer recipient and a moderator-claimed owner all see an empty page on apps they can plainly edit. Server: - `listMyAppListings` now carries `iconUrl` / `coverUrl` / `updatedAt`. The media columns are integer FKs to `Image`, so this is a select widening plus a shared URL projection (`listing-media-url.ts`, also adopted by the public store read so there is one copy of the widths). No screenshot fallback here — a missing cover is the fact its author needs to see. - New `appListings.listingHistory`: one listing's merged publish history, authorized through `resolveListingAccess` (ownership ∪ accepted seat), gated on `app-blocks-author` only. UI: - Active/inactive partition on LISTING status only (`rejected`, `removed`). `draft` stays in the main table; a withdrawn SUBMISSION never moves an app. - Inactive collapse: closed by default, count in the header, paginated, with `aria-expanded`/`aria-controls` derived from the same state the panel renders. - Icon + cover per row at fixed dimensions with `loading="lazy"`, and a placeholder for each — all 11 `removed` listings in production have no cover, so that path is the main render path for the collapse. - Mobile degrades to cards; exactly one layout is rendered. - The "My submissions" sub-nav tab is retired and `hasSubmissions` now widens "My apps", so no population loses its route. * test(apps): use the canonical dbMock in the two new block-service tests `no-direct-shared-module-mock` went red on them: a per-file mock of the db-client specifier freezes that file's mock shape into every later file in the same worker under `isolate: false`. Both now take `dbMock.dbRead` / `dbMock.dbWrite` from `src/__tests__/mocks/db.mock`, which the global setup registers and resets per file. No assertion changed — the local `mockDb` / `mockWriteDb` handles keep their names, so the bodies are untouched. The guard is a TEXT scan over the file, so the forbidden call cannot be quoted even in a comment; the note explaining the migration says so rather than spelling it. * fix(apps): surface first-version submissions, restore the completeness advisory Four audit findings on #4154. §1 (blocking) — `app_block_publish_requests.app_block_id` is NULL until approve, and the history query keyed on it alone. Measured on production 2026-08-20: 3 of 3 `rejected` and 27 of 33 `withdrawn` rows carry a NULL FK, so 100% of rejections were unreachable — including from the "your app was rejected" notification this PR repoints at /apps/mine. Two sub-cases: (a) a PENDING first version still has its draft listing, only the FK is null. `blockRequestWhereForListing` now falls back to the listing's slug, scoped to its CANONICAL OWNER (never the viewer, so a collaborator still sees it; never unscoped, so a recycled slug cannot leak a stranger's review) and gated on `kind === 'onsite'`, which is now what keeps off-site listings out of the code stream. (b) a REJECTED/WITHDRAWN first version has its listing deleted to release the slug. New submitter-scoped `listMyOrphanedSubmissions` + `appListings.listMyOrphanedSubmissions` surface those as their own always-visible "Submissions without a listing" group, with the reviewer's reason. Submitter-scoped because it is the only identity the surviving record carries. No schema change, no migration; the deletion is deliberately left as-is. §2 (blocking) — `computeListingProblems` had lost its only renderer when the two my-submissions tables lost their importer, which also falsified `listingCoverUrl`'s "the author must see the gap" rationale. `listMine` now carries `problems` and the row renders `ListingProblemsIndicator`. §3 — Withdraw was offered to everyone. Both procs are submitter-scoped, so a collaborator / transfer recipient / mod-claimed owner got a button that only red-toasts. Entries now carry a server-computed `canWithdraw`. §4 — `blocks.withdrawPublishRequest` carries `enforceAppBlocksFlag` while this page gates on `appBlocksAuthor` only. The UI is gated rather than the mutation loosened: removing an authorization gate is not a change to make in passing. Also fixed here: the empty state was an early return, so an account whose only records were orphans would have seen "you don't own any apps yet" instead of them — the same defect one layer up. Caught by its own test. `app-access.accessible-listings.test.ts`'s fake discriminated its two `findUnique` reads by `select.slug`, which worked only because the access resolver happened not to want that column; it now keys on `connectClientId`, which is asked for by exactly one of them on purpose. * fix(apps): close the seams an independent mutation sweep found open My sweep reported 9/9 killed; an independently-constructed one found 7 survivors out of 13. Two of them reintroduced, undetected, findings this PR had just fixed. That is the standing lesson about a sweep built from its own assertions, and it is why these are behavioural fixes, not extra assertions. (a) The off-site half of `canWithdraw` had no guard. The two streams are mapped by two separate `.map()` calls, so the wiring exists twice and can be wrong in one alone — rebinding the LISTING stream to treat every viewer as the submitter SURVIVED, while the identical block-stream mutation died. Each stream is now pinned on its own, plus a case asserting both answers in ONE payload with the two ids deliberately different. (b) The `problems` wiring was unpinned server-side: `computeListingProblems` is well tested as a pure function and the client test supplies `problems` by hand, so both sides were green in isolation and nothing tested the join. `problems: []`, `coverId` fed from `r.iconId`, and the screenshot default `0 -> 99` all passed. Fixtures now carry pairwise-distinct sibling values (iconId 7, coverId 9, screenshots 3) so an operand swap changes the answer. The screenshot mutant needed one more turn: `0 ?? 99` is `0`, so with the relation PRESENT the default is unreachable by construction. It fires only when `_count` is absent, which now has its own case — and pins the direction, since a default that hides `no-screenshots` silently turns an incomplete listing into a clean one. (d) Two silent-zero paths on the one surface this population has. A failing `listMine` early-returned and took the orphan group with it; and `orphansQuery.error` was read NOWHERE, so a failed orphan read rendered nothing and said nothing — indistinguishable from "you have no rejected submissions", the exact lie the group exists to stop telling. Both now surface, neither blanks the other, and the empty state no longer renders over a broken read. Also, all cheap and all wrong before: - the kind-gate rationale claimed a no-op; it is a real behaviour change for the offsite-listing-that-carries-a-block shape (#3844), where the old `appBlockId ? … : null` DID run the block query. Stated as the tightening it is, with why. - a `describe` title my own delta falsified ("CONDITIONAL on the block, not on the kind" — it is now conditional on the kind). - `take: limit` ran BEFORE the ownership de-dup, so the orphan group could return a short page while more existed. Split into `ORPHAN_SCAN_LIMIT` (the read) and `ORPHANED_SUBMISSIONS_LIMIT` (applied after the filter). The dead `opts.limit` — unreachable from the router — is dropped rather than wired. - softened the null-FK branch's coverage claim: it does NOT restore first-version history for a transferred or moderator-claimed listing, only for an owner who submitted it themselves. * fix(apps): close the two guards that became silent-zero paths themselves F1 — the early return keyed on orphan DATA length but not orphan ERROR, so with both reads failed and zero orphan rows only the listMine alert rendered and the orphan failure reported nothing, permanently. Zero rows is not the same fact as "the read succeeded and found none" — the silent-zero lesson, applied to the guard written for it. F2 — the container never forwarded `orphansQuery.isLoading`, so `hasNothingAtAll` could not tell "no orphans" from "orphan read in flight". The two procedures batch into one request under `httpBatchStreamLink` but stream back independently, so `rowsQuery` resolving empty first is ordinary, not exotic — and the page asserted "You don't own or collaborate on any apps yet" over a pending read. Streaming makes that MORE reachable, not less. The `isLoading && orphanedSubmissions.length === 0` guard was also uncovered (reverting it to plain `isLoading` survived the last battery). Its real effect is not the error case its comment described: once orphans resolve while rows are still loading, falling through renders the group rather than a spinner over data that already arrived. Covered and documented as what it does. Two comments corrected, both over-claiming rather than wrong: - the de-dup ordering test claimed it kills a `take` mutant. It does not — `mockImplementation` ignores `take`, so all 28 rows come back regardless and the case passes. It pins the ORDER of de-dup vs display cap; renamed and re-commented to say so, and the remaining self-referential `take` coverage is recorded in the follow-up issue. - `ORPHAN_SCAN_LIMIT` read as though under-return were closed. It is 4× harder, not closed: still reachable at ≥76 of the newest 100 rows de-duping as owned with non-owned rows past position 100. Stated with the real bound, plus the index reality — neither existing index can serve the ORDER BY under this predicate, so Postgres top-N sorts either way and a wider scan is cheap.
2026-08-19 23:37:47 -05:00
{
source: '/apps/my-submissions',
feat(apps): consolidate the build funnel into one state-aware /apps/build (#4685) * feat(apps): consolidate the build funnel into one state-aware /apps/build Three sub-nav items and two pages become one. `Build apps` (/apps/get-started), `Create` (/apps/submit) and `My apps` (/apps/mine) were largely one another's content: get-started was 100% static marketing whose every CTA pointed off-platform, and /apps/submit's on-platform branch was the same copy-paste wall a second time. Only /apps/mine did real work. A developer's path through them was three tabs that mostly showed each other. /apps/build replaces them with one route in three states: A pitch - not an author: the recruiting page + an access CTA B first-app - author, nothing yet: quickstart + create C workbench - author with apps or submissions: the app list + New app /apps/get-started and /apps/mine 301 to it (/apps/my-submissions repointed straight there too, so there is no 301 chain). /apps/submit KEEPS its route - getOwnerEditHref deep-links every offsite listing at /apps/submit?edit=<id> from the store card and the listing-detail menus - it simply loses its tab. ONE PREDICATE, TWO CALLERS. canAccessAppsBuild (shared/utils/app-blocks-access) is called by both the SUB_NAV_LINKS row and the page's getServerSideProps. The defect this exists to prevent has shipped twice - a tab offered to a cohort whose page answers notFound (#3899, and again as a deploy-blocking finding on #4668) - and both times the two rules were written separately and drifted. Also closes a third instance of that defect that was documented and deliberately left open: Marketplace was `visible: () => true` while /apps gates on resolveAppsPageAccess. It could not be fixed before because closing it dropped the get-started-only cohort to one tab and the <2 collapse deleted their bar. Build being store-gated removes that objection. Adds the first instrumentation this funnel has ever had (one AppsBuild_Action type carrying its step in details, plus the ClickHouse Enum16 widening it needs, to be applied by hand before deploy). Verified: unit tier 38558 passed; the four AppsSubNav browser suites 93/93 under a PLAYWRIGHT_BROWSERS_PATH shim; typecheck clean on both configs for every touched file. The new sub-nav/page agreement guard was measured RED at origin/main with a byte-identical file and green here. * fix(apps): carry the POST-APPLY tracker-restart marker on the new actions migration Caught by a MERGED-TREE run, not by either side on its own. `main` added `action-type-enum-drift`s marker requirement after this branch was cut; the two changes touch no file in common, so both sides were green and the merge was red. Applying the DDL without restarting civitai-clickhouse-tracker ships a type that collects zero rows while every signal says it worked. * fix(apps): round-2 audit fixes — widen the gate ledger, compare the Build row to its page, correct three voided claims F1 (downgraded to prose-only). The audit called the OWNER_SUBMISSIONS_URL repoint deploy-blocking. It is not: measured in Flipt v2 env `civitai-app`, `app-blocks-author`, `app-listings` and `app-blocks-enabled` all roll out to the SAME two segments (`moderators`, `app-dev-testers`), so `appBlocksAuthor` implies `hasAppsStoreAccess` and the `{isAuthor, no store access}` cohort is empty by construction. Routing unchanged. What WAS wrong is the prose: - comment.notifications.ts: the decision record still argued `/apps/mine` was better because it needed "the developer cohort rather than the narrower store-access one". `/apps/build` requires store access as a hard AND, so that argument is void — and it inverts: on the cohort axis the destination is now NARROWER than the public detail page. Rewritten to say the destination is chosen for CONTENT (state C is the owner's submissions table) rather than reach, and the superseded measurement is kept but labelled as no longer discriminating. - app-listing.notifications.ts: replaced "every App-Blocks flag is staged mod-only and a moderator holds all of them" with the actual segment proof, plus what would break it (widening one flag's segments without the other) and the live counter-example that shows they can diverge (`app-listings-public-external` already uses `testers`). F2. appsBuildGateCallSites SCAN_ROOTS was ['components/Apps', 'pages/apps'] — the sibling ledger's PRE-FIX value — so its "fails when the set GROWS" claim was blind outside two directories. Measured: a third module calling canAccessAppsBuild at components/AppLayout/AppHeader/auditThirdGate.ts SURVIVED the whole node tier (9/9 green). Widened to ['components', 'pages']; the floor moved 40 -> 1200 so narrowing the roots back cannot pass silently, and a new guard names the two blind-spot directories explicitly. F3. subNavRowsMatchPageGates claimed to evaluate EVERY row against its page's gate and evaluated one. The Build row was compared to nothing: both `(_s, c) => c.isAuthor` and `(_s, c) => c.canSeeStore` SURVIVED the blocking tier, the latter being #4668 reintroduced verbatim. Added the Build loop against the real resolveBuildPageAccess across (store x author x getStarted), plus a per-term positive control. Docstring narrowed to the two flag-gated rows it actually covers, with why the other four have no second copy to drift from. F5. feature-flags.service.ts still promised widening `app-blocks-get-started` to ['public'] was "a one-line flag change". Post consolidation the pitch sits behind `canAccessAppsBuild`, so widening it alone gives the new cohort a notFound from /apps/build, no user-menu entry and no sub-nav — the pitch unreachable by every route. Names the new precondition. F7. cliCommands.ts cited apps-my-submissions-redirect.test.ts, which this PR deletes. Repointed to apps-build-redirects.test.ts. F8. CopyableCommand's onCopy doc said "Fired on a successful copy"; handleCopy calls copy() then onCopy?.() unconditionally and Mantine surfaces no success signal. Corrected the doc rather than the code, and said why. Mutation matrix (node `unit` tier, pre-fix tree = 9b5df27961): F2 outside-roots mutant PRE-FIX SURVIVES 9/9 -> POST-FIX DIES on the ledger's own "the set of modules deciding /apps/build access has changed" message F2 in-roots control still DIES post-fix (no regression) F2 components/AppBlocks DIES post-fix (the other named blind spot) F3 Build -> c.isAuthor PRE-FIX SURVIVES 6/6 -> POST-FIX DIES, store=false author=true getStarted=false F3 Build -> c.canSeeStore PRE-FIX SURVIVES 6/6 -> POST-FIX DIES, store=true author=false getStarted=false Each post-fix death is a single failing test carrying that assertion's own message, not a neighbour's. Verification: unit 1663 files / 38562 tests green; typecheck 0 errors (main tsconfig) and 1164 (tsconfig.tests.json) = the recorded baseline, with a planted error moving each count as a positive control; eslint 0 errors; prettier clean, validated against a planted formatting error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(apps): say SUBSET, not SUPERSET — the old phrase named the requirement while the sentence was about the cohort Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(apps): round-3 audit fixes — per-root walk floor, and withdraw seven voided claims H1 — the walked-file floor did not pin what its comment claimed. `appsBuildGateCallSites.test.ts`'s single floor of 1,200 was satisfied by `components` (1,786 non-test modules) alone, so narrowing `SCAN_ROOTS` back to `['components']` passed silently — and both `BUILD_GATE_SITES` entries and both named directories live under `components`, so nothing else caught it either. Measured on this branch: narrowed roots + a planted `canAccessAppsBuild` importer at `src/pages/apps/auditThirdGate.ts` gave 10 passed (10) — invisible. Replaced with a PER-ROOT floor plus an assertion that `SCAN_ROOTS` and `ROOT_FLOORS` name the same roots; the mutant now dies on that assertion's own message. Live counts components 1,786 / pages 575; floors 1,200 / 350, i.e. ~33% and ~39% of margin, so ordinary churn cannot trip them. Comment rewritten to state what the assertion pins. H2 — two stale quotes. (a) `cliCommands.ts` quoted a `feature-flags.service.ts` comment saying the get-started widen was blocked on "the real Request-access link". `f5ad1d6deb` rewrote that comment; the sentence is gone (grep = 0 at head, 1 at 9b5df27961). The citation is marked as no longer existing rather than repointed at a substitute. (b) `GetStartedBody.tsx` still described itself as rendered by a page gated on `appBlocksGetStarted` and widened in "a one-line flag change", pointing at `src/pages/apps/get-started.tsx` — a file this PR deletes. Replaced with what is true: it is mounted by `AppsBuildBody` as state A of `/apps/build`, whose gate is `canAccessAppsBuild`, under which widening the flag alone yields a `notFound`. H3 — the voided cohort argument, swept at every site. Round 2 fixed three and left six; all six are fixed here, and a SEVENTH was found by the sweep and fixed: - app-moderator-message.notifications.test.ts — test RENAMED; it does not assert the recipient can open the URL, and its name said it did. - comment.appListing-owner.test.ts x3 — header, the 2026-08-20 measurement (now dated and labelled as no longer separating the two destinations), and the "all five" total. - AppsSubNav.browser.test.tsx x2 and AppsSubNav.hydration.browser.test.tsx — the named cohorts and the "verified live" claims are withdrawn; the SHAPE under test is kept and is still reachable via a store flag that does not imply authorship. - SEVENTH (not in the report): AppsSubNav.browser.test.tsx's Build-tab block carried the same "verified live on a real tester account" claim. In every case the argument is withdrawn and explicitly NOT replaced. L1 — the two `edit.tsx` pages pointed at a fuller note on the deleted `/apps/get-started`; the note was not relocated, and that is now what they say. L2 — the two "five" totals beside `OWNER_SUBMISSIONS_URL` are removed rather than corrected to seven. The constant's own note already records that a stated total drifted once; readers are pointed at the importers instead. Verification: unit project 27 failed / 38535 passed, all 27 in four files on the known-flake list (localStorage unavailable on this Node, plus a real-deadline timing test) and none touched here. eslint 0 errors (1 pre-existing warning), prettier clean, `typecheck.mjs -p tsconfig.tests.json` at the 1164 baseline. Instruments validated with planted positive controls in each case. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(apps): correct two stale clauses in listMyOrphanedSubmissions, wrong in opposite directions The final audit round reported one factually-wrong sentence here; reading it, there were two, and they were wrong in different directions. The destination clause named /apps/mine, a route this PR deletes — the notification points at OWNER_SUBMISSIONS_URL, now /apps/build state C. The gate clause said "same appBlocksAuthor-only gate as the page". That was true of /apps/mine and is NOT true of the page that replaced it: this procedure still gates on appDeveloperProcedure (appBlocksAuthor only), while /apps/build gates on canAccessAppsBuild, which requires store access on top. So the two are no longer the same gate, and a caller this procedure serves can be refused by the page that displays its rows. That cohort is empty today only because app-blocks-author and app-listings roll out to the same two Flipt segments — a property of civitai/flipt-state that nothing in this repo enforces. The comment now says so and points at the measurement rather than restating it. Sized the residual: 61 files under src/ still mention /apps/mine (positive control: 51 files mention /apps/submit, which still exists; negative control: 0). This commit fixes only the site that misstates behaviour. The rest is route-rename doc-rot with no behavioural consequence and is left open deliberately, recorded on the PR rather than filed as an object nobody closes. Verified: eslint 0 errors on the changed file (6-problem positive control on src/utils/zod-helpers.ts in the same invocation); prettier clean; typecheck 0 errors in 88s. Ends the audit ladder for this PR. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SzWdpMmKwGdvb2K3eSua8h --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 19:51:11 -05:00
destination: '/apps/build',
statusCode: 301,
},
{
source: '/apps/mine',
destination: '/apps/build',
statusCode: 301,
},
{
source: '/apps/get-started',
destination: '/apps/build',
feat(apps): merge /apps/my-submissions into /apps/mine — one author table, nested history, inactive collapse (#4154) * feat(apps): merge /apps/my-submissions into /apps/mine — one author table `/apps/mine` becomes THE author surface: one table over every app the caller owns or holds an accepted collaborator seat on, on-site and off-site, with that app's publish history nested in its row and fetched on expand. `/apps/my-submissions` 301s here; its page component is deleted rather than stubbed. The row set stays `appListings.listMine` (→ `resolveAccessibleListingIds`). Re-deriving it from a submissions read is the regression this page exists to prevent: a publish request is scoped to `submittedByUserId`, so a collaborator (who submitted nothing), a transfer recipient and a moderator-claimed owner all see an empty page on apps they can plainly edit. Server: - `listMyAppListings` now carries `iconUrl` / `coverUrl` / `updatedAt`. The media columns are integer FKs to `Image`, so this is a select widening plus a shared URL projection (`listing-media-url.ts`, also adopted by the public store read so there is one copy of the widths). No screenshot fallback here — a missing cover is the fact its author needs to see. - New `appListings.listingHistory`: one listing's merged publish history, authorized through `resolveListingAccess` (ownership ∪ accepted seat), gated on `app-blocks-author` only. UI: - Active/inactive partition on LISTING status only (`rejected`, `removed`). `draft` stays in the main table; a withdrawn SUBMISSION never moves an app. - Inactive collapse: closed by default, count in the header, paginated, with `aria-expanded`/`aria-controls` derived from the same state the panel renders. - Icon + cover per row at fixed dimensions with `loading="lazy"`, and a placeholder for each — all 11 `removed` listings in production have no cover, so that path is the main render path for the collapse. - Mobile degrades to cards; exactly one layout is rendered. - The "My submissions" sub-nav tab is retired and `hasSubmissions` now widens "My apps", so no population loses its route. * test(apps): use the canonical dbMock in the two new block-service tests `no-direct-shared-module-mock` went red on them: a per-file mock of the db-client specifier freezes that file's mock shape into every later file in the same worker under `isolate: false`. Both now take `dbMock.dbRead` / `dbMock.dbWrite` from `src/__tests__/mocks/db.mock`, which the global setup registers and resets per file. No assertion changed — the local `mockDb` / `mockWriteDb` handles keep their names, so the bodies are untouched. The guard is a TEXT scan over the file, so the forbidden call cannot be quoted even in a comment; the note explaining the migration says so rather than spelling it. * fix(apps): surface first-version submissions, restore the completeness advisory Four audit findings on #4154. §1 (blocking) — `app_block_publish_requests.app_block_id` is NULL until approve, and the history query keyed on it alone. Measured on production 2026-08-20: 3 of 3 `rejected` and 27 of 33 `withdrawn` rows carry a NULL FK, so 100% of rejections were unreachable — including from the "your app was rejected" notification this PR repoints at /apps/mine. Two sub-cases: (a) a PENDING first version still has its draft listing, only the FK is null. `blockRequestWhereForListing` now falls back to the listing's slug, scoped to its CANONICAL OWNER (never the viewer, so a collaborator still sees it; never unscoped, so a recycled slug cannot leak a stranger's review) and gated on `kind === 'onsite'`, which is now what keeps off-site listings out of the code stream. (b) a REJECTED/WITHDRAWN first version has its listing deleted to release the slug. New submitter-scoped `listMyOrphanedSubmissions` + `appListings.listMyOrphanedSubmissions` surface those as their own always-visible "Submissions without a listing" group, with the reviewer's reason. Submitter-scoped because it is the only identity the surviving record carries. No schema change, no migration; the deletion is deliberately left as-is. §2 (blocking) — `computeListingProblems` had lost its only renderer when the two my-submissions tables lost their importer, which also falsified `listingCoverUrl`'s "the author must see the gap" rationale. `listMine` now carries `problems` and the row renders `ListingProblemsIndicator`. §3 — Withdraw was offered to everyone. Both procs are submitter-scoped, so a collaborator / transfer recipient / mod-claimed owner got a button that only red-toasts. Entries now carry a server-computed `canWithdraw`. §4 — `blocks.withdrawPublishRequest` carries `enforceAppBlocksFlag` while this page gates on `appBlocksAuthor` only. The UI is gated rather than the mutation loosened: removing an authorization gate is not a change to make in passing. Also fixed here: the empty state was an early return, so an account whose only records were orphans would have seen "you don't own any apps yet" instead of them — the same defect one layer up. Caught by its own test. `app-access.accessible-listings.test.ts`'s fake discriminated its two `findUnique` reads by `select.slug`, which worked only because the access resolver happened not to want that column; it now keys on `connectClientId`, which is asked for by exactly one of them on purpose. * fix(apps): close the seams an independent mutation sweep found open My sweep reported 9/9 killed; an independently-constructed one found 7 survivors out of 13. Two of them reintroduced, undetected, findings this PR had just fixed. That is the standing lesson about a sweep built from its own assertions, and it is why these are behavioural fixes, not extra assertions. (a) The off-site half of `canWithdraw` had no guard. The two streams are mapped by two separate `.map()` calls, so the wiring exists twice and can be wrong in one alone — rebinding the LISTING stream to treat every viewer as the submitter SURVIVED, while the identical block-stream mutation died. Each stream is now pinned on its own, plus a case asserting both answers in ONE payload with the two ids deliberately different. (b) The `problems` wiring was unpinned server-side: `computeListingProblems` is well tested as a pure function and the client test supplies `problems` by hand, so both sides were green in isolation and nothing tested the join. `problems: []`, `coverId` fed from `r.iconId`, and the screenshot default `0 -> 99` all passed. Fixtures now carry pairwise-distinct sibling values (iconId 7, coverId 9, screenshots 3) so an operand swap changes the answer. The screenshot mutant needed one more turn: `0 ?? 99` is `0`, so with the relation PRESENT the default is unreachable by construction. It fires only when `_count` is absent, which now has its own case — and pins the direction, since a default that hides `no-screenshots` silently turns an incomplete listing into a clean one. (d) Two silent-zero paths on the one surface this population has. A failing `listMine` early-returned and took the orphan group with it; and `orphansQuery.error` was read NOWHERE, so a failed orphan read rendered nothing and said nothing — indistinguishable from "you have no rejected submissions", the exact lie the group exists to stop telling. Both now surface, neither blanks the other, and the empty state no longer renders over a broken read. Also, all cheap and all wrong before: - the kind-gate rationale claimed a no-op; it is a real behaviour change for the offsite-listing-that-carries-a-block shape (#3844), where the old `appBlockId ? … : null` DID run the block query. Stated as the tightening it is, with why. - a `describe` title my own delta falsified ("CONDITIONAL on the block, not on the kind" — it is now conditional on the kind). - `take: limit` ran BEFORE the ownership de-dup, so the orphan group could return a short page while more existed. Split into `ORPHAN_SCAN_LIMIT` (the read) and `ORPHANED_SUBMISSIONS_LIMIT` (applied after the filter). The dead `opts.limit` — unreachable from the router — is dropped rather than wired. - softened the null-FK branch's coverage claim: it does NOT restore first-version history for a transferred or moderator-claimed listing, only for an owner who submitted it themselves. * fix(apps): close the two guards that became silent-zero paths themselves F1 — the early return keyed on orphan DATA length but not orphan ERROR, so with both reads failed and zero orphan rows only the listMine alert rendered and the orphan failure reported nothing, permanently. Zero rows is not the same fact as "the read succeeded and found none" — the silent-zero lesson, applied to the guard written for it. F2 — the container never forwarded `orphansQuery.isLoading`, so `hasNothingAtAll` could not tell "no orphans" from "orphan read in flight". The two procedures batch into one request under `httpBatchStreamLink` but stream back independently, so `rowsQuery` resolving empty first is ordinary, not exotic — and the page asserted "You don't own or collaborate on any apps yet" over a pending read. Streaming makes that MORE reachable, not less. The `isLoading && orphanedSubmissions.length === 0` guard was also uncovered (reverting it to plain `isLoading` survived the last battery). Its real effect is not the error case its comment described: once orphans resolve while rows are still loading, falling through renders the group rather than a spinner over data that already arrived. Covered and documented as what it does. Two comments corrected, both over-claiming rather than wrong: - the de-dup ordering test claimed it kills a `take` mutant. It does not — `mockImplementation` ignores `take`, so all 28 rows come back regardless and the case passes. It pins the ORDER of de-dup vs display cap; renamed and re-commented to say so, and the remaining self-referential `take` coverage is recorded in the follow-up issue. - `ORPHAN_SCAN_LIMIT` read as though under-return were closed. It is 4× harder, not closed: still reachable at ≥76 of the newest 100 rows de-duping as owned with non-owned rows past position 100. Stated with the real bound, plus the index reality — neither existing index can serve the ORDER BY under this predicate, so Postgres top-N sorts either way and a wider scan is cheap.
2026-08-19 23:37:47 -05:00
statusCode: 301,
},
feat(apps): migrate /apps/installed to /apps/activity — widen past the slot flag, default to the feed, Build to the end of the sub-nav (#4699) * feat(apps): migrate /apps/installed to /apps/activity — widen past the slot flag, default to the feed, move Build to the end of the sub-nav The page stopped being an installs surface. It now opens on the activity feed, its Installs tab is gated on the model-slot flag alone, and the page itself is reachable by anyone holding either runtime flag. The route name follows. 1. ROUTE. src/pages/apps/installed.tsx moves to src/pages/apps/activity.tsx and /apps/installed gains a 301 in next.config.mjs, on the precedent of the /apps/build consolidation (statusCode: 301, not permanent: true — Next maps permanent to 308). The page component is MOVED, not stubbed: a stub whose only job is to redirect reads as a live route, and the appsPageWidths fs-walk would demand a width classification for a page that never renders. Every in-repo link is repointed; the prose is swept. 2. GATE. getServerSideProps now calls the shared resolveActivityPageAccess, which calls the shared canAccessAppsActivity = appBlocks || appBlocksPages. appBlocks is the model-SLOT flag; someone who has only ever run a full-page app (/apps/run/<slug>, gated on appBlocksPages) has generations, scope-gated API calls and Buzz spends recorded against them and no slot install at all. Refusing them a page called "Activity" was the dishonest half of the rename. The page body re-checks with the same predicate, so the two cannot drift. 3. INSTALLS TAB. Narrows to features.appBlocks — the tab's content is slot subscriptions and per-model installs, which is exactly what that flag governs. This is only NON-VACUOUS because of (2): with the page gate unchanged the tab check could never be false. The PANEL is gated with the tab, since a Tabs.Panel with no tab still mounts its children. 4. TABS ARE URL-BACKED. Controlled value + onChange writing ?tab= through router.replace(..., { shallow: true }); the default tab drops the key rather than writing ?tab=activity. Resolution rules live in a pure module (appsActivityTabs.ts) so they run in the blocking node tier: an unknown, repeated, or prototype-key value falls back, and so does ?tab=subscriptions for a viewer without the slot flag — handing that to Tabs.value would render a bar with nothing active over an empty panel. 5. SUB-NAV ORDER. Marketplace → Activity → Invites → Revenue → Build → Review. Build was first on the argument that it is "the front door for someone who has not built anything yet"; it is the narrowest-audience row in the table (canAccessAppsBuild), so leading with it put the smallest cohort's destination in the position that reads as what the bar is for. The stale comment is rewritten rather than moved. The chrome's two /apps/installed items are repointed and the platform-nav one relabelled to "App activity". 6. HEADER CTA. The AppsPageLayout "Browse marketplace" button is gone — Marketplace is a sub-nav tab. The three in-empty-state anchors stay: an empty panel with no way forward is a dead end. 7. WHEN COLUMN. Relative, via the existing DaysFromNow, with the absolute stamp still in the tooltip (and now also in the <time datetime> attribute). 8. APP NAME LINKS TO THE STORE DETAIL, GATED. getListingDetailHref(slug), gated on hasAppsStoreAccess read through useOptionalFeatureFlags so an absent provider fails CLOSED — AppNameCrumb's solution copied, not re-derived. /apps/store-preview/<slug> getServerSideProps-gates on that predicate and answers notFound, and appListings.getAppDetail throws NOT_FOUND at scope none, so an ungated link is a 404 affordance: the class #4668 shipped and #4685 exists to prevent. 9. COLUMN LEDGER RE-MEASURED. The appsWideLayout EXEMPT entry justified itself with a measurement taken while When rendered a fixed-width YYYY-MM-DD HH:mm stamp, so it no longer described the table. Redone at 768/1200/1440/2560 on the same fixture: When gave up ~10% of its width at every viewport (411.09 to 372.33 at 2560) and the other four columns absorbed it; ROW HEIGHT is 36.19 at all four widths, before and after. The no-surplus argument is a claim about max-content sum versus container width at 768, and a narrower When makes that sum smaller — the exemption is unchanged. The before@2560 row reproduces the figure already recorded in that comment, which is the control on the re-measurement. TESTS. Seven new files. resolveActivityPageAccess.test.ts and appsActivityTabs.test.ts run in the blocking node tier; apps-activity-redirect invokes next.config.mjs's redirects() rather than scanning the file, and carries the same positive controls as its /apps/build sibling. Two browser files mount the real panel and the real page. Every new test was measured RED at origin/main: 8/8 in AppActivityPanel.storeGate, 9/11 in AppActivityPage (the two that pass there are the positive-direction cases main already satisfies). The criterion-8 gate is mutation-checked: deleting "!canSeeStore ||" from ActivityAppName makes exactly the two gate tests fail, on their own assertion — "an ineligible viewer must not be given an anchor: expected 'A' not to be 'A'" — while the three eligible-viewer tests stay green, which is what makes the red attributable to the gate rather than to the feature. Also re-cut the blanking-ratio bound in appsStoreAccessCallSites from 0.55 to 0.65. Measured on origin/main it sat at 0.5486 against a 0.55 bound — 0.0014 of headroom, so any added documentation tripped it — and it now carries a negative control on the real file (the historical apostrophe desync) proving the bound can still fire. * fix(apps): repoint the two label-only references the route sweep could not see Both were found by the FULL component tier, not by the sweep, and that is the point worth recording: the sweep keyed on the route string "/apps/installed", so a reference that names only the LABEL was structurally invisible to it. Sampling one surface is not enumerating the surface. - AppBlockChromeMobileShell.browser.test.tsx asserts the platform nav folds into the phone-width overflow sheet by looking for its label for this route. That label went "Installed apps" to "App activity" with the rename; the test's own RED-at-origin note is updated to say the label moved rather than the claim. - subNavRowsMatchPageGates.test.ts named the summary-driven rows in prose as "Installed / Invites / Revenue / Review". * fix(apps): give the Activity tab to the page-app cohort, and stop offering links into a notFound Audit follow-ups on #4699. The page gate widened to `appBlocks || appBlocksPages`, but the sub-nav row pointing at it did not — and two affordances inside it now lead somewhere the newly-admitted cohort cannot go. 1. `blocks.getNavSummary` gains `hasActivity` — true when the viewer has a row in EITHER table the activity feed actually walks (`block_buzz_attribution` as the SPENDER, `block_scope_invocations`). The scope probe mirrors the feed's own WHERE clause, external-OAuth rows excluded, so the flag cannot light a tab over an empty page. Two bounded `findFirst({ select: { id } })` in the existing Promise.all; the flag-off shape gains the key too. 2. The Activity row keys on `s.hasInstalls || s.hasActivity`. `hasInstalls` is a `block_user_subscriptions` row — a SLOT install — and a full-page app is stateless by design, so a viewer who only runs page apps had activity and no tab. Both terms are documented at the row so neither gets "simplified" away. 3. The two empty-state `/apps` anchors are gated on `hasAppsStoreAccess`. `appBlocksPages` is not one of that predicate's disjuncts, so for the cohort this PR admits they were links into a `notFound`. The CTA is omitted rather than reworded; `pages/apps/activity.tsx` joins the store-gate call-site ledger. 4. `appSlug` is a listing slug or NULL — never an `AppBlock` primary key. Both feeds emitted `r.appBlock?.blockId ?? r.appBlockId`, whose fallback is the FK, so an unresolved join produced `/apps/store-preview/<pk>`: a guaranteed 404 on exactly the rows where the app is least resolvable. Fixed at the source, not with a per-row fetch; `ActivityAppName` renders plain text when the slug is absent, and the slug badge is dropped with it. 5. `subNavRowsMatchPageGates` pins the Activity row against `/apps/activity`'s own resolver, and its header claim that summary-driven rows have "no second copy to disagree with" is corrected — WHICH fields a row ORs together is exactly such a copy. 6. Two stale comment claims fixed: `AppsSubNav`'s `getNavSummary` `enabled` rationale ("all point at pages that themselves 404 without appBlocks" — Activity no longer does), and the test header above. 7. The `When` cell drops its Mantine `Tooltip`: `DaysFromNow` already renders a `<time title>`, so one hover raised two tooltips formatting the same instant two different ways. `live` is now passed so a long-lived tab does not freeze at "20m ago". 8. The run-frame permissions drawer passes `linkable={false}`: the shared panel is mounted OVER a running full-page app there, and the new app-name link would top-level-navigate the user out of it. Recorded on both components. Red→green measured per change; the two gates are mutation-checked (details in the PR body). The double-tooltip guard was rewritten after its first, structural form survived the mutation — a closed Mantine Tooltip is invisible in the DOM. * fix(apps): single-source the scope-activity predicate, and correct a residual stated wider than it is Audit round 2. Two findings actionable; the headline one was DEMONSTRATED rather than argued, and this fixes it the same way. F2 (guard) — "the probe MIRRORS the feed" was TWO copies of one literal, and nothing pinned the relationship. `getNavSummary`'s `hasActivity` probe and `listMyScopeInvocations` each asserted their OWN `where` against a hand-written literal in their own suite; neither read the other. The audit tightened the FEED's clause and updated the FEED's own literal — exactly the edit a developer making that change would make — and BOTH suites stayed green at 65/65 while the probe silently over-matched. That is the "docstring names a RELATIONSHIP, body inspects one SIDE" shape. Fixed by construction, not by assertion: `GLOBAL_SCOPE_ACTIVITY_OR` is exported once from `user-app-surface.service.ts`; the feed spreads it and `blocks.router.ts` imports it. A future edit moves both. Proof, same mutation: tightening the predicate at its single source now reds THREE tests across ALL THREE files — the feed's, the probe's, and the new `scopeActivityPredicate.test.ts` — where it previously reddened none. The new seam test deliberately does NOT re-spell the clause as a third literal; it asserts the SHAPE and that neither call site carries its own copy. F5 (comment) — the documented residual said a viewer holding `appBlocksPages` without `appBlocks` "still gets an all-false summary and no tab", and that closing it means widening the procedure's gate. As written that invites a future widening of a `blocks.*` flag gate to close a gap that is effectively EMPTY: that cohort is refused by `/apps/run` itself (which requires BOTH flags) and cannot install, so all-false is the CORRECT tab set for them, not a deprivation. Corrected in place. F1 (perf) — NOT fixed, because it was MEASURED rather than reasoned about, and the measurement inverts the analysis. The audit flagged `block_buzz_attribution` having no index leading on `user_id`, correctly: seven live indexes, none on it. But on prod that table holds ZERO rows / 72 kB, and the probe plans as a Seq Scan costing 0.00 at 0.021 ms. Meanwhile the probe the audit CLEARED — scope invocations, which does have `bsi_user_invoked_idx` — is the slower of the two: 863,305 rows / 314 MB, a Bitmap Heap Scan at 6.0 ms with 48 buffers read. Neither is a merge blocker on a query cached for 60 s per nav. The index is worth adding when that table grows; it buys nothing today, and adding an index here is a manual, human-applied migration (CLAUDE.md rule 8), not a code change. F3, F4 — left, deliberately. F3 (the store-gate ledger is narrower than its new header) is bounded by whether the browser tier gates a merge, which it does not. F4 (`live` gives each feed row its own 15 s interval) is real but harmless at these volumes and pre-existing in shape. Verified: unit 7,246 passed across 309 files (routers + blocks services + Apps); component 78 passed on the three touched specs; prettier clean; typecheck 0 errors under src/. The 3 eslint errors on these files are PRE-EXISTING — measured at branch HEAD via --stdin-filename, same count, same rules. * test(apps): make the single-sourcing STRUCTURAL — my own guard was walkable Audit round 3. Every finding was in MY round-2 fix, not in the product, and the headline one was WALKED rather than argued. F-A (guard) — the guard I added asserted (a) the string GLOBAL_SCOPE_ACTIVITY_OR appears somewhere in blocks.router.ts, satisfied by the COMMENT the same commit added, and (b) a 29-char literal is absent. Neither establishes that the probe's `where` derives from the constant. The audit walked it in one edit: swap the spread for a differently-spelled divergent predicate, leave the symbol alive in that comment, update the router test's own literal — the edit a developer making that change makes — and 67/67 stayed green while probe and feed diverged, silently restoring the no-tab defect this PR exists to fix. A guard on WORDS is satisfied by re-wording. Replaced with IDENTITY, in the suites that can observe the actual Prisma call: blocks.router.getNavSummary.test.ts expect(where.OR).toBe(GLOBAL_SCOPE_ACTIVITY_OR.OR) user-app-surface.orchestration.test.ts same toBe on the feed's where.OR `toBe` can only pass if the object handed to Prisma IS the exported one. The file-scanning half is deleted rather than tightened — a stricter regex is the same class of guard. Proof, the audit's exact walk re-run: 1 failed where it was 67/67 green, on this assertion's own message ("the probe did not pass the SHARED predicate to Prisma — it re-spelled its own copy"). F-D (payload) — my `{ OR: Array<Record<string, { not: null }>> }` annotation, chosen only to dodge a readonly-tuple error, erased a real compile-time check: `Record<string, …>` does not constrain key names. Measured by the audit, and re-measured here: `appBlockId` -> `appBlokId` inside the constant now gives `TS2561 … 'appBlokId' does not exist in type 'BlockScopeInvocationWhereInput'` at scope-activity-predicate.ts(41,10), where it previously typechecked clean. Typing the array as Prisma's own input restores it and compiles — the loose form bought nothing. F-B + F-C (payload/scaffolding) — putting the constant in user-app-surface.service.ts dragged that heavy service into blocks.router.ts's STATIC import graph, which is exactly what that router's five `await import(…)` sites exist to avoid, and which four router suites' one-key vi.mock factories would have tripped over on the next nav-summary case. Both close with one move: the constant now lives in its own leaf module whose ONLY import is type-only. Pinned by a test that classifies every import in that file. Verified: unit 7,246 passed / 309 files; the three predicate suites 67/67; prettier clean; typecheck 0 errors under src/ AND red on the planted typo. The 2 eslint errors on blocks.router.ts are PRE-EXISTING — same count at HEAD via --stdin-filename; my two new files are 0/0.
2026-09-08 18:33:12 -05:00
// ── `/apps/installed` → `/apps/activity` ───────────────────────────────────
// The page was renamed when it stopped being an installs surface: it now opens
// on the activity feed, and its gate widened from `appBlocks` (the model-slot
// flag) to `appBlocks || appBlocksPages`, so a viewer whose only app usage is a
// full-page app reaches it. The page component is MOVED (`installed.tsx` →
// `activity.tsx`), not stubbed — same reason as the three rules above.
//
// 🔴 `statusCode: 301`, matching its neighbours and for the same reason: Next
// maps `permanent: true` to 308, and these are GET-only pages whose inbound
// links are bookmarks and search results. The two keys are mutually exclusive
// in Next's schema.
//
// Next preserves the query string across a redirect, so an inbound
// `/apps/installed?tab=permissions` keeps its `?tab=` — which only became a
// meaningful statement when the tabs went URL-backed in the same change.
{
source: '/apps/installed',
destination: '/apps/activity',
statusCode: 301,
},
{
source: '/api/download/training-data/:modelVersionId',
destination: '/api/download/models/:modelVersionId?type=Training%20Data',
permanent: true,
},
{
source: '/github/:path*',
destination: 'https://github.com/civitai/civitai/:path*',
permanent: true,
},
{
source: '/discord',
destination: 'https://discord.gg/civitai',
permanent: true,
},
{
source: '/twitter',
destination: 'https://twitter.com/HelloCivitai',
permanent: true,
},
{
source: '/reddit',
destination: 'https://reddit.com/r/civitai',
permanent: true,
},
{
source: '/instagram',
destination: 'https://www.instagram.com/hellocivitai/',
permanent: true,
},
{
source: '/tiktok',
destination: 'https://www.tiktok.com/@hellocivitai',
permanent: true,
},
{
source: '/youtube',
destination: 'https://www.youtube.com/@civitai',
permanent: true,
},
{
source: '/twitch',
destination: 'https://www.twitch.tv/civitai',
permanent: true,
},
{
source: '/ideas',
destination: 'https://github.com/civitai/civitai/discussions/categories/ideas',
permanent: true,
},
{
source: '/v/civitai-link-intro',
destination: 'https://youtu.be/EHUjiDgh-MI',
permanent: false,
},
{
source: '/v/civitai-link-installation',
destination: 'https://youtu.be/fs-Zs-fvxb0',
permanent: false,
},
{
source: '/v/ally-parting-message',
destination: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
permanent: false,
},
{
source: '/gallery/:path*',
destination: '/images/:path*',
permanent: true,
},
{
source: '/canny/bugs',
destination:
'https://civitai-team.myfreshworks.com/login/auth/civitai?client_id=451979510707337272&redirect_uri=https%3A%2F%2Fcivitai.freshdesk.com%2Ffreshid%2Fcustomer_authorize_callback%3Fhd%3Dsupport.civitai.com',
permanent: true,
},
{
source: '/bugs',
destination:
'https://civitai-team.myfreshworks.com/login/auth/civitai?client_id=451979510707337272&redirect_uri=https%3A%2F%2Fcivitai.freshdesk.com%2Ffreshid%2Fcustomer_authorize_callback%3Fhd%3Dsupport.civitai.com',
permanent: true,
},
{
source: '/support-portal',
destination:
'https://civitai-team.myfreshworks.com/login/auth/civitai?client_id=451979510707337272&redirect_uri=https%3A%2F%2Fcivitai.freshdesk.com%2Ffreshid%2Fcustomer_authorize_callback%3Fhd%3Dsupport.civitai.com',
permanent: true,
},
{
source: '/leaderboard',
destination: '/leaderboard/overall',
permanent: true,
},
{
source: '/forms/bounty-refund',
destination: 'https://forms.clickup.com/8459928/f/825mr-8331/R30FGV9JFHLF527GGN',
permanent: true,
},
2025-02-27 09:52:57 -07:00
{
source: '/air/confirm',
destination: '/studio/confirm',
permanent: true,
},
{
source: '/education',
destination: 'https://education.civitai.com',
permanent: true,
},
{
source: '/cosmetic-shop',
destination: '/shop',
permanent: true,
},
{
source: '/shop/cosmetic-shop',
destination: '/shop',
permanent: true,
},
{
source: '/projectodyssey_season2',
destination: '/collections/6503138',
permanent: true,
},
{
source: '/creators-program',
destination: '/creator-program',
permanent: true,
},
Mantine v7 Migration (#1717) * Start migration, start updating files & details * Attempt at migrating ContainerGrid styles * Continue migration * Migrate tag/[tagname] * Checkpoint: Before the adsProvider migration * General progress - migrate complex AdUnit useStyles * Migrates VotableTags component to mantine v7 * Migrate card styles, profile styles and more * More migrations * Nits * Migrates components to mantine v7 * Migrate all articles components * Updates User components to mantine v7 * Cleanup to files up to ChatList.tsx * Checkpoint: Container Grid * Migrate up to CosmeticShopItemUpsertForm * Mantine migration: Training and subscription components * Fixes alpha color mix in css modules * Checkpoint up to QuickSearchDropdown * Checkpoint up to RouterTransition * Checkpoint up to ImageResources * Fixes after merging with main * Checkpoint - main components * Some pages * Fix minor nit * Completes migration of pages components * 2nd pass of fixes' * More fixes after typecheck * Last type fixes * Somehow, manage initial render * Minor nits, initial render, improvements * Update colors in theme * Minor changes and improvements * Fixes app header and footer styles * Replaces sx and text color everywhere * Fixes media queries * Fixes ContainerGrid * Fixes ActionIcon styles everywhere * Fixes alpha color mix in css modules * Fixes dark/light theme not working with tailwind * Fixes after merging with main * Fixes bad css module imports * Fixes issues preventing build * Main fixes to account and model feed pages * Fixes after merging with main * Fixes animation module file typo * Fixes to feeds * Fixes after merging with main * Fixes icons in menu items * Fixes homeblocks and other things * Fixes to AuctionFiltersDropdown * Fixes home page and most feed cards * Fixes most styles in the generation form * Fixes gen panel * Fixes shop styles * Updates package-lock file * Fixes after merging with main * Creator program nits * Fixes search page styles * Several fixes to small pages * Fixes css type issues * Fixes reviews page and css modules types issues * Fixes lock file and tailwind vars * Fixes package-lock * More fixes to package-lock * Buzz dashboard nits * Minor model form nits * Fixes model details page and richTextEditor styles * Start working on the training page * Brings back old color scheme * loader types + auction * Fixes most forms * Fixes type issues * Adjusts text link color * Fixes collections layout and styles * Final touches to the image detail page * Fixes after merging with main * Fix autocomplete search * Small tweaks and fixes * More style fixes * Fixes notifications and navigation progress * Fixes after merging with main * Adds creatable multiselect component * Fixes grouped select input * Adjusts aria-label for close buttons in modal * More small fixes * Fixes nits from review * Bunch of fixes all around * Fixes after merging with main * Another wave of fixes * Fixes after merging with main * Bunch of updates based off review feedback * General fixes * Finishes migrating all user facing pages and components * Fixes type issues * Fixes admin pages * Last round of feedback incoming --------- Co-authored-by: Luis Rojas <lrojas94@gmail.com> Co-authored-by: Brett Woodward <flipperbw@gmail.com>
2025-06-18 15:20:59 -04:00
{
source: '/research/rater',
destination: '/games/knights-of-new-order',
permanent: true,
},
2026-05-01 11:56:21 -06:00
{
// Reserved-name redirect: the legacy 'civitai' user account moved to
// 'CivitaiOfficial'. Handled here (not in middleware) so it runs at
// framework/edge level before any middleware, with no JS execution
// per request.
source: '/user/civitai',
destination: '/user/CivitaiOfficial',
permanent: true,
},
];
},
output: 'standalone',
})
);