Repository navigation
Conversation
🦋 Changeset detectedLatest commit: 32c2602 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 |
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.
Rewrites build.js from a 3-pass esbuild BUNDLE into a single transpile-only rolldown pass: every src module is emitted 1:1 to dist (mirroring the tree), with bare + relative + @/ imports kept as real ESM imports (relative rewritten to ./x.js). No bundling → no chunks → no `node:module` createRequire shim leaking into browser entries (the reason build.js was the last esbuild holdout). The model-registry singleton (shared ./registry.js) and the CLI sibling layout are preserved for free, without `splitting`. Details: - JSON imports (`version` from package.json) are inlined via rolldown's native json module type — Node can't import JSON without an attribute, unlike .ts. - oxc's tagged-template helper is INLINED (don't externalize @oxc-project/runtime) so the published package carries no dep on that fast-moving oxc-internal 0.x pkg. - CSS: side-effect `import './x.css'` are stripped from JS; src/pages/globals.css is run through the package PostCSS (Tailwind) into dist/pages/index.css. - tsconfig.json now pins `useDefineForClassFields: false` — oxc's ES2022-default `true` lowering breaks class-field initializer timing the compiler/ORM rely on (esbuild's `true` happened not to). Matches the shipped tsconfig.pylon.json. Verified: dist structure + declarations correct, no node:module in browser entries, docs (real consumer) builds + serves 200. ~260ms for 162 modules.
With build.js on rolldown, the only remaining esbuild users were the analyzer's
esbuild adapter and the tests that drove it. Removed esbuild entirely:
- Deleted the esbuild adapter (useDataStaticAnalyzer + extractEntryPaths) and the
esbuild import from the analyzer; the rolldown adapter + bundler-agnostic core
remain.
- Migrated build/integration/document-injection tests off
`esbuild.build({plugins:[useDataStaticAnalyzer()]})` to a `runAnalyzer` helper
that drives `createUseDataAnalyzerCore` directly. This is strictly better: the
snapshots are now the analyzer's OWN output, not esbuild's bundled codegen
(helpers, inlined React, type-stripping) — smaller and bundler-independent.
Added runAnalyzerMulti for the cross-file aggregation tests (load the whole
graph, one core) — verified the aggregation is preserved (a field accessed in a
page still propagates through a hook chain into the target useData). runAnalyzer
throws on the core's `errors` to keep the fail-loud (unknown-field) behaviour
esbuild gave via failed builds.
- Deleted esbuild.bench.ts + its baseline; dropped esbuild from all three
package.json deps.
Verified: 780 unit tests (incl. 153 analyzer tests via the core) + typecheck +
build + docs build all green. esbuild is gone from the repo's direct deps.
rfcs/DEV_SERVER.md: the build/dev/deploy pipeline plan — reload-aware runtime (static boot vs dev worker), persistent dev worker + reload protocol (pages/ server/config kinds), nft standalone deploy workstream (nft over ncc: sharp's native assets + the pages' computed dynamic imports + the unbundled transpile-only output), and the invariant tying them together (prod entry stays statically traceable; reload machinery is a separate, undeployed dev entry). Step 0 (this commit): factor usePages' manifest-read + SSR-routes-import + handler build into a reusable loadPages() that populates mutable routes/handler/manifest refs the catch-all reads, and expose it as a dormant hook (globalThis.__PYLON_DEV_RELOAD_PAGES__). In prod it's set once and never called — behavior-identical, no new dynamic imports, so server.mjs stays traceable. The dev worker (Step 1) will drive page hot-swaps through it: re-read manifests + re-import the content-hashed (cache-clean) SSR routes with no app re-import, no DB reconnect, no plugin re-setup. Verified: typecheck + boundaries clean, 179 pages tests, dev-pages-loop e2e 6/6, docs build+serve behavior-identical.
The dev server no longer kill+respawns the app on every edit. It spawns ONE persistent worker (default, no `-c`) and hot-swaps the page layer on pages-only edits — no restart, no DB reconnect, no plugin re-setup. - emit-server-glue: in dev, also emit `dev-worker.mjs` — a thin wrapper that imports the pristine `server.mjs` (boots + serves + sets the reload hook) then listens for IPC hot-reload. `server.mjs` stays statically traceable for the nft standalone build; the reload machinery is a separate, never-deployed entry. - cli dev: persistent mode spawns dev-worker with an IPC channel; the Supervisor classifies each change — `src/**`→server, `pylon.config.*`→config (both restart), everything else (pages/·public/·components/·lib/·styles)→pages hot-swap (buildPages + `worker.send(reload:pages)` → `loadPages()` in-worker → SSE). A failed hot-swap falls back to a restart. `-c` override keeps the legacy loop. Proven: page edit keeps the SAME worker pid (hot-swap); src edit gets a NEW pid (restart). dev-pages-loop e2e 6/6 with the page-edit test 1518ms→514ms (~3x), out-of-pages component edit 3042ms→1067ms; serve-build e2e green (prod path unaffected — dev-worker.mjs is dev-only); 179 pages tests + typecheck clean.
Three coherent pieces of the rolldown-vite dev engine (rfcs/DEV_SERVER.md). Step 2 — server-plane resolver hot-swap (no restart): - pylon-handler: swapSchema seam (rebuild Yoga's schema over a mutable ref) - db/registry: replace-by-name idempotency so app re-import doesn't accumulate models - cli/dev/vite-hot-server: dev-only rolldown-vite module runner (ssr.external=framework → durable DB/registry/ALS) exposed as @getcronit/pylon/dev-engine - dev-worker reload:server + CLI routes src edits to a hot-swap (fallback restart) - proven: src edit keeps the same worker pid (resolver + schema change) Serving as a config plugin (pure entry): - kill the `if (__isNode) serve` auto-serve smell; the built entry is now pure (export default app, no import side effect) - useNodeServer() — dev-aware 'last' plugin (binds in prod, no-ops under `pylon dev`, which owns serving); add @hono/node-server dep - migrate all serve fixtures + the docs app Step 3b — client-only Vite for React Fast Refresh (the "clean split"): - Vite confined to serving client modules + HMR; SSR/serving/CSS stay on the rolldown +Hono path (manifest CSS `<link precedence>` → no FOUC, hydration-safe) - pages/dev/vite-dev-server fronts the port (middleware → app.fetch fallback); bridge = clientEntry (bootstrap) + transformHtml (inject @vite/client + refresh preamble) - useDataStaticAnalyzerVite + injectHydrationVite; resolve.dedupe + cacheDir + optimizeDeps - validated on the docs app: styled first paint, clean hydration, state-preserving Fast Refresh, no console errors; dev-pages-loop e2e 7/7 Note: a root-layout `<link rel="stylesheet">` needs a React-19 `precedence` prop (hydrate-document requirement, bundler-independent).
…ds (3c) The clean-split made the SSE live-reload dead: the Vite-served client never subscribed to it. Remove it and route every reload through Vite instead. - delete the SSE reload server (dev-reload-server.ts + its unit test) and the reload.notify() calls; drop PYLON_DEV_RELOAD_PORT (→ a plain PYLON_DEV flag for the SSR-minify check) and the dead EventSource injection in the rolldown client hydration - server/resolver edits now full-reload the browser over Vite's ws: the dev worker calls bridge.reloadBrowser() after the schema hot-swap so pages re-fetch (component edits stay in-place via Fast Refresh) - drop the obsolete Tier-0 e2e test Validated: docs (component edit → in-place; src edit → full-reload + re-hydrate); dev-pages-loop 6/6. Deferred: the rolldown client build still runs in dev — its JS is unused but its CSS output feeds the SSR precedence links (no FOUC).
… dev rebuild In dev, Vite serves the client, so the rolldown client JS bundle is dead weight. But its CSS output is load-bearing (the SSR `<link precedence>` tags that give styled first paint / no FOUC). The server build already traverses the same app graph and collects the same CSS, so let it own CSS in dev: - runServerBuild(writeCssAssets): resolve url() assets + return the collected CSS - extract writeCssFiles(); add writeDevStaticManifest() (CSS-only: index.css/ app.css/version, no app.js) - rebuild(): in dev (PYLON_DEV) run only the server build + write CSS; skip the client build. Prod still runs both (client app.js + server), unchanged. Halves the rolldown work per dev rebuild (one traversal, not two). Validated on docs: styled first paint, hydrated, Fast Refresh, no errors; `Pages [client]` build gone in dev / present in prod; dev-pages-loop 6/6.
A module-imported image (`import img from './x.png'` → `<Image src={img}>`) would
have diverged in dev: the rolldown SSR emits an optimized object with a media URL,
while Vite's client gives its own default asset URL → different `<img src>` →
hydration mismatch. (Latent — no app does this yet — but now guarded.)
- emitDevImageUrl(): shared helper copying the image to the media dir under the
same hashed name imagePlugin uses, returning the identical public URL
- imagePlugin({dev}): in dev, skip the sharp blur/dimensions and emit just {url};
prod stays full. Build-time image optimization is a prod-only feature.
- pylonImageVite: a `pre` Vite plugin resolving image imports to the SAME {url},
wired into the pages dev server
- server build passes dev=writeCssAssets
Validated: with a real module-imported image, SSR `<img src>` and the client
`?import` module resolve to the IDENTICAL /__pylon/static/media/... URL (served
200), no hydration mismatch, no errors.
Replace the persistent-worker + IPC dev machinery with a single-process direct-execution model: `pylon dev` now runs `src/index.ts` through Vite's backend module runner, compiles the schema in-process (the warm type-introspection compiler), and boots the app in-memory with the SAME framework functions the prod glue uses (executeConfig -> handler -> executeConfig 'last'). A src edit recompiles + re-imports + calls the swapSchema seam directly. No generated glue, no worker, no IPC. - cli/dev/dev-server.ts: startDevServer() — the direct-exec supervisor (in-process compile, boot, chokidar single-flight, config-edit reboot, pages bridge on the clean split). - cli/builder/index.ts: expose ctx.compile (the cached getBuildDefs) so dev compiles without emitting glue. - cli/dev/vite-hot-server.ts: add importApp/importId/invalidate for the direct-exec runner (legacy reloadServer kept for now). - cli/index.ts: rewire the `dev` command to startDevServer. Validated: real `pylon dev` against the docs app (SSR homepage, precedence CSS, Vite client bootstrap, graphql fallthrough, no glue files, clean SIGINT shutdown) and the API app (hot-swap same-pid V1->V2).
The direct-execution dev server (Stage B) replaced the worker+IPC model, so the machinery it superseded is now dead. Remove it: - emit-server-glue.ts: drop devWorkerBootstrap + the dev-worker.mjs emit and the `devWorker` option. The prod glue (server.mjs/schema.mjs/resolvers.js/ schema.graphql) is untouched. - bundler.ts: stop passing devWorker in the dev buildServer branch. - vite-hot-server.ts: drop the legacy file-reading reloadServer + its schemaPath/resolversPath options (direct-exec invalidates + calls the swap hook itself). Keep importApp/importId/invalidate/close. - cli/index.ts: drop defaultDevWorkerCommand (the `worker` queue command and defaultWorkerCommand stay). - package.json: drop the ./dev-engine subpath — its only consumer was the generated dev-worker.mjs, never a public API. Validated: full unit/integration suite (780 passed) + the direct-exec API hot-swap and docs SSR module tests all green; prod glue path unchanged (build.integration + project-runner + worker-cli green).
…Stage D) - test/cli/dev-prod-schema-conformance.test.ts: asserts the dev in-process schema (ctx.compile().typeDefs, what the direct-exec dev server feeds to handler()) is byte-identical to the prod-emitted .pylon/schema.graphql (what server.mjs serves). Both share getBuildDefs today, so this locks the contract: if a future change makes one path derive the SDL differently, dev and prod would silently serve different schemas — now it fails loudly. Fast, in-process, ORM-backed fixture. - dev-pages-loop.e2e.test.ts: refresh the now-stale narration (server.mjs self-serve / worker child / IPC / restart) to the direct-execution model — one process owns compile + boot + serve + hot-swap. Behavior unchanged; all 6 still green against the real CLI. Validated: conformance 2/2, dev-loop e2e 6/6.
- Replace the deprecated @vitejs/plugin-react-oxc (0.4.3) with @vitejs/plugin-react (^5.2.0) in the usePages client dev server. The experimental -oxc fork was folded back into the unified plugin, which uses the oxc transform automatically under rolldown-vite. react() is called with no options → drop-in. (v6 wants vite@^8; v5 matches rolldown-vite 7's vite-7 API.) - Delete the untracked spike-dev-tier2/ prototypes now that the direct-execution dev server has landed; drop the stale spike reference from vite-hot-server.ts. Validated: dev-pages-loop e2e 6/6 (exercises the Vite client + Fast Refresh plugin).
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.
…tinue)
Vite's dev server runs its connect middlewares before the Hono listener, and its
CORS middleware answers every OPTIONS preflight with 204 without calling next() —
so an app route that must answer OPTIONS itself (e.g. a WebDAV mount advertising
the DAV header, or any custom OPTIONS handler) never runs under `pylon dev`. Pass
`cors: {preflightContinue: true, origin: true}` so Vite still sets the CORS headers
but falls through on preflight. Dev-only; production has no Vite.
A `[...x]` catch-all merges up to `seg/*` only when it is the segment's ONLY
child. With static sibling routes (e.g. `files/[...assetId]` + `files/new` +
`files/settings`) that merge can't run, leaving the base path (`/files`) with no
index route — React Router then renders the parent's empty <Outlet/> ("Matched
leaf route ... does not have an element"). An unoptional catch-all is meant to
cover the base too, so synthesise an index route reusing the catch-all component
(empty splat params). The DATA component id baked into `lazy` stays the catch-all's,
so its analyzed operations still resolve; only the React Router route id differs.
collapseInterfaceTwins folds a promoted alias of an STI base interface (returning the
abstract base, or a subtype union, from a method/mutation) into the base, so the alias
over-broad field set — which can include the base HIDDEN columns — never demands fields
the hidden-stripped subtypes do not expose.
The fold only fired when the alias implementers were EXACTLY ALL the base subclasses. A
return that resolves to a SUBSET (a 2-of-4 subtype union, or any polymorphic position
narrower than all subtypes) produced an alias with fewer implementers, so it was not
folded — it survived as a duplicate <Base>_1 interface carrying the hidden columns, and
schema build failed with
"Interface field Media_1.s3Key expected but GenericFile does not provide it".
Relax the match to a NON-EMPTY SUBSET: fold when every implementer is a subclass of the
base. impls.every(n => subs.has(n)) already excludes any interface with an implementer
outside the base subclasses, so a genuine distinct interface is still left alone.
Tests: a subset alias folds away; a cross-base interface is kept. Verified in a consumer
app: a mutation returning {media: GenericFile | MediaImage} now yields media: Media (the
interface) with no Media_1 and no schema error.
…tructive plan
push was create-table-if-missing only: a new model field on an EXISTING table never
got its column (push reported success, then queries failed "column x does not exist");
a new FK column's ADD CONSTRAINT ran before the column existed.
syncSchema now, additively (Stage 1):
- ALTER ADD every model column an existing table lacks, BEFORE the FK pass (so a new
FK column exists before its constraint); NOT NULL without a default is added NULLABLE
with a warning (the enforced NOT NULL + backfill belong in a migration), a new PK is
skipped.
- reports orphan columns (DB has, model dropped) non-destructively: never drops, only
relaxes a leftover NOT NULL so it can't block inserts, and logs the drift. Extra TABLES
(another app's, shared DB) are left alone.
And takes an EXPLICIT destructive plan from the caller (Stage 2) — never inferred:
SyncOptions { renames, dropColumns, dropTables }. Renames are data-preserving and applied
before the additive snapshot; drops are surgical (only the listed columns/tables).
Covered by two integration suites (10 tests): additive convergence incl. FK ordering,
NOT NULL relax, data preservation, idempotency; and the destructive plan: rename keeps
data, surgical column drop keeps the rest, explicit table drop, rename re-run tolerated.
…m resolver
pushPlan(db): read-only detection of the destructive drift a push would face — orphan
columns on model-owned tables (drop candidates) + the likely renames among them, reusing
the SAME renameCandidates heuristic as migrate. Column-level, matched by physical table
name; extra tables (another service's, shared DB) are ignored. Mutates nothing.
resolvePushPlan(): shared decision logic (push + migrate) turning detected candidates +
orphans into a confirmed {renames, dropColumns} plan via one of three channels — --force
(drop all), interactive prompt (rename/drop/keep, injected for testability), or
non-interactive/CI (keep + emit --rename/--force hints, the migrate warn-then-re-run
contract). Pure except the injected prompt.
Tests: pushPlan detection (4, real DB) + resolver decisions (5, no DB).
Wire the push CLI onto the engine + detection via a detect→(prompt)→apply two-pass: - bridge: syncSchema(models?, opts?) carries the confirmed plan; new pushPlan() detect method. - push case (subprocess): --detect returns the destructive drift (orphan cols + rename candidates) and applies nothing; otherwise syncSchema(opts) applies the confirmed plan. - push command (parent): detects only when the operator might act (TTY / --force / --rename), runs the shared resolver (interactive prompt rename/drop/keep, or --force drop-all, or non-interactive keep+hints), then applies. Default push stays a single additive pass. - --rename uses the SAME table.old=table.new grammar as migrate. e2e through the real bridge (3 tests): detect reports + applies nothing, apply with a confirmed dropColumns drops it, plain additive push keeps an orphan (no accidental loss). Full CLI suite green (140).
migrate already DETECTED renames (renameCandidates) but only warned "re-run with
--rename". On a TTY it now asks. Flow: a detect-only diff pass (new generate({dryRun})
returns candidates and writes NOTHING) → prompt per candidate (Rename, keep data / drop+add,
lose data; table renames too) → the confirmed ones fold into renames/tableRenames, so the
real generate emits a data-preserving RENAME. Off a TTY / --non-interactive / CI the existing
warn-then-re-run contract stands — same --rename grammar as the flags.
- generate({dryRun}): early return with candidates, no refusal check, no file write.
- generateGroup + bridge thread dryRun through; db diff gains detectOnly (writes nothing).
- db diff gains --non-interactive.
Tests: detectOnly is side-effect-free + returns candidate shape; full migration suite (99)
and CLI suite (141) green.
…n bundle
createMigrationLoader bundled each migration with rolldown into the PROJECT ROOT
(bundle.write({dir: cwd})) so bare imports resolve against the project's node_modules,
then removed only the entry file. When a (data) migration imported project modules,
rolldown code-split into a shared rolldown-runtime-*.js + per-module <name>-<hash>.js
chunks that the cleanup missed — leaking build artifacts into the user's repo (and not
gitignored).
The runner already runs under tsx, and project-runner imports the models entry (the same
field-builder .ts) directly. So load the migration the same way — a direct import from
disk: resolves against the project node_modules, shares the one ORM instance the models
registered into (a bundled copy risked a second instance), and writes NOTHING. Removes the
rolldown dependency from this path.
Migration-loading paths green (diff/status/e2e/roundtrip, 49) + full CLI suite (141).
… temp file runDbCommand spawned a tsx child (project-runner) and shuttled the result back through a temp JSON file in os.tmpdir. The child existed for isolation, but for db it bought little: the entry only registers, doesn't serve. Load the project IN-PROCESS instead. loadProjectApp(cwd, entry): register tsx as a loader, resolve the project's OWN @getcronit/pylon/db from the entry via import.meta.resolve (ESM condition — ./db is an import-only export; createRequire's CJS condition can't see it), import the entry to register models, with the entry's stdout redirected to stderr so a command's machine-readable stdout (inspect JSON) stays clean. Cached per entry; a follow-up op in the same invocation (push detect→apply) reuses the instance and reconnects. closeProjectApp destroys the pool after each command so the CLI exits. runDbCommand now calls runDbCommandCore directly on the loaded ORM — no spawn, no IPC, no temp file. Faster too (db-cli suite ~1.7s vs ~15s; push e2e 0.9s vs 4.7s — no per-command child). Build is transpile-only (es2022/esm), so import.meta.resolve survives to dist (as dist/cli/project-runner.js already shows); the vitest fallback to a bare import is test-only (monorepo = one shared instance). spawnProjectRunner still backs inspect/queues (separate follow-up). 161 tests green.
… CLI reads one surface
The CLI read queues by separately importing @getcronit/pylon/queues and reading its
module-global registry (import.meta.resolve dance). Make it app-centric instead, matching
how queues already STORE data (per-app WeakMap, queuesOf): each satellite contributes a
reader via a new `introspectors` list on the extension bus, and core merges them in
app.introspect(). Core also tracks constructed apps (getApps()).
- core: getApps() + app.introspect() (runs bus.introspectors over the app); bus gains an
introspectors array alongside constructHooks. Data stays in-app; the globalThis bus still
only carries functions (the optional-peer decoupling seam — satellites never import core).
- queues: registers an introspector (app) => ({queues: queuesOf(app)}) via the bus.
- CLI introspectAppData: now in-process (loadProjectApp), reads queues off
core.getApps()[].introspect() — no @getcronit/pylon/queues import, no per-subpath resolve.
Models/IR still via the ORM (per-app models = deferred bigger step).
Tests: app.introspect() aggregates an app's queues; broad suite (814) green bar the
pre-existing node-lease flake.
Both db and inspect load the project in-process now (loadProjectApp + getApps/introspect), so spawnProjectRunner has no callers. Remove it and the whole child-process apparatus: project-runner.ts, project-runner-protocol.ts (RunnerEnvelope), tsxCliPath, projectRunnerPath, the os.tmpdir result-file IPC, and the node:fs/child_process/os/module imports that only it used. No subprocess, no IPC temp file anywhere in the CLI's project loading. cli + queues suites green (152).
Contributor
🦋 Canary published from
|
65245fa switched migration loading from a rolldown bundle to a direct `import()` of the `.ts`, on the assumption that tsx (registered by loadProjectApp) would transpile and resolve it like the models entry. It doesn't when the migration lives outside the project (the db-migrate e2e puts it in a tmpdir, as may any `--dir`): tsx picks CJS for a file with no `"type":"module"` package.json above it, so `import()` becomes `require()` of the ESM `@getcronit/pylon/db` while that module is mid-evaluation up the stack → Node throws `require(esm) ... in a cycle`. Loading it as ESM (.mts) instead only trades the cycle for `Cannot find package '@getcronit/pylon'`, because the ESM resolver walks up from the tmpdir and never reaches the project's node_modules. The bundle was right; only its cleanup was buggy (it wrote the whole bundle to cwd and removed just the entry, leaking code-split sidecars into the repo). So bundle again — bare imports external (one ORM instance), relative/project code inlined — but `generate()` in memory with `inlineDynamicImports` (one chunk) and write only that entry to cwd, so nothing can leak. The temp file is `.mjs` (ESM cycles are legal), dot-prefixed, removed in finally. project-bridge: `SyncOptions` type now imports via the `@getcronit/pylon/db` self-ref, not `../db` — a type-only import erases at build, but check-boundaries is text-level and the self-ref is the documented convention for crossing a feature boundary. Verified: db-migrate e2e 11/11 (shipped dist vs live Postgres), CLI suite 141, no temp leak. node-lease integration flake is pre-existing (passes in isolation).
a715f0d added getApps to core's public exports, which tripped the docs coverage hard gate (every public export must be documented). It's the same category as executeConfig / currentRole / renderLine: exported from core only so the subprocess-free CLI loader can reach it across the feature boundary via the self-ref (load the project's core, introspect each app) — not a user-facing feature. Add it to the registry's `internal` denylist.
This branch is waiting to be 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.