DocsWorking on the code
Code conventions
On this page3 sections
House rules for writing code here, per app. The dashboard’s staged-save convention is the one most code comments point at.
Resolver hygiene
- Private resolver helpers (e.g.
customerAuthPayload) live inhelpers.goor<domain>_helpers.goinside the same surface dir - gqlgen strips unrecognized methods from*.resolvers.goon regeneration. - Resolver field for the admin service is
AdminSvc(notAdmin) to avoid shadowing the generatedAdmin(id)query resolver method. - Front model autobinds to
apps/backend/core/graph/admin/modelfor shared types (Customer, Product, Order, …); cart-only types live inapps/backend/core/graph/front/model. - Migrations in
apps/backend/core/internal/db/migrations/- one dbmate file each,<timestamp>_<name>.sqlfromskelet db migrate new <name>, holding its-- migrate:upand-- migrate:downhalves. - Every stream applies itself: the processes that open a database migrate it as
they boot, before anything reads it, so nothing runs against a stale schema. Adding
a migration means restarting the app, not remembering to run
skelet db migrate- see database.md.
Dashboard hygiene
-
apps/dashboard/src/features/<domain>/*.gql.ts- most are graphql-codegen output, but a few (e.g.Categories.gql.ts) are hand-written to share fragments. Don’t blindly delete and regen the whole tree. -
apps/dashboard/src/lib/apollo.ts- silent refresh on 401, default fetch policynetwork-only(no cached reads). -
Routes are code-split. Pages register in
src/nav/routes.tsxvialazyPage(() => import('@/features/x/Page'), 'Page')(seesrc/nav/lazy.tsx) - never import a page statically there, or it lands back in the eager bundle. The Suspense fallback lives in App.tsx. -
Cross-cutting code lives in
src/shared/(auth, errors, saving) -features/is for actual product domains only. -
State colours are tokens, never raw Tailwind hues.
index.cssdefinessuccess,warning,infoanddestructiveoutside the palettes and registers each in@theme, so they are plain utilities that flip with the mode on their own - nodark:twin at the call site. Each state has three roles:Utility Use text-success,bg-success,border-success,ring-successthe colour itself: text, icons, dots, bars, strong lines text-success-foregroundtext on a solid bg-successfillbg-success-softtinted badge or banner background A tinted badge is
bg-warning-soft text-warning border-warning/30, or<Badge variant="warning">;Alerthas the same variants. Opacity modifiers (border-info/40) cover one-offs. Never useprimary/accentfor state - admins recolour them. Categories with no state (a kind badge, an order status like Processing) map to the nearest state or tomuted, not to a raw hue likebg-violet-500. A new colour goes in as a CSS variable plus a--color-*entry in@theme. Chart series and legend dots that only tell categories apart useseries-1…8andseries-other(bg-series-3in markup;SERIES/SERIES_OTHER/CHART_ACCENTfromsrc/lib/chart-colors.tsin recharts props) - never a hex. The auth screens’ fixed white-card chrome is the one deliberate exception. -
Staged save is THE rule, not a pattern to opt into. On any detail page, field edits must be staged locally and applied through the single page-header Save button, which opens
SaveConfirmDialog(src/shared/saving/SaveConfirmDialog.tsx) listing every pending before→after change for confirmation. Never give an individual section/card its own Save button, and never fire a field mutation on change. Wiring: the page callsuseSaveRegistry()and wraps its body inSaveRegistryContext.Provider(src/shared/saving/saveRegistry.tsx); each editable section registers a(diff, save)pair viauseSaveSection(...); the header Save opens the dialog with the dirty sections and confirming runs eachsave()sequentially. Reference implementations:ProductDetail.tsx,AttributeDetail.tsx,CategoriesPage.tsx(productSaveRegistry.tsre-exports the same registry under the names the product pages import). Allowed exceptions: entity CRUD rows/dialogs with explicit Save/Cancel (e.g. adding an attribute value, menu items, url-rewrite dialogs) - though any editors embedded inside such a form must flush with that form’s Save, not carry their own button; destructive actions behind their own confirm dialog; lifecycle / non-field actions (publish, reindex, rotate token, order transitions); personal preferences (/preferences, nav pins, Home layout, table layouts) which write immediately because the control IS its own preview and there is no entity to diff - their destructive resets still carry a confirm. Diff entries must be a FAITHFUL projection of the staged draft or rely onuseSaveSection’s call-time save thunk - the registry only re-registers when the diff STRING changes, so a save closure captured alongside a lossy diff (clipped text, rich text shown as ‘previous → updated’) would otherwise persist stale drafts. Also key draft-reset effects on entity IDENTITY ([entity.id]), never the refetched object, or unrelated refetches wipe staged edits mid-edit. -
Diff entries follow the standard contract (
src/shared/saving/diff.ts). Build them withchanged(label, before, after)/added(label, summary)/removed(label, summary)or, for form sections,fieldDiff(original, draft, fields)- never hand-roll placeholder rows. Rules: one entry = one human-readable change; the label names the thing (Value · Deluxe · price);before/afterare display strings (display()normalizes: ” ≡ null ≡ undefined, yes/no booleans, clipped text) - no ‘(edit)’/‘(new)’/‘(no change)’ placeholders, no raw JSON, no bare IDs. Entries carrykind: 'change' | 'add' | 'remove'so SaveConfirmDialog renders +/− rows correctly.changed()returns null for equal values and the registry centrally DROPS no-op change entries (before === after, with a dev-console warning naming the section) - so a section that round-trips its form state can never light the Save button. If you see that warning, fix the section’s diff computation rather than relying on the central filter. -
Every page declares its unsaved work, in one of five ways. A page that holds edits the admin could lose has to say so, or closing the tab, refreshing, switching channel/locale, closing a workspace tab and clicking a link all discard them silently. Everything lives in
src/lib/unsaved-changes.ts, and which entry point you use is decided by what the baseline is, not by taste:- Staged-save pages get it free -
useSaveRegistry()registers for you, so a page following the staged-save rule above needs no extra wiring. This is the default; prefer it. useDraft(initialValues, label)(src/shared/saving/useDraft.ts) for a single form - create forms, and editors whose inner component takes its record as aninitialprop. It OWNS the values, so dirt is complete by construction rather than depending on a field list somebody has to remember to extend. It replaces theuseStatepairs and generates setters named exactly like them (setName,setSlug), so field bindings in the JSX don’t change:
An editor that stays on the page after saving must callconst draft = useDraft({ name: '', slug: '' }, 'New menu') const { name, slug } = draft.values const { setName, setSlug } = draft.settersdraft.saved()on success to move the baseline, or it reads dirty until it remounts.draft.reset()throws edits away;reset(next)adopts a refetched record as the new baseline. Keep UI-only state (slugTouched, dialog flags, errors) out of the draft - it is for the admin’s work, not the page’s chrome.useUnsavedFields([...values], label)when the state genuinely cannot live in a draft object: xyflow owns the nodes and edges inFlowBuilder,EmailLibraryDetailkeeps an undo history, andPromotionRuleFormhas initializers that read values declared further down. Same mount-snapshot semantics, hand-listed.useUnsavedChangesGuard(isDirty, label)when the page computes dirt itself, which it must when state is re-seeded AFTER mount (seeMenuDetail.tsx, which re-syncs from the query and so diffs against the loaded menu). A mount snapshot would read as dirty forever there.- Record forms (
useRecordForm+RecordFormPage, both insrc/shared/saving/) are the default for create and edit pages, for new ones and for existing ones when they’re next touched. The page passes a zod schema, a memoisedbaseline, adiff, apersistkey and its mutations. The hook wires the rest: the save registry, a TanStack form (useAppForm,src/components/form/form.ts), the stored draft, the section the header Save reviews, and the confirm dialog.RecordFormPagerenders the header with the one Save button, the stored-draft banner, the create-failure alert andSaveConfirmDialog, so the page file is only its sections. Bind fields with<form.AppField name="x">{(f) => <f.TextField … />}</form.AppField>;TextField,SwitchField,CheckboxFieldandMetadataFieldare registered. A save rejects when validation fails orsubmitthrows, so the dialog stays open with the reason, andfieldErrorsFromcan route a server message to the field it’s about.- Schemas live in
features/<domain>/schemas/<entity>.ts: the zod schema, itsValuestype,to<Entity>Values(record),to<Entity>Input(values), the field labels and the diff, with a<entity>.test.tsbeside it. One schema serves both create and edit. Key/value metadata usessrc/shared/schemas/metadata.ts, so it’s always diffed, one entry per key. - Drafts (
persist,src/lib/form-drafts.ts) keep the admin’s work in localStorage, keyed admin → workspace tab → page, with a 7-day TTL. After a refresh or a closed browser the draft is offered, never applied on its own: the banner says one was found, Review lists what it would change (the Save dialog’s rows, plus any field someone else changed since), and Restore puts it back into the form, which still needs Save. Editing instead of deciding drops the offer. Refresh, close and scope switches stop prompting; closing the workspace tab still does, and deletes the draft. Dialogs passpersist: false. List secrets inexclude, because they must never reach storage. Other staged sections on the same page (a values table, the registry’s config fields) keep no draft, so while one of them is dirty the refresh prompt comes back - the registry skips it only when every dirty section is persisted. - Every migrated page gets a page test (
<Page>.test.tsx, ApolloMockedProvider): edit, check that Save enables, check what the dialog lists, confirm, and assert the mutation’s variables. A section registered in the same component asuseSaveRegistry()needs the registry passed explicitly (useSaveSection(reg, registry)), because context can’t reach the component that renders the Provider. Several pages lost their Save button that way. Reference:features/inventory/InventoryLocationForm.tsx.duringCommit()(src/lib/nav-guard.ts), or the work they just saved gets a “discard changes?” prompt on the way out.ConfirmDialoganduseSaveRegistry().saveAll()already do this for you.src/lib/unsaved-work-coverage.test.tsenforces this: it reads the route table and fails when a detail page stages and commits edits without declaring them, which is how the product page silently lost edits for a while. Genuine exceptions go in itsNO_STAGED_EDITSmap with a reason.
- Schemas live in
- Staged-save pages get it free -
Storefront hygiene
- Next.js 16, not your training data. Read
templates/nextjs-vetrina/node_modules/next/dist/docs/before writing code; APIs, conventions, and file layout differ from older versions. Heed deprecation notices. - Logic in the packages, design in the template. What a storefront
does - the GraphQL client and operations, auth, cart, checkout, channel
and locale resolution - is
packages/storefront-core, with no framework in it. A template keeps its pages, components and styles, so a fork changes how it looks without taking on code that upstream keeps fixing. Inside a package imports are relative - a storefront’s@/alias would resolve against the storefront. - The core asks the request it runs in. Cookies, headers, locale,
channel, a cache-aware fetch, redirects and revalidation go through
request()(storefront-core/request.ts), nevernext/*-skelet checkfails on a framework import in the core.storefront-next/setup.tsanswers it the Next way; a Next template imports reads and server actions from@skelet/storefront-next/<module>, whose modules load that setup, and pure helpers from@skelet/storefront-core/<module>.storefront-astro’s middleware answers it the Astro way, and an Astro template imports everything from the core directly. Routing is the adapter’s too: a Next template’sproxy.tscallsstorefrontProxyand keeps only thematcherNext reads from that file. Reads memoize per request withperRequest, not React’scache. A test answersrequest()withuseTestRequestfromstorefront-core/testing.ts. - Server-only GraphQL. The core’s
graphql.tsis RSC/route-handler only - the browser can’t reachBACKEND_INTERNAL_URL. If you need client-side data, expose it via a Next.js route handler instead of leaking the internal URL into the client bundle. - Force-dynamic everywhere.
app/layout.tsxsetsexport const dynamic = "force-dynamic"andgqlFetchusescache: "no-store"- every request hits the backend. Don’t introduce Full Route Cache / ISR without a deliberate per-route decision. - Operations are colocated.
*.graphqlfiles live next to their consumers in the core; codegen produces*.gql.tssiblings. The shared baseline is the core’sgraphql/schema.types.ts(DO NOT EDIT). - Channel + locale are server-resolved.
getChannel()(channel/read.ts) andgetCurrentLocale()/getCurrentChannelId()(locale/read.ts) are the source of truth; pass results down via theChannelProviderfor client components. - JWT in cookies, not localStorage. The core’s
auth/reads customer tokens from the cookie store;gqlFetchattachesAuthorization: Bearer <token>automatically when present. - URL routing is backend-driven. The catch-all
[...slug]route resolves arbitrary paths via the fronturlResolverquery (url-resolver/operations.graphql) so canonical URLs + 301s defined ininternal/urlrewrite/work end-to-end. - HMR over bind mounts. If file changes don’t trigger reloads,
confirm
CHOKIDAR_USEPOLLING/WATCHPACK_POLLINGare still set indocker-compose.yml(inotify is unreliable on WSL2/macOS).
