Skip to content

v3: fullstack line (ORM, global IDs, queues, pages) → main - #112

Draft
schettn wants to merge 744 commits into
mainfrom
feat/v3-fullstack
Draft

v3: fullstack line (ORM, global IDs, queues, pages) → main#112
schettn wants to merge 744 commits into
mainfrom
feat/v3-fullstack

Conversation

@schettn

@schettn schettn commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

What this is

Promotes the v3-fullstack line to main. main is an ancestor of this branch, so the merge is conflict-free — this PR is the whole line since 1d1697c (Oct 2025), ~482 commits.

⚠️ Draft — not merge-ready. Opened for CI + review. Merging is a real release (see below); hold until reviewed.

Highlights

  • pylon-db — type-driven ORM (migrations/diff engine, relations, pagination, STI, keyed-query batching, signals)
  • Snowflake IDs + Relay global IDs (gid) across db/query/pages
  • pylon-queues — first-class background jobs
  • pylon-pages — usePages fullstack React (SSR streaming, static analyzer, image/LQIP, sitemaps) — reimplements & supersedes the parallel v3 branch's pages work
  • pylon-query — owned gqty replacement
  • pylon-auth / resource authz, gateway (delegate/patch/pull)
  • Docs → Coolify: distroless non-root image (532 MB), /health healthcheck, per-PR canary-pr-<n> npm tags; Vercel workflows removed

Release impact (read before merging)

  • Pending changesets bump @getcronit/pylon3.0.0 (major) and @getcronit/pylon-dev → major.
  • Opening this PR publishes isolated npm snapshots under @canary-pr-<this-PR-number> (via canary.yml).
  • Merging to main triggers release.yml → real changeset publish to npm @latest.

Notes

  • Supersedes the divergent v3 branch (its pages/analyzer features were independently reimplemented here in pylon-pages; a couple of v3-only fixes — analyzer cross-run cache, config-extraction dep-tracing — remain uncherrypicked).
  • Includes in-flight work (e.g. a wip(v3): checkpoint commit); review scope accordingly.

@changeset-bot

changeset-bot Bot commented Aug 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6fdf598

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@getcronit/pylon Major
create-pylon Major
@getcronit/pylon-docs Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

schettn added 29 commits August 13, 2026 08:29
@getcronit/pylon-<feature> -> @getcronit/pylon/<feature> across the moved source
(imports, emitted-code templates, and test snapshots all update to the renamed
package). Core self-refs (@getcronit/pylon) unchanged. Fixed the pages build's
node_modules resolve to the fat package. Build is red until the package.json
exports map + self-reference paths + merged deps land next.
Step 1-3 of consolidation:
- Merge all folded packages' third-party deps into pylon/package.json (44
  regular, 8 peer). Internal @getcronit/pylon* workspace refs dropped. Existing
  optional-peer flags preserved (react/react-dom/zod/@bull-board/postcss-tooling).
  §5 optional-peer reclassification (pg/bullmq/sharp/lucide) deferred — needs the
  lazy-load code changes.
