Skip to content
Pre-MVP. The 1.0 codebase is not a released product — see the 1.0 product boundary.

Extending your backend

You generated a standalone backend from a site. It is a normal repository and it import nothing from the CMS at runtime. This document say where your code go, what a regeneration rewrite, and what it refuse.

Short answer: src/owned/ is yours, forever. apps/site/src/owned/ and apps/site/src/pages/owned/ are yours in the emitted site. package.json, wrangler.jsonc, .nvmrc, .gitignore, README.md and your own workflows are yours to keep. Everything else is rewritten from the model definitions on every regeneration.

Source of the rule: backend/src/lib/graduate/zones.ts. The table below is checked against that code by backend/test/extending-your-backend-doc.test.ts, so this document cannot drift from the emitter.

Generated backend have three kind of file. Rule decide zone from PATH alone — not from marker comment, not from list somebody maintain.

Zone Who own What regeneration do
generated emitter rewrite every run
owned developer write once as scaffold, never touch again
shared both write when absent, never overwrite

One exception, keyed: package.json — both root and apps/site/ — is MERGED, not left alone. See “Manifest merge”.

Rule, exact. Site questions asked FIRST, because apps/site/package.json wear a shared name the API also use and apps/site/src/owned/x do not start with src/owned/:

  • Path start with apps/site/src/owned/ or apps/site/src/pages/owned/ = owned.
  • Path is apps/site/package.json, apps/site/package-lock.json, apps/site/.npmrc or apps/site/wrangler.jsonc = shared.
  • Anything else under apps/site/ = generated.
  • Path start with src/owned/ = owned.
  • Path is .github/workflows/dee-wan-build.yml or .github/workflows/deploy-from-repository.yml = generated. Both are machine configuration; fix to them must reach adopted project.
  • Path is package.json, package-lock.json, .npmrc, .nvmrc, wrangler.jsonc, .gitignore, README.md, or start with .github/ = shared.
  • Everything else = generated.

Two path never scanned at all, so never fingerprinted and never staged: src/serve.local.mjs and src/credential-digest.mjs. They are BUILD OUTPUT that live in src/ because they keep generated Prisma client external. They are in .gitignore — offering them to git add made whole push fail.

Edited generated file is REFUSED BY NAME on next regeneration, with place to move work to. Never silently overwritten. See “What is refused”.

Path Zone
prisma/schema.prisma generated
graduation.manifest.json generated
src/app.ts generated
src/worker.ts generated
src/security.ts generated
src/owned/routes.ts owned
src/serve-cli.ts generated
migrations/0002_graduation_ledger.sql generated
scripts/regenerate.mjs generated
scripts/emit-migration.mjs generated
scripts/file-manifest.mjs generated
scripts/portable-client.mjs generated
scripts/configure-deployment.mjs generated
tsconfig.json generated
wrangler.example.jsonc generated
wrangler.build.jsonc generated
scripts/load-snapshot.mjs generated
data/published-snapshot.json generated
.github/workflows/dee-wan-build.yml generated
.github/workflows/deploy-from-repository.yml generated
.github/workflows/anything-else.yml shared
package.json shared
package-lock.json shared
.npmrc shared
.nvmrc shared
wrangler.jsonc shared
.gitignore shared
README.md shared
apps/site/src/owned/config.mjs owned
apps/site/src/pages/owned/thanks.astro owned
apps/site/package.json shared
apps/site/package-lock.json shared
apps/site/.npmrc shared
apps/site/wrangler.jsonc shared
apps/site/src/lib/pages.json generated

src/owned/routes.ts is scaffold, written once, and it ship EMPTY on purpose. Put your route there:

export function ownedRoutes(prisma: GuardedPrisma) {
const routes = new Hono()
routes.post('/checkout', async (c) => {
const body = await c.req.json()
return c.json({ ok: true })
})
return routes
}

src/app.ts mount it with app.route('/api', ownedRoutes(deps.prisma)), LAST — after every model router, so a path you choose can never shadow a generated one. Your /checkout above serve at /api/checkout.

What you inherit: authentication run on app.use('*') before anything is mounted, so every request that reach you is already identified or refused. Inside a handler you have c.get('principal'), c.get('accessVariant') and c.get('prisma'). The guarded client is PASSED IN rather than imported, so your query run under the same extension the generated router do.

What you must not assume — the permission check ask which MODEL a request is for, and it answer by PREFIX /api/<model>…:

  1. Path starting with a model’s name is permission-checked AS THAT MODEL. /api/articles-report match the article model, so caller need article permission to reach it — more or less than you intended.
  2. Path matching no model resolve to no model. Session-based caller is then governed by the operation alone (GET, POST…); read-token caller is refused unless its scope is *.

So pick first segment no model slug can take — /checkout, /webhooks/stripe, /reports/daily. If a route need a rule of its own, enforce it in the handler. Nothing there is checked for you.

Site route, not API route: add file under apps/site/src/pages/owned/. Astro route exist by being file under src/pages, so an owned page cannot live in apps/site/src/owned/. It serve under /owned/…, and the address resolver refuse any generated page, chrome route or redirect that would shadow that URL space.

