diff --git a/.changeset/analyzer-tolerate-nonnull-selectors.md b/.changeset/analyzer-tolerate-nonnull-selectors.md new file mode 100644 index 00000000..c81d4647 --- /dev/null +++ b/.changeset/analyzer-tolerate-nonnull-selectors.md @@ -0,0 +1,15 @@ +--- +'@getcronit/pylon': patch +--- + +Let the usePaginatedData selector analyzer see through type-only wrappers. + +A connection selector on a nullable single-entity lookup — +`usePaginatedData(q => q.post({ id })!.comments)` — needs a `!` (or an `as`) to satisfy the +type-checker, since the client types a single-entity accessor as nullable. But the selector +path walker only recognized identifiers, property accesses, calls, and parentheses, so the +`NonNullExpression` made it bail with "usePaginatedData expects a connection selector". + +The walker now sees through `NonNullExpression` and `AsExpression` (both erased at runtime, so +the connection PATH is unchanged), matching the non-paginated `useData` analyzer, which already +strips non-null assertions. diff --git a/.changeset/consolidate-fat-package.md b/.changeset/consolidate-fat-package.md new file mode 100644 index 00000000..8c867c31 --- /dev/null +++ b/.changeset/consolidate-fat-package.md @@ -0,0 +1,27 @@ +--- +'@getcronit/pylon': major +--- + +Consolidate the monorepo into a single batteries-included `@getcronit/pylon` package. + +The previously separate `@getcronit/pylon-{db,ir,query,queues,auth,pages,dev}` +packages are now folded into `@getcronit/pylon` and exposed as subpath exports. +The `pylon` CLI now ships from `@getcronit/pylon` itself (no separate +`@getcronit/pylon-dev`). + +### Migration + +- Replace feature imports with the matching subpath: + - `@getcronit/pylon-db` → `@getcronit/pylon/db` + - `@getcronit/pylon-ir` → `@getcronit/pylon/ir` + - `@getcronit/pylon-query` → `@getcronit/pylon/query` + - `@getcronit/pylon-queues` → `@getcronit/pylon/queues` + - `@getcronit/pylon-auth` → `@getcronit/pylon/auth` (`/contract`, `/zitadel` preserved) + - `@getcronit/pylon-pages` → `@getcronit/pylon/pages` +- Plugin factories now live under a per-feature `/plugin` subpath: + - `useDatabase` → `@getcronit/pylon/db/plugin` + - `useQueues` → `@getcronit/pylon/queues/plugin` + - `useIdentity` → `@getcronit/pylon/auth/plugin` + - `usePages` → `@getcronit/pylon/pages/plugin` +- Drop every `@getcronit/pylon-*` dependency from your `package.json` and depend on + `@getcronit/pylon` alone. The `pylon` binary comes from it. diff --git a/.changeset/create-pylon-rolldown-build.md b/.changeset/create-pylon-rolldown-build.md new file mode 100644 index 00000000..6c595380 --- /dev/null +++ b/.changeset/create-pylon-rolldown-build.md @@ -0,0 +1,9 @@ +--- +'create-pylon': patch +--- + +Build `create-pylon` with rolldown instead of esbuild. esbuild was removed when the repo +standardized its build pipeline on rolldown, which left `create-pylon`'s build script +calling a binary that no longer exists (`esbuild: not found`). It now bundles the CLI via +a `rolldown.config.mjs` — same output shape (single ESM file, deps external, shebang +preserved). diff --git a/.changeset/create-pylon-v3-templates.md b/.changeset/create-pylon-v3-templates.md new file mode 100644 index 00000000..d4257459 --- /dev/null +++ b/.changeset/create-pylon-v3-templates.md @@ -0,0 +1,60 @@ +--- +'create-pylon': major +--- + +Update the scaffold templates to the v3 contract — every runtime `create-pylon` produced +failed on its first `pylon build`. + +The templates still emitted the pre-`Pylon`-class shape: a named `export const graphql` +plus a `serve(app, …)` side effect in the entry. Since the compiler now type-introspects +the DEFAULT export, Node projects died with "Pylon entry must export default the app", +while Bun and Cloudflare Workers projects got further on `export default app` (the empty +singleton) and then failed with the far less obvious "Query root type must be provided" — +their resolvers silently dropped. + +- The entry is now `export default new Pylon({graphql})` and is identical across runtimes: + pure, with no import-time serving side effect. +- Serving is declared in `pylon.config.ts`. Node scaffolds get `useNodeServer()`, ordered + last so the port binds only after every route (including the usePages catch-all) is + mounted; Bun, Deno and workerd need no plugin — they serve the default export of the + built `.pylon/server.mjs` themselves. +- Dropped `pylon dev -c ""` from the Bun, Deno and Cloudflare Workers scripts. `pylon + dev` is direct in-process execution now and rejects `-c` outright. +- Deno projects get a `package.json`. `pylon dev`/`pylon build` are Node processes, and + Node's TypeScript loader reads the nearest `package.json` for ESM-vs-CJS — without + `"type": "module"` the build failed on `require(esm)` loading `pylon.config.ts`. It also + becomes the single source of truth for the dependency, so `deno.json` drops its duplicate + `imports` entry. Its `start` task uses `deno serve` (`deno run` does not serve a default + export). +- Dropped the now-unneeded `@hono/node-server` dependency from Node scaffolds (the entry no + longer imports it, and `@getcronit/pylon` depends on it). +- Fixed both Dockerfiles running `{npm,bun} run pylon build`, which is not a script in the + generated `package.json`. + +Pages templates: the starter Button carried a typo'd `inline-flexxx` class, which Tailwind +does not generate — the button silently lost `display: inline-flex` and the +`items-center justify-center gap-2` layout that depends on it. `components.json` also +pointed `tailwind.config` at a `tailwind.config.js` the scaffold never emits; the scaffold +is Tailwind v4 (theme in `globals.css` under `@theme`), so that field is now `""`, which is +what `npx shadcn@latest add …` reads to target the v4 component shape. + +Pages templates, Tailwind v4: the theme block is now `@theme inline`. A plain `@theme` +emits `--color-background: hsl(var(--background))` into `:root`, where custom-property +substitution happens once — the resolved colour then inherits down as a literal, so a +nested `.dark` re-declaring `--background` never reached it and `bg-background` (and even +`dark:bg-card`) stayed light. That contradicted the scaffold's own +`@custom-variant dark (&:is(.dark *))`, which targets descendants of `.dark` rather than +requiring it on ``. Both scopings now work. + +Pages templates: the root layout is typed with the exported `LayoutProps` instead of an +ad-hoc `{children: React.ReactNode}`, so the starter shows that a layout also receives +`params`, `searchParams`, `path` and `context`. + +CLI: a bare `--features` (no values) crashed with `features is not iterable`; it now means +"no features". Added `--no-install` and `-y, --yes` so the CLI can run unattended in +scripts and CI. + +A new e2e (`e2e/tests/create-pylon-scaffold.e2e.test.ts`) scaffolds every runtime and +feature combination with the shipped binary, runs the shipped `pylon build` on each, and +boots the built Node artifact to query it over HTTP — so the templates cannot drift away +from the framework unnoticed again. diff --git a/.changeset/dev-alias-node-selection.md b/.changeset/dev-alias-node-selection.md new file mode 100644 index 00000000..066e5859 --- /dev/null +++ b/.changeset/dev-alias-node-selection.md @@ -0,0 +1,19 @@ +--- +'@getcronit/pylon': patch +--- + +Fix `usePaginatedData`/`useData` node selections collapsing to `{ id }` in dev. + +The dev pages analyzer runs a ts-morph project that maps every non-relative import to an +empty dummy (so third-party packages don't need to be parsed). That dummy also swallowed the +app's own `@/*` path aliases, so a page importing its components and the connection node type +via `@/…` (e.g. `DataGridColumn` from `@/components` + `@/.pylon/client`) left the +project too thin for the connection pass to trace inline node-field reads — the selection +collapsed to `node { id }`. On soft navigation the client then fetched partial nodes, +normalized them, and components reading the missing fields (`e.actorLabel`, `e.metadata`) +threw. The production rolldown build was unaffected because it feeds the whole module graph +through the analyzer (every file already loaded by absolute path). + +Dev now hands the analyzer the app's `tsconfig.json` so `@/*` resolves to real files, and the +per-module eager-loader follows imports transitively (through aliases, stopping at +`node_modules`) — so the dev selection matches the build byte-for-byte. diff --git a/.changeset/dev-inspector-port.md b/.changeset/dev-inspector-port.md new file mode 100644 index 00000000..1242ab4f --- /dev/null +++ b/.changeset/dev-inspector-port.md @@ -0,0 +1,24 @@ +--- +'@getcronit/pylon': patch +--- + +Improve `pylon dev` debugging and quiet a dev-server warning. + +- **`pylon dev --inspect [port]`** (and `--inspect-brk`) opens the Node inspector on + the dev process itself — the one that runs your resolvers — so breakpoints bind and + you get a single clean debug target. It works through a package manager + (`pnpm pylon dev --inspect`) because it doesn't rely on an inherited `--inspect` flag + that the package-manager wrapper would grab first. +- When dev *is* launched with an inherited inspector (`node --inspect …/pylon dev` or + `NODE_OPTIONS=--inspect`), the flag is now stripped from what the bundler workers + inherit (`NODE_OPTIONS` + `execArgv`), so they no longer race the app for the port — + no more `Starting inspector … address already in use` stack, and DevTools attaches to + your code instead of a worker. +- Filtered a spurious `optimizeDeps.esbuildOptions … deprecated` warning from the pages + dev server. It originates in `@vitejs/plugin-react` (the version compatible with the + transitional `rolldown-vite`); rolldown-vite honors the option but warns, and the fix + upstream needs Vite 8. The warning is dropped until those majors line up. +- With a debugger attached, the dev logger now splits its output cleanly: the terminal + keeps a single pretty line per record while the Chrome DevTools console receives the + full record as an expandable, inspectable object (via `inspector.console`, which + bypasses stdout) — no more multi-line object dumps in the terminal. diff --git a/.changeset/dev-runner-loaders.md b/.changeset/dev-runner-loaders.md new file mode 100644 index 00000000..6f0bdcb1 --- /dev/null +++ b/.changeset/dev-runner-loaders.md @@ -0,0 +1,19 @@ +--- +'@getcronit/pylon': patch +--- + +`pylon dev`: externalize all node_modules in the backend runner + add JSON/CSS loader hooks. + +The dev runner now sets `ssr.external: true` so every dependency loads natively through Node +instead of Vite's SSR transform — routing CJS packages through the transform ESM-ifies them and +breaks their dynamic `require`s. That native-load path then needs two Node module hooks (registered +before the app boots): + +- **JSON** — Node's ESM loader requires an explicit `with { type: 'json' }` attribute, so a + dependency doing a bare `require('./x.json')` (e.g. `i18n-iso-countries`'s `langs/*.json`) threw + `ERR_IMPORT_ATTRIBUTE_MISSING`. The resolve hook stamps `type: 'json'` on `.json` URLs. +- **CSS** — a server-side `import 'pkg/x.css'` is meaningless for SSR (styles ship via the client + build) and Node can't load `.css`; the load hook returns an empty module instead of crashing. + +Together with keeping node_modules external in the build, this lets apps that pull in packages with +dynamic data `require`s or bare CSS imports run `pylon dev` and `pylon build`. diff --git a/.changeset/dev-version-mismatch-reload.md b/.changeset/dev-version-mismatch-reload.md new file mode 100644 index 00000000..ed3499f3 --- /dev/null +++ b/.changeset/dev-version-mismatch-reload.md @@ -0,0 +1,12 @@ +--- +'@getcronit/pylon': patch +--- + +Fix an infinite reload loop in dev on pages that refetch on mount. + +The query fetcher reloads the page when a response's `X-Pylon-Version` header differs from the +client's `window.__PYLON_VERSION__` (a production signal to pull a fresh client after a deploy). +In dev the client is stamped `'dev'` while the server sends the content-hashed pages-manifest +version, so they ALWAYS differ — and any page that refetches on mount (e.g. `usePaginatedData`'s +SWR revalidation) looped: fetch → version mismatch → reload → refetch → reload. The check now +no-ops in dev (client version `'dev'`), where HMR handles updates. diff --git a/.changeset/dev-watch-source-only.md b/.changeset/dev-watch-source-only.md new file mode 100644 index 00000000..7182a1cd --- /dev/null +++ b/.changeset/dev-watch-source-only.md @@ -0,0 +1,12 @@ +--- +'@getcronit/pylon': patch +--- + +Fix `pylon dev` reload loop: only react to source-code edits, not runtime file writes. + +The dev watcher watched the whole project and full-reloaded the browser on any file change +(outside node_modules/.pylon/.git). A request whose resolver wrote a file into the project at +runtime — a media thumbnail, cache, log, upload — tripped the watcher, which reloaded the +page, which re-ran the request, which wrote again: an infinite reload loop on that route. The +watcher now reacts only to source extensions (.ts/.tsx/.js/.jsx/.mjs/.cjs/.css) and logs which +file triggered a reload, so runtime data writes no longer cause reloads. diff --git a/.changeset/gold-boxes-switch.md b/.changeset/gold-boxes-switch.md new file mode 100644 index 00000000..5a273c30 --- /dev/null +++ b/.changeset/gold-boxes-switch.md @@ -0,0 +1,17 @@ +--- +'@getcronit/pylon': major +--- + +- Integrated `@getcronit/pylon-builder` directly into `@getcronit/pylon-dev`. + - Removed the `pylon-builder` package. + - The builder now utilizes the `esbuild` watch mode for development. This is a much faster and more efficient way to build the project. +- Implemented `pm2` for process management: + - `pm2` is now used to manage the `pylon-dev` server. After files are built, the server is restarted automatically. + - The stdout and stderr logs are logged directly with `consola`. +- Now builds a cross-environment client in `.pylon/client` using `gqty`. This will be used for pylon/pages. + +### Breaking Change: Removed Client Generation Feature + +- **What**: The client generation feature has been removed. +- **Why**: We have decided to use `gqty` directly to streamline the development process and reduce complexity. +- **How to Update**: Consumers should now use the [GQty CLI](https://gqty.dev/api-reference/cli#basic-usage) directly to generate their clients. Update your build scripts and development workflows to integrate `gqty` as described in the GQty documentation. diff --git a/.changeset/in-context-analyzer.md b/.changeset/in-context-analyzer.md new file mode 100644 index 00000000..4ed3f0df --- /dev/null +++ b/.changeset/in-context-analyzer.md @@ -0,0 +1,44 @@ +--- +'@getcronit/pylon': minor +--- + +Compiled documents carry `@inContext`, so resolvers are locale-correct with no per-query +wiring. + +```ts +Query: { + productName: async (id: number): Promise => + (await ProductTranslation.objects + .filter({productId: id, locale: getLocale() ?? 'en'}) + .first())?.name ?? (await Product.objects.get({id})).name +} +``` + +```tsx +const data = useData() +data.product({id}).name // German on /de, French on /fr — nothing passed at the call site +``` + +With `usePages({i18n})` configured, every operation the analyzer compiles is emitted as +`query page_0($__locale: String) @inContext(locale: $__locale)`, and the query client supplies +the locale. Queries and mutations both — a mutation returns localized content too. + +The locale is held **per client**, not in a module-level variable: the SSR pass builds one +client per request, so concurrent renders in different locales cannot bleed into each other. +The browser has one client and one locale, because switching locale is a document navigation. + +It is merged into the variables **before** the cache key is computed, at every entry point +that computes one (`fetch`, `ensure`, `revalidate`, `refetch`) — otherwise one path would read +a different slot than another wrote. Verified end to end: + +``` +qd09ab19da988723b~1emsiz4 → {"serverGreeting": "Server: hallo"} +qd09ab19da988723b~1emubph → {"serverGreeting": "Server: bonjour"} +``` + +Same document, different variables hash. With the locale in a header these would have been one +entry, and one language would have served the other's data. + +Documented in the i18n guide, including why a resolver cannot simply 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. diff --git a/.changeset/in-context-dev-parity.md b/.changeset/in-context-dev-parity.md new file mode 100644 index 00000000..e8ee0410 --- /dev/null +++ b/.changeset/in-context-dev-parity.md @@ -0,0 +1,25 @@ +--- +'@getcronit/pylon': patch +--- + +Fix `@inContext` missing from client documents under `pylon dev`, and drop the dead +`createPagesClient` export. + +`pylon dev` builds the SSR bundle with the rolldown analyzer and the CLIENT bundle with the +Vite one. Only the rolldown path was told about `i18n`, so dev shipped a server that knew the +locale and a client that did not: SSR rendered German, then the first refetch sent a +directive-less document and the page flipped to English. Production was unaffected — the +worst place for a difference to live, since dev is where it would be seen and dismissed. + +Reproduced in a browser before fixing: the wire carried `query page_0 { serverGreeting }` with +no variables, and the DOM went `Server: hallo` → `Server: hello`. + +The dev server now reads the `usePages` plugin's own options — `usePages` in dev is only a +`pages/` directory check, so the plugin is the sole source of whether i18n is configured — and +`Plugin.options` exposes them, since options passed to a plugin factory are otherwise captured +in its closure. + +Also removes `createPagesClient` from the generated client. It was introduced with the +pylon-query layer and never called by anything; parameterising it with a locale (as an earlier +pass did) was polish on dead code. Apps needing a per-request SSR client can use +`createPylonQueryClient` from `@getcronit/pylon/query`, which takes a locale. diff --git a/.changeset/in-context-directive.md b/.changeset/in-context-directive.md new file mode 100644 index 00000000..2f6cba4f --- /dev/null +++ b/.changeset/in-context-directive.md @@ -0,0 +1,37 @@ +--- +'@getcronit/pylon': minor +--- + +`@inContext` — per-operation request context, read by resolvers. + +```graphql +query Products($__locale: String) @inContext(locale: $__locale) { + products { name } +} +``` + +```ts +import {getLocale} from '@getcronit/pylon' + +Query: { + greeting: (): string => translations[getLocale() ?? 'en'] ?? translations.en +} +``` + +The directive is defined in every emitted schema, read in `onExecute`, and exposed to +resolvers through `getLocale()` / `getInContext()`. A locale may be an inline literal or a +variable; the variable form is what compiled documents will use, so one document serves every +locale. + +**Why a directive rather than an HTTP header.** `pylon-query` keys its store on +`documentId ~ variablesHash(variables)` and nothing else. With the locale in a header, the +same document with the same variables would be the same cache entry — English and German +results colliding on one key, in the client store and the hydration envelope alike. Putting +the context in the document makes it part of the cache key by construction. Shopify's +Storefront API arrived at the same directive for the same reason. + +`getLocale()` returns `undefined` when the operation states no locale, rather than defaulting: +the caller did not ask, so the resolver decides what neutral means. + +This is the server half. Automatic injection into `useData`-compiled documents — so a pages +app gets locale-correct resolvers with no per-query wiring — is the remaining piece. diff --git a/.changeset/inline-json-imports.md b/.changeset/inline-json-imports.md new file mode 100644 index 00000000..ec5b2f53 --- /dev/null +++ b/.changeset/inline-json-imports.md @@ -0,0 +1,17 @@ +--- +'@getcronit/pylon': patch +--- + +Inline JSON imports in the build instead of externalizing them. + +Both the pages SSR build and the backend transpile externalized everything under +`node_modules`, including `.json`. A bundler-visible `import x from "pkg/x.json"` (e.g. the app +importing `i18n-iso-countries/langs/de.json`) therefore survived to runtime, where Node's +strict ESM loader rejects it for lacking a `with { type: 'json' }` attribute +(`ERR_IMPORT_ATTRIBUTE_MISSING`) — crashing a plain `node .pylon/server.mjs` / standalone +artifact at plugin setup. + +`.json` is now inlined by rolldown (its default, and what the client bundle already did), +keeping the output runtime-agnostic (no attribute, no loose file) rather than relying on a +per-runtime Node loader hook. A dependency that dynamically `require`s its OWN data files at +runtime is unaffected: its JS stays external, so those requires still resolve from disk. diff --git a/.changeset/legible-fk-violations.md b/.changeset/legible-fk-violations.md new file mode 100644 index 00000000..de7b8e3d --- /dev/null +++ b/.changeset/legible-fk-violations.md @@ -0,0 +1,14 @@ +--- +'@getcronit/pylon': patch +--- + +Surface foreign-key violations with a message a human can act on. + +A manyToMany link whose owner or target row doesn't exist failed with Postgres's opaque +`insert or update … violates foreign key constraint ""` — no hint at WHICH side, WHICH id, +or WHICH relation, buried under a kysely/pg stack. `ManyToManyManager.add`/`set` now map the +23503 SQLSTATE (via the new `foreignKeyViolation` helper + `ForeignKeyViolationError`) to a +message that names the missing model and id from the driver's `detail` — e.g. *"Cannot link +ProductVariant ↔ ProductOptionValue: ProductOptionValue \"…\" does not exist. It was referenced +but not found — likely created out of order, or removed earlier in the same operation."* — with +the driver error kept as `cause`. This mirrors the existing unique-violation (23505) mapping. diff --git a/.changeset/legible-plugin-setup-errors.md b/.changeset/legible-plugin-setup-errors.md new file mode 100644 index 00000000..c6e6ba1b --- /dev/null +++ b/.changeset/legible-plugin-setup-errors.md @@ -0,0 +1,15 @@ +--- +'@getcronit/pylon': patch +--- + +Make plugin-setup failures legible. + +When a plugin's `setup()` threw, the framework wrapped it by stringifying the original error's +full `.stack` into a NEW error's message. Node then printed that blob PLUS the wrapper's own +stack — a duplicated, nested wall of text with the actual one-line cause (e.g. "Module …/de.json +needs an import attribute of type: json") buried in the middle. + +The wrapper now keeps its message to one human-readable line (the underlying error's `.message`) +and attaches the original error via `cause`, so Node renders a clean `[cause]:` chain with the +real stack intact. Built-in plugins that lacked a `name` (`usePages` → `pages`, `useQueues` → +`queues`) now set one, so the failing plugin is named in the message instead of shown as `#1`. diff --git a/.changeset/log-route-load-failure.md b/.changeset/log-route-load-failure.md new file mode 100644 index 00000000..df734c82 --- /dev/null +++ b/.changeset/log-route-load-failure.md @@ -0,0 +1,10 @@ +--- +'@getcronit/pylon': patch +--- + +Log the error when a route module fails to load (before the recovery reload). + +The generated lazy-route loader reloads the page if a page chunk fails to import (recovering +from a stale chunk after a rebuild). It now `console.error`s the failing module + the actual +error first, so a persistently-failing chunk (which otherwise reload-loops silently) is +diagnosable instead of invisible. diff --git a/.changeset/pages-i18n-catalogs.md b/.changeset/pages-i18n-catalogs.md new file mode 100644 index 00000000..53e57c84 --- /dev/null +++ b/.changeset/pages-i18n-catalogs.md @@ -0,0 +1,40 @@ +--- +'@getcronit/pylon': minor +--- + +Message catalogs with typed keys AND typed placeholders — no codegen. + +```ts +usePages({i18n: {locales: ['en', 'de', 'fr'], defaultLocale: 'en', catalogs: './messages'}}) +``` + +```tsx +const t = useTranslations('checkout') +t('total', {amount: '12.00', count: 3}) +``` + +The default-locale catalog is a `.ts` module with `as const`, which keeps the message +literals and makes both the key space and each message's placeholder names recoverable by +inference. A typo'd key, a missing placeholder, a spurious one, placeholders passed to a +message that takes none, or an unknown namespace are all compile errors — with no generated +file and nothing to run before the editor is correct. Translations may be `.ts` or `.json`; +only the default catalog must be `.ts`, because it is the type source. + +`catalogs` is a DIRECTORY, and the build owns it: `usePages`'s build hook compiles +`/` into `.pylon/messages/`, so catalogs live wherever the app likes. Passing +pre-imported objects cannot work — only `src/**` is transpiled into `.pylon/`, so a catalog +imported from `pylon.config.ts` resolves at runtime to a file that was never emitted. + +Catalogs are server-only. The active locale's messages travel in the hydration envelope as +data, and switching locale is a document navigation, so the browser never needs a second +catalog — "only the active locale ships" holds by construction, and an e2e asserts no other +locale's copy appears in the client bundle. Fallback to the default locale is resolved once on +the server, so the browser receives a single complete catalog rather than two to search. + +Also adds `useFormatter()` — `Intl.NumberFormat`/`DateTimeFormat`/`RelativeTimeFormat` bound +to the active locale and memoised per options. + +Apps register the catalog through `interface Register { messages: … }` rather than +`interface Catalog extends …`: the latter is illegal (TS2499) and, because `pylon.d.ts` is a +declaration file, `skipLibCheck` hides the error — the augmentation would silently do nothing +and every key would resolve to `never`. diff --git a/.changeset/pages-i18n-metadata.md b/.changeset/pages-i18n-metadata.md new file mode 100644 index 00000000..abf2414d --- /dev/null +++ b/.changeset/pages-i18n-metadata.md @@ -0,0 +1,42 @@ +--- +'@getcronit/pylon': minor +--- + +Automatic `` and `hreflang` alternates for localized pages. + +```ts +usePages({ + origin: 'https://example.com', + i18n: {locales: ['en', 'de', 'fr'], defaultLocale: 'en'} +}) +``` + +Every localized page now emits its own canonical plus the full alternate cluster: + +```html + + + + + +``` + +The cluster is byte-identical on every locale, which is what makes it bidirectional and +self-referential — a missing return link makes search engines discard the whole thing. Each +locale is its OWN canonical; cross-canonicalising locale variants is the classic way to make +Google drop every version but one. Live-site surveys put the hreflang error rate near 75% +with one bad entry voiding the cluster, which is the case for generating this rather than +documenting it — Next's i18n guide never mentions either tag. + +- `origin` is required and is configuration, not derived from the request: both tags need + absolute URLs, and behind a proxy the Host header is attacker-influenced — a canonical + built from a spoofed host points search engines at another domain. Configuring `i18n` + without `origin` warns once at boot, since silently emitting nothing is the exact failure + this prevents. +- Alternates are computed per locale rather than assumed to be one path under different + prefixes, so translated slugs (`/de/kontakt`) can be added later without reworking it. +- Error responses emit neither tag — a 404 must not advertise itself as a canonical page + with translations. + +The tags ride the existing SSR provider and React 19 hoists them into ``, so apps need +no component and no config beyond `origin`. diff --git a/.changeset/pages-i18n-negotiation.md b/.changeset/pages-i18n-negotiation.md new file mode 100644 index 00000000..3f67912c --- /dev/null +++ b/.changeset/pages-i18n-negotiation.md @@ -0,0 +1,42 @@ +--- +'@getcronit/pylon': minor +--- + +SSR locale negotiation for usePages — P1 of rfcs/SSR_I18N.md. + +```ts +usePages({i18n: {locales: ['en', 'de', 'fr'], defaultLocale: 'en', routing: 'cookie'}}) +``` + +```tsx +const {locale, localeWasExplicit, suggestedLocale} = useLocale() +``` + +The negotiated locale reaches pages as `useLocale()` and travels in the hydration envelope +alongside `context`, so the client reads the SERVER's locale instead of deriving one from +`navigator.language` — hydration parity is structural rather than a discipline, and there is +no locale flash. Verified in a browser: with `navigator.language === 'en-US'` and a `locale=de` +cookie, the page renders German with no hydration warning. + +**Negotiation never redirects.** The pattern Next.js documents — read `Accept-Language` in +middleware, redirect to the negotiated locale — sends every crawler to the default locale, +because Googlebot "sends HTTP requests without setting Accept-Language" and Bingbot, GPTBot, +ClaudeBot and PerplexityBot generally don't either. `negotiate()` returns a locale and nothing +else, so there is no redirect for a caller to perform, and the e2e pins the absence of one. + +- `routing: 'cookie'` — cookie, then `Accept-Language`, then the default. For AUTHENTICATED + app UI only: one URL serving several languages has no second URL to canonicalise. +- `routing: 'prefix'` — the URL is authoritative; a disagreeing cookie or `Accept-Language` + becomes `suggestedLocale` (render a "Auf Deutsch ansehen" link) rather than changing what is + served. Negotiation is implemented and unit-tested; locale ROUTING — mounting the route tree + under a locale segment on server and client — lands in a later phase, and `prefix` becomes + the default then. +- `hasLocale()` narrows `string` to a supported locale so an unrecognised segment can 404 + rather than silently falling back. +- `Accept-Language` parsing honours q-values and falls back from region to base language + (`de-AT` → `de`). It never throws on malformed input, and is deliberately lenient about a + malformed weight (`de;q=NaN` still means German) — the header is a hint, not a boundary. +- `Vary: Cookie, Accept-Language` is emitted whenever i18n is configured. + +Opt-in: without `i18n`, nothing about locales runs. `useLocale()` throws when unconfigured +rather than inventing `'en'`, which would look like it worked and mistranslate everything. diff --git a/.changeset/pages-i18n-plurals.md b/.changeset/pages-i18n-plurals.md new file mode 100644 index 00000000..76961279 --- /dev/null +++ b/.changeset/pages-i18n-plurals.md @@ -0,0 +1,40 @@ +--- +'@getcronit/pylon': minor +--- + +Plural messages, and an opt-in seam for full ICU. + +```ts +// messages/en.ts +checkout: {items: {one: '{count} item', other: '{count} items'}} +``` + +```tsx +t('items', {count: 7}) // "7 items" +``` + +A plural message is an object keyed by CLDR category rather than an ICU string. Catalogs are +TypeScript, so an object is the natural shape: no parser ships, each branch stays an ordinary +interpolated string, and the categories are visible to the type system instead of hidden +inside a string literal. `Intl.PluralRules` selects against the ACTIVE locale — Polish picks +`few` for 2–4 where English says `other` — and falls back to `other` when a translation omits +the selected category, so a partly-translated catalog still renders. + +Typing follows: `cart.items` is the key (a plural object is a leaf, not a branch, so +`cart.items.other` is not offered), `count` is required, and it must be a `number` because it +drives the selection. + +Full ICU — select, ordinals, nesting — is opt-in via `setMessageFormatter()`: + +```ts +import IntlMessageFormat from 'intl-messageformat' +setMessageFormatter((message, values, locale) => + String(new IntlMessageFormat(message, locale).format(values)) +) +``` + +A module-level setter rather than a config option, deliberately: config is server-only and a +function cannot travel in the hydration envelope, so a configured formatter would format the +SSR pass and not the hydration pass — mismatching every ICU message. Called from app code both +sides import, the two agree by construction. The formatter receives the already-selected +plural branch, so the two compose. diff --git a/.changeset/pages-i18n-prefix-routing.md b/.changeset/pages-i18n-prefix-routing.md new file mode 100644 index 00000000..f38dbcc6 --- /dev/null +++ b/.changeset/pages-i18n-prefix-routing.md @@ -0,0 +1,33 @@ +--- +'@getcronit/pylon': minor +--- + +Prefix locale routing for usePages — `/pricing` is English, `/de/pricing` German, from ONE +`pages/` tree. + +```ts +usePages({i18n: {locales: ['en', 'de', 'fr'], defaultLocale: 'en'}}) // routing defaults to 'prefix' +``` + +There is no `[locale]` folder. Prefix routing is React Router's `basename`, not a duplicated +route table: `createStaticHandler(routes, {basename})` per locale on the server, and the same +basename on the client — read from the hydration envelope rather than re-derived, so the two +cannot disagree about where routes are mounted. A plain `` under `/de` +resolves to `/de/pricing` on its own. + +`prefix: 'as-needed'` (default) serves the default locale unprefixed; `'always'` prefixes +every locale. Each owes one deterministic redirect so only one URL per locale is canonical — +`/en/pricing` 301s to `/pricing`, and the mirror under `'always'`. Deterministic is the +operative word: `canonicalRedirect()` takes only a path and the config, so a varying redirect +is not expressible. That matters because crawlers send neither cookies nor `Accept-Language`, +and a redirect that varies on them funnels every crawler into the default locale. + +`routing` now defaults to `'prefix'` (it was `'cookie'` while routing was unimplemented). +`'cookie'` remains for authenticated app UI, where one URL serving several languages is fine +because nothing crawls or canonicalises it. + +Fixes a hydration failure this exposed: the client pre-resolves lazy route modules with +`matchRoutes(routes, location)` before creating the router, and that call needs the basename +too. Without it `/de/pricing` strips to nothing, matches no route, the modules stay lazy, and +the router renders `HydrateFallback` over the server's markup — surfacing only as a generic +"Hydration failed" naming a `
` that appears nowhere in the served HTML. diff --git a/.changeset/pages-i18n-sitemap.md b/.changeset/pages-i18n-sitemap.md new file mode 100644 index 00000000..260cb007 --- /dev/null +++ b/.changeset/pages-i18n-sitemap.md @@ -0,0 +1,27 @@ +--- +'@getcronit/pylon': minor +--- + +Locale-aware sitemap, and a warning for routes shadowed by a locale. + +**Sitemap.** Each declared URL now expands into one entry per locale, every entry repeating +the full `xhtml:link` alternate cluster — the sitemap equivalent of the `` hreflang +cluster. Declaring `/pricing` once yields `/pricing`, `/de/pricing` and `/fr/pricing`; +previously a localized site advertised only its default language, leaving the other locales +discoverable solely by being linked from somewhere. + +A URL that already carries a locale prefix is emitted verbatim — the app named an exact URL, +so expanding it would invent siblings it did not ask for, and that doubles as the per-URL +opt-out. Per-item `lastmod`, `changefreq` and `priority` are carried onto every expanded +entry. The configured `origin` is preferred over the request host, so a sitemap never +advertises `localhost` — or, behind a proxy, whatever host an attacker supplied. + +**Shadowing warning.** A top-level route whose segment is a configured locale is unreachable +under `as-needed` prefixing: `/de` serves the German home page, and `/en/de` 301s back to it, +so a `pages/de/` route has no URL at all. Boot now warns, naming the route and both fixes +(rename it, or `prefix: 'always'`, where `/en/de` does reach the page). + +Scoped precisely to the case that breaks: only single top-level segments, only in `as-needed` +mode. `/de/de` is unambiguous — locale, then page — and `/docs/de` never collided, because a +prefix only ever occupies position 0. A warning rather than an error, since the app may never +link the route. diff --git a/.changeset/pages-link-locale.md b/.changeset/pages-link-locale.md new file mode 100644 index 00000000..eb890519 --- /dev/null +++ b/.changeset/pages-link-locale.md @@ -0,0 +1,28 @@ +--- +'@getcronit/pylon': minor +--- + +`` — cross-locale links, i.e. the language switcher. + +```tsx +Deutsch {/* this page, in German */} +Tarifs {/* a specific page, in French */} +``` + +A plain `` is confined to the active locale by React Router's `basename`, which is +right for navigation and useless for switching language: from `/de`, `` +can only ever mean `/de/pricing`. `locale` crosses that boundary, resolving through every +locale's basename — which `negotiate()` now precomputes and ships in the hydration envelope +as `basenames`, so the browser never re-derives the rule from config it would have to be told +about. + +Crossing locales renders a plain `` — a full document navigation — on purpose. A +client-side transition would leave everything the server rendered in the OLD language in +place: ``, SSR-resolved copy, and the hydration envelope. The other language is a +different document, so it is fetched as one. The anchor carries `hreflang`, and React +Router-only props (`replace`, `preventScrollReset`, `relative`, `viewTransition`, …) are +stripped rather than leaked onto the DOM. + +A `locale` equal to the active one is not a switch and stays an ordinary router link, so +client-side navigation is unaffected. An unconfigured locale falls through to a normal link +rather than emitting a broken URL. diff --git a/.changeset/pages-request-context.md b/.changeset/pages-request-context.md new file mode 100644 index 00000000..4d5ec504 --- /dev/null +++ b/.changeset/pages-request-context.md @@ -0,0 +1,30 @@ +--- +'@getcronit/pylon': minor +--- + +Make the usePages SSR request-context channel a documented, typed, first-class seam, and +re-export Hono's cookie helpers. + +`pagesContext` already carried request state into an SSR render and hydrated it identically +(the catch-all reads `c.get('pagesContext')` and serialises it into +`window.__pylonStaticData.context`), which is what makes cookie-driven theme, sidebar or +locale state flash-free. But nothing documented it, nothing set it in any fixture or example, +it was read as `c.get('pagesContext' as any)`, and `PageProps.context` was +`Variables['pagesContext']` behind a `@ts-expect-error` — which silently resolved to `any`. + +- `useRequestContext(factory, {vary})`, exported from `@getcronit/pylon/pages/plugin`, + populates it. It is a `'first'`-strategy plugin, so it beats the `usePages` catch-all + (`'last'`) regardless of its position in the `plugins` array — the ordering footgun a + hand-rolled middleware silently depends on. +- `vary` appends to the response `Vary` additively, without duplicating existing entries. +- `PageProps.context` is now `PagesContext`, which resolves to the app's declared + `Variables['pagesContext']` or to `unknown` when undeclared. Apps that relied on the + suppressed `any` must declare the shape (or narrow at the use site). +- `getCookie`, `getSignedCookie`, `setCookie`, `setSignedCookie` and `deleteCookie` are + re-exported from `@getcronit/pylon`. An app cannot `import {getCookie} from 'hono/cookie'` + itself: `hono` is pylon's dependency, so under pnpm's strict layout the specifier does not + resolve — and under npm's flat hoisting it resolves by accident, which is worse. + +Setting cookies from inside a render (P1 of rfcs/SSR_REQUEST_CONTEXT.md) is not included here; +middleware can already do it, since the SSR HTML is fully buffered before the response is +built. diff --git a/.changeset/pages-response-cookies-hardening.md b/.changeset/pages-response-cookies-hardening.md new file mode 100644 index 00000000..07d1a1a3 --- /dev/null +++ b/.changeset/pages-response-cookies-hardening.md @@ -0,0 +1,27 @@ +--- +'@getcronit/pylon': patch +--- + +Harden `useResponseCookies()`: secure defaults, name validation, and shared-cache protection. + +Probing the API with hostile input found no injection — Hono percent-encodes cookie values, so +`\r\n` becomes `%0D%0A` and `;` becomes `%3B`, neither of which can start a new header or a +new cookie attribute. Three real gaps around it are now closed: + +- **Defaults.** Cookies were emitted with `Path=/` and nothing else. `SameSite=Lax` is now the + default (blocking the cookie on cross-site subrequests while keeping it on top-level + navigations), and `Secure` is added when the request arrived over TLS — detected from the + URL scheme or `x-forwarded-proto`, so `http://localhost` in development is unaffected. An + explicit option always wins. +- **Invalid cookie names 500'd the page from the wrong place.** The flush runs after the + render, outside its try/catch, so a name containing CRLF reached the platform and threw an + opaque `Headers.append` TypeError that took the whole response down. Names are now validated + in `set()` against the RFC 6265 token grammar, so the error is raised inside the render where + the stack names the offending component and the error boundary can handle it. +- **A response carrying `Set-Cookie` had no `Cache-Control`.** If a shared cache stored it, one + visitor's cookie would be replayed to every other visitor. Responses that set cookies are now + marked `private, no-cache`, unless the app has already chosen its own policy. + +Note this API is for non-secret, client-readable state — theme, sidebar, locale. It sets no +`HttpOnly` default because such cookies are usually read by the client too; it is not the +right tool for session or auth tokens. diff --git a/.changeset/pages-response-cookies.md b/.changeset/pages-response-cookies.md new file mode 100644 index 00000000..f1631fcb --- /dev/null +++ b/.changeset/pages-response-cookies.md @@ -0,0 +1,31 @@ +--- +'@getcronit/pylon': minor +--- + +`useResponseCookies()` — set cookies on the SSR response from inside a page or layout. + +Previously only middleware could set a cookie on a rendered page, so a component had no way to +persist something it had just computed (a negotiated locale, a first-visit marker). + +```tsx +const cookies = useResponseCookies() +if (!context.seen) cookies.set('seen', '1', {path: '/', maxAge: 31536000, sameSite: 'Lax'}) +``` + +This is possible only because the SSR render is fully BUFFERED — `usePages` collects the +stream to a string and builds the response afterwards — so the tree can write into a +per-request collector that the handler flushes before any response is built, including the +component-thrown redirect and critical-error paths. An ambient Next-style `cookies()` is not +available: React's async render breaks out of AsyncLocalStorage, so the collector rides the +existing provider alongside `pagesContext`. + +Writes are keyed by cookie NAME rather than appended. The SSR error path renders the tree +twice (once to discover the throw, once with the error context populated), so an append-style +collector emits duplicate `Set-Cookie` headers for that request — verified by mutating the +implementation, which makes the error-path test fail with two headers instead of one. + +Because this writes during render and React may render a component more than once, it is safe +for "set this cookie to this computed value" and unsafe for anything order-dependent. In the +browser the hook is a no-op that warns once, so components need no `typeof window` guard. + +Implements P1 of rfcs/SSR_REQUEST_CONTEXT.md. diff --git a/.changeset/popular-pugs-serve.md b/.changeset/popular-pugs-serve.md new file mode 100644 index 00000000..78cd61b2 --- /dev/null +++ b/.changeset/popular-pugs-serve.md @@ -0,0 +1,39 @@ +--- +'@getcronit/pylon': minor +--- + +Extend plugin system with setup, middleware, and build functions. +The viewer is now integrated via a built-in `useViewer` plugin. + +Custom plugins can now access the app instance and register routes, middleware, and custom build steps. + +```ts +import {Plugin} from '@getcronit/pylon' + +export function myPlugin(): Plugin { + return { + setup(app) { + app.use((req, res, next) => { + console.log('Request:', req.url) + next() + }) + + app.get('/hello', (req, res) => { + res.send('Hello, World!') + }) + }, + middleware: (c, next) => { + // This middleware will be inserted higher in the middleware stack + console.log('Middleware:', c.req.url) + next() + }, + build: async () => { + // Custom esbuild build + const ctx = await esbuild.context(...) + + // Must return the context + return ctx + } + } +} +``` diff --git a/.changeset/pylon-cli-shebang.md b/.changeset/pylon-cli-shebang.md new file mode 100644 index 00000000..a7832989 --- /dev/null +++ b/.changeset/pylon-cli-shebang.md @@ -0,0 +1,19 @@ +--- +'@getcronit/pylon': patch +--- + +Add the missing `#!/usr/bin/env node` shebang to the shipped `pylon` CLI. + +`package.json` maps `bin: {pylon: "./dist/cli/index.js"}`, and on POSIX npm and yarn classic +link a bin as a bare symlink that relies on its shebang — only pnpm writes a `#!/bin/sh` +shim that invokes node itself. The CLI entry had no shebang, so under pnpm everything +worked (the monorepo, the e2e suite, existing projects) while an npm-installed project +could not run `pylon` at all: + + $ npm run build + node_modules/.bin/pylon: line 1: import: command not found + +That is the default path for `create-pylon` — its Node scaffold and Dockerfile both use npm +— so a freshly created project was dead on arrival for npm users. The whole e2e suite +missed it because every test spawns `node /dist/cli/index.js`, which bypasses the +bin entirely; a new `cli-bin` e2e now execs the bin through an npm-shaped symlink instead. diff --git a/.changeset/queues-logger-boundary.md b/.changeset/queues-logger-boundary.md new file mode 100644 index 00000000..fd8bbda6 --- /dev/null +++ b/.changeset/queues-logger-boundary.md @@ -0,0 +1,19 @@ +--- +'@getcronit/pylon': patch +--- + +Stop the queues battery importing the core logger across a feature boundary. + +`src/queues/{outbox,queue}.ts` reached the logger via a relative `../core/logger.js`. The +build is transpile-only, so a relative import across a feature boundary INLINES that module +into the importing feature's bundle — meaning the queues battery would carry a second logger +instance with its own async context and configuration, rather than sharing the one the rest of +the runtime uses. Both now use the `@getcronit/pylon` self-ref, which the build keeps external. + +`renderLine` and `jobLogLevel` are exported from core to make that possible: they are what the +BullMQ per-job log tee needs. Exporting them was the deliberate choice over adding the pair to +`allowsRelative` in `scripts/check-boundaries.mjs`, since inlining a logger is exactly what the +check exists to prevent. + +This also unbreaks `pnpm --filter @getcronit/pylon typecheck`, which runs the boundary check +before `tsc` and so had been failing outright. diff --git a/.changeset/quiet-standalone-trace-warnings.md b/.changeset/quiet-standalone-trace-warnings.md new file mode 100644 index 00000000..acb4045f --- /dev/null +++ b/.changeset/quiet-standalone-trace-warnings.md @@ -0,0 +1,17 @@ +--- +'@getcronit/pylon': patch +--- + +Stop `pylon build --standalone` from drowning real trace warnings in noise. + +`@vercel/nft` emits a warning for every dynamic/conditional dependency it can't statically +follow. Most are pure noise for a deploy trace — it tried to parse non-code files (license +text, prebuilt binaries) as JavaScript, or probed an optional native dep (`pg-native`, +`cloudflare:sockets`, …) or another platform's `@img/sharp-*` / `@esbuild/*` variant that is +absent by design — and dumping the whole list (dozens of lines) read like a catastrophe over a +working build. + +Trace warnings are now classified: benign classes are counted and hidden, and only warnings +that could mean a genuinely MISSING runtime file (an unresolved bare/relative specifier the app +may need via `--include`) are surfaced. A build with only benign notes stays quiet at the +default level. diff --git a/.changeset/real-horses-smash.md b/.changeset/real-horses-smash.md new file mode 100644 index 00000000..00f69188 --- /dev/null +++ b/.changeset/real-horses-smash.md @@ -0,0 +1,5 @@ +--- +'@getcronit/pylon': patch +--- + +Fix broken field descriptions in schema parsing diff --git a/.changeset/remove-define-queue.md b/.changeset/remove-define-queue.md new file mode 100644 index 00000000..6321fac4 --- /dev/null +++ b/.changeset/remove-define-queue.md @@ -0,0 +1,23 @@ +--- +'@getcronit/pylon': minor +--- + +Remove the legacy functional `defineQueue` from `@getcronit/pylon/queues`. + +The class form is the queue authoring API: + +```ts +import {Queue, manager} from '@getcronit/pylon/queues' +import {z} from 'zod' + +export class SendEmail extends Queue.input(z.object({to: z.string().email()})) { + static jobs = manager(SendEmail) + async process({data, job, log}) { + /* … */ + } +} +``` + +`cron(name, pattern, handler)` is unchanged for scheduled jobs. Migrate any +`defineQueue('name', opts).process(handler)` to a `class extends Queue` with +`static jobs = manager(...)`. diff --git a/.changeset/remove-loader-debug-log.md b/.changeset/remove-loader-debug-log.md new file mode 100644 index 00000000..ee1dc749 --- /dev/null +++ b/.changeset/remove-loader-debug-log.md @@ -0,0 +1,6 @@ +--- +'@getcronit/pylon': patch +--- + +Remove a stray `console.log("EXECUTED LOADER 404")` that the usePages route generator +baked into every layout's not-found loader, so it no longer prints on 404s at runtime. diff --git a/.changeset/rolldown-runtime-dependency.md b/.changeset/rolldown-runtime-dependency.md new file mode 100644 index 00000000..9bfcaf68 --- /dev/null +++ b/.changeset/rolldown-runtime-dependency.md @@ -0,0 +1,19 @@ +--- +'@getcronit/pylon': patch +--- + +Declare `rolldown` as a runtime dependency (was a devDependency). + +`pylon build`/`pylon dev` import `rolldown` directly at runtime (the pages build, the +client build, transpile-app, the db CLI). As a devDependency it wasn't installed for +consumers, so `import 'rolldown'` fell back to the `rolldown@1.0.0-beta.53` that +`rolldown-vite` pulls in — an old beta whose resolver doesn't apply `tsconfig` `paths`. +The result: every `@/…` alias in a consumer's pages silently failed to resolve during +`pylon build` (treated as external → broken client bundle), while the monorepo was +unaffected because a workspace install *does* install devDependencies. + +Making `rolldown` a dependency installs the pinned `1.2.4` in pylon's own resolution +scope while `rolldown-vite` keeps `beta.53` in its scope — the two coexist, so the build +gets tsconfig-path resolution and the dev server keeps `rolldown/experimental`'s +`viteWasmFallbackPlugin`. No consumer-side `rolldown` override needed (and such an +override is harmful — it forces `rolldown-vite` onto 1.2.4 and breaks `pylon dev`). diff --git a/.changeset/rotten-ravens-sin.md b/.changeset/rotten-ravens-sin.md new file mode 100644 index 00000000..4f5cec55 --- /dev/null +++ b/.changeset/rotten-ravens-sin.md @@ -0,0 +1,26 @@ +--- +'@getcronit/pylon': minor +--- + +Add `usePages` plugin to support file-based (Fullstack React) routing. https://github.com/getcronit/pylon/issues/69 + +```ts +import {app, usePages, PylonConfig} from '@getcronit/pylon' + +export const graphql = { + Query: { + hello: () => { + return 'Hello, world!' + }, + post: (slug: string) => { + return {title: `Post: ${slug}`, content: 'This is a blog post.'} + } + } +} + +export const config: PylonConfig = { + plugins: [usePages()] // Enables the Pages Router +} + +export default app +``` diff --git a/.changeset/shy-countries-help.md b/.changeset/shy-countries-help.md new file mode 100644 index 00000000..1ade9587 --- /dev/null +++ b/.changeset/shy-countries-help.md @@ -0,0 +1,13 @@ +--- +'@getcronit/pylon': minor +--- + +Show a fallback page for the landing page and unhandled routes / 404s. + +This behavior can be disabled via the pylon config: + +```ts +export const config: PylonConfig = { + landingPage: false +} +``` diff --git a/.changeset/slimy-garlics-battle.md b/.changeset/slimy-garlics-battle.md new file mode 100644 index 00000000..60648949 --- /dev/null +++ b/.changeset/slimy-garlics-battle.md @@ -0,0 +1,13 @@ +--- +'create-pylon': patch +--- + +- Use `consola` for clearer interactive prompts and logs. +- Remove `--client`, `--client-path`, and `--client-port` flags in favor of [GQty CLI](https://gqty.dev/api-reference/cli#basic-usage) +- Improved package manager detection and dependency installation. https://github.com/getcronit/pylon/issues/73 +- Removed `--template` flag in favor of `--features` flag. Each runtime can now support multiple features which pre-configure the project for different use-cases. + Currently supported features: + - `pages`: React SSR Pages with file-based routing + - `auth`: OIDC Authentication (Primarily for ZITADEL but can be used with any OIDC provider) +- The success message now only shows the `deploy` script if it is available. +- Improved error handling and messaging. diff --git a/.changeset/soft-goats-run.md b/.changeset/soft-goats-run.md new file mode 100644 index 00000000..c50cebdf --- /dev/null +++ b/.changeset/soft-goats-run.md @@ -0,0 +1,36 @@ +--- +'@getcronit/pylon': major +--- + +**Summary:** +This changeset introduces a major overhaul to the built-in authentication system. The new implementation automatically sets up `/auth/login`, `/auth/callback`, and `/auth/logout` routes, injects an `auth` object into the context, and manages token cookies. Role-based route protection is now enhanced via `authMiddleware` and the updated `requireAuth` decorator, configurable through the streamlined `useAuth` plugin. + +--- + +**Breaking Changes:** + +- **WHAT:** + The authentication configuration has been completely revamped. The previous manual setup is replaced by the `useAuth` plugin. Custom authentication route definitions are no longer necessary, and existing middleware or decorator usage may require adjustments. + +- **WHY:** + This change was implemented to simplify authentication setup, reduce boilerplate, improve security by automating context and cookie management, and offer better role-based access control. + +- **HOW:** + Consumers should: + 1. Remove any custom authentication route setups. + 2. Update their configuration to use the new `useAuth` plugin as shown below: + ```typescript + export const config: PylonConfig = { + plugins: [ + useAuth({ + issuer: 'https://test-0o6zvq.zitadel.cloud', + endpoint: '/auth', + keyPath: 'key.json' + }) + ] + } + ``` + 3. Replace previous authentication middleware or decorators with the updated `requireAuth` and `authMiddleware` APIs. + 4. Test the new authentication endpoints (`/auth/login`, `/auth/callback`, and `/auth/logout`) to ensure proper integration. + +Ensure you update your code accordingly to avoid disruptions in your authentication flow. diff --git a/.changeset/ssr-externalize-node-modules.md b/.changeset/ssr-externalize-node-modules.md new file mode 100644 index 00000000..280e9c97 --- /dev/null +++ b/.changeset/ssr-externalize-node-modules.md @@ -0,0 +1,17 @@ +--- +'@getcronit/pylon': patch +--- + +Keep node_modules external in the usePages SSR/node build. + +The SSR bundle previously externalized only a 5-package allowlist and **bundled every +other dependency** — backwards for server-side rendering, where node_modules are on disk +at runtime. Bundling them duplicates singletons and breaks any dependency that +dynamically `require`s its own data files (e.g. `i18n-iso-countries`'s `langs/*.json`, +which aren't emitted beside the chunk → `Cannot find module './langs/br.json'`). The SSR +build now externalizes anything that resolves into `node_modules` (keeping the bare +specifier so Node resolves it at runtime), while still bundling app code — relative +imports and tsconfig path aliases (`@/…`) — and still handling CSS/asset imports +(a `.css` from a package like `nprogress` stays bundled for the css plugin, not +externalized). Workspace-linked framework packages resolve outside node_modules, so the +explicit allowlist remains for those. diff --git a/.changeset/structured-runtime-logger.md b/.changeset/structured-runtime-logger.md new file mode 100644 index 00000000..7c1e5ba3 --- /dev/null +++ b/.changeset/structured-runtime-logger.md @@ -0,0 +1,42 @@ +--- +'@getcronit/pylon': minor +--- + +Structured runtime logger (phase 1). + +A tiny, zero-dependency, runtime-agnostic structured logger now backs request logging. It +replaces the `hono/logger` text access line and exposes a request-correlated logger to your code. + +- `getLogger()` — the current request logger (correlated by a generated `requestId`, tagged + `http`, plus `method`/`path`). `logger(tag)` — a module-scoped, lazy, tagged logger. +- One structured access line per request: `{time, level, msg:"request", requestId, method, path, + status, durationMs, tag:"http"}`. +- Levels (`trace`…`fatal`) gate cheaply, including **per-tag** levels: `LOG_LEVEL=info,db=debug` + or `config.logger.level = {'*': 'info', db: 'debug'}` raises one subsystem without flooding the + rest (most-specific tag prefix wins). Tags compose (`withTag`). +- `config.logger` accepts an object — `{level, format, base, redact, sink}` — as well as `false` + (disable the access line). Env `LOG_LEVEL` / `PYLON_LOG_FORMAT` override without a redeploy. + `redact` masks dotted paths (e.g. `authorization`, `user.password`); `base` adds fields to every + record; `sink` swaps the destination (pino/OTel/…). +- **Errors are logged** through it: unhandled route errors (`Pylon.onError`) at `error` + (request-correlated); GraphQL execution errors via an envelop hook — server exceptions at + `error` (tagged `graphql`), client `GraphQLError`s at `debug`. This is independent of Sentry: + `useSentry` still captures separately. +- **Queue jobs** run in a correlated logger scope (`{queue, jobId, attempt}`, tag `queue:`) + that **fans out** to both stdout *and* BullMQ's persisted `job.log` (dashboard). `ctx.log` now + routes through the logger. The `job.log` tee has its own threshold (`config.logger.job.level`, + default `info`) so `debug`-on-stdout doesn't bloat Redis. The outbox relay logs under an + `outbox` tag and surfaces previously-silent tick errors. + +### Note: access-log format changed + +The per-request access line is now **structured** — JSON in production, a **colored, timestamped +pretty line** in development (the formatter is loaded lazily, so production never evaluates it) — +instead of the previous `hono/logger` text. If you parse the old format, update your log tooling. + +Dev format is `auto` by default: an ANSI **pretty** line in a terminal, and — when you launch with +`--inspect` — a **`devtools`** format that logs a colored headline *plus the full record as an +expandable object* in the Chrome DevTools console. Force either with `config.logger.format`. + +Runtime-agnostic (no Node-only deps beyond `async_hooks`, already used for request context) and no +new dependency; the CLI/build logger (consola) is unaffected. diff --git a/.changeset/use-sentry-plugin.md b/.changeset/use-sentry-plugin.md new file mode 100644 index 00000000..b5353624 --- /dev/null +++ b/.changeset/use-sentry-plugin.md @@ -0,0 +1,25 @@ +--- +'@getcronit/pylon': minor +--- + +`useSentry` is now a public, opt-in Pylon plugin that owns the whole Sentry integration. + +Previously the framework auto-installed two things unconditionally: the GraphQL-layer +Sentry envelop plugin and the `@hono/sentry` HTTP middleware. Both are now folded into a +single `useSentry()` plugin exported from `@getcronit/pylon`, and neither is installed +automatically. + +**Migration:** add it to your config `plugins`, passing a DSN (without one it's a no-op, +so the same config is safe in development): + +```ts +import {useSentry} from '@getcronit/pylon' + +export default { + plugins: [useSentry({dsn: process.env.SENTRY_DSN})] +} satisfies PylonConfig +``` + +Apps that relied on automatic Sentry wiring must add this line or they will no longer +report to Sentry. The plugin accepts the GraphQL-instrumentation options as before, plus +the `@hono/sentry` middleware options (`dsn`, `environment`, …) at the top level. diff --git a/.changeset/young-islands-bow.md b/.changeset/young-islands-bow.md new file mode 100644 index 00000000..22857fde --- /dev/null +++ b/.changeset/young-islands-bow.md @@ -0,0 +1,16 @@ +--- +'@getcronit/pylon': minor +--- + +- Option to disable the playground and introspection in the Pylon configuration. https://github.com/getcronit/pylon/issues/72 + +### Example + +To disable the playground and introspection, set the `graphiql` property to `false` in your Pylon configuration: + +```ts +export const config: PylonConfig = { + // Disable the playground and introspection + graphiql: false +} +``` diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..ad9fc75e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,24 @@ +# Build context is the repo root (see docs/Dockerfile). Keep it lean and +# deterministic — deps are installed inside the image from the lockfile. +**/node_modules +**/dist +**/.pylon +**/*.tsbuildinfo + +.git +.github +.claude +.vscode +.changeset + +# Never ship local env / secrets into the build +**/.env +**/.env.* + +# Nothing test/example-related is needed to build & serve the docs +e2e +examples +**/test +**/*.test.ts + +**/.DS_Store diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml deleted file mode 100644 index 81e2cc9b..00000000 --- a/.github/workflows/canary.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Canary Release - -on: - pull_request: - paths-ignore: - - 'docs/**' - - 'examples/**' - - '.vscode/**' - branches: - - main - -jobs: - release-canary: - uses: the-guild-org/shared-config/.github/workflows/release-snapshot.yml@main - if: - ${{ github.actor != 'dependabot[bot]' && github.actor != - 'dependabot-preview[bot]' && github.actor != 'renovate[bot]' }} - with: - packageManager: pnpm - npmTag: canary - secrets: - githubToken: ${{ secrets.GITHUB_TOKEN }} - npmToken: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/db-migrations.yml b/.github/workflows/db-migrations.yml new file mode 100644 index 00000000..cd70848a --- /dev/null +++ b/.github/workflows/db-migrations.yml @@ -0,0 +1,123 @@ +name: DB Migrations + +# Runs the migration "moat" against a real Postgres: the pylon-ir diff-engine unit +# tests and the pylon-db integration suite — including the round-trip / fuzz walks +# that generate → apply → rollback → re-apply real migrations and assert they +# converge and reverse cleanly. See packages/pylon-db/test/integration/. +on: + push: + branches: + - main + paths: + - 'packages/pylon/**' + - '.github/workflows/db-migrations.yml' + pull_request: + paths: + - 'packages/pylon/**' + - '.github/workflows/db-migrations.yml' + +jobs: + migrations: + name: Migration round-trip (Postgres) + runs-on: ubuntu-latest + + services: + postgres: + # pgvector-enabled image: the pylon-db suite includes pgvector tests + # (upsert/vector-nearest) that `CREATE EXTENSION vector`, which stock + # postgres:16-alpine doesn't ship. This image is postgres:16 + pgvector. + image: pgvector/pgvector:pg16 + env: + POSTGRES_USER: pylon + POSTGRES_PASSWORD: pylon + POSTGRES_DB: pylon_test + # Match the docker-compose / test default (postgres://pylon:pylon@localhost:5433/pylon_test) + ports: + - 5433:5432 + options: >- + --health-cmd "pg_isready -U pylon -d pylon_test" + --health-interval 2s + --health-timeout 5s + --health-retries 15 + + env: + DATABASE_URL: postgres://pylon:pylon@localhost:5433/pylon_test + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4.0.0 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Setup pnpm store + run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v4 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm i --frozen-lockfile + + # The ir/db tests run against SOURCE (vitest aliases every @getcronit/pylon + # subpath to src/), so no build is needed for them. We still build so the + # dev/CLI-spawning tests (which exec dist/cli/index.js) work if included. + - name: Build packages + run: pnpm build + + # ir diff-engine unit tests + db round-trip/fuzz integration. DATABASE_URL is + # set (below), so the db integration suite runs (describe.skipIf(!runDb)). + - name: pylon ir + db tests (diff engine + round-trip/fuzz) + run: pnpm --filter @getcronit/pylon test test/ir test/db + + cli-e2e: + name: pylon db CLI (e2e) + runs-on: ubuntu-latest + # The e2e suite owns its own Postgres via docker-compose (global-setup.ts, + # port 5434) — ubuntu runners have Docker, so no service container needed here. + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4.0.0 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Setup pnpm store + run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v4 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm i --frozen-lockfile + + # `pnpm test` runs the e2e `pretest` (builds the shipped CLI + deps) first, + # then vitest against just the migration-CLI file; global-setup brings the DB up. + - name: pylon db migrate e2e (shipped CLI vs live Postgres) + run: pnpm --filter pylon-e2e test tests/db-migrate.e2e.test.ts diff --git a/.github/workflows/docs-coverage.yml b/.github/workflows/docs-coverage.yml new file mode 100644 index 00000000..30838c2d --- /dev/null +++ b/.github/workflows/docs-coverage.yml @@ -0,0 +1,67 @@ +name: Docs coverage + +# Keeps the docs honest against the code. Two checks (see docs/coverage/): +# - check:coverage — every public export / CLI command / config key is documented. +# Parses SOURCE (no build needed), so it runs first and fast as the hard gate. +# - check:examples — type-checks the docs' ```ts examples against the built .d.ts, +# reporting import drift. Advisory (needs a build; fuzzier), so it does not block. +on: + push: + branches: + - main + paths: + - 'packages/**' + - 'docs/**' + - '.github/workflows/docs-coverage.yml' + pull_request: + paths: + - 'packages/**' + - 'docs/**' + - '.github/workflows/docs-coverage.yml' + +jobs: + coverage: + name: Docs coverage + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4.0.0 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Setup pnpm store + run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v4 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm i --frozen-lockfile + + # Hard gate — parses source, so no build required. Fails the job on a gap. + - name: Coverage (public API / CLI / config) + run: pnpm --filter @getcronit/pylon-docs check:coverage + + # The example type-check resolves '@getcronit/*' imports against the built .d.ts. + - name: Build packages + run: pnpm build + + # Advisory — reports import drift in docs code blocks but does not block the + # build. Remove `continue-on-error` to promote it to a hard gate. + - name: Examples type-check (advisory) + run: pnpm --filter @getcronit/pylon-docs check:examples + continue-on-error: true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..2fe027d4 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,304 @@ +name: Publish + +# ONE workflow file for every npm publish path, because npm's Trusted Publisher +# (OIDC) binds to a single repo + workflow filename (+ at most ONE environment) per +# package. Splitting publishing across files/environments means only one of them can +# authenticate. +# +# push to main → `version` job (open/update the Version PR — UNGATED) +# → `publish` job (publish `latest` — GATED by the `release` env, +# runs only when a release is actually pending) +# pull_request → `canary` job (per-PR snapshot; same `release` env) +# +# The `release` GitHub Environment is the human gate: add Required Reviewers in +# Settings → Environments → release. Because npm allows only one environment on the +# Trusted Publisher, `publish` and `canary` BOTH use it (so both are covered by the +# same reviewer gate). Point each package's Trusted Publisher at this file +# (.github/workflows/publish.yml), environment `release`. +on: + push: + paths-ignore: + - 'docs/**' + - 'examples/**' + - '.vscode/**' + branches: + - main + pull_request: + paths-ignore: + - 'docs/**' + - 'examples/**' + - '.vscode/**' + branches: + - main + +jobs: + # Version management — UNGATED. Opens/updates the "Version Packages" PR from pending + # changesets (never publishes). Also reports whether a publishable version is not yet + # on npm → a real release is pending (i.e. the Version PR was just merged), which is + # what arms the gated publish job. Reading versions BEFORE changesets/action runs so + # we see committed `main`, not the action's in-tree bumps. + version: + name: Version + if: ${{ github.event_name == 'push' }} + runs-on: ubuntu-latest + permissions: + contents: write # changesets/action opens the Version Packages PR + pushes tags + pull-requests: write # ...and manages that release PR + outputs: + shouldPublish: ${{ steps.pending.outputs.shouldPublish }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # Full history so Changesets can generate changelogs with the correct commits + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4.0.0 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + registry-url: 'https://registry.npmjs.org' + + - name: Setup pnpm store + run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v4 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install Dependencies + run: pnpm i --frozen-lockfile + + # BEFORE changesets/action mutates the tree: is any publishable package's + # committed version missing from npm? Only then does `publish` need approval — + # so the gate never fires for the Version-PR-open run or for no-op pushes. + - name: Detect pending release + id: pending + run: | + set -euo pipefail + should=false + for pj in packages/*/package.json; do + [ -f "$pj" ] || continue + priv=$(node -p "require('./$pj').private === true" 2>/dev/null || echo true) + [ "$priv" = "true" ] && continue + name=$(node -p "require('./$pj').name") + ver=$(node -p "require('./$pj').version") + if npm view "$name@$ver" version >/dev/null 2>&1; then + echo "· $name@$ver already on npm" + else + echo "· $name@$ver NOT on npm → release pending" + should=true + fi + done + echo "shouldPublish=$should" >> "$GITHUB_OUTPUT" + + - name: Create/Update the Version Packages PR + uses: changesets/action@v1 + with: + version: pnpm ci:version + # No `publish:` here — the gated `publish` job below does that. + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Publish `latest` — GATED by the `release` Environment. Runs only when `version` + # reported a pending release, so a reviewer is asked to approve ONLY a real publish. + publish: + name: Publish (latest) + needs: version + if: ${{ github.event_name == 'push' && needs.version.outputs.shouldPublish == 'true' }} + runs-on: ubuntu-latest + environment: release + permissions: + contents: write # changesets/action pushes the release git tags + id-token: write # OIDC trusted publishing to npm (no stored NPM_TOKEN) + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4.0.0 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + registry-url: 'https://registry.npmjs.org' + + # OIDC trusted publishing needs npm >= 11.5.1; the runner ships npm 10, so upgrade to + # latest. npm@latest (12.x) requires node >= 22 (used here) and rejects unknown config — + # so pnpm must be >= 10.34.5 (see the root `packageManager`): older pnpm leaked its + # `--no-git-checks` flag into the `npm publish` subprocess it shells out to, which npm 12 + # fatally rejected (EUNKNOWNCONFIG); hence the pnpm 10.34.5 pin. + - name: Upgrade npm for OIDC publishing + run: npm i -g npm@latest + + - name: Setup pnpm store + run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v4 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install Dependencies + run: pnpm i --frozen-lockfile + + - name: Build Packages + run: pnpm build + + - name: Publish to npm + # changesets/action runs the publish command AND pushes the release git tags. + # With no pending changesets (the state that made shouldPublish true), it just + # publishes. `changeset publish` only pushes versions not already on the + # registry. OIDC: id-token: write + npm >= 11.5.1 + a Trusted Publisher + # (repo + this workflow + environment `release`) per package. + uses: changesets/action@v1 + with: + publish: pnpm ci:release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Per-PR canary snapshot under `canary-pr-`. Uses the SAME `release` + # environment (npm allows only one), so it's covered by the same reviewer gate. + canary: + name: Publish snapshot under channel dist-tag + # SECURITY: only same-repo PRs (trusted collaborators). Fork PRs have + # head.repo.full_name = "/" != github.repository → SKIPPED, so + # untrusted fork code never runs build/publish or reaches the OIDC id-token. + if: + ${{ github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository && + github.actor != 'dependabot[bot]' && github.actor != 'dependabot-preview[bot]' + && github.actor != 'renovate[bot]' }} + runs-on: ubuntu-latest + environment: release + permissions: + contents: read + pull-requests: write # post the canary-versions report comment + issues: write # delete the prior report comment so the new one lands at the bottom + id-token: write # OIDC trusted publishing to npm (no stored NPM_TOKEN) + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4.0.0 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + registry-url: 'https://registry.npmjs.org' + + # OIDC trusted publishing needs npm >= 11.5.1; the runner ships npm 10, so upgrade to + # latest. npm@latest (12.x) requires node >= 22 (used here) and rejects unknown config — + # so pnpm must be >= 10.34.5 (see the root `packageManager`): older pnpm leaked its + # `--no-git-checks` flag into the `npm publish` subprocess it shells out to, which npm 12 + # fatally rejected (EUNKNOWNCONFIG); hence the pnpm 10.34.5 pin. + - name: Upgrade npm for OIDC publishing + run: npm i -g npm@latest + + - name: Install dependencies + run: pnpm i --frozen-lockfile + + - name: Build packages + run: pnpm build + + - name: Resolve PR dist-tag + id: channel + run: echo "tag=canary-pr-${{ github.event.number }}" >> "$GITHUB_OUTPUT" + + - name: Version snapshot + publish under PR tag + id: publish + env: + # No NPM_TOKEN: `changeset publish` authenticates to npm via OIDC + # trusted publishing (id-token: write above + npm >= 11.5.1). Requires + # a Trusted Publisher configured for this repo+workflow on each package. + # `changeset version` runs the @changesets/changelog-github plugin + # (.changeset/config.json), which calls the GitHub API and needs a token. + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + TAG="${{ steps.channel.outputs.tag }}" + echo "Publishing canary channel: $TAG" + # Invoke the changeset BINARY directly via `pnpm exec`. Plain + # `pnpm changeset ...` runs the repo's `"changeset": "changeset --"` + # script, so args land as `changeset -- version --snapshot ...` → + # "Too many arguments" (and it exited 0, so the step went green while + # publishing nothing). `--snapshot $TAG` stamps unique versions via the + # config's prereleaseTemplate; `--tag $TAG` sets the per-PR dist-tag. + pnpm exec changeset version --snapshot "$TAG" + pnpm exec changeset publish --tag "$TAG" --no-git-tag | tee changeset-publish.log + # Collect what was actually published (name@version) for the PR comment. + grep -oE '(@[a-z0-9-]+/[a-z0-9-]+|create-pylon)@[0-9][A-Za-z0-9.-]*' \ + changeset-publish.log | sort -u > published.txt || true + echo "published-count=$(wc -l < published.txt | tr -d ' ')" >> "$GITHUB_OUTPUT" + + - name: Report canary versions on the PR + if: always() && github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const tag = '${{ steps.channel.outputs.tag }}'; + const marker = ''; + let pkgs = []; + try { + pkgs = fs.readFileSync('published.txt', 'utf8').split('\n').filter(Boolean); + } catch {} + const sha = (context.payload.pull_request?.head?.sha || context.sha || '').slice(0, 7); + // Lead with the EXACT immutable versions (these carry the datetime.commit + // hash and are what this run actually published). The `${tag}` channel is a + // moving pointer — same string every push — so it goes second as a convenience. + const exact = pkgs.map(p => `npm install ${p}`); + const channel = [...new Set(pkgs.map(p => p.replace(/@[0-9].*$/, '') + '@' + tag))] + .map(i => `npm install ${i}`); + const body = pkgs.length + ? `${marker}\n### 🦋 Canary published${sha ? ` from \`${sha}\`` : ''}\n\n` + + `**Pinned to this build** (immutable — reproducible):\n` + + '```bash\n' + exact.join('\n') + '\n```\n\n' + + `
Or track the latest on this PR — ${tag} (moves every push)\n\n` + + '```bash\n' + channel.join('\n') + '\n```\n
' + : `${marker}\n### 🦋 Canary (\`${tag}\`)\n` + + `No packages were published (no pending changeset touched a publishable package, ` + + `or the publish step failed — check the job log).`; + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + // Delete any prior canary-report comment, then post a fresh one — so the report + // always lands at the BOTTOM of the PR. An in-place update stays at the original + // comment's position, which GitHub collapses into the folded history and is easy to + // miss. Deletes are best-effort (never fail the publish job over a comment). + const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number }); + for (const c of comments) { + if (c.body && c.body.includes(marker)) { + try { + await github.rest.issues.deleteComment({ owner, repo, comment_id: c.id }); + } catch (e) { + core.warning(`Could not delete prior canary comment ${c.id}: ${e.message}`); + } + } + } + await github.rest.issues.createComment({ owner, repo, issue_number, body }); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 12df6f39..00000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: Release - -on: - push: - paths-ignore: - - 'docs/**' - - 'examples/**' - - '.vscode/**' - branches: - - main - -jobs: - release: - name: Release - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits - fetch-depth: 0 - - - name: Setup pnpm - uses: pnpm/action-setup@v4.0.0 - with: - run_install: false - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: 'pnpm' - - - name: Setup pnpm store - run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - - name: Setup pnpm cache - uses: actions/cache@v4 - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - - name: Install Dependencies - run: pnpm i --frozen-lockfile - - - name: Build Packages - run: pnpm build - - - name: Create Release Pull Request or Publish to npm - id: changesets - uses: changesets/action@v1 - with: - # This expects you to have a script called release which does a build for your packages and calls changeset publish - publish: pnpm ci:release - version: pnpm ci:version - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/vercel-merge.yml b/.github/workflows/vercel-merge.yml deleted file mode 100644 index a3e48e00..00000000 --- a/.github/workflows/vercel-merge.yml +++ /dev/null @@ -1,26 +0,0 @@ -# vercel-merge.yml -name: Deploy to vercel on merge -on: - push: - branches: - - main - paths: - - 'docs/**' -jobs: - build_and_deploy: - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - repository-projects: write - steps: - - uses: actions/checkout@v4 - - uses: amondnet/vercel-action@v20 - with: - vercel-token: ${{ secrets.VERCEL_TOKEN }} - github-token: ${{ secrets.GITHUB_TOKEN }} - vercel-args: '--prod' - vercel-org-id: ${{ secrets.ORG_ID}} - vercel-project-id: ${{ secrets.PROJECT_ID}} - scope: ${{ secrets.ORG_ID }} - working-directory: ./docs diff --git a/.github/workflows/vercel-pull-request.yml b/.github/workflows/vercel-pull-request.yml deleted file mode 100644 index d456e7a9..00000000 --- a/.github/workflows/vercel-pull-request.yml +++ /dev/null @@ -1,32 +0,0 @@ -# vercel-pull-request.yml -name: Create vercel preview URL on pull request -on: - pull_request_target: - types: [labeled] - branches: - - main - paths: - - 'docs/**' -jobs: - build_and_deploy: - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - repository-projects: write - steps: - - uses: actions/checkout@v4 - with: - ref: refs/pull/${{ github.event.pull_request.number }}/merge - - uses: amondnet/vercel-action@v20 - id: vercel-deploy - with: - vercel-token: ${{ secrets.VERCEL_TOKEN }} - github-token: ${{ secrets.GITHUB_TOKEN }} - vercel-org-id: ${{ secrets.ORG_ID }} - vercel-project-id: ${{ secrets.PROJECT_ID }} - scope: ${{ secrets.ORG_ID }} - working-directory: ./docs - - name: preview-url - run: | - echo ${{ steps.vercel-deploy.outputs.preview-url }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5b1b17be..2bb8f284 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ .cache public +# …but docs/public holds committed source brand assets (favicons, logo), not build output +!docs/public/ +!docs/public/** node_modules .npmrc @@ -9,4 +12,27 @@ node_modules bun.lockb -.DS_Store \ No newline at end of file +.DS_Store + +examples + +# Pylon build / verify artifacts +.pylon +.pylon-verify + +# create-pylon scaffold e2e workdir (generated projects, removed after each run) +e2e/.tmp-create-pylon + +# pylon eval run workdirs + reports +.eval-runs +eval-report.json + +# Local MCP server config (absolute machine paths — kept out of the repo) +.mcp.json + +# Local package tarballs (npm pack output) +tarballs + +# Local planning / design notes (kept out of the repo) +dd +.claude/launch.json diff --git a/GID_DESIGN.md b/GID_DESIGN.md new file mode 100644 index 00000000..fb81d4e4 --- /dev/null +++ b/GID_DESIGN.md @@ -0,0 +1,170 @@ +# Global Object IDs (`gid`) + +Shopify-style opaque, type-qualified entity handles for Pylon: + +``` +gid://pylon/Order/123456789012345 + ^^^^^ ^^^^^ ^^^^^^^^^^^^^^^^^ + ns type raw primary key (snowflake / cuid / uuid) +``` + +A gid is a **presentation wrapper only** — storage and every foreign key stay +raw. It gives you a single addressable handle per entity and a universal +`node(id)` refetch entry point, without changing how ids are stored or generated. + +## The constraint that shapes the design + +A GraphQL scalar's `serialize(value)` receives **only the value** — no parent +type. So a scalar *cannot* emit `gid://pylon/Order/…`, because at serialize time +it has no idea the value belongs to an `Order`. That splits the work into three +layers, with the scalar being the thinnest: + +| layer | knows the type? | job | +| --- | --- | --- | +| encode (output) | yes — field-level | raw pk → gid (per-model `id` resolver) | +| decode (input) | depends | gid → raw + type (`node()` / where-builder) | +| `ID` scalar | no | validate + normalize; stays tolerant | + +Pylon's advantage: because the compiler *generates* both the `id` field resolver +and the query/where resolvers from your models, the type name is known at codegen +time on **both** boundaries — sidestepping the `globalIdField('Order')` +boilerplate every Relay stack pays. + +## Type name ↔ model + +A model's (underscore-normalized) **class name IS its GraphQL type name**, and is +unique project-wide (`registry.ts` relies on this for cross-bundle resolution). +`modelForTypeName(type)` is the reverse map used for `node()` dispatch. Because +snowflakes are globally unique, the gid needs only `type + localId` — **no tenant +in the handle**; tenant scoping stays in the ambient resolve context. + +## Layers, concretely + +**Encode (output).** For each model type `T`, the compiler emits +`resolvers[T].id = root => toGid('T', root.id)`. `root.id` stays raw in the DB and +in every FK. Requires two compiler touches: (1) map primary keys to SDL `ID`, and +(2) emit `interface Node { id: ID! }` with every model `implements Node`. + +**Decode (input).** +- `node(id: ID!): Node` needs the type → decodes the full gid itself + (`resolveNode`), dispatches to the owning model, looks up by PK through the + normal manager (auth/tenant still apply), tags `__typename`, returns `null` on + miss (Relay semantics). +- Ordinary `id: ID!` args → the ORM where-builder knows the target model, so it + calls `decodeId(value, ExpectedType)`: validates the embedded type (passing a + `User` gid where `Order` is expected throws) and uses the local id. Bare ids + pass through untouched → back-compatible while clients migrate. + +**`ID` scalar (thin).** `serialize` = identity (field resolvers already produced +gids). `parseValue`/`parseLiteral` = validate format + stay tolerant (accept a +gid *or* a bare id); it does **not** strip or dispatch, because it can't know the +expected type. + +## Status + +### Landed (runtime foundation, `pylon-db`) — tested, no MCP + +- `gid.ts` — `toGid` / `fromGid` / `isGid` / `decodeId` / `resolveNode` / + `GID_NAMESPACE`. Local id is opaque (may contain `/`). +- `errors.ts` — `BadRequestError` (400) for malformed / wrong-type gids. +- `registry.ts` — `modelForTypeName(type)` reverse dispatch. +- Exports wired; `test/gid.test.ts` (codec, 9) + `test/integration/gid-node.test.ts` + (`resolveNode` dispatch + Relay-null + error paths). + +### Landed (slice 1 — compiler / schema wiring) — tested + +Two config surfaces, split by build-vs-runtime: + +- **Opt-in** (build/SDL decision) is a **per-app `db` flag**: `new Pylon({ db: { + models, globalIds: true } })`. It sets a project-wide registry flag + (`enableGlobalIds`) that `toIR()` auto-detects, so the build's `contributeIR` + picks it up — the introspect child constructs the app, so it sees the flag. +- **Tunables** (runtime) live on **`useDatabase`**, not env / not hardcoded: + `useDatabase({ nodeId, gidNamespace })`. Applied at `setup()`: + - `nodeId` → the snowflake node id for `id({snowflake:true})` PKs; read lazily + so it's set before the first insert. Either a **number** (0..1023), or + **`'lease'`** — claim a unique slot from the database at boot (multi-instance / + PM2 cluster safe; see below). Omitted → `0`. + - `gidNamespace` → `gid:///…`; seeds a process global the serialized `id` + encoder reads, so encode (build-emitted) and decode (`fromGid`) share ONE + namespace with no baked-in literal (this also removes the earlier drift bug). + +**Node-id lease (`nodeId: 'lease'`).** Uniqueness is required per-database (all +writers share one id space), so the DB is the coordinator. At `setup()` each +instance takes a transaction-scoped advisory lock, ensures a `_pylon_nodes` +ledger, and upserts the **lowest node id (0..1023) whose row is missing or +stale**, then heartbeats (unref'd timer) to hold it. A crashed instance's slot +goes stale after the TTL (default 60s) and is reclaimed; graceful shutdown +(SIGINT/SIGTERM) frees it immediately. Zero config, no `PYLON_NODE_ID` env, no +birthday-collision risk — the fix for the "every PM2 instance defaults to node 0" +trap. (`leaseNodeId(db, {max, ttlSeconds})` is exported for direct use.) + +Snowflake PKs are authored as **`id({ snowflake: true })`** — a `text` PK (so the +64-bit value round-trips as a string, no precision loss) with the generator wired +and a format validator, reading the process node id. `id()` stays a bigint +identity. The low-level `snowflake()` generator remains for `default:` on +arbitrary columns. + +1. **`Node` interface + `node(id)` field + `id: ID!`** — `applyNodeInterface` in + pylon-db `ir.ts` (gated by `toIR(defs, {node})`, default off / auto from the + flag). Emits `interface Node { id: ID! }`, makes every single-PK entity and + STI-base interface `implements Node`, and adds root `node(id: ID!): Node`. +2. **Runtime resolvers** — `attachNodeResolvers` in pylon-dev `builder.ts`: + `Query.node` → `resolveNode` (via dynamic `import('@getcronit/pylon-db')`, + since `resolvers.js` inlines function source with no imports in scope), and + each `Node` type's `id` → an inlined `gid://pylon//` encoder. + Merged one level deep over the app's resolvers by `mergeResolverMaps` in + pylon `pylon-handler.ts` (preserves user `Query`/entity resolvers). + `Node.__resolveType` is the universal `__typename`-first resolver already + attached to every SDL interface. + +`useDatabase`'s error mapper also maps a thrown `BadRequestError` → +`extensions.code = 'BAD_REQUEST'` (so a malformed/wrong-type gid surfaces +cleanly instead of being masked). + +Tests: +- pylon-db `node-interface` (SDL + config wiring) + `gid` codec + `gid-node` + (`resolveNode` dispatch vs live DB). +- pylon-dev `orm-global-ids` (full build: SDL + emitted resolvers through + `introspectViaRunner`). +- pylon `node-resolvers` (the one-level-deep merge). +- **e2e `globalids-serve`** (Dockerized, full stack: `pylon build` → `db push` → + serve `server.mjs` → HTTP): a created entity + list queries return + `gid://pylon/Note/` ids, `node(gid)` refetches it, an absent gid → + `null`, a malformed gid → `BAD_REQUEST`. + +### Landed (slice 2 — gid on input) — tested + +Decoding lives in the **ORM where-builder**, not a global `ID` scalar (a scalar +can't see the expected type, so it couldn't type-check *and* would break `node`'s +dispatch — see the "why ORM, not scalar" reasoning). `compileWhere` decodes gids +for any **PK or FK** filter to the raw local id, type-checked via `decodeId`: + +- `Model.objects.get({id: gid})` / `.filter({id: {in: [gid]}})` accept a gid OR a + raw id interchangeably; the type name comes from the model's own type (PK) or + the FK's target type. +- A wrong-type gid (`User` gid where a `Note` is expected) throws + `BadRequestError` → `BAD_REQUEST`. +- Nested relation filters (`where: {author: {id: gid}}`) are covered for free — + `compileWhere` recurses into the target scope, which decodes against the target + type. +- The gid is stripped the moment it touches the DB, so every query + row sees the + raw id — the "code only sees the number" property, kept without a scalar. + +`resolveNode` moved to `node-resolve.ts` so `gid.ts` is a pure codec the +where-builder can import without a manager cycle. + +Tests: pylon-db `gid-input` integration (get/filter/`in`/FK/wrong-type, 6) + the +`globalids-serve` e2e (a hand-written `note(id)` resolver fetched by gid over +HTTP, round-tripping back to the same gid). + +## Edge cases + +- **Composite PKs** — no single scalar id; give only single-PK models a gid to + start. +- **Client cache** — key becomes `__typename:gid`; gid already embeds the type, + so it stays globally unique (harmless redundancy). +- **FK scalar fields** — don't gid-encode raw FK columns; expose relations as + nested objects so each `User.id` is encoded by `User`'s own resolver. +- **Output opacity** — readable URI by default (debuggability + no tenant leak); + base64 is an opt-in output mode for public APIs. diff --git a/PROJECT_LOADER_DESIGN.md b/PROJECT_LOADER_DESIGN.md new file mode 100644 index 00000000..fc15d657 --- /dev/null +++ b/PROJECT_LOADER_DESIGN.md @@ -0,0 +1,224 @@ +# Project Loader — Target Architecture + +**Status:** proposed · **Owner:** platform · **Supersedes:** the bundle-based `loadProjectApp` + +One-line: replace the in-process **bundle-and-import** loader with a **project-context child runner**, so the CLI reads the project's real modules — which makes per-app migrations *zero-config* and deletes the bundle/strip/shim machinery. + +--- + +## 1. Goal + +**Primary:** an app declares nothing about where its migrations live; they default to the app's own folder. + +```ts +// src/apps/blog/index.ts — no migrations line +export const blog = new Pylon({name: 'blog', db: {models: [Post]}}) +// → migrations resolve to src/apps/blog/migrations, automatically +``` + +An explicit `db.migrations` remains as an override for non-standard layouts. + +**Secondary (the enabler, and the real prize):** stop bundling the user's app to introspect it. That removes an entire class of "works differently under the loader than at runtime" bugs (`import.meta`, `__dirname`, stack traces, `require.resolve`) and deletes the strip-serve transform, the temp-file dance, and the `import.meta` shim. + +### Non-goals +- Changing the authoring surface (`new Pylon`, `models.app`, migration files) beyond making `db.migrations` optional. +- Changing the runtime/serving path (`.pylon` bootstrap). This is about the **build/CLI-time** loader only. + +--- + +## 2. Background — how loading works today + +`loadProjectApp(cwd, entry)` ([packages/pylon-dev/src/project-bridge.ts](packages/pylon-dev/src/project-bridge.ts)): + +1. Reads the entry source, `prepareModelSource` **strips `serve()`** and rebinds `export default`. +2. `esbuild.build` **bundles** that stripped source + `export * from '@getcronit/pylon-db'` (+ queues) into one temp `.mjs` at the project root. +3. `import()`s that temp file **in the pylon-dev process** and returns a `ProjectApp` of **live objects** — `MigrationRunner`, `connect`, `appGroups`, `schemaDrift`, `toIR`, … + +It exists for two reasons, both real: +- **Strip serve** so importing doesn't boot a server. +- **Instance unification.** pnpm can resolve *two physical copies* of `@getcronit/pylon-db` — one for pylon-dev, one for the project. The models register into the project's copy; the CLI must read *that* registry. The bundle guarantees it by re-exporting the ORM from the same module the models registered into. + +Consumers: + +| Consumer | Path | Wants | +| --- | --- | --- | +| `pylon inspect` / `mcp` | `loadProjectApp` → `toIR()` | **data** (AppModel/IR) | +| `pylon build` | `loadAppContribution` → `PylonIR` | **data** (IR) | +| `pylon verify` | build + introspect | **data** (verdict) | +| `pylon db ` | `loadProjectApp` → live ORM | **live ORM + DB access** | + +`loadAppContribution` already returns serializable `PylonIR` — build/inspect/verify fundamentally want *data*, not live objects. Only `db` needs live behavior. + +--- + +## 3. Why the current design blocks the goal + +To default migrations to `/migrations`, the system must learn each app's **source directory** automatically — captured at `new Pylon({name})` from the call site (stack trace or `import.meta`). + +Bundling flattens every module into one temp file, so **both the stack trace and `import.meta` point at the temp file, not `src/apps/blog/index.ts`.** Auto-detection is impossible under the bundle. The shim (rewriting `import.meta.*` per file) makes an *explicit* `path.join(import.meta.dirname, …)` resolve, but it cannot remove the declaration — the constructor still has no way to know its caller. + +**Zero-config genuinely requires the bundle to go.** + +--- + +## 4. Design principles + +1. **Execute the project's real modules, in the project's context.** No source rewriting, no flattening — what the CLI sees is what runs. +2. **Parent = UX, child = execution.** The pylon-dev CLI owns flags, output, and formatting. A child owns loading and running against the project. +3. **Data across the boundary where possible; execute-in-child where not.** Introspection returns JSON; stateful DB commands run entirely in the child. +4. **One instance, by resolution not by bundling.** The child resolves `@getcronit/pylon-db` from the project, so the entry and the command logic share it for free. + +--- + +## 5. Proposed architecture — the project runner + +A small **child entrypoint** shipped in pylon-dev, executed via the bundled `tsx` with `cwd = projectRoot`: + +``` +pylon-dev CLI (parent) project runner (tsx child, cwd=project) +────────────────────── ──────────────────────────────────────── +spawnProjectRunner(cwd, op, args) ── spawn ▶ 1. resolve project @getcronit/pylon-db + 2. import(entry) → registers models, + ◀── JSON result + streamed logs ── captures each app's source dir + 3. dispatch `op`: + introspect → serialize IR → stdout(JSON) + db → run against DB → stdout(JSON) +format for the user (consola) 4. exit code +``` + +### 5.1 Instance unification without a bundle + +The child resolves the ORM **from the project**, so it is the same physical module the entry registers into: + +```js +// in the child, cwd = projectRoot +const dbUrl = pathToFileURL(require.resolve('@getcronit/pylon-db', {paths: [projectCwd]})) +const orm = await import(dbUrl.href) // MigrationRunner, connect, appGroups, … +await import(pathToFileURL(entryAbs).href) // registers models INTO orm's registry +``` + +Both resolve from `projectCwd/node_modules` → one instance → one registry. This is the mechanism the bundle's `export *` was faking. + +### 5.2 Two operation modes + +- **`introspect`** (inspect, build, verify, mcp): after import, call `orm.toIR()` / build the AppModel, `JSON.stringify` to a **result channel**, exit. Parent parses and does all rendering (SDL/DDL/verdict) and, for build, feeds the IR to `SchemaBuilder`. +- **`db `** (migrate/deploy/diff/…): the command runs **in the child**, where the live ORM + `DATABASE_URL` + real migration files exist. It returns the existing `DbCommandResult` (already plain data) as JSON; per-app progress is emitted as log lines. Parent formats with consola exactly as today. + +### 5.3 Output protocol + +- **stderr** — human logs (streamed to the user live). +- **fd 3** (or a stdout sentinel block) — a single JSON envelope: `{ ok, result?, error? }`. +- **exit code** — 0 / non-zero, mirrored by the parent. + +Using a dedicated fd keeps the machine channel clean regardless of what the user's code prints. + +### 5.4 Serving on import + +v3 entries are `export default new Pylon(...)` — **serving is a boot-time config plugin**, not a top-level call, so a plain import is side-effect-free (verified: e2e `apps-app`, `runtime-app`). The strip-serve transform is legacy. Defense: the child sets `PYLON_INTROSPECT=1`; the serve plugin/bootstrap treats it as a no-op. No source rewriting needed. + +--- + +## 6. Zero-config migrations — source-dir capture + +### 6.1 Capture at construction + +The `Pylon` constructor records the **caller's file** using V8 structured stack frames (not string parsing): + +```ts +function callerDir(): string | undefined { + const prep = Error.prepareStackTrace + Error.prepareStackTrace = (_, frames) => frames + const frames = new Error().stack as unknown as NodeJS.CallSite[] + Error.prepareStackTrace = prep + for (const f of frames.slice(1)) { + const file = f.getFileName() + if (!file || file.includes('node_modules') || isPylonPackage(file)) continue + return path.dirname(fileURLToPath(file)) // the app's own directory + } +} +``` + +Stored on the instance (`this.#sourceDir`). Because the child runs the **real file** (tsx maps stacks to `.ts` via source maps), `getFileName()` is `.../src/apps/blog/index.ts`. Under the old bundle it would be the temp file — which is exactly why this only works post-refactor. + +### 6.2 Default derivation + +In pylon-db `register()` ([packages/pylon-db/src/app.ts](packages/pylon-db/src/app.ts)): + +```ts +recordApp(name, { + dependsOn: opts.dependsOn, + dir: opts.migrations ?? (app.sourceDir && path.join(app.sourceDir, 'migrations')), +}) +``` + +Explicit `migrations` always wins; otherwise `/migrations`. `appGroups()` already carries `group.dir`; the CLI already resolves + requires it. So this change is *only* about populating the default — the rest of the per-app-dir plumbing (already landed) is untouched. + +### 6.3 Failure mode + +If capture returns nothing (exotic construction, capture disabled), `dir` is undefined and the CLI throws its existing "declare `migrations`" error. Zero-config is a *default*, never a silent guess. + +--- + +## 7. Consumer migration + +| Consumer | Before | After | +| --- | --- | --- | +| `inspect` / `mcp` | `loadProjectApp().toIR()` in-process | `spawnProjectRunner('introspect')` → IR JSON | +| `build` | `loadAppContribution()` → IR | `spawnProjectRunner('introspect')` → IR JSON → `SchemaBuilder` | +| `verify` | load + build + check | same, introspection via child | +| `db ` | `loadProjectApp()` + `runDbCommand` in-process | `spawnProjectRunner('db', {command,…})`; `runDbCommand` runs **in the child** with the project ORM | + +`runDbCommand` is refactored to take an **already-resolved `orm`** instead of calling `loadProjectApp` itself; the child provides it. The parent `db.*` handlers become thin spawners. + +`loadProjectApp` (bundle), `prepareModelSource` (strip), and `importMetaPlugin` (shim) are **deleted**. + +--- + +## 8. Rollout (incremental, each phase shippable) + +- **Phase 0 — runner + introspect.** Add `project-runner` and `spawnProjectRunner`. Route `inspect` through it behind a flag. **Parity gate:** child IR byte-identical to bundle IR across fixtures. +- **Phase 1 — build/verify/mcp.** Switch the IR-data consumers. Delete `loadAppContribution`'s bundle path. +- **Phase 2 — db.** Move `runDbCommand` into the child. Gate on the full `db-migrate` + apps-deploy e2e. +- **Phase 3 — delete the bundle.** Remove `loadProjectApp`, `prepareModelSource`, `importMetaPlugin`. +- **Phase 4 — zero-config.** Add source-dir capture; default `/migrations`; make `db.migrations` optional; drop the explicit line from fixtures/docs. + +Reversible: phases 0–2 keep the bundle available; only phase 3 commits. + +--- + +## 9. Risks & mitigations + +| Risk | Mitigation | +| --- | --- | +| **Spawn overhead** (~100–300ms/tsx boot); dev/build call it often | Introspect once per build; keep a warm child for `pylon dev` (persistent runner, re-`import` on change); measure before/after | +| **Stack capture flaky** across tsx/source-maps | CallSite API not regex; unit tests per fixture; explicit `migrations` fallback; never guess silently | +| **`require.resolve` from project** edge cases (pnpm symlinks, ESM `exports`) | Test in the real e2e install layout; fall back to `import.meta.resolve` with project paths | +| **Entry serves on import** (legacy) | v3 entries don't; `PYLON_INTROSPECT` guard as defense | +| **Child error/exit propagation** | Structured fd-3 envelope + mirrored exit code; parent surfaces child stderr | +| **Watch mode** re-import staleness | Persistent dev runner invalidates module cache per change (today's unique-temp-name trick, but without a bundle) | + +--- + +## 10. Alternatives considered + +1. **Bundle + `import.meta` shim (shipped).** Makes the explicit line *work*; cannot *remove* it. Leaves the bundle machinery standing. Rejected as the end state — solves the wrong problem. +2. **Bundle + esbuild plugin injecting per-app source dirs.** Requires correlating runtime app name → source file during bundling; fragile. Rejected. +3. **Require apps to pass `import.meta`** (`new Pylon({meta: import.meta})`). Robust, no bundle change needed, but still per-app boilerplate — misses the goal. Viable fallback if stack capture proves unreliable. +4. **Child process + stack capture (this doc).** Zero-config, deletes the bundle, corrects `import.meta`/`__dirname`/stacks in user code. Biggest change; chosen because it's the only option that meets the primary goal. + +--- + +## 11. Testing + +- **Parity:** inspect/build IR identical pre/post refactor across all fixtures. +- **e2e:** `db-migrate` (single-app) + apps `deploy` (multi-app) through the child; the pre-existing `apps-build` server-spawn staleness is fixed or quarantined separately. +- **Unit:** `callerDir()` → source dir per fixture; zero-config default; explicit override precedence; capture-failure → CLI throw. +- **Safety net:** the migration round-trip / fuzz harness ([packages/pylon-db/test/integration/migration-roundtrip.test.ts](packages/pylon-db/test/integration/migration-roundtrip.test.ts)) is unaffected (tests the engine, not the loader) and guards against regressions. + +--- + +## 12. Open questions + +- **Warm child for `pylon dev`** — persistent process with cache invalidation, or accept per-reload spawn cost? (Affects dev-loop latency; see [[pylon_dev_loop_hmr]].) +- **queues** — same project-resolution treatment as pylon-db; confirm no second registry. +- **Monorepo/workspace** where pylon-dev and the project *do* share one pylon-db instance — the child still works (resolution just points at the same file); confirm no double-registration. diff --git a/PYLON_DB_VECTOR_DESIGN.md b/PYLON_DB_VECTOR_DESIGN.md new file mode 100644 index 00000000..aed0dd10 --- /dev/null +++ b/PYLON_DB_VECTOR_DESIGN.md @@ -0,0 +1,386 @@ +# pylon-db — Dense Vector Retrieval (`vector` / `.nearest()`) — Design Draft + +**Status:** **F1–F5 implementiert** (Draft → gelandet) · **Scope:** pylon-db + pylon-ir framework primitives only +**Motiviert von:** [SOCKEL_pgvector.md](../sockel/SOCKEL_pgvector.md) §10 — die *dense* Retrieval-Seite. +**Nicht-Ziel:** die SOCKEL-App-Schicht (EmbeddingProvider, Embed-Text-Komposition, `resolveEach`-Semantik, Hybrid-Fusion) — bleibt bewusst außerhalb (§9 unten). + +> **Implementierungsstand.** F1 (`models.Vector`), F3 (`CREATE EXTENSION vector`), F2 (HNSW/ivfflat-Index + Metrik→ops + `WITH`), F4 (`.nearest().matches()`) und F5 (natives `upsert`) sind gelandet — inkl. Write-Serialisierung (`number[]`→`'[…]'`). Getestet: pylon-ir 69/69, pylon-db 277 Unit + **12 live-e2e gegen echtes pgvector** (Extension→Spalte→HNSW-Index→`.nearest().matches()`-Ranking/Scores/Filter-Komposition; `upsert` insert/update-in-place + Tenant-Isolation). Bleibt App-seitig SOCKEL (§9). Rest-Entscheidungen: Auto-Index-Default (#4), `embed`/`sensitivity` (#5). + +--- + +## 0. Ausgangslage (verifiziert gegen den Code, nicht gegen das SOCKEL-Doc) + +Die *sparse* Seite ist real und liefert das Bauplan-Muster: + +- `.search(query, {column?, language?, rank?})` — FTS über eine aus `@model({search})` synthetisierte `tsvector`-Spalte, komponiert mit `.filter()` + Tenant-Scope, `{rank}` ordnet nach `ts_rank`. → [`manager.ts:882`](packages/pylon-db/src/manager.ts:882). +- `pg_trgm` wird **ops-getrieben** installiert (ein Index mit `ops:'gin_trgm_ops'` triggert `CREATE EXTENSION`) → [`diff.ts:603`](packages/pylon-ir/src/diff.ts:603), [`schema-sync.ts:325`](packages/pylon-db/src/schema-sync.ts:325). +- Tenant/fail-closed wird zentral in `QuerySet.predicates()` injiziert ([`manager.ts:793`](packages/pylon-db/src/manager.ts:793)) — jede neue Query-Methode, die auf `state.raw` schiebt, erbt das gratis. + +**Korrekturen am SOCKEL-Doc** (Grep-verifiziert, existieren nicht): + +| Doc-Behauptung | Realität | +|---|---| +| „`embed`-Feldmarkierung (`FieldMeta.embed`, schon da)" | Kein `FieldMeta`. `FieldOptions` ([`fields.ts:56`](packages/pylon-db/src/fields.ts:56)) hat **kein** `embed`. | +| `sensitivity !== 'normal'` als Feld-Attribut | Existiert nirgends. | +| `ModelIndex.where` (Partial-Index) | `ModelIndex` = `{columns, unique?, method?, name?}` — kein `where`. | + +⟹ `embed` / `sensitivity` sind **noch keine** Primitiven. Entscheidung dazu in §7. + +--- + +## 1. Umfang: vier Framework-Gaps (+ ein Nice-to-have) + +| # | Feature | Kern-Andockpunkt | Priorität | +|---|---|---|---| +| **F1** | `models.Vector({ dim })` → `vector(N)`-Spaltentyp | `SqlType`-Union (×2) + `fields.ts` Factory + Type-Mappings (×3) | P0 | +| **F2** | ANN-Index `method: 'hnsw'\|'ivfflat'` + ops-Klasse + `WITH`-Params | `ModelIndex`/`IndexSpec` + `dialect.indexMethod` + Auto-Index-Synthese | P0 | +| **F3** | `CREATE EXTENSION vector` (konditional, **vor** Table-DDL) | Extension-Sammelpass, ops-getriebene Sites | P0 | +| **F4** | `.nearest(vec, { column?, metric?, k?, rank? })` | `QuerySet` (spiegelt `.search()`) | P0 | +| **F5** | Natives `upsert` per Unique-Key | Manager/QuerySet Writer | P1 (Nice-to-have) | + +**F1–F4 sind gekoppelt** und müssen zusammen landen (ein `vector`-Feld ohne `.nearest()` ist nutzlos; ein Index ohne Extension bricht). F5 ist unabhängig und dient `ctx.mirror` + idempotentem Re-Embed. + +--- + +## 2. F1 — Vektor-Spaltentyp `models.Vector({ dim })` + +### API +```ts +class ArtikelEmbedding extends Model { + static config = { table: 'artikel_embedding', tenant: 'tenantId' } satisfies ModelConfig + id = id() + objectRef = text() + model = text() // Provider.id, z.B. 'voyage-3' + embedding = models.Vector({ dim: 1024 }) // → column: vector(1024) NOT NULL +} +``` +`Vector` gibt TS-seitig `number[] | null` zurück (nullable analog zu allen Scalars, `.nonNull()` o. `{nullable:false}` erzwingt NOT NULL). + +### Touch-Points (alle verifiziert) +1. **`SqlType`-Union — beide Kopien** (werden von Hand synchron gehalten): + - [`registry.ts:4`](packages/pylon-db/src/registry.ts:4) → `… | 'tsvector' | 'vector'` + - [`ir.ts:32`](packages/pylon-ir/src/ir.ts:32) → identisch. +2. **`ColumnDefinition` + `ColumnSpec` brauchen `dim?: number`** — analog zu `length?/precision?/scale?`: + - [`registry.ts:17`](packages/pylon-db/src/registry.ts:17) (`ColumnDefinition`) + - [`ir.ts:50`](packages/pylon-ir/src/ir.ts:50) (`ColumnSpec`) +3. **Factory** in [`fields.ts`](packages/pylon-db/src/fields.ts) (Muster von `struct()`/`uuid()`): + ```ts + export function vector(options: FieldOptions & { dim: number }): number[] | null { + if (!Number.isInteger(options.dim) || options.dim < 1) + throw new Error(`vector(): dim must be a positive integer, got ${options.dim}`) + return field('vector', { dim: options.dim }, options) as number[] | null + } + ``` + Registriert unter `models.Vector` (dieselbe Stelle, wo `models.Struct = struct` exportiert wird). +4. **Type-Mappings — drei parallele Stellen** (SQL-Typ-String): + - `postgres.columnType` [`dialect.ts:37`](packages/pylon-ir/src/dialect.ts:37) → `vector(${col.dim})` + - `pgColumnType` [`schema-sync.ts:31`](packages/pylon-db/src/schema-sync.ts:31) (kysely-Pfad, `db push`) → `vector(${dim})` + - `scalarForSqlType` [`ir.ts:43`](packages/pylon-db/src/ir.ts:43) → TS-Reflection `number[]` +5. **`buildColumn`** [`fields.ts:895`](packages/pylon-db/src/fields.ts:895) — `dim` vom Builder in `ColumnDefinition` durchreichen. + +### Serialisierung (Runtime) +pgvector akzeptiert das Literal `'[0.1,0.2,…]'`. Beim Insert/Query muss `number[]` → `'[…]'` gehen (analog zur jsonb-Serialisierung in [`rowFromInstance`](packages/pylon-db/src/manager.ts), commit `abd9812`). Parameter-Binding: `$1::vector`. **Offen:** Reader-Seite — pgvector liefert `'[…]'` als Text zurück; Parse zu `number[]` im Row-Hydrator. + +### Validierung (optional, P1) +Length-Check `vec.length === dim` beim Insert (analog `min/max`-Rules in `ColumnDefinition`) — verhindert stillen Dim-Mismatch, der sonst erst Postgres wirft. + +--- + +## 3. F2 — ANN-Index (`hnsw`/`ivfflat` + Metrik) + +### API — explizit (empfohlen, wegen Tuning) +```ts +static config = { + indexes: [{ + columns: ['embedding'], + method: 'hnsw', // NEU + metric: 'cosine', // NEU → ops-Klasse + with: { m: 16, ef_construction: 64 } // NEU → WITH (...) + }] +} satisfies ModelConfig<…> +``` + +### API — Field-Level (Single-Column) + Zero-Config-Shorthand +Ein `vector`-Feld mit `{ index: true }` synthetisiert einen HNSW/cosine-Index (btree geht auf `vector` nicht) — analog zur `tsvector`→Auto-GIN-Synthese ([`ir.ts`](packages/pylon-db/src/ir.ts)). Getunt wird **am Feld** (Single-Column gehört ans Feld, Composite in die Config): +```ts +embedding = vector({ dim: 1536, index: { method: 'hnsw', metric: 'l2', with: { m: 32 } } }) +``` +`FieldOptions.index` ist `boolean | SingleColumnIndex` (`{method?, metric?, with?}`); `buildColumn` normalisiert → `ColumnDefinition.index` (Flag) + `indexOptions`; `entityFromDefinition`s `singleColumn`-Zweig löst Methode/Metrik/Params auf (Default `hnsw`/`cosine` für vector, sonst btree). Eine ANN-Methode auf einer Nicht-Vektor-Spalte wirft schon in `buildColumn` (Authoring-Zeit). Die `config.indexes` (§F2) bleiben für **Composite**. + +### Metrik → Operator + ops-Klasse (die zentrale Tabelle) +| `metric` | ANN-`ORDER BY`-Op | ops-Klasse | Score (`rank:true`) | +|---|---|---|---| +| `cosine` (default) | `<=>` | `vector_cosine_ops` | `1 - distance` | +| `l2` | `<->` | `vector_l2_ops` | `-distance` | +| `ip` (inner product) | `<#>` | `vector_ip_ops` | `-(<#>)` | + +**Invariante:** Index-Metrik **muss** Query-Metrik matchen, sonst nutzt der Planner den ANN-Index nicht (Seq-Scan-Fallback). → Die Metrik wird auf der `vector`-Spalte gemerkt (aus ihrem Index abgeleitet) und ist der Default für `.nearest()` (§5). Mismatch = Warn/Throw. + +### Touch-Points +1. **`ModelIndex` erweitern** [`registry.ts:152`](packages/pylon-db/src/registry.ts:152): + ```ts + method?: 'gin' | 'btree' | 'hnsw' | 'ivfflat' + metric?: 'cosine' | 'l2' | 'ip' // NEU — mappt auf ops-Klasse + with?: Record // NEU — WITH (m=…, ef_construction=…) + ``` +2. **`IndexSpec` erweitern** [`ir.ts:116`](packages/pylon-ir/src/ir.ts:116) — hat schon `ops?`; ergänze `with?`. Metrik wird beim Bridging (`entityFromDefinition`) zu `ops` aufgelöst. +3. **`postgres.indexMethod`** [`dialect.ts:50`](packages/pylon-ir/src/dialect.ts:50) — gibt bereits `USING ` für non-btree zurück ⟹ `hnsw`/`ivfflat` funktionieren **ohne Änderung**. +4. **Spalten-Rendering mit ops-Klasse** — der ANN-Index braucht die ops-Klasse *pro Spalte* im Klammerausdruck: `USING hnsw (embedding vector_cosine_ops)`. Der Trigram-Pfad macht das schon ([`schema-sync.ts:329`](packages/pylon-db/src/schema-sync.ts:329), [`ir.ts:236`](packages/pylon-db/src/ir.ts:236) setzt `ops:'gin_trgm_ops'`). ⟹ dieselbe Column+ops-Renderung wiederverwenden. +5. **`WITH`-Klausel** — **neu**, existiert nirgends. In `addIndexSQL` [`diff.ts:601`](packages/pylon-ir/src/diff.ts:601) und im `db push`-Pfad [`schema-sync.ts:329`](packages/pylon-db/src/schema-sync.ts:329) ein `WITH (${entries})`-Suffix anhängen wenn `ix.with`. +6. **`indexEqual`** [`diff.ts:146`](packages/pylon-ir/src/diff.ts:146) — muss jetzt auch `metric`/`ops`/`with` vergleichen, sonst wird ein Metrik-Wechsel nicht als Diff erkannt (falsch-grüne Migration). +7. **Auto-Synthese** [`ir.ts:219`](packages/pylon-db/src/ir.ts:219) — Zweig „für jede `vector`-Spalte einen HNSW-Index" neben dem bestehenden `tsvector`→GIN-Zweig. + +--- + +## 4. F3 — `CREATE EXTENSION vector` (die kritische Ordering-Abweichung) + +**Wichtiger Unterschied zu `pg_trgm`:** `pg_trgm` braucht die Extension nur für den *Index*. `vector` braucht sie schon für den *Spaltentyp* `vector(N)` — also **bevor** die `CREATE TABLE` läuft. Ops-getriebene Extension-Erzeugung (die pg_trgm nutzt) reicht daher **nicht**: sie feuert bei Index-Erzeugung, zu spät für die Tabelle. + +### Design +Ein **Extension-Sammelpass**, der *vor* jeder Table-DDL läuft: +- Trigger = „irgendein Modell hat eine `vector`-Spalte" (nicht der Index). +- Emittiert `CREATE EXTENSION IF NOT EXISTS vector` als allererste Statement-Gruppe. + +### Touch-Points +- **`db push`** [`schema-sync.ts:301`](packages/pylon-db/src/schema-sync.ts:301) (`syncSchema`) — Extensions aus allen Modell-Spalten sammeln, vor der Tabellen-Sync-Schleife ausführen. Der `ops`-getriebene `pg_trgm`-Block [`:325`](packages/pylon-db/src/schema-sync.ts:325) bleibt für Index-Extensions; `vector` kommt in den neuen Vorab-Pass. +- **Migrationen** [`diff.ts`](packages/pylon-ir/src/diff.ts) — `CREATE EXTENSION vector` als eigene `SchemaChange` (oder in den bestehenden Extension-Sammelmechanismus), garantiert vor `createTable`-Changes einsortiert. **Down-Migration:** `DROP EXTENSION` bewusst **nicht** (andere Objekte könnten sie brauchen) — nur no-op oder `IF EXISTS … RESTRICT`. + +--- + +## 5. F4 — `.nearest()` (das Herzstück, spiegelt `.search()`) + +### API +```ts +// Nur die Objekte — T bleibt sauber getippt: +ArtikelEmbedding.objects + .filter({ model: 'voyage-3' }) // Pre-Filter (WHERE) — HNSW Post-Filter + .nearest(queryVec, { k: 5 }) // ORDER BY embedding <=> $q LIMIT 5 + .all() // → T[] (Score verworfen) + +// Mit Score — eigenes Terminal, Envelope statt T-Pollution: +ArtikelEmbedding.objects + .filter({ model: 'voyage-3' }) + .nearest(queryVec, { k: 5 }) + .matches() // → { item: T; score: number }[] +// tenant-Scope + Policy sind automatisch AND-verknüpft (predicates()) +``` + +Signatur — `.nearest()` verengt den Rückgabetyp auf `NearestQuerySet` (⊃ `QuerySet`), der zusätzlich das `.matches()`-Terminal trägt: +```ts +interface NearestOptions { + column?: string // default: die einzige vector-Spalte (throw bei Mehrdeutigkeit) + metric?: 'cosine' | 'l2' | 'ip' // default: die Index-Metrik der Spalte + k?: number // → .limit(k); default z.B. 10 +} +interface Match { item: T; score: number } + +nearest(vec: number[], options?: NearestOptions): NearestQuerySet + +// NearestQuerySet ist ein SCHMALES Interface (kein QuerySet-Subtyp) — es +// exponiert NUR die kNN-sinnvollen Terminals; .paginate()/.filter()/Writer sind +// gar nicht am Typ: +interface NearestQuerySet { + matches(): Promise[]> // Envelope mit Score + all(): Promise // Reihen distanz-sortiert, Score verworfen + first(): Promise // die eine nächste Reihe +} +``` + +> **Verfeinert gegenüber dem ursprünglichen Entwurf:** `NearestQuerySet` war als `QuerySet`-**Subklasse** geplant — die hätte `.paginate()` (u.a.) geerbt und zur Laufzeit werfen müssen (lügender Typ). Stattdessen ist es ein **schmales Interface**: der Laufzeit-Wert ist intern eine `QuerySet`-Subklasse (`NearestQuerySetImpl`, für `build()`-Reuse), aber `.nearest()` gibt sie als das schmale Interface zurück. `.paginate()` ist damit **gar nicht am Typ** — kein Laufzeit-Throw als Primärmechanismus nötig (der bestehende Guard bleibt nur als Defense-in-Depth). + +### Warum ein Terminal-Envelope, nicht ein `score`-Flag (und nicht `.withScore()`/`rank`) +Der Kern: **der Score gehört nicht zur Entität.** `Artikel` hat keinen Score — der *Match* hat einen. Ein Flag `{score:true}` klebte `_score` auf die Row (`T & {_score}`) und vermischte Entitätsdaten mit Query-Metadaten. `.matches()` legt den Score auf einen **Envelope**, geschwister zum Item — genau wie `.paginate()` `Connection` liefert statt `T[]` ([`manager.ts:1196`](packages/pylon-db/src/manager.ts:1196)). Drei Konsequenzen: + +- **Kein `T`-Pollution.** `.all()` gibt weiter reines `T[]`; den Envelope zahlt man nur bei `.matches()`. +- **Kein Orphan-Problem — durch Typen erzwungen.** `.matches()` sitzt auf `NearestQuerySet`, den *nur* `.nearest()` produziert. `Model.objects.filter(...).matches()` existiert typseitig nicht. Das ist der Grund, warum eine freie `.withScore()`-Methode auf dem Basis-`QuerySet` verworfen wurde (dort wäre sie ohne `.nearest()` bedeutungslos und müsste werfen). +- **Shape-Entscheidung am Terminal**, wo man konsumiert — nicht als Flag in den `.nearest()`-Optionen vergraben. Kein Overload-Gymnastik. + +Ein `rank`-Flag (wie `.search()`) wäre ohnehin doppelt falsch: `.search({rank})` toggelt **Sortierung** (die dort abschaltbar ist — `.count()`/`.exists()`, Sortierung nach `created_at`, Bulk-`.update()`, Keyset). Bei `.nearest()` **ist** die Distanz-Sortierung die Operation selbst und nicht abschaltbar; die einzige togglebare Achse ist die Score-*Projektion* — und die löst `.matches()` als Terminal sauberer als jedes Flag. + +**Wann man den Score *nicht* will:** eine UI-Liste „5 ähnlichste Artikel" → `.all()`, die Reihenfolge trägt schon alles. **SOCKEL will ihn immer** (`resolveEach`: Schwelle `minScore`, `candidates`, Provenance — §5) → `.matches()`. + +### Warum die Signatur von der Doc (`nearest(field, vec, …)`) abweicht +`.search()` auto-entdeckt die Spalte via `def.columns.find(c => c.sqlType === 'tsvector')`. Für Vektoren ist Multi-Spalte realistisch (§5.1 unten) ⟹ Auto-Discovery nur wenn **genau eine** `vector`-Spalte existiert, sonst `column` verpflichtend (klarer Throw). Konsistenter mit `.search()`s Options-Objekt als die positionale `field`-Form. + +### Implementierung (Kontrast zu `.search()`) +`.search()` schiebt ein **WHERE-Prädikat** (`@@`) auf `state.raw` **und** setzt `state.rank` fürs `ORDER BY ts_rank`. `.nearest()` ist reines **ORDER BY + LIMIT** (kein WHERE-Prädikat): + +1. **Neuer `QueryState.nearest`** [`manager.ts:748`](packages/pylon-db/src/manager.ts:748): `{ ref, vec, metric }`. +2. **`build()`** [`manager.ts:908`](packages/pylon-db/src/manager.ts:908) — Zweig neben `state.rank`: + ```ts + if (this.state.nearest) { + const { ref, vec, metric } = this.state.nearest + const op = { cosine: sql`<=>`, l2: sql`<->`, ip: sql`<#>` }[metric] + q = q.orderBy(sql`${sql.ref(ref)} ${op} ${vecLiteral(vec)}`, 'asc') // Distanz ASC + if (this.state.withMatches) // nur für .matches() + q = q.select(sql`1 - (${sql.ref(ref)} ${op} ${vecLiteral(vec)})`.as('__score')) // metrik-abhängig, Tabelle §3 + } + ``` +3. **`k` → `.limit(k)`** (bestehende Limit-Mechanik). +4. **`.matches()`-Terminal** — setzt `state.withMatches`, führt `build()` aus, mappt jede Row zu `{ item: hydrate(row), score: row.__score }` und **strippt** `__score` vom hydratisierten Item (bleibt sauberes `T`). `.all()` setzt das Flag nicht → keine Score-Spalte im SQL. +5. **`selectableColumns`** [`manager.ts:1638`](packages/pylon-db/src/manager.ts:1638) — Vektor-Spalten aus dem Default-SELECT ausschließen (`… || c.sqlType === 'vector'`), damit weder `.all()` noch `.matches().item` das Embedding zurückholen (§5.2). +6. **Tenant/Policy** — gratis: `predicates()`/`applyWhere` laufen unverändert, ANDen den `WHERE tenant_id=$t` **vor** den ANN-Scan (= HNSW-Post-Filter, wie SOCKEL §3). + +Die Score-Projektion (`__score`, nur beim `.matches()`-Pfad) ist die **eine neue Fähigkeit** über `.search()` hinaus (dessen `{rank}` nur ordnet, nie projiziert). Metrik-abhängige Score-Formel: cosine `1-dist`, l2 `-dist`, ip `-(<#>)` (Tabelle §3). Das `__score` ist ein interner Alias — es taucht **nie** auf `T` auf, nur im `Match.score`. + +### Constraints +- **Keyset-Pagination inkompatibel** — Distanz hat keinen seekbaren Cursor (wie `state.rank`). Deshalb ist `.paginate()` **gar nicht** am `NearestQuerySet`-Interface (nicht bloß ein Laufzeit-Throw). Für „mehr laden" `k` erhöhen. +- **Metrik-Konsistenz** — default = Spalten-Index-Metrik (aus einem ANN-Index auf der Spalte), sonst `cosine`; abweichende `metric` ⟹ ANN-Index wird nicht genutzt (Seq-Scan-Fallback). + +### 5.1 Mehrere Embeds — zwei Patterns +„Mehrere" hat drei Achsen, die **nicht** alle zu Multi-Spalte werden: + +| Achse | Lösung | Framework-Sicht | +|---|---|---| +| Mehrere `embed`-**Felder** pro Objekt (`bezeichnung`+`beschreibung`) | zu *einem* Embed-Text konkateniert (SOCKEL §1) | **eine** `vector`-Spalte | +| Mehrere **Modelle** (`voyage-3` vs `bge-m3`) | `model`-Diskriminator-Spalte, `UNIQUE (tenant, ref, model)` | `.filter({model}).nearest(vec)` | +| Mehrere Vektor-**Spalten** am selben Modell (Inline) | separate Spalten | explizit `.nearest(vec, {column})` | + +Daraus zwei tragfähige Patterns: + +- **Zentrale Embedding-Tabelle** (SOCKELs Wahl): *eine* `vector`-Spalte, „mehrere" via Diskriminator-Spalten (`model`, `object_type`) + Pre-`.filter()`. Auto-Discovery greift. +- **Inline-Vektoren** am Domänenmodell: mehrere `vector`-Spalten → `column` verpflichtend. + +⟹ Deshalb ist die `.filter()`-Komposition (Punkt 4) der Kern: SOCKELs „mehrere Embeds" ist **Zeilen-Diskriminierung + Pre-Filter**, nicht Multi-Spalte. + +### 5.2 Was `.matches()` zurückgibt — Vektor wird nie projiziert +`Match.item` = das **volle hydratisierte Modell *ohne* die Vektor-Spalte**. Nicht ids-only, nicht das Embedding: + +- **Nicht ids-only** — SOCKELs `candidates`-Dropdown (der `correct`-Flow, §5) braucht `objectRef` + Anzeigefelder; ids-only erzwingt N+1-Nachladen. Die Zeile wurde beim ANN-Scan ohnehin gelesen → die billigen Scalar-Spalten mitzugeben ist gratis. +- **Nicht das Embedding** — 4–8 KB/Row über die Wire; landete in SOCKEL sonst in der GraphQL-Antwort. Der Vergleich passiert *in der DB*; das rohe Embedding will client-seitig praktisch nie jemand. +- **Vektor nur im `ORDER BY`, nie im `SELECT`** — `ORDER BY col` verlangt kein `SELECT col`. Kein Wire-Cost. + +**Mechanismus (Präzedenz schon da):** `selectableColumns` [`manager.ts:1636`](packages/pylon-db/src/manager.ts:1636) schließt heute die synthetisierte `tsvector`-Spalte aus dem Default-SELECT aus — mit exakt der Ratio, die auf Vektoren zutrifft („large, hidden from the API, write-only by the DB, and never read as an instance value … fetching them just wastes wire + CPU"). Der Filter ist aktuell `c.generatedAs && c.hidden`; ein user-deklarierter `vector` ist nicht `generatedAs`, fällt also nicht drunter. Fix = Prädikat um den Typ erweitern: +```ts +// manager.ts:1638 +def.columns.filter(c => !((c.generatedAs && c.hidden) || c.sqlType === 'vector')) +``` +`.all()` **und** `.matches().item` erben das automatisch — gleiches Item-Shape, `.matches()` trägt nur zusätzlich `score`. + +**Feinheit (bewusste Grenze):** weil `embedding = models.Vector(...)` als Property deklariert ist, bleibt `number[]` Teil von `T`; per Default nicht selektiert ⟹ `instance.embedding` ist zur Laufzeit `undefined` (dieselbe Lücke wie bei jeder ausgeschlossenen Spalte). Für den seltenen Roh-Vektor-Read (Debug/Export/client-seitiges Re-Rank) ein Opt-in-Escape-Hatch — `.nearest(...).matches({ includeVector: true })` bzw. `.withVector()`. Nicht v1-kritisch. + +### Nice-to-have: Filter-Operator-Form +Analog zum `tsvector`-`{search}`-Operator in `compileField` [`manager.ts:333`](packages/pylon-db/src/manager.ts:333): ein `vector`-Feld mit `{ near: vec }` im `.filter()`-DSL. Niedrigere Prio — die Methodenform deckt SOCKEL ab. + +--- + +## 6. F5 — Natives `upsert` (implementiert) + +SOCKEL braucht idempotenten Re-Embed über `UNIQUE (tenant_id, object_ref, model)` (§3) und `ctx.mirror`. Gelandet als nativer `INSERT … ON CONFLICT DO UPDATE`: +```ts +Emb.objects.upsert(values, { + onConflict: ['tenantId', 'objectRef', 'model'], // Property-Keys eines UNIQUE-Index + update: ['contentHash', 'embedding'] // Default: alle Spalten außer Konflikt-Ziel + PK +}) +// → INSERT … VALUES … ON CONFLICT (…) DO UPDATE SET contentHash = excluded.contentHash, … +Emb.objects.upsertMany([...], opts) // Bulk: ein Statement für alle Reihen +``` + +**Design** ([`manager.ts` `upsertMany`](packages/pylon-db/src/manager.ts)): +- **Tenant-sicher** — der gebundene Tenant wird beim Insert gestempelt (`applyCreateDefaults`), und ein `WHERE . = $bound`-Guard am DO UPDATE verhindert, dass ein Konflikt eine *fremde* Tenant-Zeile überschreibt (load-bearing, falls das Konflikt-Ziel den Tenant nicht selbst enthält; redundant, wenn doch — wie in SOCKELs `UNIQUE (tenant, ref, model)`). +- **Gleiche Write-Pfade wie `create`** — Validierung + Serialisierung (inkl. jsonb **und** vector) via `rowFromInstance`/`dbValueForColumn`. +- **Korrekte Signale** — `(xmax = 0)` in `RETURNING` unterscheidet pro Reihe Insert vs. Update, sodass `postSave` `created` akkurat meldet. +- **Robustes Return-Mapping** — Reihen werden per Konflikt-Key zurückgematcht (nicht positional), damit ein vom Tenant-Guard verworfener Cross-Tenant-No-op die Zuordnung nicht verschiebt. + +Verifiziert: [test/integration/upsert.test.ts](packages/pylon-db/test/integration/upsert.test.ts) — 5 live-e2e (Insert→Update-in-place, Default-Update-Set, `upsertMany` gemischt, und der **SOCKEL-Re-Embed-Loop mit Tenant-Isolation**: t2-Upsert überschreibt t1 nicht). + +--- + +## 7. Entscheidung: `embed` / `sensitivity` — Framework oder SOCKEL? + +Existieren heute **nicht** (§0). Das SOCKEL-Doc positioniert die *Embed-Text-Komposition* als App — aber der **Marker** muss am Feld in der Ontologie hängen. Optionen: + +- **A) Explizite FieldOptions** `embed?: boolean` + `sensitivity?: 'normal'|'restricted'|…` in [`fields.ts:56`](packages/pylon-db/src/fields.ts:56). Framework **speichert** sie nur (opaker Passthrough auf `ColumnDefinition`), **handelt nicht** darauf. SOCKEL liest sie. → Deklaration bleibt in der Ontologie (Doc-Intent), minimaler Eingriff. +- **B) Generischer `meta?: Record`-Passthrough** auf FieldOptions. SOCKEL liest `meta.embed`/`meta.sensitivity`. Flexibler, weniger framework-spezifisch, aber untypisiert. +- **C) Rein SOCKEL-seitig** (Side-Registry im App-Code). Kein Framework-Change, aber Deklaration wandert aus der Ontologie raus. + +**Empfehlung: A** — zwei getippte, folgenlose FieldOptions. Hält die Deklaration deklarativ und in der Ontologie, ohne dass pylon-db Embedding-Semantik kennt. `sensitivity` ist ohnehin breiter nützlich (die im Doc erwähnte Rechte-Durchsetzung). + +--- + +## 8. Rollout — abgebildet auf SOCKEL §7 (jede Stufe grün testbar) + +| SOCKEL-Stufe | Braucht Framework? | Features | +|---|---|---| +| 1. `pg_trgm`-only | **Nein** — heute baubar | `.search()` + Trigram existieren | +| 2. pgvector dazu (dense) | **Ja** | **F1 + F3** (Spalte + Extension), **F2** (Index), **F4** (`.nearest`) | +| 3. Hybrid (dense+sparse) | Nein (Framework) | Fusion = SOCKEL; Zutaten (`ts_rank` + Distanz-Score) kommen aus `.search()`/`.nearest()` | +| 4. Async Embed-Job | Nein | BullMQ/Queue = SOCKEL/App | + +**Interne PR-Sequenz für Stufe 2** (jede für sich grün): +1. **F1** — `vector`-Typ end-to-end (Union ×2, Factory, 3 Mappings, `db push` + Migration + Round-Trip-Harness-Test). Kein Query noch. +2. **F3** — Extension-Vorab-Pass (Ordering-Test: Extension vor `CREATE TABLE`). +3. **F2** — Index-Method + Metrik + `WITH` + Auto-Synthese + `indexEqual`-Diff-Test. +4. **F4** — `.nearest()` + `.matches()`-Terminal + Tenant-Komposition-Test + Paginate-Throw-Test. + +Jede Stufe hängt am **Migration-Round-Trip-Fuzz-Harness** (generate→apply→rollback→re-apply) — der Regressionsnetz-Moat. + +--- + +## 9. Bewusst SOCKEL (NICHT Framework) + +- `EmbeddingProvider` (voyage/bge hinter Interface, wie `ModelProvider`). +- Embed-Text-Komposition (`embed`-Felder konkatenieren, `content_hash`). +- `resolveEach`-Semantik (Schwelle `minScore`, `candidates`, `prüfpflichtig`, Provenance) — konsumiert `.matches()` direkt: + ```ts + const matches = await Embedding.objects.filter({ model }).nearest(vec, { k: 5 }).matches() + const top = matches[0] + return top && top.score >= minScore(type) + ? { resolved: refOf(top.item), score: top.score, candidates: matches } + : { unresolved: 'below-threshold', candidates: matches } + ``` +- **Hybrid-Fusion** (§9.1) und **Reranking** (§9.2) — beides App-Ebene. +- Feedback-Loop / `decision`-Objekte (§8 SOCKEL). + +Der Framework-Beitrag ist präzise: **ein Spaltentyp, ein Index-Method, eine Extension, eine Query-Methode** — sodass die dense-Seite dasselbe Auto-Scoping / fail-closed erbt wie `.search()`, statt auf rohes SQL auszuweichen (das genau die Grounding-/Tenant-Zusage brechen würde). + +### 9.1 Hybrid-Suche — Fusion bleibt App (RRF als v1) +Dense (`.nearest`, Cosinus) fängt Semantik, sparse (`.search`, FTS/Trigram) fängt exakte Tokens (SKU „HEL-20L"). Das Framework liefert die zwei geordneten Listen, das **Mischen ist SOCKEL**. Robustester v1-Default ist **Reciprocal Rank Fusion** — sie braucht nur die *Position*, nicht den Score (Cosinus 0–1 und `ts_rank` sind unvergleichbar skaliert): + +```ts +// SOCKEL-App-Ebene — beide Listen kommen aus pylon-db, tenant-gescopet +async function hybridResolve(text: string, vec: number[], tenant: Filter) { + const K = 60 // RRF-Dämpfung (Standard) + const [dense, sparse] = await Promise.all([ + Embedding.objects.filter(tenant).nearest(vec, { k: 50 }).all(), // semantisch + Artikel.objects.filter(tenant).search(text, { rank: true }).all() // FTS + Trigram + ]) + const score = new Map() + const fuse = (list: { ref: string }[], w: number) => + list.forEach((row, i) => score.set(row.ref, (score.get(row.ref) ?? 0) + w / (K + i + 1))) + fuse(dense, 1.0) // w_dense + fuse(sparse, 0.8) // w_sparse + return [...score.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5) +} +``` +Bei „HEL-20L" steht der exakte Treffer in `sparse` auf Rang 1 → dominiert; bei „Torbogen bedruckt" gewinnt `dense`. **RRF braucht den Score nicht** (nur Ränge) — deshalb der bessere v1-Default; hier reicht `.all()`. Die gewichtete Summe (`w_d·cosine + w_s·trigram`, SOCKEL §6) braucht echte Scores → dann `.nearest(vec).matches()` (FTS-Score-Projektion wäre ein separates `.search()`-Feature). + +### 9.2 Reranking — zweite Stufe, ganz App-seitig +Retrieval (dense/hybrid) ist ein **Bi-Encoder**: Query und Dokument getrennt eingebettet, verglichen wird nur der Vektor-Abstand (billig, indexierbar, grob). Ein **Reranker** ist ein **Cross-Encoder**: er sieht `(Query, Kandidat)` als Paar mit voller Cross-Attention → präziser, aber zu teuer für den ganzen Index. Daher als **zweite Stufe** über die top-N des Retrievals: + +``` +Retrieval (ANN, top-50) ──▶ Reranker (Cross-Encoder) ──▶ top-5 + billig, breit präzise, schmal +``` + +**Muss NICHT ins Framework** — dieselbe Grenze wie beim Embedding. Ein Reranker ist (1) ein Modell-Call hinter einem Interface (wie `EmbeddingProvider`), (2) eine In-Memory-Umsortierung bereits geholter Kandidaten — kein SQL, kein Index, kein Tenant-Scope: +```ts +interface RerankProvider { // SOCKEL, analog EmbeddingProvider/ModelProvider + readonly id: string // 'voyage-rerank-2' | 'cohere-rerank-3' + rerank(query: string, docs: string[]): Promise<{ index: number; score: number }[]> +} +``` +Das Framework endet beim **Retrieval** (Kandidaten mit Distanz-Score, tenant-gescopet); Reranking sitzt *danach* zwischen `.nearest()` und `resolveEach`s Schwellen-Logik (`nearest` → `rerank` top-N → `minScore`/`candidates`). Optional — für einen sauberen Katalog reicht dense+hybrid oft. + +--- + +## 10. Offene Entscheidungen + +1. ~~**Score-Projektion** — Flag vs. Methode vs. Envelope.~~ **Entschieden + gebaut (§5):** `.nearest()` → schmales `NearestQuerySet`-Interface (`matches`/`all`/`first`) → `Match[]` (`{item, score}`). Score auf dem Envelope, nicht auf `T`; kein Orphan (typ-erzwungen); `.paginate()`/`.filter()`/Writer sind **gar nicht am Typ** (kein lügender Subtyp). Präzedenz: `.paginate()`→`Connection`. +2. ~~**Was `.matches()` zurückgibt** — ids-only / Item / mit Vektor.~~ **Entschieden (§5.2):** volles Item **ohne** Vektor (`selectableColumns` um `sqlType==='vector'` erweitern); Vektor nur im `ORDER BY`. Offen nur noch: der Escape-Hatch-Name für den Roh-Vektor-Read (`.matches({includeVector})` vs. `.withVector()`) — nicht v1-kritisch. +3. **Reader-Hydration** — `vector`-Text `'[…]'` → `number[]` parsen; wo im Row-Hydrator (nur relevant, wenn der Vektor via Escape-Hatch doch selektiert wird). +4. **Auto-Index default an/aus** — HNSW ist teuer zu bauen; Zero-Config-Default (Parität zu tsvector) vs. explizit-erforderlich. Vorschlag: Default **an**, opt-out via `{index:false}`. +5. **`embed`/`sensitivity`** — §7 Option A vs. B. +6. **Multi-Metrik pro Spalte** — mehrere ANN-Indizes (cosine + l2) auf einer Spalte? Selten; vorerst 1 Metrik/Spalte. +7. **ivfflat-`lists`-Param** — via `with:{lists}` abgedeckt; braucht `ANALYZE`-Hinweis in Doku (ivfflat baut erst nach Daten sinnvoll). +8. ~~**F5-`upsert`** — jetzt mitnehmen oder separater PR.~~ **Gebaut (§6):** nativer `ON CONFLICT DO UPDATE`, tenant-sicher, `upsert`/`upsertMany`, live-e2e verifiziert. diff --git a/SINGLE_TABLE_INHERITANCE_DESIGN.md b/SINGLE_TABLE_INHERITANCE_DESIGN.md new file mode 100644 index 00000000..d7db29bf --- /dev/null +++ b/SINGLE_TABLE_INHERITANCE_DESIGN.md @@ -0,0 +1,167 @@ +# Single-Table Inheritance (STI) for pylon-db + +**Status:** proposed · **Owner:** platform · **Depends on:** the schema analyzer's existing `extends → interface` support + +One-line: let a pylon-db model be **subclassed** (`class VideoAsset extends Asset`) so the base projects to a **GraphQL interface named after the class** (no `I` prefix) and each subclass to an **implementing type**, all backed by **one physical table + a discriminator column** — while the base **stays a usable ORM model** (`Asset.objects.get(id)` / `create`). No new tables, no data migration, non-breaking. + +--- + +## 1. Why + +Polymorphism is already a recurring need, and the codebase has hand-rolled **two different answers**: + +- **Asset** (`apps/files`): one `files_asset` table + a `type` enum (`FILE`/`FOLDER`/`EXTERNAL_VIDEO`), exposed via **plain view-classes** + an `Asset.content()` accessor. Boilerplate; not real managers. +- **Contact** (`apps/contacts`): one `Contact` table + a `type` enum (`ContactType`) plus **separate 1:1 profile tables** (`PersonProfile`, `OrganizationProfile`). Composition, not inheritance. + +Both want the same primitive: **one row-type that resolves to several GraphQL types by a discriminator, without giving up the base as a queryable model.** Build it once. + +```ts +// files: one files_asset table, discriminated by `type` +class Asset extends Model { … } // → interface Asset (base stays a usable ORM model) +class FileAsset extends Asset { … } // type === FILE → type FileAsset implements Asset +class FolderAsset extends Asset { … } // type === FOLDER → type FolderAsset implements Asset +class ExternalVideoAsset extends Asset { … } // type === EXTERNAL_VIDEO → type ExternalVideoAsset implements Asset + +// contacts: one contacts table, discriminated by `type` +class Contact extends Model { … } // → interface Contact +class Person extends Contact { … } // type === PERSON +class Organization extends Contact { … } // type === ORGANIZATION +``` +```graphql +asset(id: ID!) { id name url ... on ExternalVideoAsset { host embedUrl } } # asset(id): Asset +contact(id: ID!){ id displayName ... on Organization { vatId } ... on Person { firstName } } # : Contact +``` +```ts +Asset.objects.get(id) // → an ExternalVideoAsset / FileAsset instance +Asset.objects.create({ type: 'EXTERNAL_VIDEO', … }) // → a video row +``` + +--- + +## 2. Principles + +1. **One physical table + a discriminator — never per-type tables.** The existing table stays; it gains the subclasses' columns as **nullable** plus the discriminator (already `type`). Per-type tables would force a data migration, break single-target FKs (`Media.assetId → files_asset`), and turn "all assets" into cross-table UNIONs. + +2. **The base projects to `interface ` — no `I` prefix.** The `I` prefix in today's analyzer is a *conservative default*: for a class used both as a concrete return value **and** a supertype, it can't have two GraphQL types named `Asset`, so it emits `type Asset` + `interface IAsset`. An **STI declaration removes that ambiguity** — the base *is* the polymorphic root, so the framework projects it as `interface Asset` (the class name) and emits **no concrete `type Asset`**. See §3. + +3. **GraphQL-kind and ORM-usability are decoupled.** Projecting the base as a GraphQL *interface* does **not** make it an unusable/abstract ORM model. `Asset` stays a real manager: `Asset.objects.get(id)` (materialises the concrete subclass), `Asset.objects.create({ type, … })` (creates a subclass row), `.all()`, filters. This is the whole point — you keep the base *and* get the clean interface name. (The TS `abstract` keyword is irrelevant to STI naming.) + +4. **Every row resolves to a concrete implementer — never to the bare base.** GraphQL needs a concrete type per row. Two ways: + - **Subclass every discriminator value** (`FileAsset`/`FolderAsset`/`ExternalVideoAsset`) → every row resolves cleanly, nothing left over. The tidy path. + - **Leave some values un-subclassed** → the framework generates **one fallback implementer** with a derived name (e.g. `AssetDefault implements Asset`) for those rows. The base itself is never a concrete type. + +5. **Interfaces carry fields → non-breaking.** Put today's fields on the interface (`Asset.url`, `mimeType`, `size`, …); existing `asset { url mimeType }` keeps working (implementers expose interface fields directly). Only **new** kind-specific fields (`embedUrl`, `host`) live on the subtypes, behind `... on X`. Breaking happens only if you *move* a field off the interface — a choice, not a requirement. Since all columns are on the one shared table (nullable), every subtype trivially satisfies the interface's fields. + +6. **`__resolveType` and `.objects` scope by the discriminator column — not structurally.** Robust and O(1): the row's `type` value maps to the implementer; `ExternalVideoAsset.objects` auto-adds `WHERE type = 'EXTERNAL_VIDEO'`; creating one sets `type` automatically. + +--- + +## 3. Config API + +The base opts into STI and names its discriminator; each subclass declares its value. + +```ts +export enum AssetType { FILE, FOLDER, EXTERNAL_VIDEO } + +class Asset extends Model { + static objects = db.manager(Asset) // base stays a usable manager + static config = { + table: 'files_asset', + inheritance: { strategy: 'single-table', discriminator: 'type' }, + } satisfies ModelConfig + + id = id() + name = text() + type = enumOf(AssetType) // the discriminator column + mimeType = text({ nullable: true }) // shared/legacy → interface fields + size = id.Int?.({ nullable: true }) // (illustrative) shared column +} + +class ExternalVideoAsset extends Asset { + static objects = db.manager(ExternalVideoAsset) + static config = { discriminatorValue: AssetType.EXTERNAL_VIDEO } satisfies ModelConfig + externalUrl = text({ nullable: true }) // subclass columns — nullable on the shared table + host = enumOf(MediaHost, { nullable: true }) + embedUrl(): string { return derive(this.externalUrl!, this.host!) } // computed field +} + +class FileAsset extends Asset { static config = { discriminatorValue: AssetType.FILE }; /* url() … */ } +class FolderAsset extends Asset { static config = { discriminatorValue: AssetType.FOLDER }; /* itemCount() … */ } +``` + +Rules: +- `inheritance.strategy: "single-table"` is **distinct from the existing `abstract: true`** (mapped superclass — columns copied, no table). STI's base **owns** the table **and** is projected as an interface. +- `discriminator` references an existing enum/string column on the base. +- Every subclass sets a unique `discriminatorValue`; the framework asserts non-overlapping values, and either warns on gaps or generates a fallback implementer (§2.4). +- Subclass columns must be nullable (a row populates only its own kind's columns). +- Optional `inheritance.interface: "SomeName"` overrides the interface name if you don't want it to match the class (rarely needed). + +--- + +## 4. What the framework must do (layer by layer) + +Extension points, from the current source: + +| Layer | File(s) | Change | +|---|---|---| +| **Declare STI + merge columns** | `pylon-db/src/fields.ts`, `registry.ts` | Accept `inheritance`/`discriminatorValue`. Detect the STI group and compute the **merged column set** = base ∪ every subclass, subclass-only cols forced nullable (`registry.ts:268-285` already merges down the prototype chain — extend to union *siblings* onto one table). | +| **Base entity → interface, no `I`, no concrete type** | `pylon-db/src/ir.ts`, `pylon-ir/src/{merge.ts,sdl.ts}` | An STI base contributes an **interface entity** named after the class (not `I{Base}`) and **no** concrete object type; subclass entities carry `implements `. This overrides the analyzer's conservative `type Base` + `IBase` split for STI bases. Interface render: `sdl.ts:46-49`. | +| **One physical table, not N** | `pylon-db/src/schema-sync.ts` + migration diff/IR snapshot | Group entities by physical table; `createTable` once with the unioned columns + discriminator (`schema-sync.ts:57` emits one per def → would duplicate). The **IR snapshot must represent the merged table** so `pylon db diff` is stable (phantom-diff risk). | +| **Fallback implementer** | `pylon-db/src/ir.ts` | If not every discriminator value has a subclass, synthesise one concrete `type Default implements ` so no row resolves to the bare interface. | +| **Read/write scoping + materialisation** | `pylon-db/src/manager.ts` | `Sub.objects` auto-adds `WHERE = `; `Base.objects` spans the group **and stays usable** (`get`/`create`/`all`). On `create`, set the discriminator. On materialise, instantiate the concrete subclass (or the fallback) by discriminator value. | +| **`__resolveType` by discriminator** | `pylon/src/define-pylon.ts` (114-159), `schema-parser.ts` (496-583) | Resolve by the `type` column value → implementer name. | + +Already free from the analyzer: `class Sub extends Base` → interface + `implements` (`schema-parser.ts:258-376`); `merge.ts:87-114` folds `implements` onto ORM entities (as it already does for `SearchEntity`). STI only changes the **naming/kind of the base entity** (interface, class-named, no concrete type) and adds the **table-sharing** — the rest of the interface machinery is reused. + +--- + +## 5. Migration & DDL semantics + +- **No new tables, no data migration.** Adding a subclass adds its columns as **nullable** — a cheap online `ALTER TABLE ADD COLUMN`. +- **Column union.** The physical table = base ∪ all subclass cols. No two subclasses may declare the same column name with conflicting types. +- **Discriminator is a real `NOT NULL` column** (already `type`). +- **IR-snapshot correctness is the risk.** The diff-engine snapshot must show the *merged* table, or every `diff` re-proposes the same columns. Add an STI-aware IR test. +- **Constraints/indexes** on a subclass apply to the shared table; per-kind UNIQUE → a partial index (`WHERE type = …`). + +--- + +## 6. Query semantics + +```ts +await Asset.objects.all() // every kind +await ExternalVideoAsset.objects.all() // WHERE type = 'EXTERNAL_VIDEO' +await Asset.objects.create({ type: 'EXTERNAL_VIDEO', externalUrl, host }) // a video row (base manager usable) +await ExternalVideoAsset.objects.create({ externalUrl, host }) // type set automatically +const a = await Asset.objects.get({ id }) // → ExternalVideoAsset | FileAsset | … instance +a instanceof ExternalVideoAsset // true, per the row's discriminator +``` + +- **`Base.objects` stays fully usable** — read *and* write. `get`/`all`/filter materialise the concrete subclass; `create` needs the discriminator (either passed to the base manager or implied by a subclass manager). +- Relations pointing at the base (`Media.asset`) resolve to the interface; shared fields select directly, kind-specific via `... on X`. +- Filters on subclass-only columns are valid on that subclass's manager (or `... on X` in a query field). + +--- + +## 7. Edge cases & open questions + +- **Un-subclassed discriminator values** → resolve to the generated `Default` implementer (§2.4). Never to the bare interface. +- **Empty interface.** The base must expose ≥1 field or the SDL interface is invalid (`sdl.ts` already strips empty ones) — trivially true here. +- **Non-breaking check.** Keep every field currently on the flat type **on the interface** (§2.5); audit consumers before *moving* any field to a subtype. +- **`abstract: true` (mapped superclass) vs STI base** — distinct features (separate tables vs one shared). Registration must reject combining them on one base. +- **Nested inheritance** (`A extends B extends STIBase`): out of scope for v1 — one level. +- **Writes through the base manager:** `Asset.objects.create({ type, … })` is allowed (sets the discriminator, populates that kind's columns). A subclass manager is the typed shortcut. + +--- + +## 8. Rollout + +1. **`pylon-db` STI core:** config, column-union + one-table migration mapping, base-entity-as-interface (class-named, no `I`, no concrete type) + fallback implementer, discriminator scoping/materialisation, `__resolveType` by discriminator. Land behind tests (IR/DDL snapshot, migration diff, query scoping). The interface machinery itself is reused from the analyzer. +2. **Adopt in `files`:** `FileAsset`/`FolderAsset`/`ExternalVideoAsset extends Asset`. `Asset` → `interface Asset`; polymorphic access points return `Asset`; `Asset.objects.get/create` keep working; delete the view-class `content()`. Keep current fields on the interface → non-breaking. +3. **Adopt in `contacts`** (later): `Person`/`Organization extends Contact`, discriminated by the existing `ContactType`; optionally fold the profile tables into the contact table. + +## 9. Non-goals + +- Multi-table (per-subtype table) inheritance. +- Multi-level class hierarchies (v1 is one level). +- Union **input** types (unchanged; first-member-only in the analyzer). +- Changing how non-STI models map (one model = one type = one table stays the default; the `I`-prefix convention is unchanged for ordinary dual-use classes — STI is the opt-in that drops it). diff --git a/bench/README.md b/bench/README.md new file mode 100644 index 00000000..6dff0cdb --- /dev/null +++ b/bench/README.md @@ -0,0 +1,54 @@ +# pylon eval — usefulness bench + +A/B harness that measures whether an agent does better **with** the Pylon MCP than +without. Same task, same model, two arms (`with-mcp` / `baseline`), scored by the same +`pylon verify` verdict an agent would trust. + +## Run it + +```bash +# one-time: the harness drives a headless Claude via the Agent SDK +pnpm add -D @anthropic-ai/claude-agent-sdk # (uses your existing Claude auth) + +pylon eval # runs every scenario in ./bench, prints an A/B table +pylon eval --json # machine-readable report +pylon eval --keep # leave run workdirs (under /.eval-runs) for debugging +``` + +Without the SDK installed the command still runs and reports the missing-SDK error per +row, so the wiring is verifiable offline (and `vitest run test/eval-harness.test.ts` +exercises the full copy→run→score→aggregate plumbing with a fake runner). + +### Auth + +The headless agent needs to authenticate, one of: + +- **API key** — `export ANTHROPIC_API_KEY=sk-ant-...` (bills API credits). Or +- **Subscription** — `npm i -g @anthropic-ai/claude-code` then `claude` → `/login` + (browser OAuth). Writes `~/.claude/.credentials.json`, which the SDK reuses. + +A `Not logged in · Please run /login` runner-error on every row means neither is set. + +## A scenario + +One subfolder per task, each holding a `scenario.json`: + +```json +{ + "name": "add-author-bio", + "base": "../../e2e/fixtures/mcp-demo-app", + "prompt": "Add an optional `bio` field to Author, then verify clean.", + "expect": { "verdict": "pass", "entityHasField": ["Author", "bio"], "migrationCreated": true } +} +``` + +- `base` — the starting app, copied fresh per run (resolved relative to `scenario.json`). +- `expect` — declarative success check: required `verdict`, an `[entity, field]` that must + exist, and/or `migrationCreated`. Success = all expectations hold. + +## Growing the bank + +Per the design (dd/MCP_IR_TARGET.md §9, R7), the bench should grow to **15–20 +intentionally-broken apps** — missing migration, broken FK, drifted schema, mismatched +queue payload, widened authz — so usefulness and reasoning-path **regressions** become +hard numbers, not intuition. Drop a new folder with a `scenario.json` to add one. diff --git a/bench/add-author-bio/scenario.json b/bench/add-author-bio/scenario.json new file mode 100644 index 00000000..a6f3c7b8 --- /dev/null +++ b/bench/add-author-bio/scenario.json @@ -0,0 +1,10 @@ +{ + "name": "add-author-bio", + "base": "../../e2e/fixtures/mcp-demo-app", + "prompt": "Add an optional `bio` text field to the Author model (nullable, so existing rows stay valid). Then make sure the project verifies clean — generate a migration if one is needed.", + "expect": { + "verdict": "pass", + "entityHasField": ["Author", "bio"], + "migrationCreated": true + } +} diff --git a/bench/add-tags-field/scenario.json b/bench/add-tags-field/scenario.json new file mode 100644 index 00000000..b2ba3787 --- /dev/null +++ b/bench/add-tags-field/scenario.json @@ -0,0 +1,10 @@ +{ + "name": "add-tags-field", + "base": "../../e2e/fixtures/mcp-demo-app", + "prompt": "Add an optional `tags` text field to the Post model (nullable). Update the createPost mutation if needed so the project still type-checks, then make the project verify clean — generate a migration if one is needed.", + "expect": { + "verdict": "pass", + "entityHasField": ["Post", "tags"], + "migrationCreated": true + } +} diff --git a/docs/.gitignore b/docs/.gitignore index 1db06a40..9b915dba 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -1,6 +1,7 @@ -.next -node_modules +node_modules/ +.pylon +dist +.env +.env.local +*.tsbuildinfo .DS_Store -.vercel -!bun.lockb -!public diff --git a/docs/Dockerfile b/docs/Dockerfile new file mode 100644 index 00000000..a860248f --- /dev/null +++ b/docs/Dockerfile @@ -0,0 +1,82 @@ +# syntax=docker/dockerfile:1 +# +# Pylon docs (a Pylon + usePages server app) for Coolify. +# +# IMPORTANT: the build context MUST be the REPOSITORY ROOT, because the docs app +# depends on the workspace packages (`@getcronit/pylon`, `pylon-pages`, +# `pylon-query`, ...) via `workspace:*` and builds them FROM SOURCE — it never +# pulls them from npm. That is what keeps a docs deploy fully decoupled from the +# npm canary channel: this image never publishes anything. +# +# Local: docker build -f docs/Dockerfile -t pylon-docs . +# Coolify: Build Pack = Dockerfile +# Base Directory = / +# Dockerfile Location = /docs/Dockerfile +# Port = 3000 + +FROM node:20-slim AS base +ENV PNPM_HOME=/pnpm PATH=/pnpm:$PATH +ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 +RUN corepack enable +WORKDIR /app + +# ---- build: compile the workspace packages, then the docs app ---- +FROM base AS build +# Copy the root manifest FIRST so corepack pins pnpm from its `packageManager` +# field (pnpm@10). Without it, `pnpm fetch` runs before any package.json exists +# and corepack grabs the latest pnpm, which is incompatible with Node 20. +# The lockfile + workspace file let `pnpm fetch` populate the store from the +# lockfile alone, so this layer stays cached until dependencies change. +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm fetch +# Now bring the full monorepo — a workspace install/build needs every package. +COPY . . +RUN pnpm install --frozen-lockfile --offline +RUN pnpm build # builds ./packages/* dist (incl. pylon-dev's `pylon` CLI) +# pnpm couldn't link docs' `pylon` bin during install: @getcronit/pylon's dist is +# gitignored and only produced by `pnpm build` above, so the bin target didn't +# exist at install time (ENOENT skip). Invoke the freshly-built CLI directly — +# cwd = docs so `pylon build` picks up docs/pylon.config.ts and emits docs/.pylon. +RUN cd docs && node node_modules/@getcronit/pylon/dist/cli/index.js build +# Prune to a standalone deployment of JUST the docs app: its built files +# (.pylon, public, ...) plus a flat node_modules of only its PRODUCTION deps, +# with the workspace packages (@getcronit/pylon, pylon-pages, pylon-query, ...) +# copied in rather than symlinked. This is what keeps the runner image small — +# the whole monorepo + every dev dependency stays behind in the build stage. +# The env var satisfies pnpm 10's deploy gate for this one command (without +# switching the repo-wide install to injected mode); --legacy uses the +# copy-from-node_modules implementation, which matches our symlinked install. +RUN npm_config_inject_workspace_packages=true \ + pnpm --filter=@getcronit/pylon-docs deploy --legacy --prod /prod +# pnpm deploy selects files by npm-publish rules, which honor .gitignore — and +# docs/.gitignore excludes the built `.pylon` (docs has no `files` field, unlike +# the workspace deps whose `files:["dist"]` keeps their dist). Copy the built +# server entry into the pruned tree so the runner can start it. +RUN cp -r docs/.pylon /prod/.pylon + +# ---- runner: distroless (no shell / no package manager, runs as non-root) ---- +FROM gcr.io/distroless/nodejs20-debian12:nonroot AS runner +ENV NODE_ENV=production PORT=3000 +WORKDIR /app +# Copy ONLY the runtime closure, dropping all app source (pages/components/src/lib), +# build tooling and configs that `pnpm deploy` copied along: +# package.json — "type":"module" so .pylon/**/*.js load as ESM +# node_modules — pruned production deps +# .pylon — the built server (server.mjs) + client + baked public assets +# (pylon build copies public/ into .pylon/__pylon/static) +# content — markdown READ FROM cwd per request by src/lib/content.ts; +# NOT baked into the bundle, so it must ship +COPY --from=build --chown=65532:65532 /prod/package.json ./package.json +COPY --from=build --chown=65532:65532 /prod/node_modules ./node_modules +COPY --from=build --chown=65532:65532 /prod/.pylon ./.pylon +COPY --from=build --chown=65532:65532 /prod/content ./content +EXPOSE 3000 +# Health check. Distroless has no curl/wget, so probe with node's global fetch. +# Yoga serves a liveness endpoint at /health (200 "alive") as a global middleware +# BEFORE the usePages catch-all, so it's a cheap check that the server is up. +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD ["/nodejs/bin/node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] +# The distroless nodejs image's ENTRYPOINT is already `/nodejs/bin/node`, so CMD +# passes only args. server.mjs boots the config, mounts the GraphQL handler + +# usePages catch-all, then binds node:http (PORT || 3000). +CMD ["--enable-source-maps", ".pylon/server.mjs"] diff --git a/docs/Dockerfile.standalone b/docs/Dockerfile.standalone new file mode 100644 index 00000000..e1485023 --- /dev/null +++ b/docs/Dockerfile.standalone @@ -0,0 +1,63 @@ +# syntax=docker/dockerfile:1 +# +# Pylon docs — deployed via `pylon build --standalone` (the nft file-trace path). +# +# vs the sibling `Dockerfile` (which prunes with `pnpm deploy`): `--standalone` traces +# the ACTUAL runtime file graph, so the runner ships only the node_modules files the app +# really touches — no package manager needed in the runner, no manual pruning. It also +# copies the sharp native binaries + the usePages SSR route chunks (nft's blind spots are +# handled as explicit trace roots inside `pylon build --standalone`). +# +# Build context MUST be the REPO ROOT — docs depends on the workspace packages +# (`@getcronit/pylon`, ...) via `workspace:*` and builds them FROM SOURCE (never npm). +# +# docker build -f docs/Dockerfile.standalone -t pylon-docs-standalone . + +FROM node:22-slim AS base +ENV PNPM_HOME=/pnpm PATH=/pnpm:$PATH +ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 +RUN corepack enable +WORKDIR /app + +# ---- build: compile the workspace, the docs app, then trace the standalone artifact ---- +FROM base AS build +# Manifest first so corepack pins pnpm and `pnpm fetch` can populate the store from the +# lockfile alone (layer stays cached until deps change). +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm fetch +COPY . . +RUN pnpm install --frozen-lockfile --offline +# docs' only workspace dep is @getcronit/pylon (the framework + the `pylon` CLI) — build +# just that, not the whole monorepo (avoids unrelated packages' build scripts). +RUN pnpm --filter @getcronit/pylon build +# Build + trace. cwd = docs so the CLI reads docs/pylon.config.ts and emits docs/.pylon, +# then `--standalone` copies the traced closure into docs/.pylon/standalone. The trace runs +# HERE (linux/glibc), so the sharp binaries + deps it copies match the distroless runner. +# docs reads its markdown from `content/` at runtime (src/lib/content.ts) — nft can't trace +# runtime fs reads, so declare it with --include and the tracer copies it into the artifact. +RUN cd docs && node node_modules/@getcronit/pylon/dist/cli/index.js build --standalone --include content + +# ---- migrate: one-shot migrator (reuses the build stage — it has the CLI + models + +# migrations/, which the serve image deliberately drops). Run this ONCE per release, before +# rolling out the runner: `docker build --target migrate` then run with DATABASE_URL set. +# `db deploy` loads the models to verify the migrations match, then applies the pending ones. +FROM build AS migrate +WORKDIR /app/docs +CMD ["node", "node_modules/@getcronit/pylon/dist/cli/index.js", "db", "deploy"] + +# ---- runner: distroless (no shell / no package manager, runs as non-root) ---- +FROM gcr.io/distroless/nodejs22-debian12:nonroot AS runner +ENV NODE_ENV=production PORT=3000 +WORKDIR /app +# The standalone artifact is self-contained: app + traced node_modules subset (workspace +# packages copied in) + the --include'd content/. The monorepo trace base is the repo root, so +# it mirrors the repo layout under /app; the generated `start.mjs` chdir's into the app dir. +COPY --from=build --chown=65532:65532 /app/docs/.pylon/standalone ./ +EXPOSE 3000 +# Distroless has no curl/wget — probe with node's global fetch. Yoga serves /health (200) +# as a global middleware before the usePages catch-all. +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD ["/nodejs/bin/node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] +# The distroless nodejs ENTRYPOINT is `/nodejs/bin/node`, so CMD passes only args. The +# launcher chdir's into the app dir + imports the traced server.mjs (binds node:http). +CMD ["--enable-source-maps", "start.mjs"] diff --git a/docs/LICENSE b/docs/LICENSE deleted file mode 100644 index 490da1fe..00000000 --- a/docs/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2022 Shu Ding - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 8881f87b..00000000 --- a/docs/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# Nextra Docs Template - -This is a template for creating documentation with [Nextra](https://nextra.site). - -[**Live Demo →**](https://nextra-docs-template.vercel.app) - -[![](.github/screenshot.png)](https://nextra-docs-template.vercel.app) - -## Quick Start - -Click the button to clone this repository and deploy it on Vercel: - -[![](https://vercel.com/button)](https://vercel.com/new/clone?s=https%3A%2F%2Fgithub.com%2Fshuding%2Fnextra-docs-template&showOptionalTeamCreation=false) - -## Local Development - -First, run `pnpm i` to install the dependencies. - -Then, run `pnpm dev` to start the development server and visit localhost:3000. - -## License - -This project is licensed under the MIT License. diff --git a/docs/bun.lockb b/docs/bun.lockb deleted file mode 100755 index f6783358..00000000 Binary files a/docs/bun.lockb and /dev/null differ diff --git a/docs/components/architecture-diagram.tsx b/docs/components/architecture-diagram.tsx new file mode 100644 index 00000000..111994ff --- /dev/null +++ b/docs/components/architecture-diagram.tsx @@ -0,0 +1,110 @@ +import { + AppWindow, + Braces, + Database, + Globe, + KeyRound, + Network, + Server, + Workflow +} from 'lucide-react' + +/** + * "Anatomy of a Pylon app" — the big picture: one TypeScript codebase, + * projected by the compiler into an API, a database, and a frontend, backed by + * first-party services, all running as a single app on any runtime. + */ +export function ArchitectureDiagram() { + return ( +
+ {/* Input: your code */} +
+
+ + Your TypeScript +
+
+ resolvers · models · pages +
+
+ + {/* Compiler spine */} +
+
+
+ type-introspection compiler +
+
+
+ + {/* Fan-out line */} +
+ + + +
+ + {/* Derived: API / ORM / frontend */} +
+ {[ + { + icon: Network, + title: 'GraphQL API', + body: 'A real, introspectable schema — any client, with a playground.', + tag: 'schema + resolvers' + }, + { + icon: Database, + title: 'pylon-db ORM', + body: 'Models become tables. Policies and tenancy live at the data layer.', + tag: 'SQL + migrations' + }, + { + icon: AppWindow, + title: 'usePages', + body: 'Server-rendered React; each page fetches exactly what it renders.', + tag: 'typed client' + } + ].map(c => ( +
+
+ + {c.title} +
+

{c.body}

+
+ {c.tag} +
+
+ ))} +
+ + {/* Backed by */} +
+ + Backed by + + + PostgreSQL + + + Redis · Queues + + + OIDC · Auth + + + Remote APIs · Gateway + +
+ + {/* Runtime band */} +
+ One app, one deploy — runs on{' '} + Node · Bun · Deno · Cloudflare Workers +
+
+ ) +} diff --git a/docs/components/authors.tsx b/docs/components/authors.tsx deleted file mode 100644 index 4cd768c7..00000000 --- a/docs/components/authors.tsx +++ /dev/null @@ -1,22 +0,0 @@ -export default function Authors({date, children}) { - return ( -
- {date} by {children} -
- ) -} - -export function Author({name, link}) { - return ( - - - {name} - - - ) -} diff --git a/docs/components/callout.tsx b/docs/components/callout.tsx deleted file mode 100644 index 8a092108..00000000 --- a/docs/components/callout.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import clsx from 'clsx' - -import {Icon} from './icon' -import React from 'react' - -const styles = { - note: { - container: - 'bg-sky-50 dark:bg-slate-800/60 dark:ring-1 dark:ring-slate-300/10', - title: 'text-sky-900 dark:text-sky-400', - body: 'text-sky-800 [--tw-prose-background:theme(colors.sky.50)] prose-a:text-sky-900 prose-code:text-sky-900 dark:text-slate-300 dark:prose-code:text-slate-300' - }, - warning: { - container: - 'bg-amber-50 dark:bg-slate-800/60 dark:ring-1 dark:ring-slate-300/10', - title: 'text-amber-900 dark:text-amber-500', - body: 'text-amber-800 [--tw-prose-underline:theme(colors.amber.400)] [--tw-prose-background:theme(colors.amber.50)] prose-a:text-amber-900 prose-code:text-amber-900 dark:text-slate-300 dark:[--tw-prose-underline:theme(colors.sky.700)] dark:prose-code:text-slate-300' - } -} - -const icons = { - note: (props: Partial>) => ( - - ), - warning: (props: Partial>) => ( - - ) -} - -export const Callout: React.FC<{ - type: keyof typeof icons - title: string - children: React.ReactNode -}> = ({type = 'note', title, children}) => { - let IconComponent = icons[type] - - return ( -
- -
-

- {title} -

-
- {children} -
-
-
- ) -} diff --git a/docs/components/clipboard-button.tsx b/docs/components/clipboard-button.tsx deleted file mode 100644 index cc0c283b..00000000 --- a/docs/components/clipboard-button.tsx +++ /dev/null @@ -1,39 +0,0 @@ -'use client' - -import {useState} from 'react' -import {Copy, Check} from 'lucide-react' -import {useCopyToClipboard} from 'usehooks-ts' -import {Button, ButtonProps} from '@/components/ui/button' - -interface ClipboardButtonProps extends ButtonProps { - text: string -} - -export function ClipboardButton( - {text, ...rest}: ClipboardButtonProps = {text: 'Copy me!'} -) { - const [isCopied, setIsCopied] = useState(false) - const [_, copy] = useCopyToClipboard() - - const handleCopy = () => { - copy(text) - setIsCopied(true) - setTimeout(() => setIsCopied(false), 2000) - } - - return ( - - ) -} diff --git a/docs/components/code-panel.tsx b/docs/components/code-panel.tsx new file mode 100644 index 00000000..e2dc0d77 --- /dev/null +++ b/docs/components/code-panel.tsx @@ -0,0 +1,46 @@ +import {cn} from '@/lib/utils' + +/** + * A code "window" with chrome and a filename tab. Children are the code body + * (pre-formatted JSX, optionally with token spans). Presentational only. + */ +export function CodePanel({ + filename, + accent, + className, + children +}: { + filename: string + accent?: boolean + className?: string + children: React.ReactNode +}) { + return ( +
+
+ + + + {filename} +
+
+        {children}
+      
+
+ ) +} + +/** Token helpers for hand-highlighted snippets. */ +export const Tok = { + k: (c: React.ReactNode) => {c}, // keyword + s: (c: React.ReactNode) => {c}, // string + f: (c: React.ReactNode) => {c}, // function / type + t: (c: React.ReactNode) => {c}, // type name + c: (c: React.ReactNode) => {c}, // comment + p: (c: React.ReactNode) => {c} // property / number +} diff --git a/docs/components/comparison-table.tsx b/docs/components/comparison-table.tsx new file mode 100644 index 00000000..60c38b8b --- /dev/null +++ b/docs/components/comparison-table.tsx @@ -0,0 +1,66 @@ +import {Check, Minus, X} from 'lucide-react' + +type Cell = 'yes' | 'partial' | 'no' + +const COLUMNS = ['Pylon', 'tRPC', 'Pothos / Nexus', 'Hasura / PostGraphile', 'RedwoodJS'] + +const ROWS: {label: string; cells: Cell[]}[] = [ + {label: 'Write plain TypeScript (no schema DSL)', cells: ['yes', 'yes', 'no', 'partial', 'no']}, + {label: 'Real, introspectable GraphQL API', cells: ['yes', 'no', 'yes', 'yes', 'yes']}, + {label: 'Non-TypeScript / public clients', cells: ['yes', 'no', 'yes', 'yes', 'yes']}, + {label: 'Built-in ORM + migrations', cells: ['yes', 'no', 'no', 'partial', 'yes']}, + {label: 'Row-level policies & multi-tenancy', cells: ['yes', 'no', 'no', 'partial', 'no']}, + {label: 'Job queues built in', cells: ['yes', 'no', 'no', 'no', 'partial']}, + {label: 'Frontend with build-time data fetching', cells: ['yes', 'no', 'no', 'no', 'partial']}, + {label: 'Edge runtimes (Workers/Deno/Bun)', cells: ['yes', 'partial', 'partial', 'no', 'no']} +] + +function Mark({value}: {value: Cell}) { + if (value === 'yes') + return + if (value === 'partial') + return + return +} + +export function ComparisonTable() { + return ( +
+
+ + + + {COLUMNS.map((c, i) => ( + + ))} + + + + {ROWS.map((row, r) => ( + + + {row.cells.map((cell, i) => ( + + ))} + + ))} + +
Capability + {c} +
{row.label} + +
+
+ ) +} diff --git a/docs/components/counters.module.css b/docs/components/counters.module.css deleted file mode 100644 index 4a5d0c84..00000000 --- a/docs/components/counters.module.css +++ /dev/null @@ -1,6 +0,0 @@ -.counter { - border: 1px solid #ccc; - border-radius: 5px; - padding: 2px 6px; - margin: 12px 0 0; -} diff --git a/docs/components/counters.tsx b/docs/components/counters.tsx deleted file mode 100644 index 2d0bc6ea..00000000 --- a/docs/components/counters.tsx +++ /dev/null @@ -1,24 +0,0 @@ -// Example from https://beta.reactjs.org/learn - -import {useState} from 'react' -import styles from './counters.module.css' - -function MyButton() { - const [count, setCount] = useState(0) - - function handleClick() { - setCount(count + 1) - } - - return ( -
- -
- ) -} - -export default function MyApp() { - return -} diff --git a/docs/components/docs/enhancers.ts b/docs/components/docs/enhancers.ts new file mode 100644 index 00000000..093d1e88 --- /dev/null +++ b/docs/components/docs/enhancers.ts @@ -0,0 +1,61 @@ +import {useEffect} from 'react' + +/** + * Client-side enhancers for the rendered markdown (which is injected as HTML, + * so it isn't managed by React): + * - copy-to-clipboard buttons on code blocks + * - scroll-spy that highlights the active heading in the table of contents + * + * Pass the current slug so the effect re-runs on client-side navigation. + */ +export function useDocsEnhancers(slug: string) { + useEffect(() => { + // --- copy buttons --- + const onClick = (e: Event) => { + const target = e.target as HTMLElement + const btn = target.closest('.code-copy') as HTMLElement | null + if (!btn) return + const pre = btn.parentElement?.querySelector('pre') + const text = pre instanceof HTMLElement ? pre.innerText : '' + navigator.clipboard?.writeText(text).then(() => { + btn.classList.add('copied') + window.setTimeout(() => btn.classList.remove('copied'), 1400) + }) + } + document.addEventListener('click', onClick) + + // --- scroll-spy --- + const headings = Array.from( + document.querySelectorAll('.prose h2[id], .prose h3[id], .prose h4[id]') + ) + const links = new Map() + document.querySelectorAll('[data-toc] a').forEach(a => { + const id = a.getAttribute('href')?.slice(1) + if (id) links.set(id, a) + }) + + let frame = 0 + const setActive = () => { + frame = 0 + if (!headings.length) return + let current = headings[0].id + for (const h of headings) { + if (h.getBoundingClientRect().top <= 104) current = h.id + else break + } + links.forEach((a, id) => a.classList.toggle('toc-active', id === current)) + } + const onScroll = () => { + if (!frame) frame = window.requestAnimationFrame(setActive) + } + + setActive() + window.addEventListener('scroll', onScroll, {passive: true}) + + return () => { + document.removeEventListener('click', onClick) + window.removeEventListener('scroll', onScroll) + if (frame) cancelAnimationFrame(frame) + } + }, [slug]) +} diff --git a/docs/components/docs/mobile-docs-nav.tsx b/docs/components/docs/mobile-docs-nav.tsx new file mode 100644 index 00000000..c6624b12 --- /dev/null +++ b/docs/components/docs/mobile-docs-nav.tsx @@ -0,0 +1,70 @@ +import {useEffect, useState} from 'react' +import {createPortal} from 'react-dom' +import {PanelLeft, X} from 'lucide-react' +import {Sidebar, type NavSection} from './sidebar' + +/** + * On narrow screens the docs sidebar is hidden, leaving no way to move between + * pages. This renders a toggle that opens the full navigation tree in a drawer. + */ +export function MobileDocsNav({ + nav, + currentPath +}: { + nav: NavSection[] + currentPath: string +}) { + const [open, setOpen] = useState(false) + + // Close when navigation completes (currentPath changes) and on Escape. + useEffect(() => setOpen(false), [currentPath]) + useEffect(() => { + const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false) + window.addEventListener('keydown', onKey) + if (open) document.body.style.overflow = 'hidden' + return () => { + window.removeEventListener('keydown', onKey) + document.body.style.overflow = '' + } + }, [open]) + + return ( +
+ + + {open && + typeof document !== 'undefined' && + createPortal( +
+
setOpen(false)} + /> +
+
+ Documentation + +
+
+ +
+
+
, + document.body + )} +
+ ) +} diff --git a/docs/components/docs/pager.tsx b/docs/components/docs/pager.tsx new file mode 100644 index 00000000..56834605 --- /dev/null +++ b/docs/components/docs/pager.tsx @@ -0,0 +1,42 @@ +import {Link} from '@getcronit/pylon/pages' +import {ArrowLeft, ArrowRight} from 'lucide-react' + +export interface PagerLink { + slug: string + title: string +} + +export function Pager({prev, next}: {prev: PagerLink | null; next: PagerLink | null}) { + return ( +
+
+ {prev && ( + + + Previous + + + {prev.title} + + + )} +
+
+ {next && ( + + + Next + + + {next.title} + + + )} +
+
+ ) +} diff --git a/docs/components/docs/sidebar.tsx b/docs/components/docs/sidebar.tsx new file mode 100644 index 00000000..0b5561fc --- /dev/null +++ b/docs/components/docs/sidebar.tsx @@ -0,0 +1,73 @@ +import {Link} from '@getcronit/pylon/pages' +import { + AppWindow, + Boxes, + Braces, + Database, + GraduationCap, + KeyRound, + Rocket, + Sparkles, + Terminal, + Workflow, + type LucideIcon +} from 'lucide-react' + +export interface NavItem { + slug: string + title: string +} +export interface NavSection { + title: string + items: NavItem[] +} + +const SECTION_ICONS: Record = { + Introduction: Sparkles, + 'Core Concepts': Braces, + 'Data — pylon-db': Database, + Authentication: KeyRound, + Apps: Boxes, + 'Frontend — usePages': AppWindow, + 'Background Jobs': Workflow, + Production: Rocket, + Guides: GraduationCap, + Reference: Terminal +} + +export function Sidebar({nav, currentPath}: {nav: NavSection[]; currentPath: string}) { + return ( + + ) +} diff --git a/docs/components/docs/toc.tsx b/docs/components/docs/toc.tsx new file mode 100644 index 00000000..a174ef48 --- /dev/null +++ b/docs/components/docs/toc.tsx @@ -0,0 +1,27 @@ +export interface TocHeading { + depth: number + id: string + text: string +} + +export function Toc({headings}: {headings: TocHeading[]}) { + if (headings.length === 0) return null + return ( + + ) +} diff --git a/docs/components/features.module.css b/docs/components/features.module.css deleted file mode 100644 index 3224c0f5..00000000 --- a/docs/components/features.module.css +++ /dev/null @@ -1,45 +0,0 @@ -.features { - display: grid; - grid-template-columns: 1fr 1fr 1fr 1fr; - gap: 1rem 2rem; - margin: 2.5rem 0 2rem; - } - .feature { - align-items: center; - display: inline-flex; - } - .feature h4 { - margin: 0 0 0 0.5rem; - font-weight: 700; - font-size: 1.1rem; - white-space: nowrap; - } - @media (max-width: 860px) { - .features { - gap: 1rem 0.5rem; - } - .feature { - padding-left: 0; - justify-content: center; - } - .feature svg { - width: 20px; - } - .feature h4 { - font-size: 0.9rem; - } - } - @media (max-width: 660px) { - .features { - grid-template-columns: 1fr 1fr; - } - } - @media (max-width: 370px) { - .feature h4 { - font-size: 0.8rem; - } - .feature svg { - width: 16px; - stroke-width: 2.5px; - } - } \ No newline at end of file diff --git a/docs/components/features.tsx b/docs/components/features.tsx deleted file mode 100644 index ad6b6ab0..00000000 --- a/docs/components/features.tsx +++ /dev/null @@ -1,230 +0,0 @@ -import {useId} from 'react' -import styles from './features.module.css' - -// import BackendAgnosticIcon from "../components/icons/backend-agnostic"; -// import LightweightIcon from "../components/icons/lightweight"; -// import PaginationIcon from "../components/icons/pagination"; -// import RealtimeIcon from "../components/icons/realtime"; -// import RemoteLocalIcon from "../components/icons/remote-local"; -// import RenderingStrategiesIcon from "../components/icons/rendering-strategies"; -// import SuspenseIcon from "../components/icons/suspense"; -// import TypeScriptIcon from "../components/icons/typescript"; - -import {Icon} from './icon' - -export function Feature({text, icon}) { - return ( -
- {icon} -

