Repository navigation
Conversation
🦋 Changeset detectedLatest commit: e29fd4a 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 |
…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.
…ator-free config
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.
Contributor
🦋 Canary published from
|
This branch was successfully deployed
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.