Same place, any file you like: whole src/owned/ tree is yours. Add src/owned/pricing.ts, import it from src/owned/routes.ts. Put there:

  • custom route,
  • business logic,
  • extra authentication beyond the emitted bearer check,
  • integration with third-party system,
  • your own table and its migration,
  • anything that must survive regeneration.

Owned file is never fingerprinted — that IS the promise. Do not put your code in a generated file “just for now”: next regeneration refuse the whole run by that file’s name.

In the site, the typed extension seam are in apps/site/src/owned/: Head.astro, BodyEnd.astro, the layout slots, the section Wrapper/overrides, and config.mjs for Astro integration and the site origin.

Edit package.json and install through the verified installer:

Terminal window
node scripts/install-dependencies.mjs

Your entries are kept — see “Manifest merge”. npm ci and npm install are not the install path: the installer check npm against .nvmrc and packageManager, extract with scripts off, audit the tree against exact allowScripts, then npm rebuild only approved script.

Site dependency go in apps/site/package.json, installed with node scripts/install-dependencies.mjs apps/site. A Svelte or React island also need its pinned integration added to apps/site/src/owned/config.mjs.

Edit wrangler.jsonc. It is shared: written when absent, never overwritten, so your binding stay. wrangler.example.jsonc beside it is GENERATED — a template with no account id, no route and no database id, because this generator do not decide where you deploy. Copy from it; do not edit it.

wrangler.build.jsonc is generated too, and it is what npm run build bundle with. wrangler.deploy.jsonc is written by the deployment (npm run deploy:configure) and is gitignored.

Site binding go in apps/site/wrangler.jsonc, shared on the same terms.

Add a workflow under .github/workflows/ with any name except dee-wan-build.yml and deploy-from-repository.yml. .github/ is a shared prefix, so your file is written by you and never touched. Those two names are generated: they are machine configuration and a fix to them must reach your project on the next regeneration.

Regeneration rewrite every generated path from current model definitions: schema, routes, worker entry, build scripts, emitted workflow, emitted site. It append migration for schema change — never rewrite past migration.

It does NOT touch:

  • anything under src/owned/, apps/site/src/owned/ or apps/site/src/pages/owned/,
  • shared file that already exist (wrangler.jsonc keep your bindings),
  • migration already applied,
  • your git history.

package.json is the one shared file it DOES change, and only by ADDING. See “Manifest merge”.

Guard options of generated routes come from Settings → Source & delivery → Generated API guards. Regeneration write them into generated route config; unset one use package default.

Settings, Source & delivery, Generated API guards: each option with its default and hardened value

Sidecar .dee-wan-files.json hold fingerprint of every generated file as emitter last wrote it. That is how “you edited this” is detected. Owned file never fingerprinted — that IS the promise.

Regeneration run without the CMS. The generator is compiled into your repository at graduation (scripts/dee-wan-generator.mjs), pinned by digest in graduation.generator.json, and each emitted script verify the pin before it import. npm run regenerate chain the schema, the app, the Prisma client and the portability rewrite in that order.

An edited generated file does not get overwritten and does not get adopted. The run stop and name the file:

  • generated file whose bytes changed — <path> is generated and has been edited. Move your change under src/owned/, then delete this file so it can be regenerated.”
  • file at a generated path that the previous manifest never recorded — <path> is not in the previous manifest. If it is yours, move it under src/owned/ and delete it from here.”

Moving the work: copy the change into a new file under src/owned/, import it from src/owned/routes.ts, delete the edited file, regenerate. The emitter rewrite the deleted file from definitions.

A file the emitter removed is deleted only when it is ours AND unmodified. Anything else is left where it is.

Content rows are the same contract one level down: generated content table belong to the run, and a row written into one make the next run refuse observed_row_set_mismatch. Your own table and your own rows survive — that is what src/owned/ and your own migrations are for.

Published snapshot data/published-snapshot.json is GENERATED. Edit it and the next regeneration refuse by name. The loader refuse it too: wrong site, wrong artifact, changed byte, partial load, conflicting replay. Same byte twice = safe, write nothing.

After detachment nothing regenerate anything, so every file — generated one included — is yours to edit. Before detachment, edited generated file is refused by exact path.

Both package.json — root and apps/site/ — are shared but MERGED. Pure “never overwrite” also mean “never add”, so day emitter start writing code that import new package, already-adopted project regenerate into source npm ci cannot resolve. Failure show up two step later as tsc --noEmit or astro check naming a module, not naming missing dependency.

Merge rule, both file, same:

  • blocks merged: dependencies, devDependencies, overrides;
  • key your file already have: YOURS, value untouched, including version you deliberately move off the pin;
  • key only template have: ADDED;
  • nothing ever removed;
  • packageManager added when absent, yours when present;
  • allowScripts entry added only where you have not decided that package and version — your true and your false stand;
  • second identical regeneration move zero byte.

Unparseable package.json left untouched. Overwriting is destructive direction, and build refuse on your behalf either way.

