Skip to content

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

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

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

Conversation

@schettn

@schettn schettn commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

What this is

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

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

Highlights

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

Release impact (read before merging)

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

Notes

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

schettn added 30 commits June 24, 2026 16:20
- content/: ground-up doc tree (introduction, core-concepts, data, authentication,
  apps, frontend, queues, production, guides, reference) + _STYLE authoring guide
  and marketing copy; refreshed landing page
- nav: Documentation mega-menu, mobile slide-over nav, docs sidebar drawer,
  scroll-to-top on route change; new section icons/order
- restructure: production/runtimes, frontend/routing, data folded in
Design/plan .md notes live in dd/ (gitignored as a folder, no per-file list);
tarballs/ ignored too. Only README, CONTRIBUTING, CODE_OF_CONDUCT stay tracked.
…thod

Cross-cutting resource rules now live in the constructor models config
(`new Pylon({name, models: {abilities}})`) — app-scoped and IR-harvestable
(tracked per-app via appAbilitiesOf), replacing the orphaned global
defineAbilities the IR can't see. Rules wire in a microtask so they register
after every @app.model() in the graph loads. The chainable .models() method is
removed; the constructor models option is the single home (no ordering footgun).
Leads with @app.model() + static config/abilities and @app.queue() class form
+ Queue.input(schema). Cross-cutting rules use new Pylon({models:{abilities}});
defineAbilities and the .models() method are removed from the docs (not
IR-harvestable / no longer exist).
MCP stdio server (describe_app / get_entity / get_operation / verify) over the
AppModel, directory-independent via --cwd. `pylon verify` composes build + tsc +
db check into a pass/review/fail verdict an agent can trust (caught real bugs in
the demo bed: nonexistent API, FK type mismatch, uncaptured migration). Adds the
mcp-demo-app fixture and the @modelcontextprotocol/sdk dep; ignores .pylon/.pylon-verify.
Measures whether an agent does better WITH the Pylon MCP than without: same task,
two arms (with-mcp/baseline), scored by the same `verify` verdict. Pluggable runner
(Agent SDK for real runs; a fake runner makes the copy→run→score→aggregate plumbing
testable with no LLM). Workdirs sit beside the base app so workspace deps resolve.
Ships a starter bench + README; the bank should grow to 15-20 broken-app scenarios.

Also: mcp --cwd (directory-independent); ignore .pylon/.pylon-verify/.eval-runs.
Adds per-row mcpCalls (0 ⇒ the MCP was never used) to the table/summary and writes
the full report incl. tool-call names to eval-report.json, so a run reveals HOW each
arm worked (and whether the MCP was actually exercised) without paying to re-run.
Convert all 19 docs pages from the deleted decorator API
(@model/@app.model/@queue/enqueuer/models.app/defineAbilities) to the
decorator-free constructor form: plain classes registered via
new Pylon({db: {models}, queues}), static config satisfies ModelConfig/
QueueConfig, and static abilities / db.abilities for authz.
Replace the decorator authoring path (@model/@app.model/@models.model,
@queue/@app.queue, the free model()/queue(), enqueuer, models.app, and the
public defineAbilities) with constructor-based registration: a plain class is
finalized by the Model/Queue base (Proxy-driven field capture) and registered by
listing it in new Pylon({db: {models}, queues}). Per-item options move to
'static config satisfies ModelConfig<T>/QueueConfig<Q>'; a model's own row rules
live in 'static abilities' (subject implicit) and cross-cutting rules in
db.abilities. A named app prefixes tables + forms a migration group.

- pylon (core): construct-hook extension bus invoked from the Pylon constructor;
  compose aggregates children's models/queues.
- pylon-db: unconditional Proxy Model, COLUMN_STORE capture in set+defineProperty,
  esbuild self-ref underscore strip, AppModelOptions (db: {models,tenant,secure,
  dependsOn,policy,abilities}), modelsOf(app), typed ModelConfig/QueryConfig.
- pylon-queues: manager() (was enqueuer), QueueConfig, processQueues hook,
  queuesOf(app); all queue decorators removed.
- pylon-dev: delete discover.ts whole-tree scan; orm-bridge -> project-bridge
  (reads the constructed app's models/queues); prepare-model-source exports
  __pylonEntry.
- Migrate every unit/integration test and e2e fixture to the constructor form;
  drop the discover-orphan fixture/test.

rework details: dd/MODEL_QUEUE_REGISTRATION.md
The pylon eval runner (packages/pylon-dev/src/eval/runner.ts) imports it, but the
manifest entry + lockfile were never committed alongside the harness.
`manager(Queue).add/addDelayed/dispatch` now accept the underlying
`JobsOptions`, so class-form queues can pass `jobId` (idempotent dedup for
re-polling producers) like the function-form `QueueDefinition.add` always
could.
`pylon inspect` read `queuesOf(__pylonEntry)`, so a COMPOSED project (queues
registered on child apps, not the composed root) reported zero queues. Read
the global `registeredQueues()` instead — the queue analogue of
`allModels()`. Also fix `usesQueues` detection: probe the project manifest,
not `require.resolve` (pylon-queues is ESM-only; its exports map blocks the
resolve, a false negative that left the registry re-export off entirely).
The runtime config (`config.js`) was a separate esbuild bundle from the
server (`index.js`); each inlined its OWN copy of the model classes, so a
model imported by config-bundle middleware (auth) was a different object
than the server registered — registry/hydration missed it. Emit both as
entryPoints of ONE `splitting:true` context: the shared model layer lands in
a chunk both import (one class object). The build-time plugin read uses a
separate standalone bundle so it doesn't collide with the split's config.js.
Defense-in-depth for duplicate model-class copies (multi-bundle, HMR
re-eval): `getModelDefinition` falls back on a registry miss to a by-name
match (underscore-normalized) and aliases the copy → the registered def
(model names are unique — they're the GraphQL type names). `hydrate`/
`create`/`createMany` instantiate `def.ctor` (the FINALIZED class), since a
`new copy()` of an unfinalized duplicate yields a blank instance.
mergeEntities/writeEntity did a shallow {...existing, ...incoming}, which
replaces an entity's inline connection object wholesale — so a narrow op writing
{totalCount} clobbered a sibling op's {nodes, totalCount}, and reading .nodes
returned undefined (wrap.ts 'PARTIAL READ'). A build-time op always fetches its
full document, so a selected field must never read back undefined; that only
happened because normalization could drop a populated field.

Make the merge non-destructive + deep: recurse into inline objects (so a
totalCount-only write can't drop a sibling's nodes), keep fields absent from the
incoming write, and let scalars/arrays/refs win when present (refetch stays
authoritative for lists). Fixes both the contact-overview crash and the
first-comment-doesn't-render bug. +6 regression tests; 54 existing pass.
Broad in-progress V3 work, committed as one checkpoint:
- pylon-db: filtered relation count-batcher (RelatedQuerySet + QuerySet.
  groupedCountByFk) + hasMany default-ordering in the batch; fields/registry/
  migration-ops adjustments; +hasmany-orderby integration test.
- pylon-dev: bundler restructure (emit-server-glue, transpile-app; drop
  inject-code/notify plugins).
- pylon-query: use-query-doc/client/wrap runtime changes.
- pylon-queues: app/plugin (super-admin dashboard) changes.
- pylon-ir: ddl/diff; pylon: app/index.
- pnpm-lock refreshed.
…, count)

keyedQuery(root, {key, where, paths}).count() coalesces N parameterized
counts sharing a shape into a few grouped queries per microtask, keyed on a
caller-supplied value. Phase 1 reuses QuerySet.groupedCountByFk for both path
kinds: a 'column' path is a direct grouped count; a 'through' path fetches the
(via→key) links once, grouped-counts the root by the join column, then rolls
up. Disjoint paths summed (UNION/distinct + the batchKey() marker rewriter are
later phases). Verified: 15 companies' own+team open-ticket rollup → 3 queries,
counts identical to the per-row id-set method. See dd/KEYED_QUERY_BATCHING_DESIGN.md.
Mark the batch dimension in a natural WhereInput; the deriver reads the
projections off the predicate (column equality → column path; belongsTo →
hasMany → some → through path) and routes QuerySet.count() to the keyed
engine. A marked-but-unbatchable predicate THROWS a precise BatchKeyError
(key in a range, under NOT, unreachable relation, two values) — never a
silent N+1 (§10 contract). Marker-free counts are untouched. Brand is a
Symbol (collision-proof, invisible to JSON). Verified: derived == explicit
paths (15 companies → 3 queries), range-key throws, unmarked runs plain.
task→team via direct column (task.teamId) + through (owner→memberships→team).
Asserts: correctness across path shapes (person in two teams counts for both,
former membership excluded, own-only, zero); a 53-key batch → ≤4 queries
(constant, not O(N) — un-batched would be ~100+), proving the coalescing;
marker == explicit keyedQuery({paths}); marked-but-unbatchable throws;
marker-free counts unaffected. Runs on PYLON_ORM_IT=1.
…rst/.exists)