- exports map (§4 target: per-feature <feature> + <feature>/plugin) + pylon bin.
- tsconfig paths so @getcronit/pylon(/*) self-refs resolve to src during build.

Build still red: needs step 4 (feature index/plugin split, flatten pages nesting,
relocate build-time code), delete folded package dirs, workspace + lockfile, and
build-system rework for per-feature entry emit.
…eDatabase

Step 4 pattern (db as template): the feature root (`@getcronit/pylon/db`) exports
only the authoring API (Model, fields, query); the config plugin `useDatabase`
lives solely at `@getcronit/pylon/db/plugin` (plugin.ts already existed). Nothing
in pylon/src imported useDatabase from the root, so this is safe.

Applies cleanly to queues (plugin.ts exists too). auth needs a plugin.ts created
(useIdentity currently in authz.ts); pages needs plugin.ts + flattening the
pages/pages nesting; query has no plugin (client runtime only).
- queues: drop useQueues re-export from index (plugin.ts already exists) →
  useQueues only at @getcronit/pylon/queues/plugin.
- auth: add auth/plugin.ts re-exporting useIdentity (impl stays in authz.ts,
  shared with the API helpers); index exports only getPrincipal/authorize/
  requireRole + IdentityProvider. useIdentity only at @getcronit/pylon/auth/plugin.

No internal src consumers import these plugins from their roots (verified).
…fix @/ alias

- New pages/index.ts barrel re-exports the runtime (pages/pages/index.ts) so
  @getcronit/pylon/pages = runtime; usePages moved to ./pages/plugin (plugin.ts).
- Rewrite the pages-only @/ alias to @/pages/ — after the move to pylon/src the
  shared baseUrl made @/* resolve to pylon/src/* (wrong); @/pages/* restores the
  pages-scoped target (17 refs). No other feature used @/.
- Internal ./pages/pages nesting kept (cosmetic flatten is a later follow-up).
…sted)

Two esbuild passes: node (core/db/queues/auth/ir/pages-plugin/cli entries) and
browser (pages + query runtimes, PostCSS). outbase=src emits one output per
exports subpath. Cross-feature self-imports externalized (resolve to sibling dist
at runtime via exports; tsconfig paths still resolve them to src for tsc).

Untested until the workspace is installable — blocked on consumer migration.
Each folded package (@getcronit/pylon-db/-ir/-query/-queues/-auth/-pages) is now a
thin DEPRECATED shim: index.js/.d.ts = 'export * from @getcronit/pylon/<feature>'
(+ subpath re-exports: auth contract/zitadel, pages plugin/index.css). pylon-dev is
a marker shim — the `pylon` bin now ships with @getcronit/pylon.

Keeps all 78 consumers + 108 husk tests resolving via workspace:* while migration
happens incrementally; shims deleted once consumers move to @getcronit/pylon/*.
…ackage builds green

- src/cli/{index,mcp}.ts: ../package.json → ../../package.json (cli moved a level
  deeper: pylon-dev/src → pylon/src/cli).
- Move pages postcss.config.js into pylon (needed by the pages CSS build).

Result: pnpm --filter @getcronit/pylon build is GREEN — esbuild emits all
per-feature entries (dist/db/index.js, dist/db/plugin.js, dist/pages/index.js +
index.css, dist/cli/index.js, ...) and tsc --declaration + tsc-alias pass, so the
self-reference paths resolve and typecheck.
…al imports

Answering 'why self-reference internally?': we shouldn't. esbuild resolves subpath
self-refs (@getcronit/pylon/ir) through the exports map and emits broken external
'../ir' dir imports. Fixes:
- Split tsconfig: tsconfig.json (esbuild) omits self-ref paths → externalizes them;
  tsconfig.typecheck.json (tsc) adds them for declarations.
- Convert the shared INTERNAL cross-feature imports (ir, query/build) to RELATIVE
  in real source (23 files) so esbuild BUNDLES them; splitting dedupes → the model
  registry singleton still holds. Snapshots left intact.
- build.js: drop tsconfigRaw/onResolve-plugin, add query/build entry.
- exports: add ./ir + ./query/build (internal-but-resolvable).

Result: pnpm --filter @getcronit/pylon build is GREEN. Docs consumer build now runs
the CLI end-to-end (server + client) and emits .pylon/server.mjs; one remaining
pages-codegen path issue (generated app.tsx imports ../pages).
… runner entry

- tsc-alias uses tsconfig.json (only @/*), not tsconfig.typecheck.json — it was
  rewriting @getcronit/pylon/pages → ../pages INSIDE the app.tsx template string in
  the esbuild .js chunks, breaking generated consumer code.
- Build cli/index.ts + cli/project-runner.ts as a SEPARATE no-splitting pass so
  they stay siblings in dist/cli/ (splitting moved spawnProjectRunner into a chunk,
  breaking its import.meta-relative lookup of project-runner.js).
- Add project-runner.ts entry (the ORM-introspection child process).

Verified: docs consumer build (via shims) is GREEN end-to-end — CLI runs, ORM
introspection succeeds, pages server+client build, .pylon/server.mjs emitted.
Rewrite @getcronit/pylon-<f> → @getcronit/pylon/<f> in consumer code; plugin fns
(useDatabase/useQueues/useIdentity) → @getcronit/pylon/<f>/plugin (they moved from
the feature root to /plugin). No mixed plugin/API imports. (examples/ is gitignored
but was migrated locally too, for install.)
…feature>

git mv (history-preserving) of auth/db/ir/queues/dev test trees. Import rewrites
follow in the next commit.
- Rewrite imports: @getcronit/pylon-<f> → @getcronit/pylon/<f> (plugin fns → /plugin),
  internal ../src/<f>/… → @/… (matches src baseUrl convention).
- vitest.config.ts: alias every self-subpath + @/ to SOURCE (one copy → shared model
  registry; no build needed to test).
- Merge docker-compose (postgres:5433 + redis:6380); add test/test:integration scripts.
- Fix stale queue-name expectations (':' → '.', BullMQ-safe; predates fold, never in CI).

409 unit pass, 285 integration skipped (need DB/Redis env).
- eval-harness/worker-cli: files moved one dir deeper (test/dev) + CLI now lives at
  dist/cli/index.js — add the extra '..' and the cli/ segment.
- prepare-model-source: track the rewritten fixture import (@getcronit/pylon/db).

Full unit suite: 437 pass, 286 integration skipped (need DB/Redis).
packages/ is now just {pylon, create-pylon}. The 7 transition shim dirs
(pylon-{auth,db,dev,ir,pages,query,queues}) are removed now that all consumers
import from @getcronit/pylon subpaths.

- Consumer deps: drop every @getcronit/pylon-* entry from docs/e2e/examples
  package.json (the pylon bin + all features come from @getcronit/pylon).
- e2e pretest: build only @getcronit/pylon.
- CI (db-migrations.yml): filter @getcronit/pylon; tests run against source via the
  vitest alias, path globs → packages/pylon/**.
- changesets: retarget @getcronit/pylon-dev → @getcronit/pylon; add a consolidation
  changeset (major) with migration guidance.
- Regenerate pnpm-lock.yaml (12 workspace projects).

Verified: pnpm build green, 437 unit tests pass (286 integration skipped), docs
consumer builds clean against the consolidated package.
- Rewrite all @getcronit/pylon-<f> examples → @getcronit/pylon/<f> across 35 doc
  pages (plugin fns → /<f>/plugin; fix a pylon-queue typo).
- coverage/check-examples.mjs: remap specifier → .d.ts to the consolidated dist
  layout (adds db/plugin, auth/plugin, queues/plugin, ir, query subpaths).
- coverage/registry.mjs: rekey packages to subpaths, point entries at
  packages/pylon/src/<f>, add the /plugin entries; CLI_SOURCE → src/cli/index.ts.
- Dockerfile: build via node_modules/@getcronit/pylon/dist/cli/index.js.

check:coverage + check:examples both pass.
Generated projects now depend on @getcronit/pylon alone (the pylon CLI + all
features + subpath exports come from it):
- Drop @getcronit/pylon-dev from every template (bun/node/cloudflare/deno) and the
  injected @getcronit/pylon-query dep (pages scaffold).
- Emit subpath imports: useIdentity → @getcronit/pylon/auth/plugin, zitadel →
  @getcronit/pylon/auth/zitadel, usePages → @getcronit/pylon/pages/plugin;
  declare module '@getcronit/pylon/pages'.
- Bump the scaffolded pylon version to ^3.0.0 (the consolidation major).
…rver

The dev script overrode the runner with plain `node .pylon/server.mjs`. The
unbundled dev entry imports the app from source (src/index.ts), and Node's native
type-stripping does NOT remap TS `.js` import specifiers to their `.ts` files —
so `./lib/content.js` failed with ERR_MODULE_NOT_FOUND before the server could
boot (and, downstream, data.docPage() never wired up). `pylon dev` with no `-c`
runs server.mjs through tsx's loader (--require preflight --import loader), which
resolves .js→.ts. Verified: /docs/getting-started serves 200, GraphQL docPage 200.
…ed entry

Generated projects pointed at the removed bundled .pylon/index.js and used runners
that can't load the unbundled .pylon/server.mjs (dev imports the app from source;
Node native type-stripping doesn't remap TS .js→.ts specifiers). Per runtime:
- node: dev → 'pylon dev' (CLI default = tsx loader on server.mjs, resolves .js→.ts);
  add start → node .pylon/server.mjs (build mode transpiles to .pylon/src/index.js).
- bun: dev/start → bun run .pylon/server.mjs (bun resolves .js→.ts natively — verified).
- deno: dev → deno run -A --unstable-sloppy-imports .pylon/server.mjs; start (post-build)
  needs no flag.
- cloudflare: wrangler main → .pylon/server.mjs (wrangler's esbuild bundles it).
- Dockerfiles (bun/node): ENTRYPOINT → .pylon/server.mjs.
src/ now holds only feature directories; the loose core files (index, context,
create-decorator, define-pylon, gateway, get-env, mutation, resolve-info) live in
src/core/. The '.' export + entry points repointed:
- package.json exports '.' → dist/core/index.js; main/types too
- build.js entry → src/core/index.ts (outbase keeps dist/core/)
- tsconfig.typecheck @getcronit/pylon → ./core/index.ts; vitest alias → core/index.ts
- docs coverage registry/check-examples → src/core / dist/core
Imports recomputed for the new depth; 4 barrel imports (from '..') → '../core'.
Build green, self-ref tests pass.
src/ is now 100% source (zero test files). Every colocated test moved to its mirror
under test/<same path>, and test/dev → test/cli (mirrors src/cli). This RE-ENABLES
~40 test files the consolidated vitest include ('test/**') had orphaned since the
fold (query, pages analyzer, cli/builder/schema, app, core).

- git mv (history-preserving) 65 files incl __tests__ dirs (with test-utils,
  __snapshots__, fixtures) and test/dev→test/cli.
- Test imports to source rewritten to the @/ alias; existence-checked so import-like
  text inside code-as-string fixtures/assertions is left untouched.
- Regenerated 45 fragile analyzer snapshots (stale on pre-fold paths + a react
  version bump) and updated 9 stale app-utils lazy expectations (analyzer emits a
  './'-prefixed relative path) — both pre-existing drift the orphaning had hidden.

Suite: 140 files / 1070 tests (was 99 / 727) — 780 pass, 286 integration skipped.
…heck

- Root tsconfig.json was compiling the ENTIRE repo under one flat config (no
  include/references) → 292 phantom errors (docs' @/ alias, ORM decorators under
  useDefineForClassFields, etc.). Scope it to a base-only config (files: []); it just
  supplies shared compilerOptions to the one config that extends it (create-pylon).
- Add a 'typecheck' script to pylon/create-pylon/docs + a root 'pnpm -r typecheck'
  aggregator — the sanctioned way to type-check each workspace with its own config.
- docs: fix a real type error (unist-util-visit index is number|undefined now, not
  number|null) in src/lib/markdown.ts.

pnpm -r typecheck: pylon + create-pylon + docs all clean.
tsconfig.json excludes *.test.ts (it builds src), so opening any of the ~139 test
files showed unresolved @/… and @getcronit/pylon/… imports in the editor (no config
covered them). Add test/tsconfig.json (extends the self-ref-aware typecheck config,
rootDir=package root, noEmit) and fold it into the 'typecheck' script. Tests
type-check clean (0 errors).
Newer TypeScript flags 'baseUrl' as deprecated. Remove it everywhere and make
'paths' self-contained (resolved relative to each config's directory):
- packages/pylon/tsconfig.json: @/* → ./src/* (was baseUrl ./src + @/* → ./*)
- tsconfig.typecheck.json: self-ref paths gain the ./src/ prefix
- docs/tsconfig.json: drop baseUrl '.' (@/* already relative to the config dir)

Verified: esbuild still resolves @/ and externalizes @getcronit/pylon/* self-refs,
tsc-alias rewrites cleanly (no @/ in dist .js/.d.ts), pnpm -r typecheck + tests green.
…ishing

npm's Trusted Publisher (OIDC) binds to a single repo + workflow FILENAME per
package, so two publishing workflows (release.yml + canary.yml) can't both
authenticate. Merge them into .github/workflows/publish.yml with two mutually
exclusive, event-gated jobs (push→release, pull_request→canary); steps preserved
verbatim. Delete the two old files.

ACTION REQUIRED (manual, on npmjs.com): repoint the Trusted Publisher for
@getcronit/pylon AND create-pylon to publish.yml.
Two concerns shared one condition:

  options.origin && i18n && (context.statusCode ?? 200) < 400

Alternates need i18n — locale basenames are the point. A canonical does not: it
is the page's own URL. So a single-locale site with origin set emitted NEITHER,
which is the clearly wrong half. Split, so canonical needs only origin.

usePages({canonical: false}) then hands the tag to the app, because the default
is a guess the framework cannot always make correctly. It cannot know which
query parameters matter — ?page=2 is a different set of items and belongs in
the canonical, ?colour=red is a filtered view of the same set and does not —
nor that two routes serve one thing.

A flag rather than an override because rendering your own alongside does not
work: React appends <link> to <head> rather than replacing, and does not
deduplicate by id or key. Measured on a real page — three canonicals in head,
two of them sharing an id — and search engines discard conflicting canonicals
outright, so the page ends up worse than with the wrong-but-single default.

Noticed while here, not fixed: a render-thrown notFound() still emits hreflang
alternates. metadata is computed before context.statusCode is assigned for that
path, so the 'a 404 must not advertise itself' guard reads undefined ?? 200.
Harmless in practice — the response is still 404 — but it is the documented
intent not holding.
The `window.__pylonStaticData` envelope was rendered as a server-only <script>
inside the hydrated document. With no client counterpart it was a hydration
asymmetry: an app <script> placed nearby (JSON-LD in <head> or body-top)
reconciled against it, so React discarded and re-rendered the whole document
client-side — throwing away the SSR HTML on every load.

Emit the pre-render half (context/i18n/messages) via React's
bootstrapScriptContent (out-of-tree, streaming-safe), keep the post-render
`cache` as a trailing inline script, and leave the hoistable <link>s in-tree
(React matches them by identity, so they carry no positional asymmetry).
Add a `loading.tsx` file convention that generates framework-managed React
Suspense boundaries per route segment. It cascades like error.tsx/not-found.tsx
(nearest ancestor unless overridden) and is wired as the route's HydrateFallback.

The segment's leaf page is wrapped in a CLIENT-ONLY boundary (withLoading): on the
server the component renders directly so a suspending useData escalates to the
shell — the buffered SSR HTML carries resolved content and a thrown notFound()
stays a real 404 — while the client gets the navigation loading state.

Buffered phase only; no send-path change. Design + phasing in
rfcs/PAGES_STREAMING.md. Validated by e2e/tests/loading-boundary-serve.e2e.test.ts
(+ fixtures/loading-app): fallback absent from SSR HTML on own/inherited/none
segments, generated routes wire own + inherited and skip uncovered ones, fallback
shipped to the client bundle.
Add a generic, always-on per-operation `context` bag carried on the `@inContext`
directive (as a JSON String, so the directive stays app-independent), typed by an
app-augmentable `OperationContext` and read server-side via `getInContext().context`.
Adding a new per-op field is a `declare module`, never a compiler change.

Its flagship use is acting-as-tenant (rfcs/ACTING_TENANT.md): a privileged principal
runs a SINGLE operation as another tenant, gated by `useDatabase({operationContext})`
— a bare value grants nothing. Every ordinary tenant-scoped resolver serves the acted
org; no parallel unscoped() admin API.

- core/in-context: @incontext gains `context: String`; OperationContext type
- use-in-context: parse the context bag into InContext.context
- db/plugin: operationContext(base, op) hook — per-op AppContext rebind via envelop
  setExecuteFn (active during resolution; connection stays request-scoped)
- query/build/compile: emit $__context on every compiled op (always-on, inert until
  the server gate acts); doc carries the opContext marker
- use-data / use-mutation: per-call { context } option, canonical-JSON, folded into
  the cache key by construction; threads through SSR unchanged
- e2e: acting-tenant-app fixture + serve test — tenant isolation, gate deny,
  per-operation no-leak, acting write (6/6, Dockerized)
…ries)

The SSR handler now always uses the streaming send path in prod: it flushes the
shell as soon as React has it and streams each Suspense boundary in as its data
resolves. There is no flag and no gating — with no boundary (no `loading.tsx` and
no manual `<Suspense>`) the shell is the whole document, so the single send path
degenerates to the buffered result with no behavioral difference.

- `loading.tsx` boundaries (from the previous commit) are now active on the server:
  a boundary whose `useData` is pending flushes its fallback in the shell and
  streams the resolved segment in behind it. `withLoading` no longer has a
  client-only guard.
- Status/containment preserved where possible: with no boundary any throw is a
  shell error, which rejects renderToReadableStream before a byte flushes, so the
  handler falls through to the buffered path whose re-render draws the errorElement
  server-side with the correct status. Only a throw BELOW a flushed boundary
  degrades (stays 200, contained client-side) — inherent, since JSX gates which
  useData run, so the executed-query set is render-determined and unknowable
  pre-flush. See rfcs/PAGES_STREAMING.md.
- Cache handoff: pre-render half via bootstrapScriptContent, post-render store
  snapshot appended by a TransformStream flush at stream end.
- Dev stays buffered (the Vite HTML transform needs the whole document string).

Validated by e2e/tests/loading-boundary-serve.e2e.test.ts + fixtures/loading-app.
…directive check

The per-operation context channel adds `context: $__context` to the compiled
directive (`@inContext(locale: $__locale, context: $__context)`), so the exact
`@inContext(locale: $__locale)` substring no longer matched. Match the locale
argument tolerant of a trailing `, context: …`. The locale channel is unchanged.
- Loading & Errors: rewrite the loading section for streaming SSR (shell flushes
  first, boundaries stream in; no boundary → resolved content in the shell), add a
  `loading.tsx` convention section (cascades like error.tsx/not-found.tsx, wired as
  HydrateFallback) and the manual <Suspense> option, and warn about the one
  trade-off: a notFound/redirect thrown below a boundary can't change the status.
- Routing: list loading.tsx alongside error.tsx/not-found.tsx in the conventions.
…tions against the mutation root

Two independent bugs, both found by a storefront running this canary.

`<Image>` preloaded its generated LQIP placeholder for EVERY image. The
`priority` preload immediately above it in the same component is gated; this one
was not, and nothing deduped it. React hoists the links into `<head>`, so a page
put each of its pictures — lazy ones far below the fold included — at the front
of the network queue, competing with the CSS and fonts the first paint waits on.
One home page emitted 39, sixteen of them duplicates because a logo marquee
renders its row twice. PageSpeed attributed ~1,750 ms of render-blocking to it
against FCP 3.5 s / LCP 4.4 s, while TBT was 20 ms — the page was not slow
because of JavaScript. Gating it on `priority` takes that page to 3. The
placeholder itself is unaffected: it is a CSS background, and only the network
hint is dropped. A lazy image preloading anything defeats being lazy.

`op.mutation` projected its result against the QUERY root type. `wrapDoc` passed
no root type name and `wrapResult` defaults to `descriptor.query`, so a mutation
field was never found: the descriptor lookup missed, `callable` was undefined,
and the proxy returned a plain value where the selector calls a function —
`<field> is not a function`, thrown AFTER the request completed. The mutation had
already run, so the write landed and only reading the result failed, which made
it look like anything but a client bug. Every `op.mutation` over a field with
arguments was affected, which is every mutation. `runMutation`, ten lines below
`wrapDoc`, has always passed "Mutation" explicitly; `op.mutation` now says so
too.

Both tests fail without their fix — the mutation one reproduces the original
`is not a function` exactly.
…ring

The generated types say `Date`; the wire carries an ISO string; nothing
reconciled the two. `article.publishedAt.toISOString()` type-checked, built,
and threw at render — `.toISOString is not a function` — so a page that touched
a date 500'd past every static check. Every consumer had to write
`new Date(x)` around a value already typed `Date`, which reads like a mistake
and silently keeps working if the scalar is ever fixed.

Revived at read, in the wrap layer, so the store stays JSON-serialisable — it
is embedded in the hydration payload, and Dates there would serialise back to
strings and diverge between server and client.

Memoised on the string. The proxy rebuilds a value on every read, so a fresh
Date per read is a new object identity per render, which silently invalidates
every useMemo/useEffect dependency it appears in.

A scalar LIST is handled in the same branch: `fd.scalar` and `fd.list` are both
true for `[Date!]` and the scalar check runs first, so elements are revived
there or not at all — the first version of this missed them and the test caught
it.

An unparseable value passes through untouched rather than becoming an Invalid
Date: a string that reaches a consumer can be debugged, `Invalid Date` is the
type lying a second time.
…hing a placeholder

The generated LQIP placeholder costs one request per image, and a CSS
background is not lazy — it is fetched as soon as the element renders, however
far down the page it sits. A thumbHash is ~25 bytes that decode to the picture's
average colour, so supplying one makes the placeholder free: it is written into
the HTML and painted on first parse, with nothing to fetch and nothing to
preload.

Only the average colour is decoded, so the blur decoder never reaches a browser
bundle. A genuinely blurred placeholder is worth roughly 5.8 KB of inline HTML
and worth it for the LCP image alone; callers who want one decode it themselves
and pass `blurDataURL`, which already wins over both paths.

Additive and safe: no hash means today's behaviour, unchanged. An unusable hash
degrades to it too rather than crashing a render — the value travels from a
database through an API, and the picture is decoration.
`runClientBuild` — the bundle a VISITOR downloads — had `minify: false`
hardcoded, while the SSR bundle twenty lines below it minifies whenever
`PYLON_DEV` is unset. The wrong one of the two was optimised: nobody downloads
the server bundle.

Measured on a deployed storefront, `/__pylon/static/app-*.js`:

    before   1,059,171 bytes   21,681 lines
    after      470,183 bytes      286 lines   (136 KB gzipped)

Skipped under `pylon dev`, matching the SSR bundle: pure rebuild cost, and an
unminified stack trace is worth more than bytes while you are working.

Smoke-tested against a production server rather than assumed — minification is
exactly the step that breaks at runtime rather than at build. Four routes serve
200 and hydrate with an empty console.
`/__pylon/static/*` sent no `Cache-Control` at all, so whatever sits in front
of the origin picked a default — Cloudflare's is four hours. A returning
visitor re-validated every hashed asset several times a day for files whose
bytes cannot change: the hash IS the version, so a different build means a
different name.

Applied only to hashed names. `manifest.json` and anything else unhashed keeps
today's behaviour, because a client that caches an unhashed name for a year
has no way of being told it is wrong.

Verified against a production server: hashed js and css come back
`public, max-age=31536000, immutable`, `manifest.json` does not.
The previous commit set the header before serving, so a 404 carried
`max-age=31536000, immutable` too. A missing hashed asset is not a rare case:
during a rolling deploy one container serves HTML naming the new hashes while a
chunk request lands on the container that still holds the old ones. Cached for
a year by the browser or the CDN, that turns a blip lasting seconds into a page
that stays broken until someone clears their cache — strictly worse than the
four-hour default it replaced.

The header now goes on the response and only when it is a hit. A miss is
explicitly `no-store`: the file usually exists moments later, and nothing
should remember otherwise.

Verified against a production server: a missing chunk is `404 / no-store`, a
real chunk and the app bundle are `200 / immutable`.
… ones

The rule lived inside `flushCookies`, after its early return, so a page that
set no cookie sent no `Cache-Control` at all. That is not "uncached": with no
directive a browser invents a freshness lifetime for the document. The visitor
keeps HTML naming content-hashed bundles that the next deploy deleted, and
every chunk 404s until they hard-refresh — which is exactly how this was found.

Moved above the early return so it applies to every page response. `no-cache`
still stores the document and revalidates before use, which the ETag turns into
a 304, so the cost is a conditional request rather than a re-download.
`private` for the reason the rule already existed: a page rendered for one
visitor must not be held by a shared cache. An app that sets its own policy
keeps it.

Verified against a production server: `/`, `/shop`, `/en` and a 404 all return
`private, no-cache`, while hashed assets stay `immutable` and a missing one
stays `no-store`.
… be cached

Two findings from a deployed storefront behind Cloudflare, where every asset
came back `cf-cache-status: DYNAMIC`.

`useRequestContext({vary})` runs on `*`, so it appended `Vary: Cookie` to every
response — the static-file route and the image proxy included. A `.woff2` or a
resized `.webp` is determined entirely by its URL and cannot differ per cookie,
and the cost is not cosmetic: a shared cache will not reuse a response that
varies on Cookie, so this quietly cancelled whatever `Cache-Control` the asset
was given. Cloudflare only caches on `Vary: Accept-Encoding`. Now applied only
to responses whose content can actually depend on the context — HTML and JSON.

The image proxy sent no `Cache-Control` at all, so a CDN in front had to guess,
and guessing "don't" means re-running the transform for every visitor of every
page. Its output is a pure function of the query, so a day plus a week of
stale-while-revalidate is safe. NOT `immutable`: `src` is a path, not a content
hash, and replacing the file behind it must not strand clients for a year.

Set on BOTH success paths. The header first went only on the transform path,
while the disk-hit path returns earlier — which is nearly every request in
production, so the common case stayed uncacheable and the fix looked like it
had not worked.
The dev sweep deleted any output untouched for 15 seconds, on the assumption
that a rebuild's predecessor was finished being used within that window. That
held when a render was buffered. It does not hold now that SSR streams: the
response stays open across Suspense boundaries, so a route chunk can be
dynamically imported long after the request began — and Node's ESM registry
keeps the old graph reachable meanwhile. The symptom is server-side only and
transient, which is what makes it confusing:

    Cannot find module '.../pages/chunks/page-Bvpo4eRj.js'
    imported from '.../pages/app-CHlw9uzg.js'

Age was standing in for "unreferenced", and it is a poor proxy. The sweep now
reads the current manifest's entry, walks its import graph, and spares every
file in it whatever its mtime — so the LIVE bundle can never lose a chunk. The
age rule still bounds growth for everything outside that graph, with the grace
raised to 60s to cover a streamed render holding an older generation.

Not a reproduction-backed fix: the original race did not reproduce under
deliberate rebuild pressure. What is verified is the invariant it protects —
the live graph stays whole across repeated rebuilds, and the output directory
still does not grow without bound.
The stylesheets were never minified. A storefront shipped 129,616 bytes across
4,789 lines, and CSS blocks rendering, so those bytes sit directly in front of
the first paint — PageSpeed attributed 600 ms of render-blocking to that one
file.

lightningcss rather than a PostCSS minifier: it already arrives through Tailwind
v4 and Vite, so for a pages project this deduplicates rather than adds. Declared
explicitly all the same — depending on a transitive dependency is how a working
build breaks on someone else's install.

Minify only, no `targets`. Tailwind v4 already lowers and prefixes with this
same library; re-running that here would be a second opinion on output that is
already correct.

Applied BEFORE hashing, so the hash names the bytes actually served. Loaded
lazily and failing open — a stylesheet that cannot be minified is worth shipping
unminified, never worth failing a build over. Skipped in dev, like the JS
minifier.

Measured on a real app: app.css 129,616 → 106,109 bytes, index.css to 25,145,
gzipped 18.3 KB and 5.5 KB. The gzip saving is modest, since compression already
handled the whitespace; the win is parse time and the raw bytes a cold cache
pays for.
`public/` was served with no `Cache-Control` at all, so fonts, favicons and
share images were left to whatever sits in front to guess — Cloudflare guesses
four hours, for files that change once a year.

Decided by ROUTE rather than by pattern-matching the filename, which is the
second attempt: a regex loose enough to catch a real hash
(`app-DYyBxXu-.js`) also matched `hanken-grotesk-400-latin.woff2`, and cached an
author-named font immutably for a year. Unfixable, and exactly the failure the
`immutable` precondition exists to prevent.

The route already knows what it is serving. Under `/__pylon/static/*`
everything is a build output and content-hashed by construction, so it is
immutable — except `manifest.json`, a fixed name rewritten every build, which
is `no-cache`. Under the public route everything is author-named and
replaceable in place, so it gets a day plus a week of stale-while-revalidate.
Misses stay `no-store` on both.
`process.env.NODE_ENV` was defined as `process.env.NODE_ENV || 'development'`,
and `pylon build` sets neither — so a production bundle resolved React's
conditional exports to the DEVELOPMENT build. The shipped JS carried
`react.development.js` and `react-dom-client.development.js`.

That is not mainly a size problem. Development React validates on every element
creation and every hydration step, and it showed: a deployed storefront painted
in 0.5s (FCP) and 0.9s (LCP) and still scored 65, because Total Blocking Time
was 1,620 ms across 20 long tasks, with 2.9s of script execution and 5.7s of
main-thread work. Mobile hid it — TBT is only charged between FCP and TTI, and
on a slow connection the work lands after that window.

It also hides itself the other way: a Dockerfile that sets `NODE_ENV=production`
on the RUNTIME stage, as ours does, looks correct while the build stage that
actually bakes the constant runs without it.

`pylon build` is the production build, so it now defaults accordingly. An
explicit NODE_ENV still wins, and `pylon dev` sets PYLON_DEV, which keeps the
development build and the warnings that are the point of running dev.

Client JS 1,036 KB → 844 KB, and the remaining work per hydration is a fraction
of what it was. Four routes serve 200 and hydrate with an empty console.
`index.css` styles Pylon's OWN surfaces — the dev error overlay, StatusPage,
GlobalErrorPage — and it was linked from the root layout of every page. On a
storefront that meant a second render-blocking stylesheet, 25 KB from a
different Tailwind version than the app's, for components the page never
renders. Two stylesheets is also two round trips, which on a slow connection
costs more than either file's bytes.

StatusPage and GlobalErrorPage already link it themselves when they render, so
nothing needed the layout to do it. Kept in dev, where the overlay can appear at
any moment; the `process.env.NODE_ENV` comparison is statically replaced at
build, so production drops the link and the request with it.

Verified: an ordinary production page now links one stylesheet, dev still links
both, and the app's own 404 is unaffected.

The app-utils snapshot is refreshed. It was already failing before this change
on a whitespace-only drift — generated lines gained two spaces of indentation —
so this update absorbs both. The suite is green.
A bare manyToMany applied NO order, so a relation came back in whatever order
the query plan yielded — and two different queries over the same relation (a
list card's batched connection vs a detail sheet's single-row read) could
disagree, swapping the rows. Even a declared hasMany orderBy over a non-unique
column left ties unordered.

- manyToMany gains a typed `orderBy` (a target property, `-`-prefixed = desc),
  threaded descriptor → binding → ManyToManyManager → loadManyToMany.
- Both loadHasMany AND loadManyToMany now append the target PRIMARY KEY as a
  final sort key, so a declared order is total even with ties, and a relation
  with no declared order falls back to the PK — deterministic instead of
  plan-dependent. The batched load orders the single IN(...) query, so every
  owner's sublist inherits it.

Does NOT steer {paginate:true} connections yet (they keyset on the target PK);
declared-order pagination needs composite cursors — a separate change.

m2m integration tests: default order is deterministic + link-order-independent,
and a declared orderBy sorts with the PK tiebreaker. 11/11 green on Postgres.
`delegate()` writes its arguments into the outgoing document rather than
declaring them as variables, so a flat local signature can be reshaped into
whatever wrapper the remote wants without Pylon having to derive a GraphQL type
for the result — a literal needs no declaration.

A file has no literal form. `astFromJSValue` fell through to the object branch,
`Object.entries(file)` is empty, and the argument arrived at the remote as `{}`
with no error anywhere: a storefront forwarding a contact-form attachment sent
an empty object and the remote's own validation reported the file as the wrong
type. Every delegated upload was silently dropped.

Values that cannot be written as literals are now lifted back into the request
payload as variables, where the HTTP executor's multipart extraction finds
them. The type comes from the TypeInfo pass that already ran to coerce enums —
`getInputType()` at the variable's position is the item type inside a list and
the field type inside an input object, which is what the definition needs. The
detection is duck-typed on arrayBuffer/stream/size rather than `instanceof
Blob`: this is the path between two processes, so the value may come from
another copy of the runtime's globals.

The pruning and inlining passes both key on the INCOMING payload, where a
lifted variable never appears — unguarded, they deleted the argument or wrote
`null` over it, which is how an upload first reached a remote as `[null]`.
Server-authored arguments (`needs.__args`, forced policy values) pass no
collector and keep the old flattening; they never carry a file.

Verified end to end against a real remote: a renamed text file is refused by
the remote's own magic-byte check with its own message, and a PNG and a PDF
arrive as separate MIME parts with the right types and byte counts.
A union's interface is the fields its members share, and "share" was decided by
serialising the whole field descriptor and comparing strings:

    type.fields.some(f => JSON.stringify(f) === JSON.stringify(field))

A field's JSDoc lives inside that descriptor (`TypeDefinition.description`), so
two members declaring `description: string | null` with different comments did
not share it and the interface lost it.

Nothing reports this. The build succeeds, the schema is valid, and a caller
that reads the field without narrowing gets a query validation error pointing
at the query rather than at the comment that caused it. Found on a storefront
whose `Page` union had carried `description` on every member since it was
written and never published it, because only one member documented it — and the
fix, from the outside, is to write WORSE documentation: one comment copied
verbatim instead of each member explaining its own case.

Fields are now compared on their API shape — name, type and arguments — with
descriptions stripped. Type identity stays strict: nullability and list-ness
are part of `TypeRefDef` and still compared, because an interface field the
implementing type cannot satisfy is an invalid schema rather than a friendlier
one. The interface takes its description from the first member that has one,
instead of only the member that happens to be listed first.

Three tests, two of which fail on the old comparison.
A page could not show that a navigation was happening. The store has always
KNOWN — `StoreEntry.promise` is set for exactly that window — but the write
that sets it is silent:

    // Silent: this may run during render (ensure() → SWR revalidate).
    this.store.patch(key, {promise}, true)

so a subscriber could observe a fetch ENDING and never one starting. The one
transition worth reporting was the one that never notified.

The reason for the silence is sound: `ensure()` runs during render and emitting
there warns. So the start is now announced on a MICROTASK — after the current
render, still inside the same frame the navigation began. An `inFlight` set on
the store makes `isFetching()` O(1) rather than a walk of every entry.

`useIsFetching()` reads it, with a 150ms delay before it reports and a 300ms
floor once it has. Both are load-bearing: most navigations resolve in tens of
milliseconds, and an indicator that flashes on every click is worse than none,
while a fetch landing just past the delay would flicker without the floor.

The pages export is a DIFFERENT hook, ORing the router's navigation state with
the query signal, because each alone misses half the wait. `useNavigation()`
covers resolving the route and fetching its chunk, then finishes before any
data is asked for — which is why an NProgress driven by it starts and stops
before the part a visitor waits through. The query client covers the long part
and cannot start until the route has mounted. The OR lives in pages because
`src/query` is a GraphQL client that knows nothing about routing.

Mount it in a LAYOUT. When a route's `useData` throws, React keeps the previous
tree mounted until the promise settles, so an indicator inside that subtree is
frozen along with it — which is also why this works for a page carrying no
`<Suspense>` of its own.

Six tests on the store half: that a start is observable at all, that it is
deferred rather than synchronous, that concurrent operations are counted, and
that an unmatched end is ignored.
A function closes over where it was WRITTEN, not where it is called. The
analyzer executed the body of a function it resolved by declaration in
the CALLER's scope, so a parameter could resolve to a same-named local at
the call site — and when that local held data, the function's own
property reads were merged onto it.

A catalogue rail hit this with two entirely ordinary lines. The component
had `const list = data.products({...})`; an imported helper walked a
plain collection tree with `const walk = (list) => list.some(node => ...)`.
The parameter and the local share a name, nothing else. The compiled
document came out asking for `products { handle children name count }`
and the build stopped at `Field "handle" does not exist on type
"ProductConnection"` — naming a type the helper has never heard of, in a
file whose only crime was calling it.

The rule is lexical, not a blanket isolate: a declaration nested inside
the component is a real closure and keeps the caller's scope, because
reading the component's data is what it is for. Only a declaration that
is NOT nested — module level, or imported, which is the same thing in
another file — runs isolated.

Three cases pinned: a module-level helper, an imported one (the shape
that broke the build), and a nested function that must still see the
component's data.
A path carries the binding its value CAME FROM, so a hoisted argument can
read `list.nodes` rather than whatever local aliased it. That only works
while the source still has a name at the call site — and a value built by
a helper carries the HELPER's own local as its source.

A catalogue page hit this with one ordinary line:

    const scope = collection
      ? collectionFilter(subtreeHandles(collections, collection))
      : undefined

`collectionFilter` returns through a local named `tokens`, so the
`useData()` variables thunk was emitted as `{ query: tokens, first: 10 }`
— and with the other helper in that chain, `{ query: root }`. Neither
name exists in the page. It builds clean, `tsc` is happy, and the route
500s at render with `ReferenceError: root is not defined`, naming a
variable that appears nowhere in the file.

`executeFunctionBody` now strips `sourceName` from a returned path when
that name was declared INSIDE the body it just ran. Only inside: a prop
drilled down from a parent component is resolved through an executed body
too, and there the source is the parent's own binding — still nameable,
and still the right thing to emit. Keying on "is it in scope right now"
instead breaks exactly those four prop-drilling cases, which is how the
narrower rule was found.

Sibling of c94af50: that stopped a helper's SELECTIONS leaking onto the
caller's data node, this stops its LOCALS leaking into the caller's
arguments. Same cause — a function body run in the caller's world.
…iable

The other half of the same failure, found by fixing the first: with the
helper's local no longer emitted as the source, the thunk came out as
`{ productQuery: vocabularyScope.handle }`.

`vocabularyScope` is its own source — a plain `const` in the component —
so the rewrite had nothing to rebuild and appended the path's segments
anyway. Those segments were recorded while the helper walked its OWN
argument: `subtreeHandles` reads `node.handle` off a plain collection
tree, and `handle` is not a property of the caller's variable. It builds,
and reads `handle` off a string at render.

When the source IS the identifier, the identifier is already the
expression. The rewrite exists for an identifier that ALIASES something
else — a prop drilled from a parent, a local standing in for
`list.nodes` — where naming the original is the whole point, and those
keep working.

Both halves are pinned: a helper local emitted as the source, and a
helper's property read appended to the caller's variable.
`const b = a ?? ''` is not `a`. The analyzer resolves each field argument
back through its initializer, and when that initializer is another local
it emits THAT local — dropping the `??` and whatever it supplied.

Its own file rather than a case in `repro_helper_local_in_args`, because
this one does not throw. Every sibling repro here ends in a build error or
a render crash, and those get found. This compiles, renders, and sends a
different value.

The storefront it came from: `brands(productQuery:)` reads an OMITTED
argument as "every brand" for a manufacturer index and an EMPTY one as
"scoped to what is visible", and `undefined` reaches the wire as omitted —
so the page needed a definite string and wrote `vocabularyScope ?? ''`.
The thunk came out as `v3: vocabularyScope`, the scope stopped applying on
the unfiltered listing, and the filter offered all 192 manufacturers
instead of the 166 with anything in stock. Nothing errored. A longer list
of real brands looks exactly like a page nobody has narrowed yet, which is
why it shipped.

Two failing cases, one per helper shape — a call taking an array and a
builder taking a filter object — since a fix that unwraps one walk and not
the other would leave half of it standing. Both currently emit
`{ productQuery: vocabularyScope, first: 200 }`.

The third case PASSES and must keep passing: `const alias = scope` really
is `scope`, and collapsing it is correct. It is there so the fix narrows
to bindings that add something rather than switching off resolution.

One thing deliberately not covered. The same source line in the other
storefront emitted `vocabularyScope.handle` — a property read lifted out
of the imported helper, which throws at render instead of misreporting.
That was read off the built chunk and is real, but it would not reduce to
a case this harness reproduces: the helper verbatim, a sibling field
sharing the binding, the duplicated call and the template-literal filter
all emitted the bare-identifier form above instead. Recorded in the
docstring so the next person does not assume it is covered.
The argument stringifier replaces an identifier with the source it was
traced to. That is sound for an ALIAS, whose value is the source, and
wrong for anything that combines the source with something else:
`const b = a ?? ''` is `a` plus a fallback, and emitting `a` throws the
fallback away.

It fails silently, which is what makes it worth more than its size. The
document compiles and the page renders; a variable simply carries a
different value than the source says it does. The storefront that found it
wrote `const brandScope = vocabularyScope ?? ''` because its gateway reads
an omitted `productQuery` and an empty one as different questions — every
manufacturer, versus the manufacturers in this collection. The thunk came
out as `v3: vocabularyScope`, so on the unfiltered listing, where that is
undefined, the argument disappeared and the field answered the wrong one:
192 brands offered where 166 have anything in stock. Nothing threw, and a
longer list of real brands is indistinguishable from a page that has not
been narrowed.

Three node kinds, not "anything that is not an identifier": a binary
expression, a conditional, a template. Calls, awaits and property accesses
are traced THROUGH deliberately and the machinery around this depends on
it — these three are the shapes that contribute a value the source cannot
supply by itself. A binding whose declaration cannot be resolved keeps the
old behaviour, so the check can only ever stop a rewrite, never start one.

Turns `repro_derived_binding_fallback` green, including its third case,
which asserts the opposite direction: `const alias = scope` really is
`scope` and must still collapse, so the fix narrows the rewrite rather
than switching it off. Full suite: 1078 passed, none failed.

Not the same bug as c94af50/26f8df9/6ccb942, though it lives two lines
from the last of them and reads like a fourth face of it: those three are
about a helper's own scope leaking into the caller's, this one is about a
caller's own binding being simplified past what it says.
@github-actions

Copy link
Copy Markdown
Contributor

🦋 Canary published from 6fdf598

Pinned to this build (immutable — reproducible):

npm install @getcronit/pylon@3.0.0-canary-pr-112-20260910211413.de6c709c3dd1a91b2af5efd1e650547ff1cba4cf
npm install create-pylon@2.0.0-canary-pr-112-20260910211413.de6c709c3dd1a91b2af5efd1e650547ff1cba4cf
Or track the latest on this PR — canary-pr-112 (moves every push)
npm install @getcronit/pylon@canary-pr-112
npm install create-pylon@canary-pr-112

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant