Skip to content

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:

ts
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:

ts
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
}
  • primary colours buttons, links, the active sidebar item, the focus ring, the avatar and the notification count; surface is cards, the sidebar, menus and dialogs; bg the page behind them.
  • What an app should not have to list is derived: hover, selection and focus tints from primary, surface and text (color-mix()); primaryHover from primary; onPrimary white or near-black, whichever reads on a hex primary; surfaceAlt between surface and bg; accent is primary. 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; stylesheet is an https:// 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 dev an 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:

ts
// 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. requires is 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.ts or anything outside an app's directory is bundled into the importer as before. The core cannot be imported: reach it through ddcore.*.
  • ddcore.callMethod(path, args) calls any exported function of any loaded module in the current transaction, with no role or whitelisted check — 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:

ts
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 table tab_project_milestone, the interface ProjectMilestone and 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. See i18n.
  • Reserved: id, owner, creation, modified, modified_by, docstatus, doctype, parent, parenttype, parentfield, idx. name is 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; never await in a controller or a service.
  • Business errors: ddcore.throw(_("message"), { title: _("Title") }) — becomes HTTP 417 ValidationError and a toast in the desk.
  • Once submitted (docstatus 1), only fields with allowOnSubmit change through save(); everything else goes through doc.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; run ddcore types.
  • A renamed field or DocType declares renamedFrom; without it the rename is a new empty column and an orphaned old one. See migrations.
  • 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. See extending.
  • Tests: each it runs 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 the Release GitHub Action, creating a GitHub Release with cross-platform static archives (ddcore-<os>-<arch>.tar.gz and SHA256SUMS) 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 starts FROM it, and that tag is the site's pin — :<X.Y> as ddcore init / ddcore deploy write it (the newest patch of the minor its ddcore range allows), :<X.Y.Z> for one exact release.
    • Pushing to main updates the rolling prerelease edge (VERSION=edge for install.sh) when the push touches what the binary carries — a push of docs alone does not; the installer's default and ddcore doctor's update check follow the newest tagged release.
  • 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.
  • The changelog is written before the tag: entries accumulate under ## Unreleased and are promoted to a ## <version> — <YYYY-MM-DD> heading in the commit the tag then points at. The file ships inside the binary — ddcore://changelog and the whats_new MCP tool read it — so a version tagged without its section publishes news no reader can find. The procedure is in DEVELOPMENT.md.

App compatibility contract ​

An app declares its own release and the ddcore releases it supports in ddcore.app.ts:

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.

json
{ "apps": ["apps/base", "apps/hr"], "ddcore": ">=0.20.0 <0.21.0" }
  • ddcore init writes it for the running minor release (and leaves it out on a build that is not a release). ddcore new-app in a site that declares it gives the new app no range of its own, only a comment pointing at ddcore.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 is ddcore.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 Dockerfile starts FROM; keep it inside this range and move both in the same commit.

  • It comes from ddcore.json only — 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.

  • ddcore is a list of space-separated constraints that must all hold: >=, >, <=, <, = (or a bare version), ^ and ~:

    rangeallowsrangeallows
    ^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.0

    A part nobody wrote is a range, not a zero: ^0 is every 0.x while the spelt-out ^0.0.0 is that patch alone, and ~1 bounds the major the way ^1 does.

  • A version is 1, 1.4 or 1.4.0, with or without a leading v; a missing minor or patch reads as 0, so >=0.14 is >=0.14.0. Nothing else is a version: ||, a comma, *, x and 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 is empty range, not an absent one.

  • version is 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 dev and mcp keep 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.0
    • app shop declares an invalid ddcore range ">= 0.14.0": "" is not a version
    • app shop declares version "1.4.0-beta.1", which is not a version (1, 1.2 or 1.2.3)

    An invalid range or version is 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.1 is 0.15.0. Only dev, latest or a bare hash are not releases; those parse the ranges but do not enforce them, and log core version is not a release; ddcore ranges are not enforced.

  • A build that injects no version is still a release. internal/engine.Version holds an in-source default, bumped by hand in each release commit, and only the Makefile and the release workflow override it through -ldflags. A plain go build, go run or go install therefore reports that default as its release and enforces ranges against it, so an app whose range excludes it fails to load with but this binary is <default>.

  • ddcore version prints the binary's version; ddcore doctor lists each app's version and range, and the export manifest records the app versions next to the core's.

  • Because 0.x minors may break, bound the range above (^0.14.0 or <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.

MIT Licensed · ddcore (Data Driven Core) · Development documentation (main)