Generalise the engine beyond count: .exists() (count>0), and .all()/.first()
via a rows executor — QuerySet.groupedRowsByFk gathers per-path rows, deduped
by pk across paths (overlap-safe) and sorted in memory by the caller's orderBy.
QuerySet.all()/.first()/.exists() route on a batchKey() marker like count().
Shared enqueue + gatherLinks across terminals. Integration: .all() returns the
right rows across both paths; .exists/.first batch; 53-key .all() → ≤4 queries.
(Pre-existing migration-ops WIP failures are unrelated — DDL codegen.)
The safe half of auto-detect: when N unmarked counts of the same shape run in
one microtask differing only in a projectable-equality column, warn that a
batchKey() there would coalesce them (console.warn '[pylon-db:batch-hint] …').
Never changes behaviour; dev-gated (NODE_ENV!=='production' || PYLON_BATCH_HINTS),
zero prod overhead. Symmetric with the marked-query error contract (§10): a
marked query that can't batch throws; an unmarked one that could gets a hint.
Integration: 13 unmarked counts differing only in teamId → the hint fires.
…ry Phase 4

belongsTo, hasMany, the relation count-batcher, and the keyed-query engine each
hand-rolled the same microtask-coalescing skeleton (per-ctx map → queueMicrotask
→ distribute). Extract it once as batchLoad(realm, token, key, load, missing) in
batch-loader.ts; every batcher now differs only in its load(keys)→Map step:
- belongsTo → WHERE pk IN (…) → Map<pk, instance>
- hasMany   → WHERE fk IN (…) ORDER BY … → Map<fk, instance[]>
- keyed engine → count/rows compute fns returning Maps
- relation count-batcher DELETED — RelatedManager/RelatedQuerySet.count() now
  route through keyedQuery (one column path), the engine's simplest case.
Each keeps its exact optimized SQL (no routing hot loaders through the planner).
~160 lines of duplicated skeleton → one 76-line primitive. Verified: full suite
green (203 integration + 198 unit), zero behavioural change.
One task matches team T via BOTH paths at once — directly (task.teamId=T) and
through its owner's membership. Disjoint-sum count() over-counts: direct(1) +
through(1) = 2, but there's one distinct open task. .all() is correct (dedups by
pk); only count()/sum is wrong. The count-should-be-1 assertion is it.fails() —
executable documentation that flips to failing the day count() uses
count(DISTINCT id) over the UNION, cueing the conversion to a normal it().
…paths

A single path can't overlap itself, so keep the cheap grouped count(*) there (the
hot relation-count path is unchanged). TWO+ paths may match the same row (e.g. a
task on team T AND owned by a member of T) — computeCounts now gathers deduped
pk-sets (QuerySet.groupedIdsByFk pulls only (fk,pk) pairs, not full rows) and
returns the set sizes = count(DISTINCT id). .all() already deduped by pk. The
former it.fails overlap test is now a passing correctness test (count()==1==
all().length). Full suite green: 206 integration + units, single-path unchanged.
Add signals.postSave/postDelete.connect(fn, {afterCommit: true}) — deferred
until the transaction commits, outside it, errors isolated, dropped on
rollback. Built on a new onCommit(cb) primitive (Django transaction.on_commit
analogue) drained after the outermost commit; reentrancy-safe. manager.ts
unchanged — emit() defers flagged receivers via onCommit.

4 new integration cases in signals.test.ts (after-commit ordering, skip on
rollback, autocommit path, nested-saves-after-outer-commit).
Add a useData(options: UseDataOptions): Data overload so the hand-written
authoring form useData({ tags: [...] }) type-checks. The build analyzer already
rewrites it to useData(doc, thunk, options) — this was a type-only gap (the
overloads only accepted a TypedDoc first arg, so { tags } mis-matched it with
'tags does not exist in TypedDoc'). Placed after the doc overload (an options
literal isn't assignable to TypedDoc's required id/body/name, so it falls
through; a real doc variable still infers TResult). Impl signature broadened to
the union.
…al read)

