Landing page for StarDust, a MySQL & MariaDB-native Vertical Schema Partitioning engine for dynamic data models.
The engine is abstract — most of what makes it interesting happens in background daemons, over time, in tables nobody sees. So the page is built around four interactive demonstrations rather than prose:
| Section | What it shows |
|---|---|
components/SlotMirror.tsx |
Edit a payload, flip fields between filterable and JSON-only, and watch values mirror into typed indexed slot columns — then see what a filter on each field actually costs. |
components/JoinSwamp.tsx |
An illustrative cost model of EAV join fan-out against StarDust's fixed single-page join, as the number of filter conditions grows. |
components/FieldLifecycle.tsx |
The promotion window: promoteFieldToFilterable() returns → the Watcher provisions a page → the Reconciler claims the slot and backfills → it flips to ready, with a live NDJSON event stream. Mirrors examples/01-field-lifecycle.php in the engine repo. |
components/DaemonBoard.tsx |
All four daemons running on their own poll periods, coordinating only through shared MySQL state. |
/playground (app/playground/,
components/playground/) is a second route: one
continuous world where the schema you define produces the rows you write, which
produce the index the daemons build. Unlike the four sections above, which each
reset on their own and share nothing, every part of it reads the state the
previous part produced — so it needs a persistent world with a clock, which is
what lib/sim/ is.
It opens guided on a first visit: a sixteen-step walk through the whole arc —
define, store, write, index, query, evolve — that dispatches the same actions a
visitor dispatches by hand, one commit per press, and hands the world it built
over to the sandbox at the end. The toggle in the page header switches between
the two at any point; they are two modes over one world, never two routes.
lib/sim/tour.ts holds the steps and, beside each one's copy,
an assertion for every number and event name that copy says out loud.
It is a simulation and says so on the page. There is no Node runtime, no PHP and no MySQL in production, so it cannot run the real engine. The rules were written by hand to match; where the two disagree, the engine is right.
Two conventions hold that honesty in place, and both are cheaper to keep than to restore:
-
Engine semantics live only in
lib/sim/. No component encodes a rule about slots, statuses or daemons. If a component needs to know whether a filter would be rejected, it asks the core. -
Event names, operators and error codes are closed unions.
lib/sim/events.tsmirrors the engine's closed event vocabulary andlib/sim/filter/ast.tsits twelve operators and thirteen validation codes, so an invented name in either is anpm run typecheckfailure rather than a plausible-looking string in a log panel or a dropdown. Both are checked-in copies — the engine is a separate repository and nothing here can verify them — so when the engine adds one, add it there in the same change. The mirrors that are behaviour rather than text can be checked against the engine directly, and two have been. A 75-case corpus run through the realJsonFilterDecoderagrees with the decoder on error code and JSON Pointer for every case, including three where PHP's inability to tell{}from[]decides the answer. A 96-case corpus run through the realRetypeCoercionEngineagrees with the ADR 0024 matrix inlib/sim/backfill.tson outcome and reason for every case; the only divergences are nine values where JavaScript's single number type cannot represent PHP's distinction ((float) 42is42.0there and42here) or loses integer precision above 2^53. -
Reducers are pure. No
new Date(), noMath.random(), no module-level counters: timestamps come fromsimNow(world)and ids fromworld.seq. React StrictMode double-invokes reducers, so anything else builds a different world in development than in production — and a random seed generator would produce rows the event log then describes wrongly. -
Where the simulation is narrower than the engine, it says so on the page. The datetime parser takes ISO 8601 and
Y-m-d H:i:s; the engine hands the value to PHP'sDateTimeImmutable, which also acceptstomorrowand reads a naked string in the server's timezone. Reproducing that in a browser would mean guessing at a server configuration and rendering the guess as fact, so it is a documented subset instead. The one narrowing that is a language limit rather than a choice is in the filter decoder'sindeduplication: PHP distinguishes1from1.0and JavaScript does not, so the engine keeps both and the simulation collapses them. -
Where the simulation is wider than the engine — because the engine has a bug — it says so at the line that causes it. There are two, and this is the only kind of divergence that needs naming rather than merely documenting, because reproducing it faithfully would make a demo teach the defect:
- The simulation resets
sweep_cursor_idwhen a slot is tombstoned and the engine does not, so a recycled slot column's second sweep skips every row below the first sweep's final cursor (lib/sim/reserve.ts). - The simulation applies a datetime filter bound's UTC offset and the engine
does not: MySQL parses the offset off an RFC 3339 literal and throws it
away, so
+07:00compares as though it were UTC — while the write path converts properly, which means a value written and then filtered for does not match itself (lib/sim/search/execute.ts, measured against a real MySQL 8.0.13).
Both comments are what stop the lines being quietly "corrected" back to match; when the engine fixes either, delete the comment rather than the line.
- The simulation resets
-
The schema is quoted, not paraphrased.
lib/sim/ddl.tsholds eachCREATE TABLEverbatim from the engine's bootstrap runner, and the playground puts it one click from the rows so the "these are the engine's columns" claim is checkable. Same duty asevents.tsand rather less forgiving: a stale event name fails a build, a staleCREATE TABLEjust looks right. When the engine's schema changes, change it there in the same commit.
Build sequencing lives in PLAYGROUND_ROADMAP.md.
/custom-fields (app/(en)/custom-fields/,
components/customfields/) sits between the
landing page and the playground on purpose: the landing page explains the
mechanism and the playground hands over every control, and neither ever shows
an application. This page walks one worked example — a multi-tenant SaaS
tenant's contact record — from defining the model through a customer asking
for a new filterable field after launch, ending on an honest list of what the
engine will not do for you.
Six of its seven sections are static: prose, real PHP, and a couple of small
CSS illustrations (TenantColumns, PayloadMirror). Section 05 is the one
exception, and it runs the real reducer. FieldRequestDemo.tsx seeds
lib/sim/'s tenant-field-request scenario and lets a visitor step through
its payoff stages one press at a time — the same registry npm run verify:scenarios folds and asserts on every run, so this section is checked
by the scenario picker's own test rather than by a second script. It never
calls persist.save() (which would silently overwrite whatever the visitor
already built at /playground/) and never starts a ticker — every press folds
its own clock/ticks, the same discipline tour.ts holds, so the section
behaves identically under prefers-reduced-motion.
/glossary (app/(en)/glossary/,
components/Glossary.tsx) lists all 44 StarDust
terms in plain language. components/Term.tsx marks a
handful of them inline in the landing page's own prose, and more densely in
/custom-fields's — a dotted underline, revealed on hover, focus, or tap —
each deep-linking to its full entry on the page.
messages/{en,id}/glossary.json is generated,
not hand-written. The canonical content — one entry per term, both
locales — lives in a separate repo, StarDustGlossary, which also compiles
the same content into the StarDust docs site's own glossary page. Clone it as
a sibling of this repo (the same layout SDDPG uses alongside the engine
repo) and run its compiler whenever glossary content changes:
git clone <StarDustGlossary repo URL> ../StarDustGlossary
cd ../StarDustGlossary && npm install && npm run compilenpm run compile (no arguments) writes into ../StarDustWebsite and
../StarDustDocs by default. Only the terms and orientation keys of each
glossary.json are regenerated — meta, hero, orientationHeading,
termsHeading, and popup stay exactly as this repo owns them, so page
chrome and tone can differ from the docs site without drifting the actual
definitions.
scripts/verify-glossary.ts still runs after
every regeneration: it walks every .tsx file under app/ and
components/, so a <Term id> used anywhere — not only on a hardcoded list of
pages remembered at the time — must resolve in both locale catalogs, the two
catalogs must carry identical keys, and every
definition must stay a single sentence — run with npm run verify:glossary.
npm install
npm run dev # http://localhost:3000
npm run typecheck
npm run build # static export into out/Requires Node 20+ (CI uses 22).
Don't run npm run build while npm run dev is up — they share .next/, and
the build overwrites the running server's webpack runtime, which turns every
request into a MODULE_NOT_FOUND 500. Stop the dev server first, or delete
.next/ and restart it if you already have.
There is no Node runtime in production. npm run build produces out/, a
self-contained folder of static files that any static host can serve.
npm ci
npm run build # → out/Publishing is putting the contents of out/ at the web root.
.github/workflows/ci.yml typechecks and builds on
every push and attaches out/ as a downloadable site artifact, so a
release never requires building locally. It deliberately does not deploy —
that would mean putting host credentials in repository secrets, which is a
decision to make on purpose rather than inherit from a template.
Deploying is instead a manual, local step: deploy.sh (npm run deploy) builds out/ and pushes it over SSH to the live host. It relies on
an stardustwebsite entry already present in the operator's own
~/.ssh/config, so no host, user, or key ever lives in this repo.
Three settings exist for the sake of static hosting, and are easy to break by tidying:
trailingSlash: trueinnext.config.mjsemits each route as<route>/index.html, which a plain file server resolves through its normal index lookup. Turn it off and routes 404 unless the visitor types.html.- No
basePath— the site is served from the domain root. Set one only if it moves into a subdirectory. public/.htaccessships alongside the build, since Next copies dotfiles frompublic/. On Apache-family hosts it wires up the exported 404 page, forces HTTPS, and caches the content-hashed_next/static/bundles for a year while holding HTML atmust-revalidate— that pairing is what makes a redeploy take effect immediately rather than after a cache expiry. Other hosts ignore the file; configure the equivalent there.
SITE_URL in lib/links.ts is the canonical origin used for
the canonical link and Open Graph tags. Those must be absolute, and a static
export has no request to derive a host from, so it is stated there and nowhere
else — update that one constant if the domain changes.
- No CSS framework. Design tokens live at the top of
app/globals.css; everything else is CSS Modules. The colour ramp is load-bearing, not decorative — teal means indexed, amber means pending/backfilling, rose means rejected or tombstoned, and neutral slate means JSON-only: stored, and never mirrored by design. Keep that mapping if you add a demo — the last two are the pair most easily confused, and colouring a JSON-only value as a failure teaches the opposite of what the engine does. - No animation library. Transitions are CSS; the value-in-flight ghosts are the
Web Animations API over measured DOM rects (
lib/fly.ts). - Every demo honours
prefers-reduced-motionby jumping to a settled end state rather than by animating faster. The end state is the lesson. - URLs live in
lib/links.ts, never in a component. That module has no'use client'on purpose: a constant exported from a client module arrives at a server component as a client-reference stub, and while a barehref={REPO}survives it,`${REPO}/issues`stringifies the stub's source code into the href and ships a broken link. This already happened once. - Claims about the engine must match the engine. Event names in the log stream
come from StarDust's closed ADR 0020 vocabulary, and the numbers in
JoinSwampare labelled on the page as an illustrative model rather than a benchmark. If a claim here and the engine's README ever disagree, the engine wins.
MIT — see LICENSE.