ddcore 0.27
0.27.32 — 2026-10-16
Added
- A dialog takes extra, non-destructive buttons (#133):
ddcore.ui.Dialog({ actions: [{ label, icon?, action(values, dlg) }] })renders them between the close button and the primary one. They keep the dialog's buttons disabled while an async action runs and show its error, likeprimaryAction, but check noreqdfield and leave closing todlg.hide(). The desk SDK'sDialogSpecalso gainshideSecondary, andsecondaryLabelis documented: it names the close button. Seeddcore.ui.Dialogindocs/agent/form-api.md.
0.27.31 — 2026-10-16
Added
- Merge duplicate documents (#129).
ddcore.rename(doctype, a, b, { merge: true }), RESTPOST /api/resource/<DocType>/<a>/renamewith{ "id": "<b>", "merge": true }, and the form menu's Merge into… fold a document into another of its DocType that already exists: every Link and Dynamic Link to it (child tables and submitted documents included), its comments, attachments, ToDos, shares, Versions, deliveries, audit events and User Permissions move to the one that stays, and it is then deleted the ordinary way, with adoc.mergeAudit Event on the survivor. The DocType needsallowRename; the caller needs write on both and delete on the one merged away. NewbeforeMerge/afterMergehooks run on that document withdoc.flags.mergeIntonaming the survivor, so an app can refuse a merge or copy fields across. A merge that would break auniqueLink is refused, and so are tree merges that would break the hierarchy.merge_previewon the same route reports what would move, and the desk shows that preview before the merge. See "Merging duplicates" indocs/agent/controller-api.md.
Fixed
- A rename carries the User Permissions that name the document.
for_valueis a Data field, so the reference sweep never saw it: renaming a Company left every user scoped to it pointing at the old name, which then matched nothing. It now follows the rename (and a merge). Seescopes. - Renaming a Role refreshes its holders' cached roles. The sweep rewrote their
Has Rolerows but left each user's cached role list naming the old role until the process restarted.
0.27.30 — 2026-10-16
Added
grids.<field>.onRowClick(frm, row): a click anywhere on a grid row (#132). A form script acts on a click on a Table's or a Report field's row outside its editable controls, links and buttons, and the row shows a pointer. It receives the row clicked, asonCellClickdoes. See "Grids: row changes and cell clicks" indocs/agent/form-api.md.
Changed
- An
onCellClickcell takes the click anywhere in it (#132), its padding and an empty value included, not only on the text it shows. A Report field's empty cell still takes none.
Fixed
- A workspace shows each reader only the entries they may open (#131). Boot used to send a workspace's
sidebar,shortcutsandlinkswhole, so a user who passed itsrolesbut could not read one of its DocTypes (or whose roles did not open one of its reports) saw the entry and landed on "DocType X does not exist". Boot now leaves out every entry whosedoctype/refDoctypeis not among the reader's DocTypes or whosereportis not among their reports, number cards and charts included, and a sidebar heading left with nothing under it. Seereport-api.
0.27.29 — 2026-10-15
Added
- The Columns menu reorders the list's columns (#130). The visible columns are dragged into another order (or moved with Alt+Up/Down); the id stays first and Modified last. The order is kept with the rest of the user's choice, and the icon set gains
grip-verticalfor its handle. SeedefineListViewindocs/agent/form-api.md.
Changed
- A list's CSV export always carries the id, first (#130), even when the id column is hidden, so the file loads back through Data Import in update mode. Both the page on screen and "All rows matching the filters" do. See "The desk" in
docs/agent/export.md. - A list's search matches the columns on screen (#130): besides the id,
titleField,searchFieldsand the listed Links, every visible text,SelectorLinkcolumn — a column a user adds with the Columns menu included. SeedefineListViewindocs/agent/form-api.md.
0.27.28 — 2026-10-15
Added
- A list's Columns menu: each user hides and adds columns (#130). Next to Filters, the List view has a Columns button that hides any column (the id and Modified included) and adds any other field the user may read. The choice is kept in the browser per user, tenant and DocType until Reset to default; the app's
columns,idColumnandmodifiedColumnare where a user starts.defineListView({ columnChooser: false })takes the menu away. SeedefineListViewindocs/agent/form-api.md.
Changed
- A list's CSV export holds the columns on screen (#130). Both the page on screen and "All rows matching the filters as CSV" follow the visible columns in their order, Modified included when shown; the server CSV used to carry every column. NDJSON still carries every column, for an import. See "The desk" in
docs/agent/export.md.
Fixed
/api/exportwithfieldsthat leave outidrepeated the first page forever: the walk pages on the id it no longer read. It now reads it and leaves it out of the file (#130).
0.27.27 — 2026-10-15
Fixed
- The browser tab keeps the site's name while the desk boots. The desk's own
<title>replaced the one the server wrote into the shell with "ddcore" until/api/bootanswered; before the boot it now falls back to the shell's name.
0.27.26 — 2026-10-12
Added
desk.themeand an imagedesk.logo: the desk wears the app's identity.defineApp'sdesk.themedeclares the palette (primary,primaryHover,onPrimary,bg,surface,surfaceAlt,border,text,muted,accent,accentSoft), the fonts (body,headingand anhttps://stylesheet) and the browser'sthemeColor;desk.logoalso accepts{ image, favicon? }, image files of the app. Resolved likehome: the site's own app decides. The server writes the theme, the favicon and the site's name into the shell, so the sign-in, password-reset and portal pages wear them from the first paint, andbootcarries them assite.brand. The desk's components now read tokens instead of ~230 colour literals, with hover, selection and focus tints derived fromprimary, so a theme of three colours holds together; status colours and the space mark stay the core's. Every site now has a favicon — its letter square when no image is declared — at/assets/site/favicon(#128).
0.27.25 — 2026-10-10
Fixed
- Entering a tenant opens the desk on
desk.home. On a site with tenancy, going from the platform into a tenant opened the first workspace boot lists, not the site'sdesk.home: the remembered workspace was the platform's, so the sidebar fell back to the first workspace and remembered it before/appchose where to land. The sidebar now falls back the way/appdoes — the remembered workspace, elsedesk.home, else the first (#127).
0.27.24 — 2026-10-10
Added
ddcore.env(name)reads a setting that is not a secret. A switch, a region or a public id comes fromDDCORE_APP_<NAME>(ddcore.env("register_meta_webhook")readsDDCORE_APP_REGISTER_META_WEBHOOK), ornullwhen unset, instead of posing as aDDCORE_SECRET_….ddcore doctorprints these on anapp envline with their values; the secrets line still shows names only (#126).onBootindefineApp. Runs once per process after it is up: onceddcore startorddcore devlistens for HTTP, or onceddcore jobs workstarts its workers, whether or not that boot migrated, and not on a reload or addcore migrate. Each app's runs in its own transaction, asAdminin the platform space; an error is logged and the process keeps serving. For registering a webhook with a provider that calls the site back to verify it, whichafterMigrate(inside the migration, before the server answers) cannot do. Every replica runs it, so keep it idempotent — enqueue a job with auniqueKey(#126).
Fixed
- A Datetime filter reads a value without an offset on the site's clock. Writes already did; a filter sent the string to Postgres, which read it as UTC, so on a site in
America/Sao_Paulo["creation", "<", ddcore.utils.now()]missed the last three hours and a bare date meant 21:00 of the day before.=,!=,<,<=,>,>=,between,inandnot inon aDatetimefield,creationormodified(a child table's included) now cast the value the way a write does, ingetAll/getList, REST, reports and every other list. A value with an offset keeps its instant; rawddcore.db.sqlis unchanged (#125).
0.27.23 — 2026-10-09
Added
- Taller text areas.
rowson aSmall Text,Text,Markdown EditororCodefield sets how many lines tall it is on a form or in a dialog — a long prompt no longer means scrolling an 8-row box. Unset keeps 2, 5, 8 and 8; a grid cell keeps its compact height; a negative value, orrowson any other fieldtype, fails the load (#124).
Fixed
- Field buttons wrap on a narrow screen. A row of
frm.addFieldButtonbuttons (under anHTMLfield, beside an input or in a grid's toolbar) ran past the form's right edge on a phone; it now breaks onto the next line (#123).
0.27.22 — 2026-10-09
Added
- A grid shows that a script is loading its rows.
frm.setDfProperty(table, "loading", true)replaces "No rows" with a spinner and "Loading...", or dims the rows on screen during a reload. While it is on, the grid's field buttons, its actions on the selected rows and "Add row" are disabled. See "A grid a script fills" inform-api(#122). - Totals above a list.
defineListView({ summary })puts cards above the rows, styled like a report's summary: a count or the sum of a field (aggregate: "count" | "sum:<field>") over every row the list's filters and search match, not only the page on screen, recomputed as the list reloads. A card may narrow itself with its ownfilters, and takes adatatypeand anindicator. It runs as the reader, through the list API. See "Totals above the list" inform-api.
Changed
ddcore.callruns the progress bar at the top of the desk while it is out, asfrm.callalready did. A page filled by a slow whitelisted call no longer looks idle (#122).- A field button waits for its
onClick. WhenonClickreturns a promise, the button stays disabled, with a spinner in place of its icon, until the promise settles. Its error is shown. Clicking "Reload" again while it runs does nothing (#122).
0.27.21 — 2026-10-08
Added
- An app adds its own cards to the profile page.
defineProfileSectionin@ddcore/desk-sdk, called from adesk.includeorportal.includescript, puts a card after the Password card on/app/profileand/portal/profile: atitle, read-onlyinfolines from whatloadreturns (nullhides the card),fieldsrendered as in a form, and asubmitwhose errors show on the card and whose success clears the fields and reloads it. Several apps may each add theirs. See "defineProfileSection" inform-api(#121).
Changed
- The profile page shows the Password card only to someone with a local password.
profile.getMyProfileanswershasPassword. Someone who signs in only through a credential provider or SSO has no current password to givechangeMyPassword, so the card was a dead end for them (#121).
Fixed
- A desk user whose roles open none of the space's workspaces is told so. The desk home said "No workspace declared" and suggested
ddcore new-app, as on a site with no workspaces. Boot now sendsworkspacesDenied, and the home names the account and the tenant, asks for a role from an administrator and offers Sign out. See the workspacerolesinreport-api(#120).
0.27.20 — 2026-10-08
Added
- A form script reads a grid's selection and acts on it. On a Table or Report field with
gridSelect: true,frm.getSelectedRows(field)returns the ticked rows still on screen (aftergridSearchandgridFilters), in screen order, andfrm.clearSelection(field)unticks them.frm.addGridAction(field, { label, onClick, condition, primary, key })puts alabel (n)button in the grid's toolbar, as a list'sactionsdo:onClick(rows)gets the selected rows that passcondition, and the selection is cleared once it settles.removeGridAction(field, key?)takes actions away, andclearButtons()clears them at eachrefresh. A tool Single no longer needs aselectedCheck column to act on chosen rows. See "Actions on a grid's selected rows" inform-api(#118). - A Select draws its indicator, with an icon per value, in grids, lists and reports. A Select that declares
optionColorsor the newoptionIcons({ Enabled: "check" }, keyed by the canonical value, names from the desk's icon set) shows each value as its coloured indicator — the icon in place of the dot, then the translated label — in a form grid's read-only cells, list columns, report columns and read-only fields, the row dialog included.optionIconOnly: truedraws the icon alone in table cells, with the label as the tooltip, for a narrow column. An unknownoptionIconsname fails the load. A report column takes the same three properties. A Select without them still reads as text (#119).
Fixed
- The list's and a report's status cells show a Select's translated label. They used the catalogue (
__(value)) and ignoredoptionLabels, so a self-describing Select showed its canonical value there while the form showed its label (#119).
0.27.19 — 2026-10-08
Changed
- Credential sign-in asks for the organization instead of listing tenants. The provider's tab on
/loginhas an organization input (the tenant id) and goes to/login/<provider>/<tenant>, which shows that organization's name and asks for the username and password.GET /api/auth/credentials/<id>/tenants/<tenant>answers one tenant ({data: {id, title}, tenancy}) or a 404 for a missing, disabled or opted-out one, and each miss counts against the client address.GET /api/auth/credentials/<id>/tenantsno longer lists anything: it answers{data: [], tenancy}./api/bootaddssite.login.tenancy. Before this, the sign-in screen showed every tenant that offered the provider to anyone who opened it. See "Sign-in through an app" inauth(#117).
0.27.18 — 2026-10-08
Added
- Sign-in through an app: credential providers. An app declares
auth.providersindefineApp: alabel, theuserFieldof User (a Data field it adds withextendDoctype) that links an account to a username of another system, an optionalenabled()per tenant, a synchronousverify({ username, password })and an optionalafterSignIn. The sign-in screen gets a tab per provider where the person picks a tenant (from a public list of the tenants whoseenabled()says so), types that system's username and password, and gets the same session a password sign-in gives. ddcore throttles before callingverify(per username in that tenant, and per address with the password sign-in), signs in only a User an administrator linked beforehand, treats averifythat throws as "unavailable" without counting it against the account, runs the network call outside the session's transaction, and auditsaccount.login_credentials.POST /api/auth/credentials/<id>/login,GET /api/auth/credentials/<id>/tenants, andsite.login.credentialsin/api/boot. Unlocking a user clears the provider's counter too. Before this, the only ways in were a local password and OpenID Connect. See "Sign-in through an app" inauth(#115). - Placeholder addresses for Users without a mailbox. A recipient under
.invalidis dropped fromsendMail; with nobody left, nothing is queued and the answer is{ delivery: "", skipped: true }instead of an error. Forgot-password sends nothing to such an address and inviting one is refused. Before this, mail to it was queued and failed in the worker (#115).
0.27.17 — 2026-10-07
Added
- Tool Singles: a form the user fills in and never saves.
tool: trueon a Single makes it a screen whose script hands what is on it to a service, such as "type a CPF, tick the installments, get a link". Anyone who can read it edits its level-0 fields and grid rows; the desk shows no Save, never marks it "Not saved", keeps no draft, does not ask before leaving and hides the sidebar; the server refuses every write to it (save,insert,dbSet, REST, MCP), to Admin too.toolon a DocType that is not a Single fails validation. Before this, a Single was read-only to a user withoutwrite, and withwriteit carried Save and a stored row that every user shared. See "DocType properties" infieldtypesand "Tool Singles" inform-api(#114).
0.27.16 — 2026-10-07
Added
- An API key can carry a prefix in its id.
ddcore.users.createApiKey(user, { prefix })andddcore apikey <user> --prefix pissue a key whose id is<prefix>.<random>, e.g.acme.ji26rchc5g:<secret>, so whoever holds it can tell which tenant or integration it belongs to without asking the database. The prefix has a tenant id's shape; anything else is aValidationError. Keys issued without one keep their random id, and every existing key keeps working. (#113)
0.27.15 — 2026-10-07
Fixed
- The sidebar's workspace switcher lists workspaces in alphabetical order. Boot sent them in Go map order, so the list, and the workspace the desk opens when none is remembered, changed from one load to the next. They are now sorted by the label the desk shows (the name when there is none), collated for the session's language and ignoring case.
0.27.14 — 2026-10-07
Fixed
ddcore maintenance on|offworks once tenancy is applied. Switching the flag first ranCREATE TABLE IF NOT EXISTS ddcore_maintenanceon the confined pool. The tenant role may write that table but not create anything inpublic, so the command failed with "permission denied for schema public". The tenancy cutover could not leave maintenance with its last step, and themaintenance_setMCP tool failed the same way. The ops tables are now created on the system pool. The same fix letsddcore backuprecord its run inddcore_backup_logagain, sodoctorsees backups taken after tenancy. (#112)
0.27.13 — 2026-10-07
Fixed
ddcore migrateno longer hangs when one run alterstab_webhookand writes a document. The migration that turns tenancy on addstenanttotab_webhook. If the same run created a new app role, a fixture, or a document from a patch or hook, the write looked up the webhook subscribers on another connection. That connection waited for the migration's lock, and the migration waited for it, with no timeout.ddcore startnever became ready. Inside a migration the subscribers are now read on the migration's own transaction. A subscription committed before the migration still receives the documents it writes. (#111)
0.27.12 — 2026-10-07
Fixed
- Inside a tenant,
getRolessees a User the same transaction inserted. The role lookup placed a user it could not find on another connection in the platform space. Soddcore.getRoles(user)answered["All"]for a User just created in a tenant, insideddcore.test.asUsertoo, and that answer could stay cached for the user. It now looks in the transaction first. (#110) - Inside a tenant, a
LinktoUseraccepts the operator who entered it.Adminand the platform's System Managers have noUserrow in a tenant, so recordingddcore.session.userfailed with "Invalid link", in the scratch tenant ofddcore testand in the desk alike. ALinkorDynamic LinktoUsernow accepts an operator there, and its title is the operator's full name. Seetenancy. (#110)
0.27.11 — 2026-10-07
Added
- Shared DocTypes only server code reaches. On a site with tenancy, a shared DocType that declares
tenantAccess: "server"is refused to every client inside a tenant, whatever the role. That covers boot, the desk, REST,/api/meta, search, link titles, export, reports, uploads and a tenant's webhooks; workspace entries that open it show in the platform space only. A tenant's server code, meaning a call that ignores permissions (db.getAll,db.getValue,db.exists,getDoc(…, { ignorePermissions: true }), jobs), reads the platform's rows. ItsinsertandsavewithignorePermissions: truewrite them in the platform space on the same transaction. Its series, Version, audit events, vault secrets, webhooks, notifications and realtime events land there, and the Version and audit events are stamped withsource_tenant.delete,setValueand rename stay refused in a tenant. This is for platform data every tenant's code contributes to, such as a paid lookup cache. Seetenancy(#108).
Fixed
- A DocType made
sharedafter tenancy was applied is shared in the database too.ddcore migrateused to report nothing to do and leave the table with row-level security, the tenant policy, thetenantcolumn and the(tenant, id)key, so inside a tenant the DocType read as empty and aLinkto it failed. It now drops the policy, turns row-level security off, keys the table byid, rebuilds the indexes without the tenant and drops the column, for the DocType and its child tables. It refuses, naming the tenants and their row counts, while any row belongs to a tenant; abeforeSchemapatch is where those are moved or deleted. (#109)
0.27.10 — 2026-10-07
Added
- A scratch tenant for
ddcore test. On a site with tenancy, the tests of an app withdefineApp({ tests: { space: "tenant" } })run inside a tenant the run creates in its rolled-back transaction. That is the default for an app withspace: "tenant", so an app whose tests use another app's tenant-only DocTypes adds the line. The tenant has every app'sonTenantCreateapplied,Adminentered and no real tenant's documents, and nothing of it survives the run.ddcore.test.inPlatform(fn)runs part of such a test in the platform space, for a shared DocType, aSite Tenantor a scheduled fan-out. Other apps' tests run in the platform space as before. Seetenancy(#107).
0.27.9 — 2026-10-07
Added
- Tenant-only DocTypes. On a site with tenancy,
space: "tenant"ondefineDoctype, or ondefineAppas the default of every DocType of the app (space: "any"opts one out), keeps a DocType out of the platform space. Boot leaves it out, so the desk has no menu entry, search row or Link for it. Every read and write there is refused with "… lives inside a tenant: enter one (on the command line, pass --tenant)", whether it comes from the desk, REST, MCP,ddcore evalorexecwithout--tenant, a job or a hook.ddcore import runwithout--tenantleaves it out and says so. Code inside a tenant,ddcore.tenant.runand migration patches are unaffected. Before, such a DocType opened and saved in the platform space, and the row landed where no tenant sees it. Fixtures of a tenant-only DocType fail the load: useonTenantCreate. Seetenancy(#105).
Changed
- Boot leaves out the DocTypes the session's space may not read, and the reports on them. Inside a tenant that is
Site Tenant, which was listed and then refused. A workspace entry with nospacethat opens a tenant-only DocType, or a report on one, shows inside a tenant only, and so does a groupedlinksitem. An explicitspacestill wins (#105).
Fixed
ddcore i18n extractno longer reports an app's override of a core key as an orphan, and--prune(ori18n_extract {prune: true}) no longer deletes it. A key the app's code does not have but an app loaded earlier translates — the core included, in any language — is an override: it is written with# overrides core(or that app's name), listed asoverrideby the CLI (the summary line gains anN overridecount) and under the newoverrideslist ofi18n_extract, and kept by--prune.set_translationsnow accepts such a key instead of refusing it as not in the code (#106).
0.27.8 — 2026-10-07
Fixed
- Security: a client can no longer set
doc.flagsby sending aflagskey in its body. A REST create or update, the desk's save, submit and cancel, and MCPinsert_docandupdate_doccopied the key into the document, and the hooks took it as the write's flags, so a user with write permission got past anyvalidaterule keyed on a flag the app's server code sets. The key is now dropped on every write, and a hook'sdoc.flagscomes from the write alone:{}for one a client started, as documented. A__beforekey, which could stand in fordoc.getDocBeforeSave()on an insert, is dropped the same way. Nothing changes for an app that setsdoc.flagsin server code (#104).
0.27.7 — 2026-10-06
Added
ddcore exec --args -reads the JSON arguments from stdin, and--args-file <path>from a file (-is stdin too). A payload carrying personal data no longer has to sit in argv, wherepsand/proc/<pid>/cmdlineshow it to every user of the host, and a large batch is no longer capped byARG_MAX. Malformed arguments are now reported before the database is opened (#103).
Fixed
ddcore migrate --pruneplans its drops after theafterSchemapatches instead of before them. A release that backfills and contracts in one go — both patchesafterSchema, the contract dropping the old column itself — no longer fails, either on a refusal for data the backfill was about to copy or on aDROP COLUMNfor a column the patch had already dropped. An orphan a patch writes into is now refused rather than dropped with what the patch wrote.migrationsdocuments the contract-in-the-same-release pattern and why the contraction is notbeforeSchema(#102).
0.27.6 — 2026-10-06
Changed
limit=0onGET /api/resource/<DocType>— and soddcore.db.getList(doctype, { limit: 0 })in a desk script — returns every matching row, aslimit: 0does on the server; it used to be read as "left out" and return a page of 20, so a bulk action over "all" silently acted on the first page. A negative or non-numericlimitis now aValidationErrorinstead of returning everything or 20.form-apidocuments the deskgetList's default of 20 (#101).
0.27.5 — 2026-10-06
Added
ddcore.session({ user, fullName, roles, lang }) andddcore.hasRole(role)in desk scripts, read from the desk's boot, so a form or list script can show a button only to the roles the server will accept. They hide UI and grant nothing: the method still checks (#93).refDoctypeon a workspace number card or chart with amethod(): the endpoint answers 403 to a user without read on that DocType before running the method. Amethod()runs as the caller with no DocType check of its own, andreport-apinow says so, along with what the workspacerolesand adoctypecard enforce (#94).defineListView({ toolbarActions }): buttons on the list toolbar that run with no rows selected, in every view.onClick(list)getsdoctype, the view'sfiltersandrefresh(); an optionalcondition()decides whether the button shows (#96).
0.27.4 — 2026-10-06
Added
ddcore.errorLog.record(error, { method?, context? })writes anError Logrow on a transaction of its own and returns its id, so code that catches a failure can leave it where operators andddcore doctorlook and go on: the row stays whether the caller's work commits or rolls back. It carries the request's id, orjob:<id>in a job's body, lands in the current tenant, and never throws. The pattern for a job of independent steps — each in a savepoint, a failing one recorded rather than rethrown — is under "Savepoints" incontroller-api(#92).ddcore.job.current()returns the running job'sJobInfoin its body and callbacks (nulloutside a job), andJobInfohasstarts: how many times a worker began the job, a run given back by a shutdown included.attemptstays the same across a give-back, sostarts > 1is how a body doing non-idempotent work learns that an earlier run may already have done it. The admin job listing,GET /api/jobsandddcore jobs showshowstartstoo. The ops docs now say thatmaxAttempts: 1is not "runs at most once" (#91).shutdownGraceSecondsinddcore.json, orDDCORE_SHUTDOWN_GRACE_SECONDS(default 30): how long a process told to stop lets its running jobs finish before it gives them back to the queue. Set the platform's stop timeout above it (Composestop_grace_period, KubernetesterminationGracePeriodSeconds) (#99).ddcore tenant adopt <slug> --dry-runprints, table by table, the rows the adopt would move into the tenant and every collision with rows the tenant already has, on the primary key and on each unique index (up to ten keys each), moves nothing, and exits non-zero on a collision. An app no longer has to re-derive the adopt's rule in SQL to fail before the cutover (#100).ddcore import run|status|reconcile <dir> --tenant <slug>loads an export into a tenant: the documents, the import ledger, the numbering series and theimport.runaudit events land in the tenant, and nothing else on the site moves.--tenantbeforeimportmeans the same. The load leaves out shared DocTypes and theAdminandGuestaccounts, and refuses an attachment whose url a file of another space holds. The same export can be loaded into two tenants. A run records its tenant:--resumerefuses another one, andstatuslists the runs of one space. The MCPimporttool takestenant(#100).ddcore.db.tryLock(key): the lock ofddcore.db.lockwithout the wait. It returnstruewith the key held to the end of the transaction, orfalseat once while another transaction holds it, so a job can queue itself again and free its worker instead of parking it for as long as the holder takes. Scoped to the tenant, aslockis (#98).ddcore.enqueue(..., { runAfterSeconds }): the job's delay from now, in seconds (#98).- Worker pools per queue:
"workers": { "default": 2, "bot": 2 }inddcore.jsongives a queue workers of its own. A named pool serves its queue only;default, which the object must name, serves the default queue and every queue not named, the scheduler's included. A number still means that many workers for every queue.ddcore jobs work --queue bot[,other] [--workers N]starts only those pools, so a queue can have a process of its own;DDCORE_WORKERSoverrides the setting with a number;ddcore doctorprints each pool (workerPoolsin--json) (#98). ddcore.throw(msg, opts)takesstatus, an HTTP error status (400-599) that overrides the type's, andretryAfter, seconds stored asextra.retryAfterthat also set theRetry-Afterheader. The SDK typesextra(it reaches the JSON error body aserror.extra) and exportsErrorType, the known types, andThrowOpts.controller-apihas the table of types and statuses, and the webhook examples refuse with{ type: "PermissionError" }(403) instead of a bareddcore.throw, which is a 417 (#89).
Changed
- A job's
timeout, an administrative cancel and a worker shutting down now also cut the outbound call the job is waiting on —ddcore.http.*,ddcore.files.save({ fromUrl }),ddcore.push.send, an external database query, mail and webhook delivery — instead of waiting for it to return. A job'stimeoutis now a bound on its HTTP calls too. Calls made while serving a request are unchanged (#99). ddcore tenant adoptrefuses with the list of colliding keys — table, columns and values — instead of stopping on the first raw primary-key violation. The docs now say what else an adopt moves: the platform space's Singles, vault secrets, Error Log, Audit Event, Version and Feedback rows (#100).ddcore importno longer refuses--tenantbefore the command. The import ledger (ddcore_import_record) is per tenant on a site with tenancy, andddcore tenant adoptmoves the platform space's ledger with everything else, so an export loaded before the adopt is not loaded again into the tenant after it.migratere-keys the ledger; nothing to do (#100).ddcore.enqueuerefuses arunAfterit cannot read with aValidationError, and one given together withrunAfterSeconds. It used to run such a job at once, which turned a job meant to wait into a hot loop. An ISO timestamp andYYYY-MM-DD HH:MM:SSare read as before, and a date (YYYY-MM-DD) or a timestamp without a zone is now read in the site's timezone too (#98).
Fixed
- SIGTERM (or Ctrl-C) no longer drops the jobs a process is running.
ddcore start,ddcore devandddcore jobs workstop claiming jobs, let the running ones finish withinshutdownGraceSeconds, give the rest back to the queue with their attempt returned and noonFailure, and only then exit. They used to exit at once: a job blocked inddcore.httpstayedrunninguntil its lease expired and was then failed, its attempt spent, as the docs said it would not be (#99). - An error thrown from app code answers its type's status for every type the server knows:
TooManyRequestsError(429),UnavailableErrorandMaintenanceError(503) andMethodNotAllowedError(405) used to answer 500 fromddcore.throw. A Go error crossing app code — theMaintenanceErrorof a write made while the site is paused, say — keeps its status and itsRetry-Afterinstead of becoming a 500 with no header. An unknown type is still a 500 (#89). - A missing mail template, print template or notification rule raises
DoesNotExistError(404) instead of the unknownNotFoundError, which answered 500 (#89).
0.27.3 — 2026-10-06
Added
ddcore.httptakesmaxRedirects(default 10):0returns the 3xx with itsLocation, so an app can follow a redirect itself and choose the headers it sends (#97).
Fixed
Date.parseandnew Datein server code read a date-time written with a space instead of theT, as Postgres writes it:"2026-10-05 10:00:00-04:00"lost its time of day and"2026-10-05 22:31:52.767353+00"gaveNaN. Both now give the instant Node gives (#90).ddcore.log.*andconsole.*no longer log an object as"[object Object]": a plain object's keys become fields of the structured record (ddcore.log.warn("retry", { status })logsmsg="retry" status=…), and other values join the message as JSON. A value with a cycle no longer makesconsole.logthrow.ddcore evalprints the fields after the message (#88).ddcore.httpandddcore.files.save({ fromUrl })no longer send the caller's headers to another host on a redirect. A hop to another host, or from https to http, now drops every header the call passed exceptContent-Type; net/http only droppedAuthorizationandCookie, so a token sent asapi_access_tokenorX-Api-Keyreached the redirect's target (#97).- The access log, the panic log and the Error Log title no longer print what follows a method's name in
/api/method/<path>/<tail>: they show/api/method/<path>/…. A webhook that carries a token in itspathTailwrote it to the logs on every delivery (#95). ddcore <command> -hand--helpprint the command's options (ddcore test -hlists--filter,--appand-v) or, for a command with subcommands such asimport,jobsoruser, its usage. Both failed with "unknown flag" and pointed at themselves (#87).
0.27.2 — 2026-10-05
Added
defineReport({ description })draws a line under the report's title, translated likelabel: a place to say what the report shows and how to read it (#81).ddcore.httpsends binary and multipart bodies withbodyEncoding:"base64"sends a base64 stringbodyas raw bytes, and"multipart"sends an array of parts ({ name, value }or{ name, base64, filename?, contentType? }) asmultipart/form-data, boundary included. AContent-Typeheader is now matched without regard to case. The typeHttpMultipartPartis exported (#83).ddcore.webhooks.verify(secret, headers, rawBody, { toleranceSeconds = 300 })checks a Standard Webhooks request on an inbound method in one call: it decodes awhsec_<base64>secret, accepts severalv1,…signatures, in one header or repeated ones, compares in constant time, enforces the timestamp window (toleranceSeconds: Infinityswitches it off), reads header names in any case and returnsfalserather than throwing when a header or the secret is missing.ddcore.crypto.hmacSha256takes{ keyEncoding: "utf8" | "base64" | "hex", output: "hex" | "base64" | "base64url" }; without options it answers as before (#84).ddcore.crypto.randomToken(bytes = 32)(base64url fromcrypto/rand),randomInt(min, max)(uniform in[min, max)) andsha256(data, { output })(hex by default), for session tokens, one-time codes and the hash to store instead of a token (#85).ddcore.push.send(subscription, payload, { ttl, urgency, topic })delivers a Web Push message from server code: the payload encrypted for the browser's subscription (RFC 8291,aes128gcm, at most 3993 bytes) and the request signed with the site's VAPID key (RFC 8292), read fromDDCORE_SECRET_VAPID_PUBLIC_KEY,_PRIVATE_KEYand_SUBJECT. It returns{ status, body, headers }whatever the status, so the app deletes a subscription that answered 404 or 410. An endpoint must behttps:outside development.ddcore.push.publicKey()gives the page the key it subscribes with, ornull, andddcore push keysprints a new pair, refusing a--subjectthat is not amailto:orhttps:URL. Seepush(#82).
Fixed
ddcore.utils.randomStringusedMath.random, which is not safe for a credential. It now draws fromcrypto/randwithout modulo bias, with the samea-z0-9alphabet and the same handling ofn: a fraction rounds up and anything not positive gives"". It now throws above 65536 characters. The ids ddcore generates for new documents share that bias fix (#85).
0.27.1 — 2026-10-05
Added
- The desk draws
keyandbug. - The desk draws 201 more lucide icons, 300 in all: money and commerce (
coins,piggy-bank,hand-coins,shopping-cart,store,package,warehouse,truck,barcode, ...), people (user-pen,user-check,id-card,graduation-cap, ...), messages (phone,message-circle,messages-square,video,megaphone, ...), documents (files,clipboard-list,file-spreadsheet,signature, ...), time, places (map,globe,church,hospital, ...), status, actions, systems (network,database,server, ...) and more. lucide's old namescheck-circle,x-circle,alert-circle,help-circle,circle-help,unlock,pie-chart,line-chart,smileandfingerprintwork too. The full list is under "Icons" inreport-api(#80). allowCreate: falseon a DocType says only server code creates its documents, such as a Transfer made by a button on another form. The desk then offers no way to make one, to Admin too:/api/metareportscreateandamendas false, so the list has no New, a Link no+, the form no Duplicate or Amend, and Data Import no insert mode. A typed/newshows a notice.POST /api/resource/<doctype>and a spreadsheet insert are refused. Server code inserts as before. An extension may set it on another app's DocType. See "DocType properties" infieldtypes(#79).- The list shows the DocType's
descriptionunder its title, translated likelabel: a place to say where the documents come from (#79).
Fixed
- API Key, Error Log and Version show an icon in the desk's System menu (
key,bug,history) instead of the dot an item without one gets. /newfor a DocType the reader cannot create shows a notice with a link back to the list. Before, it opened a form that took the typing and whose save the server refused (#79).
0.27.0 — 2026-10-05
Breaking
- An
iconthe desk does not draw now fails the load, on a DocType, a workspace or anything a workspace lists (sidebar,shortcuts,links), naming where it is:workspace "Payments", sidebar[3]: icon "piggy-bank" is not one the desk draws. Before, the desk drew a circle and nothing said so. Upgrade: runvalidate_meta(or start the server) and replace each name it reports with one from the list under "Icons" inreport-api(#78).
Added
- The desk draws
wallet,credit-card,banknote,landmark,percent,qr-code,send,inbox,undo-2,book-open,briefcase,building,wrench,file-text,folder-tree,chart-bar,penanduser-plus, and accepts lucide's other names for icons it has:triangle-alert,chart-column,ellipsis,square-check-big,edit-2. The list inreport-apiis generated from the desk's own table, so it names every icon there is (#78). - Feedback. Every desk user can report a bug, an improvement or a feature request from Feedback in the user menu. The dialog asks for the fields of the chosen type, takes up to 10 files (picked, dropped or pasted screenshots) and an audio note recorded in the browser, and sends the current page's address and context data (both checked by default; the context is previewed before sending). It is written as a core
Feedbackdocument, in the platform space on a site with tenancy, stamped with itssource_tenant. The platform's System Managers get it in the desk inbox, and the addresses in"feedback": {"to": [...]}(orDDCORE_FEEDBACK_TO) get a mail with the files. The author follows status and response under "My feedback". It is on by default;"feedback": {"enabled": false}orDDCORE_FEEDBACK=0turns it off. App scripts can open it withddcore.ui.openFeedback({ type, title }). Endpoints:POST /api/feedback,GET /api/feedback/mine. Seefeedback. - Audio files (
.webm,.ogg,.m4a,.mp3,.wav, …) are served in place with their audio type, like images and PDFs, so an<audio>element plays them. - The desk draws
bug,lightbulb,mic,squareandtrending-up.
Changed
- The sidebar's Notifications and To-Do links moved from the top of the menu to its footer, just above the user's avatar, so they no longer read as one of the workspace's modules. Their counters and the collapsed rail work as before.
Fixed
- The desk's own icons that drew a circle — the open workspace menu's chevron, the assign dialog's and the editable title's — draw what they name; in the browser, an unknown name draws a circle with a console warning (#78).