mergeEntityFields replaced arrays wholesale, so a narrow op that reads only
list.length — selecting `values { __typename }` with id-less, un-normalizable
elements — overwrote a wider op's [ref, ref] on the shared entity. The reader
then saw undefined for every element field (the 'PARTIAL READ' crash on
soft-nav). Keep the existing list when incoming is a non-empty list of purely
id-less objects while existing carries refs; genuine updates (refs / empty /
id-less-both-sides) still replace. Honors the invariant the merge doc already
claimed. +2 tests.
Add a data-preserving table-rename op (authoring-only, like renameColumn — the
diff can't infer a rename from drop+create). migrations.renameTable({from, to,
fromTable, toTable}): from/to are the IR keys (model names, how the snapshot is
keyed); fromTable/toTable the physical names. SQL: ALTER TABLE … RENAME TO (with
reverse). Snapshot: re-keys by model name + updates .table on the entry AND its
nested indexes/FKs (Postgres keeps those on the renamed table under old names) —
names kept so a follow-up diff reconciles them via safe drop/recreate. +2 tests.
Complete the renameTable story: tableRenameCandidates() flags a dropTable+
createTable whose column-name sets match as a probable rename (conservative —
exact match, no false positives), mirroring column renameCandidates. A
--rename-table Old=New hint collapses the drop+create into a single
data-preserving renameTable: diffSchema remaps prev[from]→prev[to] up front
(moving the physical table + nested FK/index .table to the new table, names kept)
so the normal column/FK/index diffs emit the rename + the dependent-object name
reconciliation in one pass. CLI warns on unconfirmed candidates. Also fixes
apps-mode diff never threading renames at all. +3 engine tests.
The features landed across the last several commits had RFCs and changesets but nothing
user-facing. Two docs pages close that.

New frontend/i18n.md: prefixed URLs from one pages/ tree, useLocale, catalogs with typed keys
and placeholders, plurals, formatting, the ICU seam, <Link locale>, the emitted canonical +
hreflang cluster and expanded sitemap, cookie mode, and the locale-shadowing warning. It also
states plainly why negotiation never redirects — crawlers send no Accept-Language, so
redirecting on it strands every non-default locale — and shows the suggestion pattern that
replaces it.

frontend/server-context.md previously documented READING `context` without ever saying how it
gets set, which was the gap useRequestContext fills. It now covers populating it, typing it
via Variables augmentation, `vary`, the re-exported cookie helpers (an app cannot resolve
hono/cookie itself), and useResponseCookies — including the idempotency contract and that it
is the wrong tool for session tokens.

Trimmed the public surface while documenting it: `matchAcceptLanguage` and `splitLocalePath`
were exported for no reason beyond testing, and their tests import from source. Only
`hasLocale` stays public.

`renderLine`/`jobLogLevel` are marked internal in the docs coverage registry — they are
exported from core solely so the queues battery can reach the logger across the feature
boundary via the self-ref, not as features. Documenting them as API would have been the wrong
way to satisfy the checker.

`pnpm --filter @getcronit/pylon-docs check:coverage` passes: every public export is now
documented. The docs site builds and the new page renders and appears in the nav.
daab68e ran `git add packages/pylon/src/pages` to stage the i18n catalog work and swept in
`use-pages/dev/vite-dev-server.ts` — an unrelated in-progress change from another session that
imports `cli/dev/vite-messages.js`. That module was never committed, so the pushed branch
imports a file that does not exist and `build:declarations` fails:

    vite-dev-server.ts(27,8): error TS2307: Cannot find module
      '../../../../cli/dev/vite-messages.js'

which took down Docs coverage and DB Migrations, both of which build the packages first.

Every local check passed throughout because the file EXISTS in the working tree, untracked —
so the local build and CI were compiling different source. Adding by directory is what made
that possible; the staged set should have been the explicit file list.

The fix is to commit the module rather than revert the importer: reverting would have thrown
away the other session's working-tree edit, and this is the state everything has been tested
against all along. Its own 15 tests pass, and with only this file added — the other two
importers (dev-server.ts, vite-hot-server.ts) still uncommitted, exactly as CI will see it —
tsc and the build are clean. Those two remain for that session to land.
A resolver had no way to know the locale. The SSR fetcher forwards request headers, so it
could read Accept-Language or a cookie — but in prefix routing the locale comes from the URL
PATH, and the GraphQL request goes to /graphql, so the header would contradict the page.

The fix is Shopify's: put the context IN the document.

    query Products($__locale: String) @incontext(locale: $__locale) { products { name } }
    Query: {products: () => Product.objects.localized(getLocale())}

