ddcore 0.21
0.21.18 — 2026-09-30
Added
ddcore.test.asUser(user, fn)(#44): insideddcore test, runsfnas another user — their roles, User Permission scopes, shares and user type — in the test's transaction, then switches back, even whenfnthrows. Users and User Permissions the test inserted count and roll back with it, so a leak test between tenants no longer needs a running server and curl. Typed in@ddcore/sdk/test; seecontroller-api, Tests.
Fixed
ddcore.hasPermission(doctype, ptype, doc, user)honoursuser(#44): it checked the current user whateverusersaid. It also accepts a document id asdoc, as its typing promised; that call used to fail with "invalid arguments".
0.21.17 — 2026-09-30
Added
setOnlyOnce: trueon a field (#43): once the document exists, the value cannot change — the server refuses it on every write path (save(), REST,ddcore.db.setValue/doc.dbSet, Data Import), whatever the permissions, and the desk shows the field read-only. An empty value may be filled once; on a child row it holds for rows already saved. An extension may impose it on another app's field. Replaces ahasValueChangedcheck repeated in eachvalidate, whichdbSetskipped.
Fixed
- A scope, share, role or session changed in one process now reaches the others (#45). The caches behind them live in each process, so a
User Permissionrevoked byddcore eval --commit,ddcore exec, an import or another replica kept its grant alive in the running server until a restart — a leak between tenants isolated by scope. Invalidations are now announced with PostgresNOTIFYwhen the transaction commits (never on a rollback), and the server,ddcore jobs workandddcore mcplisten and drop the same keys; a listener that reconnects drops its whole cache, andddcore migrateclears every process's cache.ddcore.cache.delfrom app code is announced the same way. Nothing to change in an app; a replica needs no restart after a revocation.
0.21.16 — 2026-09-30
Added
gridSearch: ["field", ...]on aTableor aReportfield (#46): a search box above the grid that shows only the rows where one of those columns contains the text typed — case- and accent-insensitive, each word matched in any of the columns, a Link by its id and its title, a Select by its value and its label. A Table's fields may behidden(checked at load). It combines with the activegridFilters, and selection and export follow the rows on screen; like them, it is display only and leaves the rows and theiridxas saved.
0.21.15 — 2026-09-30
Added
defineListViewtakesplainLinks: ["field"]: those Link (or Dynamic Link) columns show the linked document's title as text instead of a link, so a click on the cell opens the row's own document. For lists whose rows are easily mistaken for what they link to (#42).
0.21.14 — 2026-09-30
Fixed
hideLabelis accepted on aReportfield (#41), as on aTable: a tab holding only a Report shows just the grid, without repeating the tab's name. The meta was refused at load with "hideLabel is for a data field, not a Report".- Chrome no longer offers to translate a Portuguese desk "from English". The desk shell shipped
<html lang="en">and corrected it only after/api/boot; the server now stamps the resolved language (user,Accept-Language, site default) into the first response, andloadBoot()keeps the attribute in step on every boot, including the reload after an SSE event.
0.21.13 — 2026-09-30
Added
raw: { contentType }on a whitelisted method (#37) writes the returned string as the whole response body, with that content type and no{data, messages}envelope: what a provider that verifies a callback URL by reading a challenge back (Meta'shub.challenge) expects.- Raw body and headers for whitelisted methods (#38).
ctx.requestgainsrawBody(the exact bytes received) andheaders(lower-cased names, withoutcookieandauthorization), andddcore.crypto.hmacSha256andddcore.crypto.timingSafeEqualverify a signature over that body, so an inbound webhook can be authenticated inside the app. A body that is not JSON no longer fails the call unless it is sent asapplication/json:argsis empty andrawBodyhas the payload. See Inbound webhooks incontroller-api. uniqueKeyforddcore.enqueue. While a job with the same key is stillqueued, the call queues nothing and returns that job's id, so "process session X" can be enqueued per event without stacking jobs. A claimed job frees the key, so work arriving mid-run queues a new one. Enforced by a partial unique index, safe under concurrent requests.- PocketID provisioning. Set
DDCORE_OIDC_<ID>_API_KEYon a provider of kindpocketid(DDCORE_OIDC_<ID>_KIND, the default for the idpocketid) and an invitation of a System User creates the account in PocketID — or reuses the one with that address — and mails PocketID's one-time link to register a passkey (core.invite_sso) instead of a link to choose a password. A mapped group PocketID does not have yet is created there on first use, by the invitation or by the role push-back, so a fresh PocketID needs no groups made by hand; a group that cannot be created fails with a message naming it. A refusal from PocketID fails the invitation with its reason. A Website User is still invited with a password. - Group → role mapping for single sign-on:
auth.sso.<provider>.groupRolesinddcore.json(andgroupsClaim, defaultgroups). Each sign-in sets the mapped roles from the provider's groups and leaves every other role alone; a missing claim changes nothing, and Admin is never touched. With provisioning on, saving a User pushes its mapped roles back to PocketID as groups, through a retried job (core.services.idp.sync, queueidp). Thegroupsscope is requested automatically. Refused at load: a policy for an unknown provider, a group with no role, andAdmin,GuestorAllas a mapped role. - The User form asks before following a disable, enable or delete to PocketID, and calls
core.services.users.setProviderDisabledon a yes; nothing is disabled there on its own.accountStatusnow also returns the provisioningprovider. afterDelete(frm)indefineForm, run after the form's Delete succeeds whilefrm.docstill holds what was deleted.ddcore doctorprobes the PocketID admin API, warns about mapped roles missing on the site, and lists the mapped groups PocketID does not have yet as created on first use (groupsToCreatein--json).
Changed
ddcore user invitetakes the same path as the desk's Save and invite: it now recordsaccount.invite, refuses an address that already has a User with the usual message, and provisions PocketID when that is on.whitelisted(fn, { methods })is now enforced. It was declared but ignored; a verb outside the list answers 405 with anAllowheader. An app that listedmethodsand relied on the other verb still working must add it to the list.ddcore mcpstarts without the database. An unreachable Postgres no longer kills the process before the handshake, which an MCP client only reported as a closed connection. The meta, scaffold, i18n and docs tools keep working; the tools that need the database answer with an error that says so, and connect on their own once Postgres is up — no reconnecting the client.engine.Config.DeferDBandEngine.Connectare what an embedder uses for the same.
0.21.12 — 2026-09-29
Added
defineListView({ filtersCollapsed: false })(#32) opens a DocType's filter card for a user who has not chosen yet.linkOrderBy(#33) on a DocType sets the order of its Link dropdowns and Table MultiSelect pickers —"field [asc|desc], ..."— without touching the list'ssortField.extendDoctypecan set it on another app's DocType,Userincluded. Seefieldtypes.calendar.newOptions(#34) sets what the + in a calendar day's corner creates: records of other DocTypes, each with its datefield(and optionally anendField) set to the day. One option links straight to its form, several open a menu, and each shows only when the user may create its DocType — so a read-only or virtual DocType that gathers others' records gets a + too. See "Views" indocs/agent/form-api.md.- An
anyfilter item ORs groups of filters (#35) insidefilters:[["status", "=", "Open"], { any: [[["priority", "=", "High"]], [["due_date", "<", today]]] }]. A row matches when every filter of one group does. It works in the list API,ddcore.db.getListandcount, and exports.or_filtersstill holds one OR group, and a list's search already takes it. A filter inside a group is checked against field permissions like any other. See "Filters" indocs/agent/controller-api.md.
Changed
Clicking a calendar day lists what the grid draws on it (#35). With
endField, the list shows every record whose bar covers the day. That includes a record that started earlier and ends on or after it, where the click used to list only the records starting that day. The day is its own filter:?calendar_day=2026-09-29in the URL, a removable Day in the filter card, counted by the Filters button. It replaces?start_date=2026-09-29, which was an equality on the calendar'sfield. WithoutendFieldit still lists the records whosefieldfalls on the day.A list's filter card starts hidden behind a Filters button (#32) in the list header. The button's badge counts the filters in force, so a hidden filter never goes unseen, and each user's show/hide choice is kept per DocType in the browser. See
defineListViewindocs/agent/form-api.md.A Link dropdown lists its options by title, A to Z (#33), and the same holds for a Table MultiSelect's picker. They used to follow the target's
sortField, ormodified desc. Typed text puts the options whose id or title equals it, then starts with it, first. Text compares ignoring case and accents. A DocType without atitleFieldkeeps itssortFieldorder; setlinkOrderByto choose another.
0.21.11 — 2026-09-29
Added
- A Table's
onChangeknows which child field changed (#30): it receives a fifth argument,changed, the child fieldnames an edit in the grid, the row dialog orfrm.setRowValuechanged.grids.<table>.onChange.<child field>(frm, row)runs for a change of that one field, before the table'sonChange— next togrids.<table>.onCellClick. See "Grids: row changes and cell clicks" indocs/agent/form-api.md. hideLabel: truekeeps a field's label off the form (#31). Screen readers still read it, and it still names the field in a grid's CSV/XLSX export, its row dialog and error messages. It can be set fromextendDoctypeandfrm.setDfProperty. On a Section or Tab Break it fails validation.
Changed
A section with no heading whose only visible field is a Table or a Report has no card (#31). The grid's own card frames it, so a
Tab Breakfollowed by one Table no longer nests one frame in another. AddhideLabel: trueto the grid and the tab shows just the grid. See "Form grids" indocs/agent/fieldtypes.md.The
ddcore runningstartup log line now carries the coreversion, so a deploy's logs say which release is serving.
0.21.10 — 2026-09-28
Added
Autocompletefieldtype: free text with suggestions, stored in a text column.optionslists the suggestions (a list, or one per line) and never restricts the value; the server trims outer spaces. The desk's combobox filters ignoring case and accents, and a form script replaces the suggestions withfrm.setDfProperty(field, "options", list). Changing aDatafield toAutocompleteneeds no migration, and the first save's trim is not recorded as a Version.Barcodefieldtype: text drawn as a barcode, stored in a text column.optionsis the symbology —Code128(the default),EAN-13orQR— and the server validates the value for it: printable ASCII up to 80 characters, 12 or 13 digits (a 12-digit EAN-13 gets its check digit), or up to 1000 bytes. The desk previews the code under the text box and, where the browser hasBarcodeDetectoron a secure origin, scans it with the camera. The standard print layout draws it as SVG, and print templates getb.barcode(value, symbology, title).GET /api/barcode?symbology=&value=returns the SVG to any signed-in user, Website Users included.Signaturefieldtype: a signature drawn on a pad with a finger, a pen or the mouse, stored as a PNG data URL in a text column — the value Frappe stores. The server accepts only a PNG data URL of at most 64 KiB and 2000×1000 pixels. The form commits each stroke cropped to the strokes, shows a stored signature as an image with "Sign again", and a grid opens the row dialog for it instead of editing the cell. Versions record asha256:marker instead of the image; lists, grid exports and child-table print cells say "Signed"; Data Import does not take it. The standard print layout draws it, and templates getb.signature(dataUrl, title). Being large, it is refused asunique,searchIndex,inStandardFilter,inListView(outside a child DocType),titleField,sortField,searchFields,linkSubtitle,uniqueKeysorgridSort.Geolocationfieldtype: points, lines and polygons drawn on a map, stored as a GeoJSON FeatureCollection in ajsonbcolumn. The server accepts a FeatureCollection, a Feature or a bare Point, MultiPoint, LineString or Polygon, and stores one canonical shape: positions[lon, lat]rounded to 7 decimals, no altitude, no properties, rings closed, at most 500 shapes and 64 KiB — so a document saved untouched records no Version. Hooks read it as an object (doc.area.features), typedGeoFeatureCollection(newGeo*types in@ddcore/sdk). The form loads Leaflet on demand, with Point, Line, Polygon and Delete tools and "Use my location"; lists, exports, history and print show a summary (lat, lon, or "2 points, 1 polygon") — print draws no map. Data Import takes GeoJSON orlat; lon. Tiles come from the newDDCORE_MAP_TILE_URLandDDCORE_MAP_ATTRIBUTIONenv vars, served to the desk in/api/bootassite.map; the default, OpenStreetMap's own server, is not meant for production traffic. Refused in the same places as a Signature, exceptinListView.
Changed
- Translated metadata carries
optionLabelsonly for aSelect. ADuration's display flags no longer get labels, and anAutocomplete's suggestions are never translated.
Fixed
- API key requests no longer run Argon2 on every call (#29). A key's secret is hashed once while its key row is cached (60 s); after that a matching secret is recognised by a per-process HMAC digest and skips the 64 MiB hash, and concurrent checks of the same secret share one hash. Revoking or disabling the key or its user drops the verification with the row, so revocation latency is unchanged, and a wrong secret is still hashed and counted by the failure brake.
last_usedis stamped when the hash runs, so at most once a minute per key. - Concurrent Argon2 computations are bounded to
max(2, GOMAXPROCS)per process. A burst of sign-ins or API-key checks queues for a slot instead of allocating 64 MiB each, which could OOM-kill the process under load.
0.21.9 — 2026-09-28
Added
- MCP tool
extend_doctypewritesextensions/<snake>.extend.ts— fields, per-field and DocType property overrides, extra roles, and optionally the.form.tsbeside it — the wayscaffold_doctypewrites a DocType. It checks the host is inrequiresbefore writing, loads the file before returning, and removes it again when the meta refuses it, so a clash never leaves the site unable to load. It only creates: an existing extend file is edited by hand.
0.21.8 — 2026-09-28
Added
- A delete leaves a trace (#28). Every deleted document gets a final Version whose new
deletedfield is checked and whosedatais{"deleted": {…}}: the document as it was, child rows included, without its Password, Vault and secret fields. Adoc.deleteAudit Event records who deleted it and points at that Version. This applies to every DocType except Version, Error Log, Email Delivery and Webhook Delivery, which are only audited. List Version with Deleted checked to see what was deleted. There is no undelete. See "What a delete leaves behind" incontroller-api.
Changed
- Deleting a document no longer deletes its Versions: a
trackChangeshistory now ends with the deletion instead of disappearing. Only a System Manager reads the Versions of a deleted document. A document later created under the same id shows other readers its own Versions only.migrateadds thedeletedcolumn totab_version. - Versions are stamped with
clock_timestamp(), so two written in one transaction sort in the order they happened.
0.21.7 — 2026-09-28
Fixed
- Deleting a User ends their sessions and clears their cached roles immediately, as disabling one already did; the delete is recorded as an
account.deleteaudit event. - Deleting or renaming a document no longer fails with
column "…" does not existwhen acomputed: trueLink of another DocType points at its DocType: computed fields have no column, so the link check and the rename skip them (#26). - A filter on a Check field accepts
1,0,"1","0"and"true"as well as a boolean — in a workspace number card'sfilters,getList,countand the REST API — instead of failing to encode an integer into abooleancolumn (#27).
0.21.6 — 2026-09-28
Added
- The User form sends invitations. Saving a User only writes the row, so nobody was told an account existed and the desk had no way to reach
users.invite. A new User form now offers Save and invite; a saved, enabled User offers Resend invitation while it has no password and Send password reset once it does. On thelogtransport the link is shown to be passed on by hand.core.services.users.accountStatus({ user })answers{ user, hasPassword }for it.
Changed
frm.setDfPropertytypes its property.propis nowDfProperty— a key ofFieldDeforcannotAddRows/cannotDeleteRows— so a misspelt property is a type error instead of a silent no-op.descriptionis documented among them: a form script may rewrite a field's help text at runtime (#24).
Fixed
- A form script named after its
.doctype.tsloads again. The engine and the asset route looked only for<Snake(name)>.form.ts, so "TagOne Settings" wantedtag_one_settings.form.tsand atagone_settings.form.tsbesidetagone_settings.doctype.tswas never loaded —formAppscame back empty and nodefineFormran. A script is now also found by the stem of its DocType's own file (#25).
0.21.5 — 2026-09-28
Changed
ddcore initandddcore deploywrite the Dockerfile on the minor series. A new site startsFROM ghcr.io/jrvidotti/ddcore:0.21rather than:0.21.0: the release workflow moves that tag to every patch, so each deploy takes the series' fixes without a commit, and it never leaves theddcorerangeddcore initwrites. An existing Dockerfile is left alone — change:0.21.0to:0.21by hand to follow the series, or keep the exact tag to pin one release.
0.21.4 — 2026-09-28
Fixed
- A reload reinstalls the scheduler's entries. A
schedulerblock added or changed whileddcore devran was loaded —ddcore jobs scheduledlisted it — but the running scheduler kept the entries it built at boot, so the new entry never fired until the server restarted (#23). Every successful reload now rebuilds a running scheduler: the file watcher, and the MCP tools that reload (reload, scaffolding, translations).
0.21.3 — 2026-09-28
Fixed
ddcore.db.getSingleValue(doctype, field)returns the Single's value again. Since 0.17 renamed a Single's id to"singleton"it looked up a row named after the DocType and answerednullfor every field (#22). It now reads thesingletonrow, answers the field's default before the first save (asgetDocdoes), and refuses a DocType that is not a Single. It is now described in the controller API.
0.21.2 — 2026-09-28
Changed
- The changelog is split by minor series.
CHANGELOG.mdand the MCP resourceddcore://changelognow carryUnreleasedand the current series only; each older series isdocs/changelog/<minor>.md, served asddcore://changelog/<minor>and published on the docs site under Changelog.whats_newstill reads across every series, so an app several minors behind hears about all of them.
0.21.1 — 2026-09-28
Changed
ddcore deploy railwaywrites the Railway project as Infrastructure as Code,.railway/railway.ts(with thepackage.jsonof its SDK), instead ofrailway.json, which Railway reads only until 2026-12-01. The file declares the site's service — its GitHub source taken from the checkout'sorigin, the/api/readyhealth check — and a PostgreSQL whoseDATABASE_URLit wires in, with every secret aspreserve(); it is applied withrailway config plan/apply. Upgrade: a site withrailway.jsonrunsrailway config migrate --apply --delete-files, then declares the service'ssource(a plan without it disconnects the repository) and its variables aspreserve(); the deployment guide's Railway section walks through it. The generated.dockerignorenow leaves.railwayout of the build context.
Fixed
- A
make buildstamped itself after the rollingedgetag (edge-1-g…), which is not a release, so it stopped enforcingddcoreranges; it now describes itself fromv*tags only.
0.21.0 — 2026-09-28
Breaking
"dev"inddcore.jsonis retired. Committed, it put every environment — production included — in development mode, and nothing could turn it off: app assets rebuilt on every request, webhooks allowed over plainhttp://, mail redirected toDDCORE_MAIL_DEBUG. Development mode is nowddcore dev, orDDCORE_DEV=1for another command. A file that still has the key loads, logsddcore.json "dev" is ignored since 0.21.0 …, and loses it on the next save;ddcore initno longer writes it. Upgrade: delete"dev"fromddcore.json, and setDDCORE_DEV=1in.envwhere a command other thandevshould run in development mode.
Changed
ddcore startmigrates before it serves, asdevalways did, so a deploy never runs against a schema its apps do not declare. Upgrade: a deployment that migrates in a step of its own setsDDCORE_AUTO_MIGRATE=0(or passes--auto-migrate=false).main's rolling build is now the prereleaseedge, not a release namedlatest, so GitHub's latest release — whatinstall.shinstalls by default andddcore doctor's update check reads — is always a tagged version.VERSION=edgeinstalls the rolling build.
Added
- The official image
ghcr.io/jrvidotti/ddcore:<version>(also:<X.Y>and:latest), linux amd64/arm64, published by every tagged release: the binary on Alpine withpg_dump/pg_restore17 and the timezone data,WORKDIR /app, uploads in/data,$PORThonoured,CMD ["ddcore", "start"]. A site's Dockerfile isFROMit plusddcore.jsonandapps/, and its tag is the site's pin. ddcore initwrites aDockerfileand.dockerignore, on the image of the release that created the site, andddcore deploy docker|railwaywrites them into an existing site;railwayaddsrailway.json(Dockerfile build,/api/readyhealth check, restart on failure) and lists the variables to set. Existing files are left alone.DDCORE_ADMIN_PASSWORD: the first migration gives Admin this password instead of generating one and printing it to the log, which a container has no console to read. It never replaces a password Admin already has, and a password the site's policy refuses fails the migration by name.- Replicas are safe against one database. A migration takes an advisory lock, so replicas that each migrate on boot take turns instead of racing; every scheduler still fires, but a cron entry is enqueued once per minute however many processes run it (the ledger is the new internal table
ddcore_scheduler_tick). install.shverifies the archive against the release'sSHA256SUMS, and takesVERSIONwith or without the leadingv(0.21.0orv0.21.0).docs/guide/deployment.mdcovers the image, Docker Compose and Railway, and no longer mentions addcore runcommand,DDCORE_ENV,/api/v1/healthor port 8090, none of which exist.
Fixed
ddcore jobs workstops gracefully on SIGTERM — how a container is stopped — putting the jobs it was running back, instead of being killed mid-job.ddcore start -handddcore dev -hname their own command, not aservethat does not exist; the boot log reports the development mode in effect.