Controllers and the server API
import { defineController, whitelisted, _ } from "@ddcore/sdk";
import type { Order } from "../../.ddcore/types";
export default defineController<Order>("Order", {
beforeInsert(doc) {}, // before the id is generated
validate(doc, ctx) { // every write (insert and update)
doc.total = (doc.items || []).reduce((s, i) => s + ddcore.utils.flt(i.qty) * ddcore.utils.flt(i.amount), 0);
if (doc.total < 0) ddcore.throw(_("Negative total"), { title: _("Order") });
},
onUpdate(doc) {}, // after writing (insert and update)
onSubmit(doc) { doc.dbSet("status", "Active"); },
onCancel(doc) {},
onUpdateAfterSubmit(doc) {},
onTrash(doc) {}, afterDelete(doc) {},
beforeRename(doc) {}, afterRename(doc) {},
beforeMerge(doc) {}, afterMerge(doc) {}, // doc is the one merged away; doc.flags.mergeInto the one that stays
onLoad(doc) { doc.balance = /* … */ 0; }, // fills `computed` fields when a form loads the doc; nothing is stored
methods: { // POST /api/resource/Order/<id>/<method>; in the desk: frm.call("summary", { x: 1 })
summary(doc, args, ctx) { return { items: doc.items.length }; },
settle(doc, args) { doc.append("settlements", { /* … */ }); doc.save(); return { balance: doc.balance }; },
},
hasPermission(doc, ptype, user) { /* true|false|undefined; ptype includes "share" — see `sharing` */ },
permissionQuery(user) { return [["owner", "=", user]]; },
});
// a loose function, exposed at POST /api/method/<app>.services.<file>.lookup
export const lookup = whitelisted((args: { postcode: string }, ctx) => ({ /* … */ }), { allowGuest: false, roles: ["Manager"] });
// callable by Website Users too (portals); their reads inside it see only what the portal grants
export const myPayslips = whitelisted(() => ddcore.db.getList("Payslip", { fields: ["id", "period"] }), { portal: true });
// `methods` is enforced: any other verb is a 405 with an `Allow` header
export const ping = whitelisted(() => "pong", { allowGuest: true, methods: ["GET"] });Inbound webhooks
A provider that calls you needs two things a JSON method does not give: an answer that is not wrapped in {data, messages}, and the exact bytes it signed. Both are opt-ins on a whitelisted method:
// the verification handshake: the challenge is echoed back as the whole body
export const verify = whitelisted(
(args) => args["hub.verify_token"] === ddcore.secret("META_VERIFY_TOKEN") ? args["hub.challenge"] : ddcore.throw("Forbidden", { type: "PermissionError" }),
{ allowGuest: true, methods: ["GET"], raw: { contentType: "text/plain" } },
);
// the event itself: check the signature over the raw body before trusting it
export const receive = whitelisted((args, ctx) => {
const { rawBody, headers } = ctx.request!;
const want = "sha256=" + ddcore.crypto.hmacSha256(ddcore.secret("META_APP_SECRET")!, rawBody!);
if (!ddcore.crypto.timingSafeEqual(want, headers!["x-hub-signature-256"] ?? "")) ddcore.throw("Forbidden", { type: "PermissionError" });
// the call arrived as Guest: do the work as the user this integration acts for
ddcore.runAs(integrationUser, () => { /* reads and writes honour that user's scopes */ });
}, { allowGuest: true, methods: ["POST"] });raw: { contentType }— the returned string is the whole body, with that content type. Anything else returned is a 500. A thrown error is still a JSON error with its type's status (see Errors below): a refusal is{ type: "PermissionError" }(403), since a bareddcore.throwis a 417ValidationError.ctx.request(andddcore.session.request) carriesrawBody, the body exactly as received (UTF-8), andheaders, names lower-cased.cookie,authorizationandx-ddcore-csrfare left out on purpose.argsis still parsed from a JSON body; a body that is not JSON (and is not sent asapplication/json) leavesargsempty instead of failing, so readrawBody.pathTail: true— the method also answers below its own path, and reads what came after it, percent-decoded, asctx.request.pathTail(""when the call named the method alone). This is for a provider that appends to the URL it was registered with: a bank that posts PIX events to<url>/pix, say. Register/api/method/<app>.services.<file>.<fn>with the bank and dispatch on the tail:tsexport const bank = whitelisted((args, ctx) => { if (ctx.request!.pathTail === "pix") { /* the PIX event, in rawBody */ } }, { allowGuest: true, methods: ["POST"], pathTail: true });A method without it does not exist at a sub-path:
/api/method/<path>/xanswers the same 404 as an unknown method, andctx.requestcarries nopathTail. The access log and the Error Log stop at the method's name (/api/method/<path>/…), so a sender that can only authenticate through its URL may carry a shared token in the tail; compare it withtimingSafeEqual.ddcore.crypto.hmacSha256(key, data)→ lower-case hex;ddcore.crypto.timingSafeEqual(a, b)compares in constant time. Never compare a signature with===. For a timestamped scheme, also check the timestamp header againstDate.now()and refuse an event older than the window you accept.hmacSha256(key, data, { keyEncoding?, output? })takes the key as"utf8"(default),"base64"or"hex"and answers"hex"(default),"base64"or"base64url".A sender that follows Standard Webhooks (the scheme ddcore itself signs with, see
webhooks) is checked in one call.ddcore.webhooks.verify(secret, headers, rawBody, { toleranceSeconds = 300 })returns a boolean: it decodes awhsec_<base64>secret, accepts several space-separatedv1,…signatures (repeatedwebhook-signatureheaders too), compares in constant time, enforces the timestamp window (toleranceSeconds: Infinityswitches it off), reads header names in any case, and returnsfalse(never throws) when a header,headersorrawBodyis missing. A missing secret (ddcore.secretreturnsnullwhen the variable is unset) or an empty one verifies nothing and givesfalse, so a site that forgot to configure it refuses every call. To have the sender retry instead of giving up, check the secret first and answer 503 with aRetry-After:tsexport const gateway = whitelisted((args, ctx) => { const { headers, rawBody } = ctx.request!; const secret = ddcore.secret("GATEWAY_SECRET"); if (!secret) ddcore.throw("Not configured yet", { type: "UnavailableError", retryAfter: 60 }); if (!ddcore.webhooks.verify(secret, headers, rawBody)) ddcore.throw("Forbidden", { type: "PermissionError" }); }, { allowGuest: true, methods: ["POST"] });
Calls from another origin
A page served from another origin — a merchant's shop calling your payment methods — can call a method only when the method opts in with cors: true and the site lists that origin:
// ddcore.json (or DDCORE_CORS_ORIGINS="https://shop.example.com,https://*.partner.example")
"cors": { "origins": ["https://shop.example.com", "https://*.partner.example"] }export const status = whitelisted((args) => ({ status: lookUp(args.code) }), { allowGuest: true, cors: true, methods: ["GET"] });- An origin is
scheme://host[:port], compared exactly and without regard to case;https://*.partner.examplematches every subdomain over https but notpartner.exampleitself;*matches any origin. An entry no browser would send as anOrigin(a path, no scheme, a wildcard elsewhere) stops the server at startup. - The browser's preflight (
OPTIONS /api/method/<path>) gets204with the origin echoed inAccess-Control-Allow-Origin,Access-Control-Allow-Methodsfrom the method'smethods(GET, POSTwithout them),Access-Control-Allow-Headers: Authorization, Content-Type, X-Tenantand a ten-minuteAccess-Control-Max-Age. A method withoutcors, or an origin outside the list, gets403. - The call itself gets the origin echoed and
Access-Control-Expose-Headers: X-Request-Id, on an error as on a result, so the page can read either. - Never credentials. No
Access-Control-Allow-Credentialsis ever sent, so the browser does not hand the page a response to a call made with the visitor's session cookie. The caller sendsAuthorization: token <key>:<secret>— only from a server or an app that can keep a key secret, never from a public page — or comes as Guest (allowGuest: true). - Everything else is unchanged:
/api/resource, every other route and every method withoutcorsanswer without CORS headers, and a request from the site's own pages (or with noOrigin) is not CORS at all. A page the app serves itself (www) is on the site's origin and needs none of this.
Hooks for another app's DocTypes: in ddcore.app.ts, docEvents: { "User": { validate(doc) {} }, "*": { onUpdate(doc) {} } }. A DocType has one controller, its owner's: defineController from a second app is refused. To add rules to someone else's DocType use docEvents, and extendDoctype for fields, properties and permissions — its hasPermission and permissionQuery chain with the owner's (any denial wins, filters are AND-ed). See extending.
Every message a person reads goes through _(), and the key is its English text. See i18n.
The document (doc)
Fields are properties; child tables are arrays. Methods: insert(), save(), submit(), cancel(), delete(), reload(), dbSet(field, value) / dbSet({ ... }) (writes straight through, no validate — allowed after submission), append(table, row), isNew(), getDocBeforeSave(), hasValueChanged(field), runMethod(name, args), applyWorkflow(action) (applies a workflow transition and reloads the document with the new state and docstatus; see workflows), doc.flags (see below).
doc.flags: context for one write
doc.flags is a free-form object that travels with one write of that document. What is set on it before insert(), save(), submit(), cancel() or delete() is what every hook of that write sees; what a hook sets is seen by the later hooks of the same write (validate can leave a note for onUpdate) and is back on the caller's doc.flags when the call returns. It is how server code tells a rule in validate that the write is the system's:
// thing.controller.ts
validate(doc) {
if (doc.hasValueChanged("category") && !doc.flags.fromMeta) ddcore.throw(_("The category cannot be changed"));
}
// the integration
const doc = ddcore.getDoc("Thing", id);
doc.category = remote.category;
doc.flags.fromMeta = true;
doc.save();The flags are never stored and never leave the server, so a client cannot set them: a write from the Desk, the REST API or MCP starts with {}. A flags key in a client's body, or in the values given to ddcore.newDoc or ddcore.getDoc({ … }), is dropped, and so is a __before one: doc.getDocBeforeSave() is the stored version, or nothing on an insert. They belong to that document only — another document saved inside a hook has its own, and getDoc of the same document again starts empty. Values must be JSON: the flags cross to the engine and back with the document.
What a delete leaves behind
A delete — from the desk, the REST API, doc.delete(), ddcore.deleteDoc or the MCP delete_doc — runs onTrash, removes the row with its child rows, attachments, comments, assignments and shares, then afterDelete. In the same transaction, so a refused or rolled-back delete leaves none of it, it records:
- A deletion Version, for every DocType, whether
trackChangesor not. It hasdeletedchecked, and itsdatais{"deleted": { …the document as it was, child rows included… }}rather than a{"changed": …}diff. Password and Vault fields, and secret-named columns, are left out, as they are from every Version. - The Versions the document already had. They stay behind, so the history of a
trackChangesdocument ends with its deletion instead of disappearing with it. - A
doc.deleteAudit Event naming who deleted what, with{"version": "<deletion Version id>"}(seeaudit).
Version, Error Log, Email Delivery and Webhook Delivery write no deletion Version: they are records already. Their deletion is still audited.
Only a System Manager reads the Versions of a document that no longer exists. To find what was deleted, list Version with Deleted checked, or filter it on the DocType and id. A document later created under the same id is a different document: other readers of it see its own Versions only, while a System Manager sees the whole timeline, deletion included.
There is no undelete. The snapshot is there to be read, and a document can be re-created from it by hand.
Merging duplicates
Two documents that turn out to be the same thing (two Units imported from two systems for one store) are merged by renaming one into the other with merge:
ddcore.rename("Unit", "A", "B", { merge: true }); // A's references move to B, then A is deletedThe desk's form menu has Merge into…, and REST has POST /api/resource/Unit/A/rename with { "id": "B", "merge": true }. A plain rename into an id that exists is still a DuplicateEntryError.
What moves to B, in one transaction:
- every Link and Dynamic Link to A, in every DocType, child tables included, and children of submitted documents too (the sweep writes the column; no hook or validation runs on those rows);
- A's comments, attachments, ToDos, shares, Versions, notifications, Email and Webhook Deliveries, Audit Events and import-ledger records, and the
User Permissions whosefor_valueis A. Where B already holds the same record — a share with the same user, a notification of the same rule to the same recipient, an import record — B's is kept and A's goes with A (or stays under A's id, for the kinds a delete keeps).
Then A is deleted the ordinary way: its onTrash and afterDelete run, its child rows go, a deletion Version holds A as it was, and a doc.delete Audit Event is written, followed by a doc.merge one on B ({"from": "A"}).
Rules:
- The DocType has
allowRename. The caller needs write on A and on B, and delete on A. - Refused: merging into itself; a
User; a submitted A (cancel it first); a tree node merged into one of its own descendants, or one with children merged into a node that is not a group; and a merge that would break auniqueLink field or auniqueKeysentry holding one (two Stores withunitunique, one on each Unit) — fix those rows first. - A tenant-owned DocType merges inside the tenant, which moves only that tenant's references. A shared one is merged from the platform space, like a rename.
- Values copied from A by
fetchFrom(an Employee'sunit_name) keep A's text until the row is saved again; only the Link itself is repointed.
Hooks, on A's document, with doc.flags.mergeInto naming B (and the same flags in A's onTrash and afterDelete):
beforeMerge(doc) { // throw to refuse
const into = ddcore.getDoc("Unit", doc.flags.mergeInto);
if (doc.tagone_code && into.tagone_code && doc.tagone_code !== into.tagone_code)
ddcore.throw(_("Both Units have a TagOne code"));
},
afterMerge(doc) { // A is gone: copy what B lacks
const into = ddcore.getDoc("Unit", doc.flags.mergeInto);
if (!into.tagone_code) { into.tagone_code = doc.tagone_code; into.save(); }
},Copy unique values in afterMerge, not beforeMerge: until A is deleted it still holds them. beforeRename and afterRename do not run on a merge.
POST /api/resource/<DocType>/A/merge_preview with { "id": "B" } answers what the merge would do, writing nothing and checking the same permissions: { from, into, links: [{ doctype, label, fieldname, fieldLabel, count }], records: [{ doctype, label, count }], blockers: ["…"] }. A non-empty blockers is the list of reasons the merge would be refused.
ddcore.* (global on the server)
ddcore.secret(name)— an integration credential fromDDCORE_SECRET_<NAME>, ornull;ddcore.env(name)— a deployment setting that is not a credential (a switch, a region) fromDDCORE_APP_<NAME>, ornull.ddcore doctornames the secrets and prints the settings with their values (seeauth, "Secrets")ddcore.db.getValue(doctype, id | filters, field | [fields])— a value or an object (ornull)ddcore.db.getSingleValue(doctype, field)— one field of a Single (see "Single DocTypes"); before the first save, the field's defaultddcore.db.getList(doctype, { filters, fields, orderBy, limit, start, groupBy })— every matching row unlesslimitis set (0is no limit too); respects permissions;getAllskips role permissions but still applies user access scopes (seescopes)ddcore.db.setValue(doctype, id, field, value)/setValue(doctype, id, { ... })— no validate; updatesmodified; skips role permissions but applies user access scopes, the closed-DocType, workflow and Audit Event refusals (seescopes)ddcore.db.count(doctype, filters),ddcore.db.exists(doctype, id | filters)→ the id ornull; applies user access scopes (seescopes)ddcore.db.sql("SELECT ... WHERE x = $1", [v])— read-only; tables aretab_<snake>ddcore.db.lock(key)— an advisory lock held until the transaction ends: another request locking the same key waits. Use it to make an operation idempotent under concurrency (ddcore.db.lock("billing:" + contract)). Inside a tenant the key is the tenant's own (seetenancy)ddcore.db.tryLock(key)→truewhen it took the key,falseat once when another transaction holds it. The same lock aslock— held until the transaction ends, the tenant's own inside a tenant — without the wait. A job that waits onlockholds a worker for as long as the holder takes; a job that does not get the key can queue itself again and free its worker instead:tsThe running job no longer holds itsif (!ddcore.db.tryLock("portal:reply:" + session)) { ddcore.enqueue("portal.services.bot.reply", args, { runAfterSeconds: 10, uniqueKey: "portal:reply:" + session }); return; }uniqueKey, so the new one is queued; a second job deferring itself under the same key meanwhile joins it instead of stackingddcore.db.savepoint(fn)→ whatfnreturns. Whenfnthrows, its writes are rolled back and the error rethrown, and the transaction goes on. See "Savepoints" belowddcore.externalDb("sql_server").sql("SELECT ... WHERE x = @p1", [v], { timeout })— read-only query on another database (SQL Server), configured fromDDCORE_SECRET_SQL_SERVER_*. Seeexternal-dbddcore.getDoc(doctype, id, { ignorePermissions }),ddcore.newDoc(doctype, values),ddcore.deleteDoc(doctype, id, { force }),ddcore.rename(doctype, oldID, newID, { merge })(merge: truefoldsoldIDinto an existingnewID; see "Merging duplicates")ddcore.getDoc(doctype, id, { ignorePermissions: true })loads a document the user has no role permission to read, so a service can load, change andsave({ ignorePermissions: true })it under the caller's identity. The user's access scopes and the tenancy wall still apply (seescopes), anddoc.reload({ ignorePermissions: true })reads it again the same way. The document comes back whole, with no field-level redaction:ddcore.redactit before a method returns it to a client (seefield-permissions)ddcore.throw(msg, { title, type, extra, status, retryAfter })— see "Errors" below;ddcore.msgprint(msg, { title, indicator, alert }),ddcore._(text, args)/_()ddcore.session→{ user, roles, lang, request }(request:{ method, path, ip, rawBody, headers }on a whitelisted call, pluspathTailwhen the method opts in — see Inbound webhooks);ddcore.user();ddcore.getRoles(user);ddcore.hasPermission(doctype, ptype, doc, user?)(docmay be just{ id, owner }, or a document id;userchecks another user's roles, scopes and shares instead of the current one's)ddcore.share.add(doctype, id, user, { write, share, overrideScope })/remove(doctype, id, user)/list(doctype, id)— per-user document shares, checked with the current user as sharer. Seesharingddcore.users.invite({ email, fullName, roles?, userType? })/resendInvite(user)— create an account and mail its invitation; returns{ user, expires, link? }. Without System Manager, only a Website User with no privileged role. Seeportalddcore.users.createApiKey(user, { label?, days?, prefix? })→{ key, secret, expires }— issues an API key for another user, whatddcore apikeydoes, so provisioning a tenant (its users and its integration's key) can be one method or anonTenantCreate. The caller must be a System Manager outside portal mode, or Admin; anyone else gets aPermissionError. Inside a tenant only that tenant's users can get one (another tenant's user is reported as not existing); from the platform space the key is made in the user's own tenant.daysdefaults to the site'sapiKeyDays(seeauth), andexpiresisnullfor a key that never expires.prefixmakes the key's id<prefix>.<random>instead of the random part alone, so whoever holds the key can tell what it belongs to (a tenant's slug, say) without asking the database; it has a tenant id's shape (lowercase letters, digits,-and_, starting with a letter or digit, at most 63 characters), and anything else is aValidationError. The secret is returned this once, since only its hash is stored; anapikey.createaudit event on the User records who issued which key, never the secret. The key signs in asAuthorization: token key:secretddcore.redact(doctype, doc)→ a copy ofdocas an API read would show it to the current user: Password/Vault blanked and fields above their permission level removed. An unknown doctype throws (NotFound). Server code sees whole documents; redact before a method or report hands one to a client. Seefield-permissionsddcore.cache.get/set(key, value, ttlSeconds)/del— an in-memory cache per process.setstays in the process that made it;deldrops the key there at once and, when the transaction commits, in every other process on the database that listens (server,jobs work,mcp)ddcore.http.get(url, opts?)/del(url, opts?)send GET / DELETE requests.ddcore.http.post(url, body?, opts?)/put(url, body?, opts?)/patch(url, body?, opts?)send POST / PUT / PATCH requests. Object bodies are JSON-encoded; string bodies are sent unchanged.bodyEncoding: "base64"sends a base64 stringbodyas raw bytes (Content-Typedefaults toapplication/octet-stream);bodyEncoding: "multipart"sends an array of parts,{ name, value }for a text field or{ name, base64, filename?, contentType? }for a file, asmultipart/form-data(the framework setsContent-Typewith the boundary; a file part'sfilenamedefaults to itsname, itscontentTypetoapplication/octet-stream). An unknownbodyEncoding, invalid base64, a part withoutname, or one with bothvalueandbase64throws.- All HTTP calls are synchronous and leave from the server.
HttpOptsacceptsheaders(a string map),timeout(seconds, default 15; in a job, the job's owntimeout, a cancellation and a worker shutting down cut the call sooner — seeops→ "Cancelling"),responseType("text", the default, or"base64"for a binary body such as an image or audio) andmaxBytes(the largest body accepted, default 10 MiB; a larger response throws instead of being cut short). The named method determines the verb;opts.methodcannot override it. Replace olderpost(url, body, { method: "PUT" })orget(url, { method: "DELETE" })workarounds withputordel. opts.clientCertpresents a client certificate to an API that authenticates by mutual TLS, as most banking APIs do. It is either{ pfx, password? }— a PKCS#12 (.pfx) file, base64-encoded — or{ cert, key }in PEM. The chain in the file is sent with the certificate; the server is still verified against the system roots. Calls with the same certificate share connections, so a token request and the operation after it pay for one handshake. Keep the file inddcore.vaultorddcore.secret:ddcore.http.post(url, body, { clientCert: { pfx: ddcore.vault.get("bank:pfx")!, password: ddcore.secret("bank_pfx_password") } }). A wrong password or an unreadable file throws aValidationErrorthat does not repeat the material.ddcore.cryptoalso makes secrets fromcrypto/rand:randomToken(bytes = 32)(base64url, no padding),randomInt(min, max)(uniform in[min, max), safe integers,max > min) andsha256(data, { output?, inputEncoding? })(hex by default;datais UTF-8 text unlessinputEncodingis"base64"or"hex", which hashes the bytes it encodes — whatsha256sumprints for a file held as base64; a stored File isddcore.files.sha256, seestorage), so a session token israndomToken(), a six-digit OTP isString(ddcore.crypto.randomInt(0, 1e6)).padStart(6, "0"), and what you store issha256(token), not the token.ddcore.utils.randomString(n)is cryptographically secure too (a-z0-9; a fraction rounds up, anything not positive gives"", and it throws only above 65536), butrandomTokencarries 6 bits per character against about 5.17 forrandomString.ddcore.crypto.pfxInfo(pfx, password?)describes the certificate in a PKCS#12 file, the same base64 input asclientCert.pfx, without making a call with it:{ notBefore, notAfter, subject, issuer, serial, chain }.notBeforeandnotAfterare RFC 3339 instants in UTC ("2027-01-31T12:00:00Z"),subjectandissuerdistinguished names ("CN=…"),seriallower-case hex, andchainhow many certificates came along with the leaf. It never returns key material. A wrong password or an unreadable file throws aValidationErrorthat does not repeat the material, so a method that stores a certificate calls it first: the password is checked on save rather than at the first call to the bank, and the expiry date comes from the file instead of being typed.ddcore.crypto.certInfo(pem)does the same for a PEM certificate, thecertof the{ cert, key }form; the certificates after the first are its chain.HttpResponseexposes{ status, body, headers, json() }.bodyis text (base64 withresponseType: "base64") andjson()parses it. Response header names use Go's canonical HTTP casing (for example,response.headers["Ratelimit-Remaining"]); repeated values are joined with", ". HTTP error statuses are returned as responses; transport errors throw.- Redirects are followed, up to 10 hops;
opts.maxRedirectschanges the limit, andmaxRedirects: 0returns the 3xx itself, with its target inheaders.Location, so the app chooses whether to follow and with which headers. A hop to another host than the one called (a different port counts), or fromhttpsto anything else, drops every header the call passed exceptContent-Type: a token sent asapi_access_tokenorX-Api-Keystays with the host it was meant for, not the object storage or CDN a download redirects to. A hop on the same host keeps them. As in every HTTP client, a 301, 302 or 303 turns the request into a GET without a body, and a 307 or 308 resends both.ddcore.files.save({ fromUrl })follows redirects under the same rule. ddcore.ai.respond({ instructions, input, tools?, schema?, model?, timeout? })→{ text, calls, output, json, usage, … }andddcore.ai.transcribe(base64, { filename, language? })→{ text }— the site's language model (an OpenAI-compatible Responses API,aiin ddcore.json, key inDDCORE_AI_API_KEY); synchronous, retried, never logged; in tests answered byddcore.test.fakeAi. Seeaiddcore.push.send(subscription, payload, { ttl, urgency, topic })→{ status, body, headers }— a Web Push message to one browser subscription, encrypted for it and signed with the site's VAPID key (DDCORE_SECRET_VAPID_*); any status is returned, so the app deletes a subscription that answered 404 or 410.ddcore.push.publicKey()→ the key a page subscribes with, ornull. Seepushddcore.files.save({ doctype?, id?, fieldname?, filename, isPrivate?, contentType?, content | contentBase64 | fromUrl, headers?, maxBytes?, timeout?, ignorePermissions? })→ theFiledocument. Stores bytes the server holds or downloads, with the rules of an upload; see storage.ddcore.files.presign(fileUrl, { ttl?, ignorePermissions? })→ a URL anyone can GET the file at forttlseconds. S3 backend only; see storage.ddcore.print.pdf({ doctype, id, format?, letterHead?, language?, pageFormat?, landscape?, filename?, ignorePermissions? })→ theFiledocument: the document's print rendered to PDF, private, attached to the document (which needs write on it), on the current transaction. WhatsendMail'sattachtakes; throwsUnavailableErrorwithout a PDF renderer. See print.ddcore.enqueue("app.services.mod.fn", args, { queue, runAfter, runAfterSeconds, timeout, maxAttempts, backoff, onStart, onFailure, uniqueKey, runAs })→ the job id. WithuniqueKey, a job stillqueuedunder the same key makes the call a no-op that returns that job's id (see ops).runAfteris when the job may start — an ISO timestamp, orYYYY-MM-DD HH:MM:SSin the site's timezone — andrunAfterSeconds(zero or more) the same as a delay from now; one or the other, and arunAfterthat cannot be read throws aValidationErrorinstead of running the job now.queue(default"default") picks the worker pool: a queue with a pool of its own inworkersof ddcore.json is served by it alone, so slow work there does not hold up the rest (seeops→ "Worker pools"). Written on the current transaction, so the job exists only if the request commits.maxAttemptsdefaults to 3;1stops retries after a failure, but it is not "runs at most once": a run a worker shutdown gives back runs again.ddcore.job.current()→ the running job'sJobInfo, ornulloutside a job; itsstartsis above 1 when an earlier run began, which is what a body doing non-idempotent work checks (seeops→ "Statuses").onStart/onFailureare method paths called asfn(args, job), each in a transaction of its own, so a document can show that its job is running or that it failed. Seeops→ "Lifecycle callbacks".runAsis the user the job acts as: without it a job runs with permissions ignored, with it the body and both callbacks run under that user's roles and access scopes.ddcore.runAs(user, fn)→ whatfnreturns. Runsfnunderuser's roles and access scopes, in the current transaction, and restores the caller afterwards, even whenfnthrows. It is how a job, a scheduled method or a guest webhook gets the isolation a request has; the user must exist and be enabled. Seescopes→ "Running as a user:runAs"ddcore.callMethod("app.services.mod.fn", args)→ what the function returns. Calls an exported function of any loaded module asfn(args, ctx), now, in the current transaction; no role orwhitelistedcheck. Seeconventions→ "Calling another app's server code"ddcore.isTest()→trueinsideddcore test;ddcore.isJob()→trueinside a background jobddcore.publish(event, payload, { user, doctype, id })— SSE to the desk when the transaction commits. Withuser, only that user's sessions; withdoctypeandid, only sessions that may read that document (withdoctypealone, the DocType) — the audiencedoc_updatehas. The name is letters, digits and_ . : -; prefix it with the app's name. The desk listens withfrm.onRealtime/ddcore.realtime.on(seeform-api)ddcore.log.info/warn/error/debug(...args)write one structured record to the server log, with the current user. A plain object's keys become fields of the record, and the other arguments are joined into its message: a string as is, anErroras"Error: message", anything else as JSON. Soddcore.log.warn("gateway retry", { method, status, requestId })logsmsg="gateway retry"withmethod,statusandrequestIdas fields a collector can index. A field namedtime,level,msgoruseris written asarg.<name>. Logging never throws: a value JSON cannot hold (a cycle) is written withString.console.log/info/warn/error/debugdo the same;ddcore evaland the MCPevaltool print each record aslevel: message {fields}.ddcore.errorLog.record(error, opts?)→ the id of a newError Logrow, written on a transaction of its own, so it stays whether the caller's work commits or rolls back, andddcore doctorcounts it.erroris anError, aDDCoreErroror a string. The row's text isType: title — message, leaving out the parts there are not, then the stack, thenopts.contextas JSON. Itsrequest_idis the request's id, orjob:<id>in a job; its source (method) isopts.method, orjob:<method>in a job, orapp.record. It lands in the current tenant, and is written in maintenance mode too. It never throws: when the row cannot be written it returns"", and the process log still has the line. A very long message, stack or context is cut. See "Savepoints" below for the patternddcore.utils:flt(v, precision),cint,cstr,getdate,nowdate(),now(),formatDate(d, "dd/mm/yyyy"),addDays,addMonths,addYears,getFirstDay,getLastDay,dateDiff(a, b),monthDiff(a, b),formatCurrency(v),roundTo,randomString(cryptographically secure)ddcore.utilsfor money:currencyPrecision(),roundCurrency(v),splitAmount(total, n)
nowdate() and now() are the site's wall clock (ddcore.json:timezone), the same day and hour the desk sees. A Datetime written without an offset — which is what now() returns — is read on that same clock.
Date.parse(s) and new Date(s) in server code read an instant with an offset in ISO form ("2026-10-05T10:00:00-04:00") and in the form Postgres and many APIs write, with a space instead of the T and a short offset ("2026-10-05 10:00:00-04:00", "2026-10-05 22:31:52.767353+00"), as Node does. Fractions beyond milliseconds are cut. A date-time without an offset is read on the server process's clock, not the site's timezone: pass an offset, or use ddcore.utils for wall-clock values.
Errors
ddcore.throw(msg, opts) aborts the transaction with a typed error. Its type (default ValidationError) picks the HTTP status an API call answers with:
type | Status |
|---|---|
ValidationError, MandatoryError, LinkExistsError | 417 |
AuthenticationError | 401 |
PermissionError | 403 |
DoesNotExistError (or NotFound) | 404 |
MethodNotAllowedError | 405 |
TimestampMismatchError, DuplicateEntryError | 409 |
TooManyRequestsError | 429 |
InternalError | 500 |
UnavailableError, MaintenanceError | 503 |
| any other string | 500 |
The same table applies to an error raised in Go that passes through app code — a DuplicateEntryError from insert, a MaintenanceError from a paused write — so it answers the same status whether or not a controller sat between it and the caller. The other options:
title— shown above the message in the desk.extra— an object of data for the caller, not prose (a machine code, the fields at fault). It reaches the API caller unchanged, in the JSON error body; the desk'sDDCoreErrorcarries it ase.extra.status— an HTTP error status (400–599) that overrides the type's:{ type: "ValidationError", status: 422 }. Any other value is ignored and the type's status stands.retryAfter— seconds the caller should wait, rounded up. It is stored asextra.retryAfterand also sets theRetry-Afterheader, so a webhook sender or an HTTP client backs off. Writingextra: { retryAfter: 60 }does the same.
ddcore.throw("Contract is suspended", { type: "PermissionError", extra: { code: "SUSPENDED" } });
ddcore.throw("Not configured yet", { type: "UnavailableError", retryAfter: 60 });answers 403, then 503 with Retry-After: 60, and a body of this shape (title and extra only when given):
{"error": {"type": "UnavailableError", "message": "Not configured yet", "extra": {"retryAfter": 60}, "requestId": "…"}}A 5xx other than MaintenanceError also writes an Error Log row, keyed by the same requestId; a 4xx does not. In server code a caught error keeps all of it: e.name is the type, e.title and e.extra are there, and so is e.status when one was given or the error came from Go; rethrowing it answers the same.
Savepoints
A failed statement aborts the whole transaction in Postgres: a try/catch catches the error, and every later query fails with current transaction is aborted. ddcore.db.savepoint(fn) runs fn inside a savepoint. When fn throws, for a SQL error or for any other reason, only fn's work is undone, the error is rethrown, and the code that catches it carries on in the same transaction. Undone with the writes are the msgprints, publishes and other after-commit effects fn made. When fn returns, its work stays and its value is returned. Savepoints nest.
A job made of independent steps keeps the work of the steps that succeeded and still makes a failing one visible: each step runs in a savepoint, and the catch files the error with ddcore.errorLog.record instead of rethrowing it, which would roll back every step.
export function reconcile() {
for (const step of [reconcilePending, sweepSettlements]) {
try {
ddcore.db.savepoint(() => step());
} catch (e) {
// its own transaction: the row stays, under this job's handle, job:<id>
ddcore.errorLog.record(e, { context: { step: step.name } });
}
}
}A value a unique index refuses, from insert, save or ddcore.db.setValue, throws a DuplicateEntryError. Catching it turns an idempotent create into an ordinary outcome, with no lock around it:
export function createCharge(args: { reference: string; amount: number }) {
const doc = ddcore.newDoc("Charge", { external_reference: args.reference, amount: args.amount });
try {
ddcore.db.savepoint(() => doc.insert());
} catch (e: any) {
if (e.name !== "DuplicateEntryError") throw e;
// a retry: return the charge the first call made
return ddcore.getDoc("Charge", ddcore.db.exists("Charge", { external_reference: args.reference })!);
}
return doc;
}fn is synchronous, as all server code is. A savepoint needs the request's transaction, which every controller, method and job has.
Money
The framework rounds a Currency field on its way into the database, at the site's precision and under the site's rounding rule (see fieldtypes). By the time validate runs, a Currency field on doc is already rounded, and anything the hook assigns is rounded again before the insert. So app code needs these only for arithmetic between fields:
u.roundCurrency(subtotal * 1.07) // the way the server is about to store it
u.currencyPrecision() // 2 on a USD site, 0 on a JPY one
u.splitAmount(100, 3) // [33.34, 33.33, 33.33] — sums to exactly 100roundTo(v, 2) hardcodes an answer the site may not share; roundCurrency asks. And splitAmount exists because three rounded thirds of 100.00 are 33.33 each and leave the schedule a cent short of its total — the residue lands on the earliest parts, always the same way, so a schedule recomputed is a schedule unchanged.
Two paths deliberately bypass all of this, because they bypass the document layer entirely: ddcore.db.sql and a patch's SQL. What they write is what the column gets.
Filters: { field: value, other: [">", 10] } or [["field", "=", v], ["Child Table", "field", ">", v]]. Operators: = != > >= < <= like not like in not in between is set not set, plus the tree operators descendants of, descendants of (inclusive), not descendants of, ancestors of and not ancestors of on a tree DocType's id or a Link to one (see trees). In the list shape, an item { any: [group, ...] } ORs groups of filters, each written in either shape above. A row matches when every filter of at least one group does, and { any: [] } matches nothing. [["amount", ">", 0], { any: [[["status", "=", "Open"]], [["status", "=", "Overdue"], ["owner", "=", user]]] }] is amount > 0 AND (status = Open OR (status = Overdue AND owner = user)). The REST list's or_filters holds a single OR group, and the Desk's search already uses it; an any item is how a list adds an OR of its own. A Datetime filter value without an offset — ddcore.utils.now(), a string you formatted — is read on the site's clock, as a write is, on creation and modified too; one with an offset keeps its instant, and a date alone (["creation", "<", "2026-10-10"]) means midnight on the site's clock. fields accepts aggregates: "count(id) as n", "sum(amount) as total".
Tests
import "@ddcore/sdk/test";
describe("Order", () => {
beforeEach(() => { /* fixtures */ });
it("adds up", () => { const o = ddcore.newDoc("Order", {/* … */}).insert(); expect(o.total).toBe(20); });
it("refuses", () => { expect(() => ddcore.newDoc("Order").insert()).toThrow("required fields"); });
});expect: toBe, toEqual, toBeTruthy/Falsy, toBeNull, toBeDefined, toContain, toBeGreaterThan(OrEqual), toBeLessThan(OrEqual), toBeCloseTo, toHaveLength, toMatch, toThrow(text|regex), .not.
beforeEach, afterEach and beforeAll belong to the describe they are written in. Written outside any describe, they belong to their file: they wrap that file's tests and no other file's, whatever app it is in.
Tests run as Admin, which no role or User Permission restricts. To see a permission or a scope at work, run part of a test as another user with ddcore.test.asUser(user, fn):
it("a parish manager sees only their parish", () => {
ddcore.newDoc("User", { email: "manager@x.test", full_name: "Manager", roles: [{ role: "Parish Manager" }] }).insert();
ddcore.newDoc("User Permission", { user: "manager@x.test", allow: "Parish", for_value: "PAR-0001" }).insert();
ddcore.test.asUser("manager@x.test", () => {
expect(ddcore.db.getList("Tither", { fields: ["parish"] }).every((t) => t.parish === "PAR-0001")).toBe(true);
expect(() => ddcore.getDoc("Tither", otherParishTither)).toThrow();
});
});fn runs with that user's roles, User Permission scopes, shares and user type, inside the test's transaction. Users and User Permissions the test inserted count, and roll back with it. ddcore.session.user names the user, asUser returns what fn returns, and the previous user is back afterwards, even when fn throws. ddcore.test exists only inside ddcore test.
On a site with tenancy, the tests of an app with tests.space: "tenant" run inside a scratch tenant the run creates. That is the default for an app with space: "tenant". ddcore.test.inPlatform(fn) runs part of such a test in the platform space. See tenancy, Tests.
ddcore.ai never reaches the network in a test: ddcore.test.fakeAi({ respond, transcribe }) answers it, receiving the exact body a real call would send. See ai, Tests.
Single DocTypes (settings)
Declare isSingle: true for one configuration per DocType and instance. Singles use normal typed tables, standard metadata, validation, hooks and child tables, with id: "singleton" and docstatus: 0 enforced by PostgreSQL.
const settings = ddcore.getDoc("Project Settings");
settings.planning_enabled = false;
settings.save();
settings.reload();To read one field without loading the document (and its child tables), use ddcore.db.getSingleValue("Project Settings", "planning_enabled"); like db.getValue, it skips role permissions.
Omitting the id is supported only for Singles. Before the first save, getDoc returns an unsaved document with the usual defaults and empty child tables; reading never inserts a record. Defaults do not overwrite persisted values. Saving requires write, including the first save; create alone does not grant it. Reads require read, including reads of defaults. Explicit privileged contexts keep their usual meaning. Owner-only permissions have no owner to match before the first save.
The first save runs insert hooks and onUpdate; subsequent saves use the update lifecycle. Rollback also rolls back settings and children. Two competing first saves produce one success and one duplicate conflict. Updates use the ordinary modified timestamp conflict check; reload before retrying a rejected save.
REST uses GET / PUT /api/resource/{doctype}/singleton; PUT also performs the first save. POST to /api/resource/{doctype} inserts only once. Other identities, deleting, renaming a record, submitting, cancelling and amending are unsupported. Singles cannot declare idGeneration, allowRename, submittable, or isChild. List/count/database queries see only persisted rows. Export of Singles is not yet supported. Password redaction and attachment/history permissions still apply.
Desk opens /app/{doctype} directly as a settings form. Browser scripts may use await ddcore.db.getSingle("Project Settings") and await ddcore.db.setValue("Project Settings", "singleton", values).