Skip to content

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

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

schettn wants to merge 772 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/pylon → 3.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: e29fd4a

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 11:38
…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.
Guard the canary job with head.repo.full_name == github.repository so fork PRs
never run the publish job — untrusted fork code doesn't execute build/publish and
can't reach the OIDC id-token. Complements pull_request's built-in fork limits
(no secrets, read-only GITHUB_TOKEN).
Add `environment: release` to the release job so npm 'latest' publishes require a
required-reviewer approval in the GitHub Environment before the job runs (and the
OIDC token carries an environment claim npm can be scoped to). Canary is left
ungated on purpose — it's internal-PR-only + fork-guarded and runs per PR.
- version job (ungated): opens/updates the Version PR only; never publishes. So the
  Version-PR-management run no longer needs approval.
- publish job (environment: release): publishes 'latest', and runs ONLY when a
  publishable package's committed version isn't on npm yet — approval is requested
  only for a real release, never for no-op pushes or the Version-PR-open run.
- canary uses the SAME 'release' environment: npm's Trusted Publisher supports only
  one environment, so publish + canary share it (both covered by its reviewer gate).
The split publish job ran 'changeset publish' directly, which creates but never
pushes tags. Use changesets/action (publish-only) + contents: write so the release
tags land, matching the old monolithic behavior.
The build output was described as a self-contained bundle at .pylon/index.js — but
pylon build now emits an UNBUNDLED .pylon/server.mjs that imports your app +
transpiled .pylon/src/ and runs against node_modules. Updated:
- getting-started, project-structure, how-pylon-works: server entry (not bundle)
- production/deployment: unbundled framing + accurate .pylon/ table (ships with
  node_modules; the Dockerfile already copied both)
- reference/cli: dev default is the built-in tsx runner on server.mjs; build order
  = server glue + transpiled source → client → pages; worker runs unbundled by default
- runtimes/observability/testing/build-an-app/background-jobs/queues: .pylon/index.js
  → .pylon/server.mjs, drop stale 'bundle' phrasing

check:coverage + check:examples pass.
The style guide still told writers to import from @getcronit/pylon-<f> entry
points; point it at the subpaths + the per-feature /plugin factories.
…ontroller seam

Design plan for replacing esbuild with rolldown and the spawn-restart dev loop with
an in-process dev server, gated on decoupling Plugin.build from esbuild's
BuildContext behind a bundler-agnostic BuildController seam (agreed). Sequenced so
esbuild + rolldown coexist during migration; spikes for the 3 parity risks
(decorators/metadata, CSS/Tailwind, watch/incremental).
- Drop contributeIR: schema contribution is a CORE harvest stage driven by model
  registration, not a plugin hook. PylonIR stays internal; the ORM has no build hook.
  The sole plugin build hook is build(ctx) → BuildController, reading ctx.out.{sdl,
  clientDir}. Added the staged pipeline (harvest→schema→server→client→artifacts) +
  runPipeline driver + useDatabase/usePages examples.
- Pillar 3: persistent-worker dev runtime with a reload(kind) IPC protocol + change
  classifier + change→reload matrix (pages/app/schema/config); singleton reset before
  cache-busted re-import is the gating spike. Subprocess -c kept as edge fallback.
…text seam

Decouple Plugin.build from esbuild (RFC BUILD_DEV_PIPELINE §3), no behaviour change:
- core/index.ts: define `BuildController {rebuild,dispose,cancel?}` + `BuildContext
  {mode,root,srcDir,outDir,out}`; change `Plugin.build` from
  `() => Promise<esbuild.BuildContext>` to `(ctx) => Promise<BuildController>`.
  Drops the esbuild import from core entirely.
- bundler.ts: construct a BuildContext and pass it to plugin.build(ctx); cancel
  fanout is optional-safe (`.cancel?.()`).
- usePages build: return a BuildController (drop the dead `watch`/onBuild/chokidar
  and the pagesWatcher the Supervisor's own watcher superseded).

Also fixes a PRE-EXISTING isolatedModules bug this surfaced: core/index.ts
re-exported types (Bindings/Context/Env/Variables) via a value `export {}`, which
crashed the project runner when it loads core from source (tsx, per-module) — split
into value + `export type {}`. Full suite 780 pass; build + typecheck green; docs
(usePages) build clean.
Ported build.js to rolldown 1.2.4 as the isolated spike. Held: entry set, external
self-refs, model-registry singleton (shared chunk), @/ resolution, Tailwind CSS.
Gaps: (1) CSS bundling removed → PostCSS-in-plugin + emitFile asset workaround;
(2) oxc runtime helpers need @oxc-project/runtime dep (no inline option); (3) BLOCKER
— rolldown hoists node:module createRequire into dist/core entry, breaking browser
consumers (usePages page build) — a tree-shaking/hoisting diff, not a toggle.

Verdict: keep build.js on esbuild behind the BuildController seam (partial migration
is supported); rolldown kept as a devDep for the next attempt. Also recorded a
check:boundaries guard to enforce the self-ref boundary (relative cross-feature
imports would inline features + break singletons).
A relative import crossing a feature boundary silently inlines that feature into the
bundle (duplication + broken singletons: model registry, auth principal symbol,
async context). scripts/check-boundaries.mjs scans src, resolves every relative
import, and fails if it crosses a feature group — except the intentionally
bundled-in, stateless targets (ir, query/build) and within the core cluster
(core/app/plugins). Wired into `typecheck` (so pnpm -r typecheck / CI enforce it)
and exposed as `check:boundaries`. Current tree: clean (165 files); verified it
catches an injected db→auth relative import.
…r 2)