Forwarding a header instead would have been an actual bug, not a style choice. pylon-query
keys its store on `documentId ~ variablesHash(variables)` and nothing else (runtime/doc.ts
opKey), so with the locale in a header the same document and variables would be the same
cache entry — English and German colliding on one key, in the client store and the hydration
envelope alike. In the document, the locale is in the key by construction.

The directive is appended to every emitted SDL (without the definition, a query carrying it
fails validation before a resolver runs), read in onExecute where the document is validated
and variables coerced, and bound to the request context that getContext() already reads.
Literal and variable forms both work; the variable form keeps one document id across locales.

getLocale() returns undefined when the operation states no locale rather than defaulting —
the caller did not ask, so the resolver decides.

e2e asserts what actually matters: the document decides, and headers do not. A request with
`accept-language: de-DE` gets English, and a document saying `fr` beats a header saying `de`.

This is the server half. Auto-injecting the directive into useData-compiled documents — so a
pages app needs no per-query wiring — is the remaining piece; it lands in the document
compiler and the query client.

e2e 272.
…ct resolvers

The analyzer half of @incontext. With usePages({i18n}) configured, every compiled operation
becomes `query page_0($__locale: String) @incontext(locale: $__locale)` and the query client
supplies the locale, so a resolver calling getLocale() is correct with nothing passed at the
call site. Queries and mutations both — a mutation returns localized content too.

The locale lives on the CLIENT instance, not a module-level variable. The SSR pass builds one
client per request, so concurrent renders in different locales cannot bleed — the same failure
that makes a module-global i18next unsafe on a server. The browser has one client and one
locale, since switching locale is a document navigation.

It is merged into the variables BEFORE opKey hashes them, at every site that computes a key
(fetch, ensure, revalidate, refetch). Missing one would have meant a read and a write landing
in different slots for the same query. Verified live: /de and /fr produce the same document id
with different variables hashes, so each locale gets its own cache entry — the property a
header-based design would have destroyed by making both keys identical.

Ordering fix: negotiation now runs before the per-request query client is constructed, since
the client needs the locale.

Docs: a "Localizing on the backend" section, including why a resolver cannot just read the
page's request context — the SSR pass reaches GraphQL through a separate in-process request,
and after hydration there is no page request at all, so such a design would be locale-correct
during SSR and locale-blind afterwards.

Three e2e assertions failed on the way and all three were the tests, not the product: the
operation cache arrives in a second Object.assign script rather than the first envelope, and
route documents are chunked into subdirectories.

Unit 914 / e2e 276. Docs coverage passes.
The `@inContext` examples called `Product.objects.localized(id, getLocale())`. No such method
exists — I made it up while writing prose, and it reached the i18n guide, two changesets (which
become the published changelog) and the `core/in-context.ts` module comment.

Replaced with a translations-row lookup using only verified API:

    const translated = await ProductTranslation.objects
      .filter({productId: id, locale})
      .first()

`filter(where).first()` is the real chain — the ORM's own node-resolve.ts and the pagination
integration tests use it. Note `first()` takes NO arguments; my first correction passed it a
where object and would have been just as wrong.

To stop guessing, the example is now type-checked: transcribed verbatim into a scratch module
with real `models.Model` classes and compiled against the built types. It passes, so the
snippet in the docs is one that actually works.

The e2e fixtures never used the invented method, so nothing in the test suites was affected —
this was purely prose, which is exactly why it survived every green run.
pylon dev builds the SSR bundle with the rolldown analyzer and the CLIENT bundle with the Vite
one. I wired the i18n flag into the rolldown path only, so dev shipped a server that knew the
locale and a client that did not: SSR rendered German, and the first refetch sent a
directive-less document and flipped the page to English. Production was correct, which made it
the worst kind of difference — visible only in the environment where it would be shrugged off.

Reproduced in a browser before fixing, by simulating the pre-fix state: the wire carried
`query page_0 { serverGreeting }` with no variables, and the DOM went "Server: hallo" →
"Server: hello". After the fix it carries
`query page_0($__locale: String) @incontext(locale: $__locale)` with `{"__locale":"de"}`.

The dev server reads the usePages plugin's own options to decide. `usePages` in dev-server.ts
is only an `fs.existsSync(cwd/pages)` check, so the plugin is the only source of truth for
whether i18n is configured — and Plugin.options now exposes them, because options handed to a
plugin factory are otherwise captured in its closure and unreachable.

