Conventions
An app's layout
<app>/ # the repository root, or apps/<app>/ in a monorepo
ddcore.app.ts defineApp: title, version, ddcore, roles, scheduler, docEvents, desk, afterInstall, afterMigrate, onBoot, fixtures
doctypes/<snake>/
<snake>.doctype.ts defineDoctype (meta)
<snake>.controller.ts defineController (rules, methods)
<snake>.form.ts defineForm (desk) — imports "@ddcore/desk-sdk"
<snake>.test.ts tests (imports "@ddcore/sdk/test")
extensions/<snake>.extend.ts extendDoctype: fields and properties added to another app's DocType
extensions/<snake>.form.ts defineForm for that DocType, loaded alongside its owner's
services/*.ts business functions; whitelisted() exposes them at /api/method/<app>.services.<file>.<fn>
reports/*.report.ts defineReport
workspaces/*.workspace.ts defineWorkspace
workflows/<name>.workflow.ts defineWorkflow: states, transitions, approval roles — see `workflows`
print/<name>.print.ts a print format — see `print`
portal/<name>.portal.ts definePortal: a self-service portal for Website Users — see `portal`
patches/NNNN_name.ts definePatch({ phase, execute }) — runs once, during migrate → migrations
client/*.ts scripts loaded across the whole desk (desk.include) or the portal (portal.include)
translations/pt-BR.csv "key,translation,# source" — generated by `ddcore i18n extract`
.ddcore/types.d.ts generated by `ddcore types` — import with `import type { Contract } from "../../.ddcore/types"`A module's dotted path is <app>.<folder>.<file> (no .ts). For example: my_app.services.tasks.run. That is the form used by whitelisted, scheduler, ddcore.enqueue, ddcore exec and the call_method tool. A scheduler entry is that path, or { method, runAs } to run it under a user's roles and access scopes instead of as Admin (see scopes).
onBoot runs once per process after it is up — once ddcore start or ddcore dev listens, or once ddcore jobs work starts its workers — whether or not that boot migrated, and never on a reload or a ddcore migrate. Each app's runs in a transaction of its own, as Admin in the platform space; an error is logged and the process keeps serving, and it is skipped while the site is in maintenance mode. It is where an app acts on its deployment, such as registering a webhook with a provider that calls the site back to verify it. Every replica and worker container runs it, so it should be idempotent: the usual body enqueues a job with a uniqueKey.
What the app puts on the desk
defineApp's desk block is the app's say over the shell around its screens:
desk: {
include: ["client/lists.ts"], // scripts loaded on every desk page
home: "Projects", // the workspace /app opens on
logo: "📁", // the square mark beside the site's name
// or files of the app: logo: { image: "assets/mark.svg", favicon: "assets/favicon.svg" },
theme: { colors: { primary: "#7c2d2d" } }, // the desk's look — see below
}home, logo and theme are resolved the same way, each on its own: the first app that declares one wins, and the core declares neither, so an app's always does. "First" is load order with the libraries last: an app that another installed app requires loads before it, but comes after every app nobody requires. So the site's own app decides, and a library's home or logo only shows where the site's app declares none. The site's name follows the same order, unless ddcore.json declares a title — see i18n.
logo is one letter or one emoji — drawn as text in a square of the theme's primary colour — or { image, favicon? }, image files of the app (.svg, .png, .webp, .jpg, .gif, .ico) by path relative to its directory. The image replaces the square in the sidebar, the mobile top bar, the portal and the sign-in pages; favicon is the browser tab's icon, the image when left out. The files must exist when the app loads, and the server serves only them, at /assets/site/logo and /assets/site/favicon — not the folder they are in. Leave logo out and the mark is the initial of the name printed beside it, which is this app's title. That title is a catalogue key like any label, so both the word and its initial follow the reader's language — see i18n. A site without an image still gets a favicon: its letter square.
The desk's look: desk.theme
theme gives the desk the app's identity — on the sign-in, password-reset and portal pages too, which a desk.include never reaches. Every key is optional and keeps the desk's own value:
theme: {
colors: {
primary: "#7c2d2d", primaryHover: "#5f2121", onPrimary: "#fbf3ea",
bg: "#f4efe4", surface: "#fcf9f3", surfaceAlt: "#f6efe1", border: "#e7dfce",
text: "#2c2420", muted: "#6f6355", accent: "#a8814a", accentSoft: "#ece0c6",
},
fonts: {
body: "'Hanken Grotesk', system-ui, sans-serif",
heading: "'Cormorant Garamond', Georgia, serif", // page titles, the site's name
stylesheet: "https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600&family=Hanken+Grotesk:wght@400;500;600;700&display=swap",
},
themeColor: "#7c2d2d", // <meta name="theme-color">; colors.primary when left out
}primarycolours buttons, links, the active sidebar item, the focus ring, the avatar and the notification count;surfaceis cards, the sidebar, menus and dialogs;bgthe page behind them.- What an app should not have to list is derived: hover, selection and focus tints from
primary,surfaceandtext(color-mix());primaryHoverfromprimary;onPrimarywhite or near-black, whichever reads on a hexprimary;surfaceAltbetweensurfaceandbg;accentisprimary. A theme of three colours already holds together. - Colours are CSS colours (
#rgb,#rrggbb, a name,rgb(),hsl(),oklch(),color-mix()…); fonts are font-family lists;stylesheetis anhttps://URL. Anything else — or a misspelt key — fails the load, naming the app. - Status colours (
green,red,orange…, on indicators and chips) and the space mark stay the core's: they carry meaning, not brand. - The server writes the theme into the page's shell, so the first paint already wears it; under
ddcore devan edit to it applies on the next reload without a new page.
defineApp's portal block holds one key, include: scripts loaded on every portal page. They are bundled apart from the desk's. See portal.
Calling another app's server code
An app that uses another app's code declares it — requires: ["billing"] in defineApp — and then has two ways in:
// apps/shop/services/checkout.ts — shop requires billing
import { gateway, createCharge } from "../../billing/services/charge"; // typed, by relative path
const charge = createCharge({ amount: 10 });
ddcore.callMethod("billing.services.charge.createCharge", { amount: 10 }); // by dotted path: fn(args, ctx)- A module has one instance, its owner's. Importing a server file of another loaded app does not copy it into the importer: both apps see the same exports and the same module state, and the file's top-level code (a
defineDoctype, a cache, a counter) runs once, under its owner. So what the import returns is what the owner's controllers call. - The import needs
requires. Without it the site refuses to load:app shop imports a module of app billing: add "billing" to requires in its ddcore.app.ts.requiresis what loads the owner first. - Only server files are shared this way — the ones in the app's layout above. A file under
client/, a.d.tsor anything outside an app's directory is bundled into the importer as before. The core cannot be imported: reach it throughddcore.*. ddcore.callMethod(path, args)calls any exported function of any loaded module in the current transaction, with no role orwhitelistedcheck — it is a function call, not a request.- State is per runtime, not per process: the server keeps a pool of runtimes, each with its own copy of every module. Module state is a cache, never the record of anything — that is the database's job, or
ddcore.cache.
Faking another app in a test. Since the instance is shared, a test replaces the export the owner's code calls and puts it back:
import { gateway } from "../../billing/services/charge";
describe("checkout", () => {
let real: typeof gateway.send;
beforeEach(() => { real = gateway.send; gateway.send = () => ({ id: "fake" }); });
afterEach(() => { gateway.send = real; });
it("charges once", () => { /* billing's controllers now reach the fake */ });
});This works for what is called through an object (gateway.send); a function the owner calls directly by name cannot be replaced from outside. A provider that must never reach the network from a test guards itself with ddcore.isTest().
Naming
- DocType name: ASCII, spaces allowed, capitalised (
"Project Milestone"). Becomes the tabletab_project_milestone, the interfaceProjectMilestoneand the Desk path/app/ProjectMilestone. - A workspace or a report name goes into the Desk path the same way, without its spaces. Two DocTypes, two workspaces or two reports whose names differ only by spaces would share an address, and the site refuses to load them.
fieldname: ASCII snake_case (due_date).- Labels are English, because a label is a catalogue key. An accent belongs in
translations/<lang>.csv, never in the code. Seei18n. - Reserved:
id, owner, creation, modified, modified_by, docstatus, doctype, parent, parenttype, parentfield, idx.nameis not reserved: it is an ordinary fieldname a DocType may declare, and declaring it does not make it the document key.
Rules that do not change
- Server code is synchronous.
ddcore.db.getValue(...)returns the value directly; neverawaitin a controller or a service. - Business errors:
ddcore.throw(_("message"), { title: _("Title") })— becomes HTTP 417ValidationErrorand a toast in the desk. - Once submitted (
docstatus 1), only fields withallowOnSubmitchange throughsave(); everything else goes throughdoc.dbSet(...). - A derived status (paid, overdue, …) is computed in
validate, never typed. - The sidebar and the workspace are explicit (
defineWorkspace), never inferred. - Never edit
.ddcore/types.d.ts; runddcore types. - A renamed field or DocType declares
renamedFrom; without it the rename is a new empty column and an orphaned old one. Seemigrations. - A fieldtype change that could lose data is refused, not guessed: declare
convert, or move the data across releases with expand → backfill → validate → contract. - A DocType belongs to one app: it is declared once, and another app adds to it with
extendDoctype. Seeextending. - Tests: each
itruns in a rolled-back transaction; create the data you need inside the test. - A Select's values are canonical English and are what the database holds; colour them with
optionColors, never by matching their text.
Versioning and releases
The framework follows Semantic Versioning 2.0.0 (vMAJOR.MINOR.PATCH):
- Git tags: Official releases use the
v*format (e.g.v0.1.0,v1.0.0). - Release artifacts:
- Pushing a version tag
v*triggers theReleaseGitHub Action, creating a GitHub Release with cross-platform static archives (ddcore-<os>-<arch>.tar.gzandSHA256SUMS) for Darwin and Linux (amd64/arm64). - A tagged release also publishes the image
ghcr.io/jrvidotti/ddcore:<X.Y.Z>(and:<X.Y>,:latest) for linux amd64/arm64; a site's Dockerfile startsFROMit, and that tag is the site's pin —:<X.Y>asddcore init/ddcore deploywrite it (the newest patch of the minor itsddcorerange allows),:<X.Y.Z>for one exact release. - Pushing to
mainupdates the rolling prereleaseedge(VERSION=edgeforinstall.sh) when the push touches what the binary carries — a push of docs alone does not; the installer's default andddcore doctor's update check follow the newest tagged release.
- Pushing a version tag
- Version bumps:
- Breaking changes to public server/desk SDKs or engine contracts increment MAJOR (while the framework is
0.x, they increment MINOR). - New backwards-compatible capabilities or hooks increment MINOR (PATCH while
0.x). - Bug fixes and optimizations increment PATCH.
- Every breaking change is listed under Breaking in
CHANGELOG.md, with the upgrade path.
- Breaking changes to public server/desk SDKs or engine contracts increment MAJOR (while the framework is
- The changelog is written before the tag: entries accumulate under
## Unreleasedand are promoted to a## <version> — <YYYY-MM-DD>heading in the commit the tag then points at. The file ships inside the binary —ddcore://changelogand thewhats_newMCP tool read it — so a version tagged without its section publishes news no reader can find. The procedure is inDEVELOPMENT.md.
App compatibility contract
An app declares its own release and the ddcore releases it supports in ddcore.app.ts:
export default defineApp({
name: "shop",
title: "Shop",
version: "1.4.0",
requires: ["crm"], // apps, not versions: core is implicit
ddcore: ">=0.14.0 <0.16.0", // the ddcore releases this app was tested against
});All three fields are optional. ddcore new-app writes version: "0.1.0" and a ddcore range from the running binary's minor release to the next (>=0.15.0 <0.16.0 on 0.15.x); on a build that is not a release it leaves the ddcore line commented out. An app without ddcore is never checked against the binary, so keep the range when you widen it by hand.
The site's range
A site that ships several apps from one repository tests them against ddcore together, so the range belongs to the site: ddcore.json takes the same field, in the same grammar.
{ "apps": ["apps/base", "apps/hr"], "ddcore": ">=0.20.0 <0.21.0" }ddcore initwrites it for the running minor release (and leaves it out on a build that is not a release).ddcore new-appin a site that declares it gives the new app no range of its own, only a comment pointing atddcore.json.It is checked on every load, next to each app's range and by the same rules below; its refusal is
site requires ddcore >=0.20.0 <0.21.0 (ddcore.json), but this binary is 0.21.0, and an invalid one isddcore.json declares an invalid ddcore range ….An app's own range still holds on top of it. Keep one in an app published on its own, which other sites load; drop it from the apps that only this site ships, so an upgrade edits one line.
The exact release a deployment runs is the tag of the image its
DockerfilestartsFROM; keep it inside this range and move both in the same commit.It comes from
ddcore.jsononly — no environment variable overrides it. A binary older than the field ignores it, so write the same range the apps had when moving it there.ddcoreis a list of space-separated constraints that must all hold:>=,>,<=,<,=(or a bare version),^and~:range allows range allows ^1.4.0>=1.4.0 <2.0.0~1.4.0>=1.4.0 <1.5.0^0.14.0>=0.14.0 <0.15.0~0.14>=0.14.0 <0.15.0^0.0.3>=0.0.3 <0.0.4~1>=1.0.0 <2.0.0^0>=0.0.0 <1.0.0^0.0>=0.0.0 <0.1.0A part nobody wrote is a range, not a zero:
^0is every0.xwhile the spelt-out^0.0.0is that patch alone, and~1bounds the major the way^1does.A version is
1,1.4or1.4.0, with or without a leadingv; a missing minor or patch reads as 0, so>=0.14is>=0.14.0. Nothing else is a version:||, a comma,*,xand a pre-release or build suffix (1.4.0-beta.1) are all load errors, and an operator must touch its version —">= 0.14.0"fails with"" is not a version. A whitespace-only string isempty range, not an absent one.versionis the app's own release. It is recorded and never compared, and only its shape is checked — by the same loose grammar, so"1"and"1.4"pass.The check runs on every load of the apps: every command that opens the engine (startup,
migrate,doctor,test,export,mcp),dev's hot reload, and the MCP tools that reload —reload,validate_meta,migrate,scaffold_doctype,extend_doctype,i18n_extract,set_translations.A binary outside the range refuses to load: the command fails, and
devandmcpkeep serving the last good state. Every problem is named in one error,incompatible apps:followed by them joined with;:app shop requires ddcore >=0.14.0 <0.16.0, but this binary is 0.16.0app shop declares an invalid ddcore range ">= 0.14.0": "" is not a versionapp shop declares version "1.4.0-beta.1", which is not a version (1, 1.2 or 1.2.3)
An invalid range or
versionis refused even on a binary that is not a release.A build on top of a release (
v0.14.0-3-gabc123) counts as that release, and so does a pre-release tag:v0.15.0-rc.1is0.15.0. Onlydev,latestor a bare hash are not releases; those parse the ranges but do not enforce them, and logcore version is not a release; ddcore ranges are not enforced.A build that injects no version is still a release.
internal/engine.Versionholds an in-source default, bumped by hand in each release commit, and only theMakefileand the release workflow override it through-ldflags. A plaingo build,go runorgo installtherefore reports that default as its release and enforces ranges against it, so an app whose range excludes it fails to load withbut this binary is <default>.ddcore versionprints the binary's version;ddcore doctorlists each app's version and range, and theexportmanifest records the app versions next to the core's.Because
0.xminors may break, bound the range above (^0.14.0or<0.15.0) and widen it after testing against the new release. Before a production pilot, pin the exact binary version the app was tested with in its image build, and upgrade by changing that pin and the range together.