{text}

-
- ) -} - -/** @type {{ key: string; icon: React.FC }[]} */ -const FEATURES_LIST = [ - { - key: 'realtimeSchema', - icon: ( - - - - - - - - ) - }, - { - key: 'typeSafety', - icon: ( - - - - - - ) - }, - { - key: 'authentication', - icon: ( - - - - - - - ) - }, - { - key: 'authorization', - icon: ( - - - - - ) - }, - { - key: 'runtimes', - icon: ( - - - - - - - ) - }, - { - key: 'errorTracking', - icon: ( - - - - - - - - - - - - - ) - }, - { - key: 'databaseIntegration', - icon: ( - - - - - ) - }, - { - key: 'optimizedDeployment', - icon: ( - - - - - - - - - - - - - ) - } -] - -export default function Features() { - const keyId = useId() - - const features = { - realtimeSchema: 'Real-time Schema', - typeSafety: 'Type Safety', - authentication: 'OIDC Auth', - authorization: 'Role-Based', - runtimes: 'Multiple Runtimes', - errorTracking: 'Error Tracking', - databaseIntegration: 'Prisma Integration', - optimizedDeployment: 'Docker Ready' - } - - return ( -
-

- A code-first approach to GraphQL API development -

-
- {FEATURES_LIST.map(({key, icon}) => ( - - ))} -
-
- ) -} diff --git a/docs/components/icon.tsx b/docs/components/icon.tsx deleted file mode 100644 index 2f1e7291..00000000 --- a/docs/components/icon.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import {SVGProps, useId} from 'react' -import clsx from 'clsx' - -import {InstallationIcon} from './icons/InstallationIcon' -import {LightbulbIcon} from './icons/LightbulbIcon' -import {PluginsIcon} from './icons/PluginsIcon' -import {PresetsIcon} from './icons/PresetsIcon' -import {ThemingIcon} from './icons/ThemingIcon' -import {WarningIcon} from './icons/WarningIcon' - -const icons = { - installation: InstallationIcon, - presets: PresetsIcon, - plugins: PluginsIcon, - theming: ThemingIcon, - lightbulb: LightbulbIcon, - warning: WarningIcon -} - -export type Icons = keyof typeof icons - -const iconStyles = { - blue: '[--icon-foreground:theme(colors.slate.900)] [--icon-background:theme(colors.white)]', - amber: - '[--icon-foreground:theme(colors.amber.900)] [--icon-background:theme(colors.amber.100)]' -} - -export const Icon: React.FC< - SVGProps & { - color?: keyof typeof iconStyles - icon: keyof typeof icons - } -> = ({color = 'blue', icon, className, ...props}) => { - let id = useId() - let IconComponent = icons[icon] - - return ( - - ) -} - -const gradients = { - blue: [ - {stopColor: '#0EA5E9'}, - {stopColor: '#22D3EE', offset: '.527'}, - {stopColor: '#818CF8', offset: 1} - ], - amber: [ - {stopColor: '#FDE68A', offset: '.08'}, - {stopColor: '#F59E0B', offset: '.837'} - ] -} - -export const Gradient: React.FC< - SVGProps & { - color: keyof typeof gradients - } -> = ({color = 'blue', ...props}) => { - return ( - - {gradients[color].map((stop, stopIndex) => ( - - ))} - - ) -} - -export const LightMode: React.FC> = ({ - className, - ...props -}) => { - return -} - -export const DarkMode: React.FC> = ({ - className, - ...props -}) => { - return -} diff --git a/docs/components/icons/InstallationIcon.tsx b/docs/components/icons/InstallationIcon.tsx deleted file mode 100644 index 954dcf1c..00000000 --- a/docs/components/icons/InstallationIcon.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import {DarkMode, Gradient, LightMode} from '../icon' - -export const InstallationIcon: React.FC<{ - id: string - color: 'blue' | 'amber' -}> = ({id, color}) => { - return ( - <> - - - - - - - - - - - - - ) -} diff --git a/docs/components/icons/LightbulbIcon.tsx b/docs/components/icons/LightbulbIcon.tsx deleted file mode 100644 index e73978cd..00000000 --- a/docs/components/icons/LightbulbIcon.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import {DarkMode, Gradient, LightMode} from '../icon' - -export const LightbulbIcon: React.FC<{ - id: string - color: 'blue' | 'amber' -}> = ({id, color}) => { - return ( - <> - - - - - - - - - - - - - - - ) -} diff --git a/docs/components/icons/PluginsIcon.tsx b/docs/components/icons/PluginsIcon.tsx deleted file mode 100644 index 9902393d..00000000 --- a/docs/components/icons/PluginsIcon.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import {DarkMode, Gradient, LightMode} from '../icon' - -export const PluginsIcon: React.FC<{ - id: string - color: 'blue' | 'amber' -}> = ({id, color}) => { - return ( - <> - - - - - - - - - - - - - - - - - - - - - - - ) -} diff --git a/docs/components/icons/PresetsIcon.tsx b/docs/components/icons/PresetsIcon.tsx deleted file mode 100644 index 53d34f2f..00000000 --- a/docs/components/icons/PresetsIcon.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import {DarkMode, Gradient, LightMode} from '../icon' - -export const PresetsIcon: React.FC<{ - id: string - color: 'blue' | 'amber' -}> = ({id, color}) => { - return ( - <> - - - - - - - - - - - - - - - - - - - ) -} diff --git a/docs/components/icons/ThemingIcon.tsx b/docs/components/icons/ThemingIcon.tsx deleted file mode 100644 index a3d9d81b..00000000 --- a/docs/components/icons/ThemingIcon.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import {DarkMode, Gradient, LightMode} from '../icon' - -export const ThemingIcon: React.FC<{ - id: string - color: 'blue' | 'amber' -}> = ({id, color}) => { - return ( - <> - - - - - - - - - - - - - - - - ) -} diff --git a/docs/components/icons/WarningIcon.tsx b/docs/components/icons/WarningIcon.tsx deleted file mode 100644 index 83d52387..00000000 --- a/docs/components/icons/WarningIcon.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import {DarkMode, Gradient, LightMode} from '../icon' - -export const WarningIcon: React.FC<{ - id: string - color: 'blue' | 'amber' -}> = ({id, color}) => { - return ( - <> - - - - - - - - - - - - - - - ) -} diff --git a/docs/components/landing.tsx b/docs/components/landing.tsx deleted file mode 100644 index 72d590d5..00000000 --- a/docs/components/landing.tsx +++ /dev/null @@ -1,448 +0,0 @@ -import {useState, useEffect} from 'react' -import Link from 'next/link' -import Image from 'next/image' -import { - ArrowRight, - Code2, - Lock, - Zap, - Cloud, - Terminal, - Box, - Puzzle, - Layers, - Copy, - ArrowUpRight, - CheckCircle -} from 'lucide-react' -import Lottie from 'lottie-react' - -import {Button} from '@/components/ui/button' -import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card' -import {ClipboardButton} from './clipboard-button' -import {HoverBorderGradient} from './ui/hover-border-gradient' -import { - GlowingStarsCard, - GlowingStarsDescription, - GlowingStarsTitle -} from './ui/glowing-stars-card' -import {cn} from '@lib/utils' -import {useTheme} from 'nextra-theme-docs' - -const GradientBackground = ({children, className = ''}) => ( -
-
- {children} -
-) - -const PatternBackground = ({pattern, className = ''}) => ( -
-) - -const FeatureCard = ({title, description, icon, pattern}) => ( - - - -
-
{icon}
-
-
-
-

{title}

-

{description}

-
-
-) - -const TechnologyCard = ({title, description, logo, link}) => ( -
-
- {`${title} -
-
-

{title}

- {/* */} -
-

{description}

- - Learn more - - -
-) - -const RuntimeCard = ({title, description, logo, link, logoClassName = ''}) => ( - - - -
- {`${title} -
- {title} -
-
- -

{description}

- - Learn more - - -
-
-) - -const patterns = { - dots: ``, - lines: ``, - squares: ``, - circles: ``, - zigzag: ``, - waves: ``, - triangles: ``, - hexagons: `` -} - -export function Landing() { - const {resolvedTheme} = useTheme() - const [animationData, setAnimationData] = useState(null) - - useEffect(() => { - fetch( - 'https://lottie.host/c32b1c68-74ef-4a25-91b2-11f792317480/we8GALnU48.json' - ) - .then(response => response.json()) - .then(data => setAnimationData(data)) - .catch(error => console.error('Error loading Lottie animation:', error)) - }, []) - - return ( -
- {/* Hero */} -
-

- The Next Generation of -
- Building APIs -

-

- Used by innovative teams worldwide, Pylon enables you to create - - {' '} - high-quality GraphQL APIs{' '} - - without defining any schema. -

- - - Get started - - - - {/* */} -
-
-
-
-
-
-
-
- - - Copy code - -
-
-
-                
-                  npm create
-                  pylon@latest
-                
-              
-
-
-
-
- - {/* Features */} -
-
-
-

- What's in Pylon? -

-

- Everything you need to build production-ready GraphQL APIs. -

-
-
- } - pattern={patterns.dots} - /> - } - pattern={patterns.lines} - /> - } - pattern={patterns.squares} - /> - } - pattern={patterns.circles} - /> - } - pattern={patterns.zigzag} - /> - } - pattern={patterns.waves} - /> - } - pattern={patterns.triangles} - /> - } - pattern={patterns.hexagons} - /> - - - - Pylon 2.3 - -
- - Full Support for TypeScript Interfaces and Unions in Pylon - - -
-
- -
-
-
- - {/* Foundation / Powered By */} -
-
-