Unlike build.js, these don't bundle pylon's core, so the node:module-hoisting
blocker doesn't apply. Both work cleanly on rolldown:

- build-client: single-entry bundle of the generated typed client (deps + the
  @getcronit/pylon/query self-ref external). No @oxc-project/runtime leak.
- transpile-app: 1:1 transpile-mirror via a resolveId hook that externalizes and
  rewrites relative specifiers (./x -> ./x.js). Reads the project tsconfig
  (tsconfig:true) so oxc applies useDefineForClassFields:false — VERIFIED it lowers
  `id = id()` to a constructor assignment (the ORM field-builder semantics) — and
  experimentalDecorators (legacy @model). Built docs end-to-end: server boots + 200.

rolldown moved devDep -> dependency (the CLI uses it at user build time, like
esbuild; both coexist during migration). esbuild still backs build.js + pages +
cli/db. typecheck (incl. check:boundaries) + full suite (780) green.
The migration-loader bundles a migration TS file to a temp .mjs and imports it
(so @getcronit/pylon/db resolves to the project's instance). Ported to rolldown:
bare imports external, single-entry bundle. rolldown has no inline tsconfigRaw, so
write a temp tsconfig to FORCE experimentalDecorators + useDefineForClassFields:false
(verified rolldown applies a tsconfig path globally, even to files in a tmpdir).
db-cli tests green (loads @model baselines, diffs). Full suite 780, boundaries clean.

esbuild now backs only build.js + the usePages page build.
Pillar 2 status: build-client + transpile-app + cli/db on rolldown; build.js and the
usePages page build stay on esbuild behind the seam (rolldown 1.2.4 CSS-removed +
oxc-helper + tree-shaking gaps make the page build a large, later effort).
The documented model API is decorator-free (class X extends Model + field builders /
static config). Remove the @model decorator machinery (fields.ts), make codegen emit
decorator-free classes, update tests, and drop experimentalDecorators +
emitDecoratorMetadata from tsconfig.pylon.json / transpile-app / cli/db (keep
useDefineForClassFields:false for field-builder semantics).
…-fullstack

Integrates the legacy @model decorator removal with the rolldown Pillar-2 ports.
Reconciled the two overlapping files:
- transpile-app.ts: keep the rolldown port; rely on tsconfig:true for
  useDefineForClassFields:false; drop experimentalDecorators (ORM is decorator-free).
- cli/db/index.ts: keep the rolldown migration-loader; temp tsconfig now forces only
  useDefineForClassFields:false (no experimentalDecorators).
All other @model changes (fields.ts, codegen.ts, db/*, tests, tsconfig.pylon.json)
applied cleanly. Verified: build, typecheck + check:boundaries, full suite (780),
and docs build all green on the merged tree.
Feasibility-spiked: JSX clean (no oxc-runtime leak), splitting/externals/manifest
feasible. Deferred anyway — the ~4300-LOC use-data-static-analyzer onLoad port + the
metafile/cssBundle/write:false manifest pipeline (cssBundle has no rolldown equivalent
since CSS bundling was removed) + it being the most CSS/asset-heavy build make it
not worth porting at 1.2.4. Revisit when rolldown CSS support returns.
Split the useData static analyzer into a pure createUseDataAnalyzerCore
(start/addEntries/transform/filter/manager/project) with the ~430-line
onLoad body kept byte-identical behind an args:{path} shim. The esbuild
Plugin is now a thin adapter that reads the file and calls core.transform,
casting the result to esbuild's OnLoadResult at the boundary.

This is Stage 1 of the usePages rolldown port: the core has no esbuild
dependency, so the rolldown page build can consume it directly.

Verified non-regressive: 153 analyzer unit tests pass, typecheck +
boundary guard clean, docs build green (esbuild path unchanged).
Replaces the esbuild `esbuild.context` dual-build (client + server) with
rolldown, completing Pillar 2 of the build/dev pipeline migration. Restructured
rather than 1:1 ported:

- rolldown-plugins.ts: new bundler-agnostic page plugins —
  - cssCollectPlugin: rolldown 1.2.4 removed CSS bundling (hard-errors the
    moment CSS enters the graph, rolldown#4271), so CSS imports are intercepted
    in a `load` hook, PostCSS-processed out-of-band, and replaced with an empty
    JS module; collected CSS is concatenated into a single hashed app.css after
    the JS bundle is written. Framework index.css is processed standalone.
  - imagePlugin / assetFilePlugin: sharp blur placeholders + font/svg assets via
    emitFile + resolveFileUrl (public-path-prefixed URLs).
  - injectAppHydrationPlugin: appends the client hydration bootstrap. Two fixes
    over the esbuild version: (1) does NOT re-import __PYLON_*_INTERNALS (already
    imported by the generated app.tsx — esbuild silently merged duplicate
    imports, rolldown/oxc is spec-strict and rejects them); (2) Sentry is opt-in.
- build/index.ts: rewritten to drive two rolldown builds, build both manifests
  from rolldown's write() output (facadeModuleId → entry mapping) instead of
  esbuild's metafile+cssBundle, and clean the hashed-output dirs each rebuild so
  bundles/CSS/chunks don't accumulate across dev rebuilds.
- use-data-static-analyzer: added a rolldown `transform`-hook adapter over the
  bundler-agnostic core extracted earlier; the esbuild adapter stays for its
  unit tests (dual-bundler coverage of the shared analysis core).
- usePages({sentry}) opt-in: @sentry/react is only imported into the client
  hydration bundle when explicitly enabled, so apps without it don't need it
  installed (replaces a package.json dependency scan that false-positived on
  hoisted monorepo deps).
- Deleted the now-dead esbuild page-build plugins (postcss/image/hydration/
  external-esm).

Verified: full unit suite 780 passed, typecheck + boundary guard clean, docs
(a real Tailwind-v4 usePages app) builds and serves — SSR 200, both CSS links +
hydration script resolve, served hashes match the manifest, Sentry excluded.
The consolidation folded packages/pylon-dev into @getcronit/pylon but left the
e2e suite pointing at the old layout, so no e2e could run:

- cliBin path in 25 test files: packages/pylon-dev/dist/index.js →
  packages/pylon/dist/cli/index.js
- dev-reload-server.unit.test import: packages/pylon-dev/src/builder/... →
  packages/pylon/src/cli/builder/dev-reload-server
- dev-pages-loop: dropped the stale `-c 'node .pylon/index.js'` serve override
  (that entry no longer exists; the default runner runs the self-serving
  .pylon/server.mjs via the tsx loader) and removed the obsolete serveLast()
  from the fixture config — server.mjs now owns Node serving, so serveLast
  double-binds the port (EADDRINUSE).

dev-pages-loop.e2e now GREEN (6/6): validates the rolldown pages dev loop
(watch → rebuild → restart → serve, live-reload SSE, out-of-pages + schema +
transitive-import edits).

NOTE: 8 other serve fixtures (+ e2e/fixtures/_serve-plugin.ts) still carry the
same obsolete serveLast() and will EADDRINUSE against server.mjs until swept.
server.mjs (emit-server-glue) took over Node serving during the class-design
work, so the `serveLast()` plugin every serve fixture carried now double-binds
the port (EADDRINUSE) against server.mjs. Stripped it from all 14 fixture
configs (matching docs' canonical no-serve-plugin pattern), fixed the stale
comments, and deleted the now-dead shared helper e2e/fixtures/_serve-plugin.ts.
…ex.js

Another consolidation rename: the built server entry is now .pylon/server.mjs
(imports the transpiled ./src/index.js; plain `node .pylon/server.mjs` serves).
The serve tests still spawned `node .pylon/index.js`, which no longer exists —
the server never started and waitForReady burned its 30s timeout (a big chunk
of the suite's wall-clock). Updated all 11 serve tests' spawn target and the
orm-build artifact assertion (index.js → server.mjs).

Leaves the genuine (non-drift) failures for separate triage: orm-build FK
scalar type (categoryId ID! vs expected Int!), inspect queues AppModel, and the
pylon-query cache-freshness revalidation count.
…fig.js)

Another consolidation rename — the CLI now emits the config artifact as
pylon.config.js; the test's dynamic import of config.js threw in beforeAll,
failing the whole file at collection.
The config fixture uses `export default`, and server.mjs reads
`default ?? config`; the test still read the old named `.config` export
(undefined → toMatchObject failed). blog-build now 11/11.
All three were tests left behind by intentional, well-justified refactors —
verified via git history (and, for two, a passing unit test / inline rationale):

- inspect: queue names are app-namespaced with '.' not ':' — ':' is forbidden
  by BullMQ/Redis as the key separator (queues/app.ts, commit 3a9fcd4). Assert
  'shop.reindex'.
- orm-build: a FK scalar surfaces as ID! (not Int!) so the ID scalar's gid-decode
  covers it on input — it references a PK (db/ir.ts, commit ab5c514; already
  locked in by test/db/node-interface.test.ts). Assert ID!.
- pylon-query: ensure() is render-pure now; SWR moved to the effect-driven
  revalidate() so an unrelated mutation can't refetch every mounted query
  (query/runtime/client.ts, commit e23a6a9). The test now calls revalidate() to
  exercise the freshness-gated background refetch, and the docstring reflects it.

No production code changed — the code behavior in all three is correct.
Closes the known limitation from the rolldown port: relative url() refs in CSS
(fonts, background images) were left as-authored and 404'd at runtime, because
rolldown 1.2.4 has no CSS-aware bundler to walk them.

New `pylonCssAssets` PostCSS plugin (appended last in processCssFile's chain, so
it runs after the app's @import inliner) walks every url() via postcss-value-parser:
- skips data:/http(s)/protocol-relative/absolute-/#fragment refs;
- resolves a relative ref against BOTH the node's source dir AND the entry dir,
  using whichever file exists — this is inliner-agnostic: Tailwind v4 rebases
  url()s to be entry-relative while keeping a node's source as the original file,
  whereas postcss-import keeps them original-relative (both verified by spike);
- copies the asset to <staticDir>/assets/<name>-<sha256-8><ext> and rewrites the
  url() to <publicPath>/assets/... (preserving ?query/#hash), deduped per source;
- missing files warn and are left untouched — never fails the build.

Threaded {outputDir, publicPath} through processCssFile + cssCollectPlugin;
enabled for the client build and the framework index.css (server build discards
its CSS, so it stays off there). Added postcss + postcss-value-parser as deps.

Verified end-to-end through the real docs Tailwind v4 pipeline: a url() inside an
@import'd file is rebased, resolved, copied, and rewritten in app.css; direct
urls, data:/remote/absolute skips, a comma-in-filename @font-face, and missing
files all behave correctly. 179 pages tests + typecheck green.
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.
Turning on `@graphql-yoga/plugin-response-cache` stops every abstract
field in an app resolving. The field nulls out and the response carries
`Abstract type "X" must resolve to an Object type at runtime` — for a
schema that has been resolving it all along.

`getSelectedFields` auto-injects `__typename` into the selection for an
interface or union, because `wrapResolver` projects each resolved value
down to its selected fields and `resolveType` has nothing else to read.
The guard that decides whether to inject asked only whether a field NAMED
`__typename` was selected. `fieldsMap` is keyed by name, so
`rcType: __typename` satisfied it — and the projection then wrote the
value out under the ALIAS. The node arrived at `resolveType` carrying
`rcType` and no `__typename`.

Nobody aliases `__typename` by hand, which is why this sat undisturbed.
Plugins do: the response cache rewrites documents to collect entity ids,
adding `__responseCacheTypeName: __typename` and `__responseCacheId: id`
to every selection set. So the bug needs no unusual query — only that
plugin, and any interface or union.

Found from the other end, through two wrong guesses. A storefront's brand
logos had gone missing with no error on the page; the first suspicion was
the gateway's own `__typename` injection, and patching that changed
nothing. Instrumenting the gateway showed the value already null on
arrival with `errors` in the envelope, which moved the search upstream,
where three queries against a plain Pylon server isolated it: the field
alone resolves, the field plus `__typename` resolves, the field plus
`rcType: __typename` does not.

The regression test is that third query, as a unit test — no cache
plugin, no gateway, no database. Before the fix it failed with the
projection having written `__typename: [function]`.
The other half of 35b4b7a. That one handled an abstract field whose only
`__typename` was ALIASED; this one handles the shape a real stack
produces — a plain `__typename` AND an aliased one on the same field.

The gateway injects `__typename` so it can choose a patch. The response
cache adds `__responseCacheTypeName: __typename` so it can collect entity
ids. Both land in one selection set, and the projection in `wrapResolver`
then sees a field with mixed aliased and unaliased nodes, so it replaces
the value with a FUNCTION that routes to the right key per execution.

That is right for an ordinary field and fatal for this one. `resolveType`
reads `node.__typename` synchronously, before any field resolver runs, so
it gets the function instead of the name — GraphQL then reports the value
it received as "[function]" and throws "must resolve to an Object type".

Nothing is lost by exempting it. `__typename` is a meta-field: graphql-js
answers it from the concrete type it has already determined, under
whatever aliases the query used, without consulting this object. All the
projection owes it is the plain string.

Isolated by capturing the document the gateway actually puts on the wire
and bisecting it: with the aliased `__typename` removed the field
resolves, with the plain one removed it resolves (35b4b7a covers that),
with both present it does not. Which is why the first fix looked
incomplete against a live stack while every smaller probe passed — the
first bug was masking the second.

Verified end to end with the response cache ON, both fixes installed:
`{ brands { nodes { logo { previewImage { url } } } } }` returns 20/20
logos where it had been erroring, and the storefront's product page shows
its brand mark again. The new test fails without this change with exactly
the "[function]" value in the message.
…ns validate

columnDefFromSpec dropped the persisted `array` flag when reconstructing a
column, so a text[] column (sqlType 'text' + array true) reconstructed as a
scalar text column. validateColumn then rejected its list value ("must be a
string"), breaking every models.get() data migration that wrote an array
column. Carry array (plus generatedAs/check/struct/precision/scale/dim/requires)
onto the historical ColumnDefinition so validation and the write path see the
column's true persisted shape.
…tead of silent undefined

The result wrapper returned a truthy object proxy whose field read resolved to a
silent `undefined` when the field's key was absent from the resolved data — a
"hole". A hole means the field was either NOT selected by the build-time query (an
analyzer gap: a read it couldn't trace, e.g. only inside a throw / template literal
/ call) or is missing from the cache (a partial read). The completeness gate
(isSatisfied) only guards fields it can see in the compiled shape, so an unselected
read sailed past it and surfaced downstream as a wrong value (e.g. Gid.id(undefined)
-> a redirect to /mail/undefined).

buildField now fails loud: a KNOWN, non-callable field whose key is absent from an
otherwise-present owner throws. Mirrors isSatisfied's present-vs-absent rule exactly
— a present key (even null) is a real answer; an absent key is the hole. Nullability
is irrelevant. Unknown (non-schema) fields stay soft (raw undefined); callable/arg
fields route via their own slots.

Tests: wrap.test.ts covers present-null (ok), nullable + non-null holes (throw), and
unknown field (undefined). The paginated arg-aliases fixture selected totalCount at
the connection level to match compiler output (compile.ts always selects it when the
type has it) — it had hand-omitted it.
`db resolve` shares the apps-mode handler whose targetGroup() requires
options.app to pick a migration group, but unlike `diff` and `rollback` the
resolve command never registered `-a/--app` nor forwarded it. In a multi-app
project every invocation therefore failed: `--app` was rejected as an unknown
option, and a bare/prefixed name hit "This is a multi-app project — pass --app".
There was no way to mark a migration applied without running it (needed when a
DB already has the schema), short of hand-writing the ledger row — which trips
the operations-checksum tamper check.

Register `-a, --app <name>` on the resolve command and forward `app:
options.app`, mirroring diff/rollback. `DbCommandOptions.app` already existed;
widen its doc to cover resolve/rollback.
…optionally-different

The schema builder reused a GraphQL type name whenever two TS types were mutually
assignable. But two object shapes that differ only by OPTIONAL properties are mutually
assignable in TypeScript's structural system, so a priced `{...; unitPrice?}` input and a
priceless `{...}` input collapsed onto one input type = their intersection, silently
dropping the extra fields. A mutation typed to the merged type then rejected the dropped
field at the GraphQL layer ("Field unitPrice is not defined by type LinesInput_3"), while
direct resolver calls (unit tests) bypassed validation and passed.

Compare the emitted GraphQL shape instead of assignability: same property set (names +
optionality) at every level, recursing into each property's array-unwrapped type; leaves
(primitives/scalars/enums) fall back to mutual assignability. Genuinely distinct shapes now
get suffixed names instead of being merged. Adds a regression test.
…nput types

Input object types backed by a named interface/class were renamed after the surrounding
field (an `interface InvoiceLineInput` used as `lines: InvoiceLineInput[]` became
`LinesInput`, then `LinesInput_1`, `_2` … across mutations). Keep the declared nominal
name when the type has one (interface / class / type alias / enum / __typename literal);
fall back to the field name only for genuinely anonymous inline shapes. Yields readable,
stable input names (InvoiceLineInput, DeliveryLineInput) instead of positional LinesInput_N.
…torial blowup

The use-data static analyzer accumulates cross-contaminated field-paths at merge
points (the property-access fallback appends onto every base path, gluing unrelated
prop configs like a grid's filterDefs/columns onto the connection chain). On a full
cold analyze of a large app this explodes to tens of thousands of paths per node and
OOMs the build.

Dedup each evaluateExpression result — a path set is a SET. But the dedup MUST be
declaration-aware: pathKey() keys on names only, so every '__decl' step shares one
key and a naive dedup collapses DISTINCT function references — e.g. every grid
column's col.cell, or list.find(cb) — dropping all but the first column's field
reads. pathKeyStrict() folds in args, list/element/virtual flags, sourceName and a
stable per-declaration id, so only truly-identical paths collapse.

Regression tests: grid columns-prop with per-column cell callbacks; and
list.find(...).subfield element selection.
…opagation

The query-propagation queue ran a full coreAnalyze pass per individual call
site (getCachedAnalysis(sf, [call])). coreAnalyze executes the whole enclosing
function body, and a page's call sites nearly all live inside the one big
component, so the same expensive body was re-executed once per call site and
once per queue item -- measured at 15x on a real grid page (contacts).

Analyze each caller file exactly once, with every call expression in it batched
as targets, and index each call's inflow selectors out of that single pass. The
recursion guard is stack-scoped (decremented in finally), so batching all
targets into one pass yields identical result[__target_i] per call; only
exportedFunctionReturns becomes file-complete, which is desirable. Also switch
the per-expression target lookup from indexOf (O(n)) to a Map, required now that
many targets are passed at once.

Effect: the grid-config repro drops from ~2700ms to ~190ms and the file is
analyzed 2x (entry + one batched pass) instead of 15x. All 166 analyzer tests
pass; the new perf repro is a hard guard.
A ground-up reimplementation of the usePages useData static analyzer on
oxc-parser + oxc-resolver, replacing the ts-morph engine's per-file, type-checker
-driven design. It is isolated alongside the existing analyzer (not yet wired into
the production build).

Core design:
- Whole-program, summary-based interprocedural data-flow with a lattice fixpoint.
  Each function is summarized once (input→selection + return fact) and composed at
  call sites; cross-file resolution rides oxc-resolver + oxc's import/export table,
  so there is no findReferences whole-project scan and no per-caller re-analysis.
- Schema-directed: the tracer records structurally, then a post-pass validates each
  seed's selection against the schema — dropping non-fields and setting __isList
  from the schema's list wrapping. Replaces the JS_PROTOCOL / list-inference guesses.
- All seed kinds: useData, usePaginatedData (connection), useMutation (incl. nested
  trigger-return), op.query/op.mutation.
- Higher-order closure-config: accessor closures carried through props/config and
  invoked deep (col.get(row)) are traced via closures-as-values + selective concrete
  inlining, same-file and cross-file (closures capture their defining frame). The
  interpreter is frame-parameterized for correct cross-file scope resolution.
- Sidecar emit: per-page virtual module holds the compiled docs; call sites are
  rewritten to import them, variables thunk kept inline. rolldown + vite adapters,
  with vite HMR invalidation of the sidecar on change.

Shared lowering (selectors-to-document) relocated to query/build/lower-selection so
both analyzers depend on the neutral query-build layer; a back-compat shim keeps the
old path working.

Measured vs ts-morph: flat with project size (ts-morph scales with total files via
findReferences), 40-900x faster cold, and the grid config-blowup page goes from
0/N field coverage to 100% at sub-6ms.

51 tests across 8 suites; new source typechecks clean.
… 1-pass)

- Arg-branch arrays: the same field read with different arguments now branches into
  an array of per-arg selection nodes (lowered to aliased fields with distinct
  variables); identical args still merge. Arg-aware `stepInto` navigator shared by
  graft/recordRead; validateSelection handles array branches.
- Persistent module graph reused across the build (createOxcAnalyzerCore holds one
  graph; analyze accepts it) instead of rebuilding the resolver + caches per page.
- Fixpoint converges in one pass for acyclic call graphs (skip the confirming pass
  unless recursion/forward-refs occur) — ~2x on every scenario.
- Worst-case comparison bench (vitest bench): oxc vs ts-morph on the config-blowup
  grid (155x), wide fan-out (293x), big project + barrel, and deep prop-drill.

55 tests green; new source typechecks clean.
Function summaries are cached on the persistent module graph and reused across
pages within a build, keyed by fn id with the content hashes of every file the
summary depended on — so an entry is reused only while all of them are unchanged
(correct incremental invalidation for dev). Only acyclic, seed-free summaries are
cached: cyclic ones must iterate to their fixpoint, and seed-creating functions
must re-run so their seed selection is re-emitted.

A shared component imported by many pages is now summarized once instead of per
importing page. Deep prop-drill chains drop accordingly; correctness verified by a
cross-call reuse test (two pages sharing a component on one graph, no state bleed).

56 tests green; typecheck clean.
Mines every `extractAdvancedSelectors(input, name)` case out of the old analyzer's
test files (analyze.test.ts, bracket-access.test.ts) and replays each through the
oxc tracer, diffing against the ts-morph analyzer as oracle.

Result: 25/34 exact match schemaless; the 9 divergences are all type-dependent —
JS intrinsics recorded as fields (length/forEach/map/startsWith), and list-vs-object
marking (a singular `node`, a `[String]` `roles`). ts-morph resolves these with the
TypeChecker; oxc resolves them in the schema-directed production path (validateSelection
drops non-fields and sets __isList from the schema), verified by lists/arg-branches
tests. They're recorded as known divergences so a regression — or an unexpected
schemaless match — fails the guard.

90 tests green.
…imports, error surfacing

Closes the remaining feature gaps vs the ts-morph analyzer:

- Interface/union member fields: validateSelection is now member-aware — a flat field
  read off an abstract value is kept if ANY possible concrete type declares it, and
  the lowering distributes it into the right `... on Type { … }` fragment. Fixes
  unions (member-only fields were dropped) and interface reads of implementer-only
  fields.
- Generic custom HOCs: unwrap any `Hoc(Inner)` wrapper (not just memo/forwardRef) to
  the inner component when resolving a component/callee.
- Namespace imports: resolve `<NS.Member/>` / `NS.member(...)` via the namespace's
  source module exports.
- Error surfacing: emit collects per-seed lowering failures (malformed selector,
  unknown field/args) with file:line and the adapters emit them as build warnings
  instead of silently skipping.

101 tests / 14 suites green; typecheck clean.
…back via env)

Wire useDataOxcRolldown/useDataOxcVite into the real page build (client + SSR
rolldown builds and the vite dev server) in place of the ts-morph plugin. The oxc
analyzer resolves its own dependency graph, so it needs neither the shared
StaticAnalysisManager nor entryPaths; it reads the schema from .pylon/schema.graphql
(already emitted before the pages build) and the app tsconfig for `@/*` aliases.

Default is oxc; `PYLON_ANALYZER=ts-morph` falls back to the legacy engine as an
escape hatch. The old plugin + module remain in place for that fallback.

Not yet validated by a full app build in-repo (no fixture app / build-pipeline test
exists) — needs `pylon dev` / `pylon build` on a real app.
A committed fixture app (schema.graphql + a page that prop-drills useData into a
cross-file helper) built through a REAL rolldown build with useDataOxcRolldown. It
exercises the whole plugin surface — buildStart, transform (analyze → lower →
rewrite), and resolveId/load for the virtual sidecar — and asserts the emitted
bundle rewrites the useData call and inlines the compiled document with the
cross-file reads folded in. Gives the production switchover automated end-to-end
coverage.

102 tests / 15 suites green.
…-test, computed-index

Five systemic tracer fixes surfaced by running the oxc analyzer over the
real lokalis app (65 pages), each an under-selection at the root:

- useMemo/useCallback (and React.useMemo/React.useCallback) now return the
  memoized value instead of OPAQUE, so columns-config closures are traced.
- NewExpression evaluates its arguments (e.g. `new Date(row.field)`).
- ConditionalExpression evaluates its test (`row.type === … ? … : …`).
- Computed MemberExpression evaluates the index and resolves string-literal
  keys, so `row[col.accessorKey]` and `LABELS[row.role]` are followed.

Raises document parity vs the ts-morph analyzer from 41/65 to 60/65
byte-identical pages. Regression tests added in real-world-patterns.test.ts.
…re-object id

Three root-cause fixes surfaced by the remaining lokalis document diffs, each
isolated as a failing test first:

- Union-of-literal computed index: `row[col.accessorKey]` where `col` ranges
  over a config array now fans out to read every column's key, not just a lone
  literal. Recovers accessorKey-only grid columns (`apiKeys.name`).

- Closure-valued callees: a call to a variable/param holding a closure (a
  `rowActions(row)` prop, or a handler passed row data) is now invoked so reads
  in its body trace against the supplied row (`doc.media.id`, `item.url`).
  `resolveCallable` only found named declarations/imports before. Gated on a
  data (prov) argument so data-free handlers (`navigate`, `toast`) stay on the
  cheap path — without the gate this regressed grid pages ~40x.

- Normalization `id` is injected only when a real field is projected onto an
  object; a bare, opaquely-read object (`data.x.choices` with no field access)
  compiles to `{ __typename }`, matching a leaf selection.

lokalis document parity vs the ts-morph analyzer is now 64/65 byte-identical;
the one remaining page is a ts-morph over-select (it conflates two grids' column
keys across the User type) that the oxc analyzer correctly avoids. Whole app
analyzes in ~8.5s.
… every dependency

getFile() wrote to parsedCache but never read from it, so every hashOf() — called
per dependency on each cross-call summary-cache lookup — re-read the file from disk
and re-parsed/re-hashed it. On an import-heavy page this dominated: a CPU profile of
the lokalis ticket-detail page (4.3s) was ~46% readFileUtf8 and ~26% content hashing,
with the interpreter itself under 3%.

Serve the parse cache for any read without fresh `text`; a file's content is stable
within a build, and dev/HMR already calls invalidate(file) on change. Fresh `text`
(a primed entry or bundler input) still re-parses and refreshes the cache.

lokalis (65 pages) cold end-to-end drops from 9.5s to 1.3s — 4.5x faster than the
ts-morph analyzer (6.0s), where before it was ~0.8x (that one ticket page flipped the
whole total). Parity unchanged at 64/65 byte-identical.
The analyzer captures a field's `__args` by slicing the object literal verbatim
from source, so an argument object written across lines can carry line or block
comments:

    query.vendors({
      // search-as-you-type
      query: q ? `${q}*` : undefined,
      first: 20,
    })

parseArgs split on the top-level comma and read the comment line as the first
argument name, producing `Field "vendors" has no argument "// search-as-you-type…"`.
Strip comments first (respecting string/template literals) so a comment is never
mistaken for an argument. Surfaced by the oxc analyzer on the real lokalis build;
the ts-morph tracer happened to drop trivia when reading arg text.
…s on the second pass

A production build runs the page analyzer twice — once for the server bundle, once
for the client — over the same frozen sources. Each build previously got its own
cold core, so the whole dependency graph was read, parsed and hashed a second time,
and every page was re-analyzed.

- Share a single core across both builds; its module graph + parse cache are built
  once (content-hash guarded) and reused, so the second pass skips dependency I/O.
- Reuse the full per-page result on the second pass (content-hash keyed): the client
  build re-wires the already-compiled documents + sidecar instead of re-analyzing.
  Gated to the build path (`reuseResults`); dev never reuses, since a page's
  dependency can change while its own source does not.

On lokalis the analyzer's build cost drops from ~2.9s (both passes cold) to ~1.8s —
the second pass no longer registers in the plugin timings — and total page rebuild
from ~6.8s to ~4.5s. In-process, the second pass goes from 1.35s to 0.001s.
The build's only timing signal was rolldown's per-plugin PLUGIN_TIMINGS, which
covers just the page-bundle passes — the stages before them (ORM introspection,
schema type-introspection, query generation) were invisible, yet they turned out
to be ~half the wall time.

Add a lightweight, opt-in timing collector (a process-global shared across the cli,
builder and pages-build modules) that records stage spans and prints an indented,
containment-nested breakdown at the end. Enabled with `PYLON_TIMING=1` or
`pylon build --timing`; zero overhead otherwise. Parallel stages (the client +
server page builds) are flattened to siblings so the tree reads correctly.

On lokalis it attributes the ~11s wall as: ORM introspection ~3.2s, schema
type-introspection ~2.1s, page bundles ~5s (the useData analyzer ~1.9s of that).
…a (fixes combinatorial blowup)

A component was concretely inlined (interpretInFrame) whenever it received ANY
closure prop, and inlining has no memoization — so in a UI kit where Button /
DropdownMenu / ContextMenu take onClick/asChild closures and nest pervasively, the
number of inline PATHS to a leaf multiplied combinatorially. On lokalis's redesigned
layout.tsx that was 609,471 inline calls for one file (Button alone 348,338×),
~11s to analyze a single page.

Inlining exists to resolve a closure config where it is invoked, which only matters
when the call also carries seed data — a component fed no `useData`-derived value
can contribute no field read, so inlining it is wasted. Gate concrete inlining on a
prov-carrying argument (a value derived from the seed), not merely on the presence of
a closure; everything else composes via its summary (the fast path). Also cache
import resolution in the module graph (deterministic per build; cleared on invalidate).

layout.tsx 11.5s → 158ms; whole lokalis corpus (112 pages) 18.7s → 0.9s in-process.
Document parity holds: 112/112 pages match the ts-morph analyzer (one differs only
in field ORDER, same fields). Full analyzer + query suites green (432).
The analyzer puts compiled documents in a SIDECAR virtual module (\0pylon-docs:…).
Vite's TS transform skips \0-prefixed virtual ids, so the doc factory's type param —
`doc<{ … }>({ … })` — was shipped to the browser as raw TypeScript. The browser
can't parse it, so the sidecar module fails to load and hydration breaks app-wide
(login included) under `pylon dev`. (`pylon build` was unaffected: rolldown strips
the loaded module.)

The generic was type-only and dead: nothing type-checks the sidecar, there is no
generated per-page .d.ts, and a page's `useData()` result type never came from it.
Drop it — emit `doc({ … })` — so the sidecar is valid JS in every bundler and dev
path with no TS transform needed. `compiled.resultType` is still computed for any
future .d.ts generation. Verified with an isolated Vite transform: the dev sidecar
is now `doc({…})`, previously `doc<{…}>(…)`.

Unrelated to query extraction / analyzer batching — this is purely the doc emit shape.
The fixpoint driver decided a summary was safe for the cross-call summaryCache
via a size delta (seeds.size > seedsBefore). Across passes `seeds` persists, so
on the 2nd+ pass a seed-bearing function's delta read 0 and the summary was
cached as "seed-free". A later analyze() on the shared graph (the paired
server/client builds, or a dev re-transform) then hit that cache and returned
before summarize() ran, so the useData/usePaginatedData seed was never
registered -> seeds=0 -> a bare useData() was emitted -> undefined data at
runtime (e.g. OrgMenu's `data.signedInAccounts.map` crashed SSR on every route).

Track seed presence explicitly with a seedStack parallel to depStack: registerSeed
calls ctx.noteSeed() on every encounter (even a seed already in `seeds`), and a
seed taints every ancestor frame. Cacheability now uses that flag instead of the
pass-order-sensitive size delta, restoring the intended invariant (only seed-free
summaries are cached) correctly.

Adds seed-cache.test.ts (same seed component analyzed twice on one graph; seed
component summarized as a dependency then as an entry) — both fail without the
fix. Full oxc analyzer suite: 115 passing.
…tract field

A field declared on several members of an interface/union with DIFFERENT object
element types — e.g. `rows: [AccountLedgerRow!]!` on AccountLedger vs
`[TrialBalanceRow!]!` on TrialBalance under one interface — was resolved by
schema-validate to the FIRST member's type, and the merged sub-selection was then
validated against only that one. Every sub-field the other members declare was
silently dropped, leaving just the intersection ({debit, credit}). Order-dependent:
it only bit when the narrowed member was not declared first.

Fix: resolve ALL candidate field types across the members (fieldTypesOf) and validate
the nested selection against their UNION (validateSelection now takes a type set);
the compiler already re-partitions the union per member via projectSelectionOntoType.
Adds repro_abstract_shared_list.test.ts.
…s nested plain-data DTOs

A mutation result read through React state can't be traced by the oxc pages analyzer (it
has no useState model), so the field selection falls back to the schema default. That default
recursed only ONE level (payload -> each object's scalars via allScalarSelectors), so a nested
object-LIST below the first level -- e.g. result.plan.accountsToCreate { number name } -- was
dropped. Surfaced in lokalis as: Field "accountsToCreate" on type "ImportPlan" was read but
is absent from the resolved data.

mutationResultSelectors now deep-expands non-entity payloads/DTOs (expandPayload): scalars +
scalar-lists at every level, nested non-entity objects/object-lists expanded recursively, nested
ENTITY sub-objects kept shallow (they normalize; relations come from explicit trigger reads).
Shallow entity-wrapping payloads are unchanged (one-level is a special case). Cycle-guarded.

Tests: compile.test.ts (deep expansion incl. object list below level 1, shallow payload
unchanged, entity-return scalars only) + repro_mutation_state_object_list.test.ts (emits the
nested object-list element subfields for the mutation-through-useState pattern). 139 query +
398 pages tests green.
…angling input ref)

When a model (whose relation/async getters are Promise-typed) is used as a GraphQL
INPUT, the input discovery (recLoop) already SKIPS Promise types for inputs, but
the parent still recorded the Promise-typed property — and the namer
(getTypeDefinition) unwraps the Promise and references the element's <Name>Input.
Discovery therefore never emits that input type, leaving a dangling reference that
crashes schema construction with 'Unknown type: XInput_N' (observed as
MediaInput_1 from a Voucher model passed as a mutation input).

Fix: skip recording a Promise-typed property when processing inputs, so both the
namer and discovery agree a Promise (relation/computed getter) is not an input
field. Adds a failing-then-passing repro test asserting every referenced input
type is defined.
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

🦋 Canary published from e29fd4a

Pinned to this build (immutable — reproducible):

npm install @getcronit/pylon@3.0.0-canary-pr-112-20261007153040.fda2e5bce204377806612aab766284804520da95
npm install create-pylon@2.0.0-canary-pr-112-20261007153040.fda2e5bce204377806612aab766284804520da95
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

This branch was successfully deployed

1 active deployment
release — e29fd4a5 Deployed Oct 7, 2026 by schettn via Publish snapshot under channel dist-tag #105
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