Also drops createPagesClient from the generated client. Nico questioned whether it was used
anywhere: it is not — introduced with the pylon-query layer, never called since, undocumented.
An earlier pass in this session gave it a locale parameter and called it a latent bug fix; a
function nobody calls cannot have one. Removed instead, along with the test that had pinned
its signature. `createPylonQueryClient` from @getcronit/pylon/query remains for apps that
need a per-request SSR client, and it takes a locale.

Unit 914 / e2e 280.
The e2e suite has no browser, so nothing automated exercises a client-INITIATED fetch — the
path where both @incontext bugs actually lived (the browser client omitting $__locale, and
the dev client compiling a directive-less document). This button is how that path was driven
by hand: load /de, patch window.fetch, click, inspect the body.

Committed with a comment saying so, rather than left as an untracked local edit, so the next
person can reproduce the same check instead of rediscovering the technique.
`apply` and `applyGroupsInterleaved` both read the ledger BEFORE taking the
advisory lock. The lock serializes the two runners, but the second still holds
a snapshot taken before the first applied anything — so it replays the whole
history and dies on `relation "…" already exists`.

That is the ordinary case of two replicas migrating at boot, or CI racing a
human: N-1 processes fail a deploy that should have been a no-op.

Move the read inside the lock in both paths. For the grouped path the file
loading and graph construction stay outside (neither depends on ledger state);
only `pending` and the tamper check — which share that read — move in.

The regression test builds two runners before either applies, the shape a
second replica has, and asserts one applies while the other no-ops.
A primary-key change on an existing column, or adding/removing generated-ness,
lands in `renderChanges`' `unsupported` list and emits NO SQL — but
`applyChanges` folds it into the reconstructed baseline wholesale. The
migration therefore applies as a no-op that still counts as captured:

  pylon db diff   → writes the migration (warning only in a file comment)
  pylon db deploy → applies it, successfully
  pylon db check  → "up to date"

against a database that never received the change. Nothing catches it: the CLI
never surfaces `unsupported` at all, and `schemaDrift` compares only table and
column NAMES, so it cannot see a primary key that never moved.

Close it at both ends. `schema()` throws when the delta can't be rendered,
which covers hand-authored files and fails at load time before anything runs;
`generate()` refuses instead of writing the file, so nothing half-captured
reaches the tree.

Add `migrations.stateOnly(changes)` — Django's SeparateDatabaseAndState, state
half — as the escape hatch: run the real DDL with an adjacent `runSql`, then
record the delta so the baseline and the database agree again. `applyChanges`
keeps folding everything, which is the correct behaviour for that pair.
A column type change emitted a bare `ALTER COLUMN … TYPE <t>`. Postgres accepts
that only when it already knows how to convert the values; otherwise it aborts
the migration with "cannot be cast automatically" — a failed deploy whose only
way out is hand-editing the generated file, which `db diff` then regenerates
identically on the next run.

Add `castsImplicitly` to the dialect. The rules come from probing Postgres 16
with every type pair the IR can emit, not from pg_cast — the catalog describes
expression casts, while ALTER TYPE additionally accepts I/O conversions to the
string types, so it would have been wrong. The matrix reduces to: same sqlType;
anything → text/varchar; within {integer,bigint,numeric}; within
{timestamptz,date}. Element rules apply to arrays too, with one asymmetry the
probe caught — text[] → text converts, text → text[] does not.

An un-castable change is now reported as unsupported (so the previous commit's
guards refuse it, naming the flag) and `pylon db diff --using table.col='<expr>'`
supplies the conversion. The hint is serialized into the migration so the stored
file doesn't re-render as a bare TYPE change.

Some casts need an expression in BOTH directions (integer ↔ boolean). Rather
than force `--using-down`, `renderChanges` reports `unsupportedDown` separately
and `schema()` marks the operation irreversible — the same contract a `runSql`
without a `down` already has, so `rollback` refuses it up front.

Fixes a second silent divergence found while testing this: the renderer emitted
a TYPE change only when sqlType or length differed, while `columnEqual` also
compares array, precision and scale. So numeric(10,2) → numeric(4,1), or
text → text[], produced an alterColumn that rendered to no SQL yet folded into
the baseline as applied. `dim` was worse — absent from `columnEqual` entirely,
so vector(3) → vector(5) was never detected. Both closed.