- Built on a Foundation of Fast, Production-Grade Tooling -

- -
- {/* Powered By Box with Lottie Animation */} -
-

- Powered By -

- {animationData && ( - - )} -
- - {/* Technology Cards */} -
- - - -
- - {/* Supported Runtimes */} -
-

- Supported Runtimes -

-
- - - - -
-

- Pylon is designed to be runtime-agnostic, allowing you to deploy - your GraphQL API to various environments. Choose the runtime - that best fits your project's needs and infrastructure - requirements. -

-
-
-
-
- - {/* Improved CTA */} -
-
-
-
-

- Start Building Powerful APIs Today -

-

- Join hundreds of developers who are revolutionizing API - development with Pylon. -

-
- -
- - - - Documentation - - - -

- Explore our comprehensive documentation to get started with - Pylon and learn about all its features. -

- -
-
- - - - Open Source - - - -

- Pylon is open source. Contribute, report issues, or star our - GitHub repository to support the project. -

- -
-
-
- -
-
- "Pylon is the foundation of our greater vision to make backend - development easier and faster. It's revolutionizing how we build - and scale APIs." -
-
- Nico Schett -
-

Nico Schett

-

CEO, Cronit

-
-
-
-
-
-
-
- ) -} diff --git a/docs/components/logo.tsx b/docs/components/logo.tsx index 6e73f4dc..466e3219 100644 --- a/docs/components/logo.tsx +++ b/docs/components/logo.tsx @@ -1,107 +1,37 @@ -import {cn} from '@lib/utils' +import {cn} from '@/lib/utils' -const Logo: React.FC<{className?: string}> = props => { +/** + * Pylon logo — the brand mark: a "gateway" (two tapered towers + a lintel forming a doorway), + * the architectural pylon the name comes from, in the signature cyan→violet gradient and paired + * with the wordmark. It's literal to the name, reads as a gateway to your API, and stays crisp + * at any size — vector, so it themes cleanly unlike the raster `public/logo.png`. + */ +export function Logo({className, withText = true}: {className?: string; withText?: boolean}) { return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + {withText && ( + Pylon + )} + ) } - -export default Logo diff --git a/docs/components/mega-nav.tsx b/docs/components/mega-nav.tsx new file mode 100644 index 00000000..16e5d929 --- /dev/null +++ b/docs/components/mega-nav.tsx @@ -0,0 +1,170 @@ +import {useEffect, useRef, useState} from 'react' +import {Link} from '@getcronit/pylon/pages' +import { + AppWindow, + Boxes, + Braces, + ChevronDown, + Database, + KeyRound, + Network, + Rocket, + Server, + Sparkles, + Workflow, + Wand2, + type LucideIcon +} from 'lucide-react' + +export interface MenuItem { + title: string + desc: string + href: string + icon: LucideIcon +} +export interface MenuColumn { + heading: string + items: MenuItem[] +} + +export const COLUMNS: MenuColumn[] = [ + { + heading: 'Get started', + items: [ + {title: 'Introduction', desc: 'What Pylon is', href: '/docs/introduction', icon: Sparkles}, + {title: 'Why Pylon', desc: 'One source of truth', href: '/docs/why-pylon', icon: Sparkles}, + {title: 'How It Works', desc: 'The compiler model', href: '/docs/how-pylon-works', icon: Braces}, + {title: 'Getting Started', desc: 'Your first API', href: '/docs/getting-started', icon: Rocket} + ] + }, + { + heading: 'Core Concepts', + items: [ + {title: 'Type-Driven Schema', desc: 'Types become the API', href: '/docs/core-concepts/type-driven-schema', icon: Braces}, + {title: 'Resolvers', desc: 'Queries & mutations', href: '/docs/core-concepts/resolvers', icon: Server}, + {title: 'The Pylon App', desc: 'Compose & serve', href: '/docs/core-concepts/the-pylon-app', icon: Boxes}, + {title: 'Gateway', desc: 'Stitch remote APIs', href: '/docs/core-concepts/gateway', icon: Network} + ] + }, + { + heading: 'Data & Access', + items: [ + {title: 'ORM Overview', desc: 'Models as classes', href: '/docs/data/overview', icon: Database}, + {title: 'Querying', desc: 'Filter & paginate', href: '/docs/data/queries', icon: Database}, + {title: 'Authentication', desc: 'Identity & roles', href: '/docs/authentication/overview', icon: KeyRound}, + {title: 'Policies', desc: 'Row-level access', href: '/docs/data/policies', icon: KeyRound} + ] + }, + { + heading: 'Frontend & Ship', + items: [ + {title: 'usePages', desc: 'Server-rendered React', href: '/docs/frontend/overview', icon: AppWindow}, + {title: 'useData', desc: 'Auto-generated queries', href: '/docs/frontend/use-data', icon: Wand2}, + {title: 'Background Jobs', desc: 'Queues & cron', href: '/docs/queues/overview', icon: Workflow}, + {title: 'Deployment', desc: 'Node, Bun, Workers', href: '/docs/production/deployment', icon: Rocket} + ] + } +] + +export function MegaNav() { + const [open, setOpen] = useState(false) + const ref = useRef(null) + + // Close on Escape (keyboard) or a click outside the menu (touch / click users). + useEffect(() => { + const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false) + const onPointer = (e: PointerEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) + } + window.addEventListener('keydown', onKey) + document.addEventListener('pointerdown', onPointer) + return () => { + window.removeEventListener('keydown', onKey) + document.removeEventListener('pointerdown', onPointer) + } + }, []) + + return ( +
setOpen(true)} + onMouseLeave={() => setOpen(false)}> + + + {open && ( +
+
+
+ {COLUMNS.map(col => ( +
+
+ {col.heading} +
+
    + {col.items.map(item => ( +
  • + setOpen(false)} + className="group flex items-start gap-2.5 rounded-lg px-2 py-1.5 transition hover:bg-bg-subtle"> + + + + {item.title} + + + {item.desc} + + + +
  • + ))} +
+
+ ))} +
+ +
+ setOpen(false)} + className="font-medium text-accent transition hover:text-accent-strong"> + Start the quickstart → + + · + setOpen(false)} + className="text-fg-muted transition hover:text-fg"> + Build an app + + setOpen(false)} + className="text-fg-muted transition hover:text-fg"> + CLI reference + +
+
+
+ )} +
+ ) +} diff --git a/docs/components/mobile-nav.tsx b/docs/components/mobile-nav.tsx new file mode 100644 index 00000000..5f31306e --- /dev/null +++ b/docs/components/mobile-nav.tsx @@ -0,0 +1,122 @@ +import {useEffect, useState} from 'react' +import {createPortal} from 'react-dom' +import {Link} from '@getcronit/pylon/pages' +import {Github, Menu, X} from 'lucide-react' +import {COLUMNS} from './mega-nav' + +const FLAT_LINKS = [ + {href: '/docs/guides/build-an-app', label: 'Guides'}, + {href: '/docs/reference/cli', label: 'Reference'} +] + +/** Hamburger + slide-over menu for narrow screens (the desktop nav is hidden < md). */ +export function MobileNav() { + const [open, setOpen] = useState(false) + + // Lock body scroll while the drawer is open, and close on Escape. + useEffect(() => { + const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false) + window.addEventListener('keydown', onKey) + if (open) document.body.style.overflow = 'hidden' + return () => { + window.removeEventListener('keydown', onKey) + document.body.style.overflow = '' + } + }, [open]) + + return ( +
+ + + {open && + typeof document !== 'undefined' && + createPortal( + // Portal to : the header's `backdrop-blur` is a containing block for + // fixed-positioned descendants, which would otherwise clip this overlay. +
+ {/* backdrop */} +
setOpen(false)} + /> + {/* panel */} +
+
+ Menu + +
+ +
+ {COLUMNS.map(col => ( +
+
+ {col.heading} +
+
    + {col.items.map(item => ( +
  • + setOpen(false)} + className="group flex items-center gap-2.5 rounded-lg px-2 py-2 transition hover:bg-bg-subtle"> + + {item.title} + +
  • + ))} +
+
+ ))} + +
+ {FLAT_LINKS.map(l => ( + setOpen(false)} + className="rounded-lg px-2 py-2 text-sm font-medium text-fg-muted transition hover:bg-bg-subtle hover:text-fg"> + {l.label} + + ))} +
+
+ +
+ setOpen(false)} + className="flex-1 rounded-md bg-accent px-4 py-2 text-center text-sm font-semibold text-accent-foreground transition hover:bg-accent-strong"> + Get started + + + + +
+
+
, + document.body + )} +
+ ) +} diff --git a/docs/components/page-preview-card.tsx b/docs/components/page-preview-card.tsx deleted file mode 100644 index bb61601d..00000000 --- a/docs/components/page-preview-card.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import {MdxFile, Meta} from 'nextra' -import {Link} from 'nextra-theme-docs' -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader -} from '@/components/ui/card' -import {Badge} from '@/components/ui/badge' - -export const PagePreviewCard: React.FC<{ - page: MdxFile & {meta?: Exclude} -}> = ({page}) => { - return ( - - {page.frontMatter?.new && ( - - 🔥 New - - )} - -

- - - {page.meta.title || page.frontMatter?.title || page.name} - -

- - {page.frontMatter?.date} - -
- - {page.frontMatter?.description} - - -

- Read more - -

-
-
- ) -} diff --git a/docs/components/playground.tsx b/docs/components/playground.tsx deleted file mode 100644 index e40f1fb0..00000000 --- a/docs/components/playground.tsx +++ /dev/null @@ -1,16 +0,0 @@ -export const Playground: React.FC = () => { - return ( -
-