v3: fullstack line (ORM, global IDs, queues, pages) → main - #112
Draft
schettn wants to merge 744 commits into
Draft
v3: fullstack line (ORM, global IDs, queues, pages) → main#112schettn wants to merge 744 commits into
schettn wants to merge 744 commits into
Conversation
🦋 Changeset detectedLatest commit: 6fdf598 The changes in this PR will be included in the next version bump. This PR includes changesets to release 3 packages
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 |
@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.
Contributor
🦋 Canary published from
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
Promotes the v3-fullstack line to
main.mainis an ancestor of this branch, so the merge is conflict-free — this PR is the whole line since1d1697c(Oct 2025), ~482 commits.Highlights
pylon-db— type-driven ORM (migrations/diff engine, relations, pagination, STI, keyed-query batching, signals)pylon-queues— first-class background jobspylon-pages— usePages fullstack React (SSR streaming, static analyzer, image/LQIP, sitemaps) — reimplements & supersedes the parallelv3branch's pages workpylon-query— owned gqty replacementpylon-auth/ resource authz, gateway (delegate/patch/pull)/healthhealthcheck, per-PRcanary-pr-<n>npm tags; Vercel workflows removedRelease impact (read before merging)
@getcronit/pylon→3.0.0(major) and@getcronit/pylon-dev→ major.@canary-pr-<this-PR-number>(viacanary.yml).maintriggersrelease.yml→ realchangeset publishto npm@latest.Notes
v3branch (its pages/analyzer features were independently reimplemented here inpylon-pages; a couple of v3-only fixes — analyzer cross-run cache, config-extraction dep-tracing — remain uncherrypicked).wip(v3): checkpointcommit); review scope accordingly.