Lockfile follow manifest. When merge add entry, carried package-lock.json no longer describe manifest, so emitter refresh it with npm install --package-lock-only before install. Lockfile and manifest cannot disagree. Proven by backend/test/graduate-site-build.test.ts (opt-in, DEEWAN_SITE_BUILD=1): adopted site manifest from before devDependencies block install, typecheck and build after regeneration.

Front door Workflow Read CMS? Regenerate? Deploy itself?
managed dee-wan-build.yml yes — DEE_WAN_URL, build token, model version yes, then commit NO — it call the other one
repository-only deploy-from-repository.yml NO no yes

Managed workflow have three job: build pull definitions, regenerate, validate, commit; deploy call deploy-from-repository.yml (workflow_call) for that exact commit and wait; report tell Dee Wan the result under the build attempt that started it. It carry no migrate step, no wrangler deploy, no probe of its own. Two copies drifted: only managed one ever ran on a managed build, only repository one ever loaded committed snapshot — so managed deploy served empty database and reported success.

Repository-only path is what survive detachment, what managed build deploy through, and what rollback use. It read no Dee Wan url, mint no build token, call no CMS endpoint. It deploy exact commit it was given and record commit it actually deployed. It hand caller back git_sha, d1_uuid, worker_name.

What make repository deployable without CMS

Section titled “What make repository deployable without CMS”

Three file. Missing any one = repository that build and serve nothing.

File Zone What it carry
package-lock.json shared resolved tree with integrity. scripts/install-dependencies.mjs need it
data/published-snapshot.json generated published row, bound to site + artifact + manifest + content digest
.github/workflows/deploy-from-repository.yml generated checkout exact commit, verified install, build, resolve D1, migrate, load snapshot, deploy, verify

Verified install = node scripts/install-dependencies.mjs [root]. npm beside .nvmrc Node must equal packageManager. npm ci extract with scripts off, installed tree audited against exact allowScripts, then npm rebuild run approved scripts only. .nvmrc shared: written when absent, never rewritten.

Lockfile is SHARED for same reason package.json is: adopter add own dependency, emitter must not throw it away. Emitter still refresh it every run by running npm install --package-lock-only over merged package.json, so lockfile and manifest cannot disagree.

Load the snapshot into a database with:

Terminal window
npm run db:init -- --database ./graduated.db # local SQLite, migration first
npm run data:load -- --d1 DB --config wrangler.deploy.jsonc # adopter D1

Walkthrough: one route, one dependency, regenerate, deploy

Section titled “Walkthrough: one route, one dependency, regenerate, deploy”

Every command below is a script the emitted project already carry.

1. Install and get a working database.

Terminal window
node scripts/install-dependencies.mjs
npm run db:init -- --database ./graduated.db

2. Add the route. Edit src/owned/routes.ts — add routes.post('/checkout', …) as above. Nothing else to wire: src/app.ts already mount ownedRoutes under /api.

3. Add the dependency. Add it to dependencies in package.json, then:

Terminal window
node scripts/install-dependencies.mjs

4. Prove it locally.

Terminal window
npm run typecheck
npm run build:serve
DATABASE_PATH=./graduated.db SITE_ID=<your site id> \
ACCESS_CONFIG_FILE=./access.local.json PORT=8787 npm run serve:local

Then in another shell:

Terminal window
curl -i -X POST -H 'content-type: application/json' -d '{}' \
http://127.0.0.1:8787/api/checkout

Digest for access.local.json come from npm run credential:digest, which read the secret from stdin so it never reach your shell history or ps.

5. Regenerate. Same order the managed build use:

Terminal window
npm run regenerate # schema, app, security, local, manifest, Prisma client, portability
npm run migrate:emit # the next migration, append-only
npm run typecheck
node scripts/install-dependencies.mjs apps/site
npm run --prefix apps/site check
npm run --prefix apps/site build
npm run files:manifest # LAST: the ownership sidecar, from the tree that exists

Your route and your dependency are still there. git diff show only generated file and the appended migration.

6. Bundle and deploy.

Terminal window
npm run build # the Worker bundle, via wrangler.build.jsonc

Deploy is the workflow, not a local script: commit, then run .github/workflows/deploy-from-repository.yml for that exact commit with your own Cloudflare credentials. It install, build, resolve or create the D1, migrate, load the snapshot, deploy and verify. Rollback is the same workflow with an older sha. npm run dev run the Worker locally against wrangler.jsonc if you want wrangler dev instead.

After a deployment, npm run verify:deployment ask the deployed origin whether it serve exactly this commit’s content, row for row, and npm run verify:media ask whether the committed projections still point at Dee Wan.

Proven by backend/test/graduate-adoption.test.ts — “keeps a developer’s own file across a schema change”, “keeps rows the adopter wrote into their own table across a schema change”, “regenerates after a schema change, appending to the migration lineage”:

  1. generate, deploy,
  2. edit src/owned/, add row to database,
  3. add field to model in CMS,
  4. regenerate.

After step 4: owned file byte-identical, new migration appended, old migration untouched, existing rows still there.