The integration test inserts '41' and '1' as text, migrates to integer with a
hint, and asserts SUM returns 42 — proving the rows converted, not merely that
the DDL succeeded — then rolls back and asserts the column is text again.
`Image` renders its low-quality placeholder as an inline
`backgroundImage: url(<proxy url>)`. React server-renders that value verbatim,
but the browser's CSSOM re-serializes `url(…)` as `url("…")` when it parses the
style attribute — so the string in the HTML and the string React expects on the
client are never equal, and hydration reports a style mismatch on EVERY Image
on the page.

It shows up as a diff on the whole style object, which points at the wrong
culprit: `height`/`width` are listed as differing too even though they are
identical, because React compares the serialized style as a unit.

Emitting the quotes ourselves makes both sides produce the same string.

Reproduced in an app with 43 server-rendered images: every one warned before,
none after, with the DOM attribute and `img.style.backgroundImage` now byte
-identical.
Every Image rendered `loading="lazy"`, which is right for almost all of them
and wrong for the one that IS the Largest Contentful Paint. A lazy hero is
only discovered after layout, so the fetch starts late and LCP moves out by
exactly that delay — with no way to opt out.

`priority` switches that image to `loading="eager"`, hints `fetchPriority`
high, and preloads it. Named after the Next.js prop so it reads the same way,
and documented as something to set on the hero and nothing else: marking every
image priority is the same as marking none.

Verified in an app with 43 server-rendered images: the hero renders eager with
the preload, every other image stays lazy, and exactly one image per page is
eager.
The media proxy could already resize, but Image never offered the browser a
choice — every device downloaded the same file. A phone rendering a 375px-wide
slot pulled the full-width master.

Image now emits `srcset`, with a `sizes` prop to say how wide it actually
renders. Three shapes, picked from what the caller gave:

  - `sizes` set        — the full width ladder, browser chooses against it
  - fixed `width`      — 1x/2x density descriptors, because a `w` descriptor
                         with no `sizes` makes the browser assume full-viewport
                         and fetch the largest file for a small slot
  - neither (`fill`)   — `sizes` defaults to `100vw` and the viewport ladder

`h` is dropped from the candidates so each stays in the source's aspect ratio
rather than being squeezed to one fixed height. A `priority` image preloads
with `imageSrcSet`/`imageSizes`, so the preload and the eventual pick agree
instead of fetching twice.

The proxy no longer enlarges. A srcset offers candidate widths without knowing
how big the source is, so a 2000px master asked for 3840 was being upscaled —
a bigger file that looks worse than the original. Clamping to the intrinsic
size makes an oversized candidate collapse onto the real maximum, which is
what makes the wide end of the ladder safe to offer at all.

Measured on a page of server-rendered images:

  hero, 375px viewport @2x   picks w=750    7,126 B  (was 27,812 B, -74%)
  hero, 1280px viewport @2x  picks w=3840  12,382 B  (clamped to the 1200px
                                                     source, not upscaled)
  w=4000 on a 2000px master  27,812 B / 2000px  (was 64,482 B / 4000px)
`priority` was the only way off `loading="lazy"`, and it does more than that —
it also preloads, which is right for exactly one image per page. There was no
way to say merely "do not defer this one", so an image the lazy heuristic
handles badly had to be promoted to LCP status to load at all.

`loading` takes `'lazy' | 'eager'`, defaults to lazy, and `priority` still
forces eager — so the common case is unchanged and the escape hatch costs
nothing.

Mirrors the Next.js prop of the same name, for the same reason.
…as wrappers

A connection selector on a nullable single-entity lookup needs a `!` (or `as`) to typecheck
— `q.post({id})!.comments` — but the paginated selector path walker only handled identifiers,
property accesses, calls, and parens, so a NonNullExpression made it bail with 'expects a
connection selector'. Walk through NonNullExpression/AsExpression (type-only, erased at
runtime), matching the non-paginated useData analyzer which already strips `!`.
The single-package coverage check flagged `migrations.stateOnly` (@getcronit/pylon/db) as an
undocumented public export. Document it alongside its companion `runSql` in the migrations
guide: for a schema change the diff engine can't express as SQL, hand-write the DDL with
runSql, then record the same delta in the baseline with stateOnly so status/check/deploy see
the folded schema.
@github-actions

Copy link
Copy Markdown
Contributor

🦋 Canary published from 581267e

Pinned to this build (immutable — reproducible):

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

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant