Table of Contents
- Client patches
- You normally do nothing
- Running locally (pnpm dev)
- They stack, and the order is load-bearing
- Why nothing else may be committed
- Line endings, and why they are pinned
- When one stops applying
- 0001-bases-date-time-picker.patch
- 0002-freenterprise-branding.patch
- 0003-formula-editor-dark-mode.patch
- 0004-dark-mode-default.patch
- 0005-base-row-pages-schema.patch
- 0006-sidebar-create-base-or-board.patch
- 0008-lock-externally-synced-groups.patch
- 0009-group-driven-workspace-roles.patch
- 0010-openapi-swagger.patch
Mirrored from
patches/README.mdat commitf31ec6a. Edit the file in the repository: this page is regenerated from it, and edits made here are overwritten.
Client patches
Fixes that have to live in files owned by the parent Docmost repository, kept here so this repo stays the source of truth.
You normally do nothing
apps/server/src/ee/docker/Dockerfile (generated by
scripts/gen-docker-wrapper.sh, or .ps1 on a Windows host) applies these
during the image build, before pnpm build. Point compose at that Dockerfile (see DEPLOYMENT.md)
and patched images are simply what you get.
The working tree is deliberately left pristine — git status clean, so
upstream rebases have nothing of ours to conflict with. Don't apply these by
hand for a Docker deployment; the build does it.
A patch that fails to apply fails the build. A silently unpatched image is the exact failure this arrangement exists to prevent, so it is never the quiet outcome.
Running locally (pnpm dev)
Nothing applies them outside Docker, so:
apps/server/src/ee/scripts/apply-patches.sh # apply
apps/server/src/ee/scripts/apply-patches.sh --revert # undo
Idempotent in both directions. This dirties the parent repo, so revert before rebasing onto a new upstream tag.
They stack, and the order is load-bearing
Each patch is generated against the tree with every lower-numbered patch already applied. Once two of them touch the same file — 0008 and 0009 both edit the group services — the later one only applies on top of the earlier, and reverting has to run newest-first.
Both scripts know this, which they did not always:
apply-patches.sh --revertwalks the list backwards. Forwards, it left 0008 un-revertable, reported it as "not applied" and moved on — a half-reverted tree that looked clean.- Going forwards, a patch buried under a later one is recognised as already
applied by asking GNU
patch, since git's reverse-check needs context the later patch has since moved. verify-patches.shapplies the stack cumulatively rather than dry-running each patch against a pristine tree, which is what the Docker build does anyway.
So: regenerate a patch only with the stack below it applied and the stack above it reverted, and keep the numbering in dependency order.
Why nothing else may be committed
The parent repo carries exactly one commit of ours, chore: unlink upstream EE submodule, and it touches no source file — only .gitignore, .gitmodules
and the apps/server/src/ee gitlink. That one cannot be a patch, because it
governs what git tracks at development time rather than what the build
compiles.
Every other change of ours is a patch. This is not tidiness, it is what makes
the patches regenerable: git diff compares the worktree against HEAD, so
a change that is committed and patched produces a patch missing that
change, with no error.
That is not hypothetical. The branding edit in 0002 was committed in the
parent as 23e6a788 and shipped as a patch, and the consequences took a while
to see:
git statuscould never be clean in the "pristine" state. Reverting the stack movedlicense-details.tsxaway fromHEAD, so the tree everyone described as pristine was dirty by construction.- Regenerating
0002the obvious way silently dropped the edition string and caption, becausegit diffsaw them as already inHEAD. The README grew a special case telling you to diff against the pin instead — a workaround for a problem that should not have existed. - The state probe lied. Reverse-applying the patch succeeded, so the patch reported itself applied even on a tree where only part of it was.
verify-patches.shcannot catch this. Its completeness check compares againstHEAD, which is exactly the reference the duplication corrupts.
Both copies rendered the same text, which is why it survived so long. Fixed in
the 5b854645 upgrade by rebasing the commit down to the submodule unlink.
To check the invariant holds — this should print nothing but the three git-metadata files:
git diff --name-only "$(cat ../upstream.pin | grep -v '^#')"..HEAD
Line endings, and why they are pinned
A Windows host checks out CRLF unless told otherwise, Docker copies the build
context byte for byte, and patch(1) compares literal lines. That combination
has already broken a build once, in a way that gave no useful error:
docker/patch-step.template was checked out CRLF, the shell generator embedded
it verbatim, and the resulting Dockerfile had CRLF RUN ... \ continuations.
The shell read \ followed by a carriage return, the command fell apart, and
the build died at the patch step with exit code: 1 and nothing else — no
patch output, because patch never ran.
Three things now keep this from recurring:
.gitattributespins this repo to LF (.ps1excepted). Everything here is consumed by Linux or a shell.- Both generators strip CR from the template before embedding it, so an existing Windows clone made before that rule is fixed by re-running the generator rather than re-cloning.
- The patch step normalises both sides — the patch and every file it touches — to LF before applying.
That last one is not belt-and-braces, it is what makes the first one safe:
pinning our patches to LF while the parent repo still checks out CRLF on
Windows creates a mismatch, and 0009 fails on exactly that. Verified by
applying the stack to a pristine tree in three configurations — all-CRLF,
all-LF, and LF-patches-against-CRLF-sources — the last of which fails without
the normalisation and passes with it.
When one stops applying
Upstream changed the file underneath it. Use the script — it handles both ways of getting this wrong:
apps/server/src/ee/scripts/gen-patch.sh 0009 prepare # pristine + lower patches, staged
# ...apply 000N, hand-edit whatever failed...
apps/server/src/ee/scripts/gen-patch.sh 0009 write # writes the patch
Doing it by hand with git diff is what produced a patch that applied cleanly
while a whole feature was missing from the tree. Two traps, and the obvious
command hits both:
- The baseline.
git diffwith no argument compares against the INDEX;git diff HEADcompares against pristine upstream. For a patch sharing a file with a lower-numbered one — 0008 and 0009 both edit the group services — diffing against HEAD returns both patches' changes, and the result conflicts with the patch below it.preparestages the lower patches so the index is the right baseline. - Created files.
git diffignores untracked files, so a patch that adds one loses it silently.writerunsgit add -Nfor every file the patch declares new, and refuses to write if one is missing.
It also gets hunk offsets right, which hand-splicing does not: 0005 and 0009
both edit db.d.ts, so 0009's line numbers depend on 0005 being applied first.
Not plain diff -ruN. It would solve the baseline honestly and include new
files, but it emits the working tree's line endings — CRLF on a Windows
checkout — while this repo pins its patches to LF. That mismatch has broken a
build before; see "Line endings, and why they are pinned". git normalises to LF
on the way out, which is the property worth keeping.
To see what a patch touches: git apply --stat apps/server/src/ee/patches/*.patch.
A patch that ADDS a file needs one extra step. git diff ignores untracked
files, so regenerating 0006 — which creates
apps/client/src/features/page/tree/components/create-page-menu.tsx — silently
drops it unless git has been told the file exists:
git add -N apps/client/src/features/page/tree/components/create-page-menu.tsx
git diff -- <the files> > apps/server/src/ee/patches/0006-sidebar-create-base-or-board.patch
git rm --cached apps/client/src/features/page/tree/components/create-page-menu.tsx
Undo the intent-to-add afterwards, as above. Leaving it staged means git status reports the file as A (added), one git commit -a away from being
committed to the parent for real — which is the duplication trap described in
"Why nothing else may be committed". Step 2 of verify-patches.sh is what
catches the dropped-file version of this mistake.
0001-bases-date-time-picker.patch
apps/client/src/ee/base/components/cells/cell-date.tsx
apps/client/src/ee/base/components/row-detail-modal/fields/field-date.tsx
Two defects in the Bases date column:
-
No time could be entered. Both used Mantine's
DatePicker, which is date-only and has no time field, so theincludeTimeproperty option had nothing to act on. Switched toInlineDateTimePickerwhenincludeTimeis set, withtimeFormatwired to itsTimePicker. -
Every value read back two hours off (in UTC+2).
new Date("2026-08-13")parses a date-ONLY string as UTC midnight per spec, while a date-time string without a zone parses as local. Picking a day stored00:00Z, which the display then rendered with localgetHours()as02:00. Values are now built from components, so a picker string is always read as local wall-clock and round-trips exactly.
Verified under TZ=Europe/Prague:
old: pick 2026-08-13 -> stored 2026-08-13T00:00:00.000Z -> shows 02:00
new: pick 2026-08-13 -> stored 2026-08-12T22:00:00.000Z -> shows 2026-08-13
new: pick 2026-08-13 14:30:00 -> stored 2026-08-13T12:30:00.000Z -> shows 14:30
Server-side date handling was already correct; this is purely client.
0002-freenterprise-branding.patch
apps/client/src/ee/licence/components/license-details.tsx
Shows "Freenterprise" as the licence edition instead of upstream's
licenseType === "business" ? "Business" : "Enterprise", and replaces the
"contact sales@docmost.com" caption — this instance is self-hosted and
upstream does not support it. The caption points bug reports about the
Freenterprise feature set at ulysia@proton.me instead, since the licence
page is where someone looks when a feature it advertises misbehaves.
The edition string is hardcoded in the client, not returned by the API, so
it cannot be driven from the server. Its counterpart is LICENSE_EDITION_NAME
in licence/license.service.ts; keep the two in sync.
This lived as a commit in the parent repo until the patch mechanism existed. It never reached the server, which runs pristine upstream — so deployed instances showed upstream's edition string. Being a patch, it now applies at build time like everything else.
Regenerate it like any other patch. It used to need git diff 7439da2f rather
than git diff HEAD, because the branding was committed in the parent as
well as being this patch, so a plain git diff silently dropped the edition
string and caption. That duplication is gone as of the 5b854645 upgrade —
23e6a788 was rebased to chore: unlink upstream EE submodule and carries no
source change. See "Why nothing else may be committed" below.
0003-formula-editor-dark-mode.patch
apps/client/src/ee/base/styles/formula.module.css
apps/client/src/ee/base/components/formula/formula-input.tsx
apps/client/src/ee/base/components/formula/function-palette.tsx
The formula editor was built against the light theme only — fixed
light-palette values rather than light-dark() pairs, so on a dark
background it rendered as bright panels with pale text:
| element | was | effect in dark mode |
|---|---|---|
| formula textarea | --mantine-color-gray-0 |
near-white editor panel |
| function chips | --mantine-color-white |
white boxes (empty ()) |
| accordion panel | --mantine-color-gray-0 |
bright band behind the chips |
| property chips | --mantine-color-blue-0/2 |
pale blue on dark |
| focus ring | --mantine-color-blue-1 |
invisible against dark |
Every colour now goes through light-dark(). .formulaHeaderRow already
did — the pattern was known, just not applied to the rest of the file.
Scope note: grid.module.css also contains bare --mantine-color-white
values, but those are foreground colours on coloured backgrounds and render
correctly. Left alone deliberately.
0004-dark-mode-default.patch
apps/client/src/main.tsx
apps/client/index.html
Makes dark the default colour scheme. Mantine defaults to light when
defaultColorScheme is unset.
Only a default — it applies when nothing is stored under
mantine-color-scheme-value, so the theme toggle still wins and persists.
Two files because a defaultColorScheme alone flashes a white page while the
bundle loads: React only applies the scheme once it mounts. The inline script
in index.html sets data-mantine-color-scheme on <html> before first
paint, reading the same localStorage key Mantine uses (and honouring auto
via prefers-color-scheme). Keep the two defaults in sync.
0001 update: submit-to-commit for date+time
With includeTime, the first calendar click used to commit and close the
editor, leaving no chance to type a time. The selection is now held locally
and committed on onSubmit (the check button). Date-only columns keep the
old click-and-close behaviour, since there is nothing further to enter.
An uncommitted draft is dropped when the editor closes, so a cancelled edit does not resurface on the next open.
0005-base-row-pages-schema.patch
apps/server/src/database/migrations/20260813T120000-base-row-pages.ts (new)
apps/server/src/database/types/db.d.ts
Schema for Notion-style row pages: each base row may own a page whose title is the row's primary cell and whose metadata is the row's other cells.
Adds base_rows.row_page_id. Deliberately not page_id — that column
already exists and means "which base this row belongs to"; this one points the
other way.
Nullable because pages are created lazily, on first open: a 10,000-row import
would otherwise materialise 10,000 pages and ydocs, most never looked at.
ON DELETE SET NULL so hard-deleting a page orphans the link rather than
destroying the row. A partial unique index enforces one page per row while
letting the many NULLs coexist.
The db.d.ts hunk is the matching type. That file is generated by
kysely-codegen, so an upstream regeneration will drop the line and this patch
re-adds it.
This is the first patch that adds a migration. It works because patches
apply before pnpm build, so the file compiles into dist and the migration
runner picks it up like any other.
0006-sidebar-create-base-or-board.patch
features/page/tree/components/create-page-menu.tsx (new)
features/page/tree/hooks/use-tree-mutation.ts
features/page/tree/components/space-tree-row.tsx
features/space/components/sidebar/space-sidebar.tsx
ee/base/types/base.types.ts
The sidebar + only ever created ordinary pages, so a base or a board could
only be made from inside an existing page via /base — not where anyone looks
for them. Both + buttons (the Pages header and the per-row one) are now a
menu offering New page / New base / New board.
All three go through the same handleCreate, which grew a kind argument, so
the optimistic tree insert, websocket broadcast and navigation are identical
whichever you pick. Bases go to /bases/create rather than creating a page and
converting it — converting would leave a plain page behind if the second call
failed.
Two details worth keeping if this patch ever needs rebasing:
- the button calls both
preventDefaultandstopPropagation: the tree row's+lives inside a<Link>, and without both, opening the menu also navigates to that page; IBasecalls the titlenamewhilebuildPageUrlreadstitle, so the created base is mapped before being handed to the shared insert path.
Server-side counterparts (no patch, they live in the bundle): CreateBaseDto
now accepts a base with no name, and getInfo returns parentPageId and
position so the tree insert needs no extra round trip.
0008-lock-externally-synced-groups.patch
apps/server/src/core/group/services/group.service.ts
apps/server/src/core/group/services/group-user.service.ts
apps/client/src/features/group/types/group.types.ts
apps/client/src/features/group/components/{group-list,group-details,group-members,group-action-menu}.tsx
SCIM-provisioned groups are written with is_external = true, and nothing
read it — not one reference in core, and none in the client. So a synced
group could be renamed, deleted, and have members added or removed through the
normal UI, none of which travels back to the IdP: the next sync either undoes
it or creates a second group under the old name.
Server guards sit beside the existing isDefault checks, which is the same
idea and the same shape:
| method | now rejects |
|---|---|
group.service.updateGroup |
rename / describe |
group.service.deleteGroup |
delete |
group-user.service.addUsersToGroupBatch |
manual add |
group-user.service.removeUserFromGroup |
manual remove |
SCIM's own writes are unaffected — scim-group.service goes through
GroupRepo/GroupUserRepo directly, not these services, so the guards never
block a sync.
Client marks them with a "SCIM" badge in the list (tooltipped) and on the detail page, explains why in a line of muted text, disables Edit/Delete in the action menu and member removal, and hides Add member. The badge matters as much as the lock: disabled controls with no explanation read as a bug.
Users are deliberately left editable — only groups are locked.
Regenerated for 5b854645. Upstream's feat: search group members
(d92716d8) restructured group-members.tsx — added a SearchInput, swapped
useCursorPaginate for usePaginateAndSearch, and re-indented the whole table
body — which broke two of this patch's three hunks there. The three edits are
unchanged in intent (useGroupQuery import, the isSynced derivation, and
disabled={isSynced} on the remove item); they were re-seated on the new
structure by hand. The other six files applied untouched.
0009-group-driven-workspace-roles.patch
apps/server/src/database/migrations/20260814T090000-group-workspace-role.ts (new)
apps/server/src/database/types/db.d.ts
apps/server/src/database/repos/{group/group.repo,user/user.repo}.ts
apps/server/src/core/group/services/{group.service,group-user.service}.ts
apps/server/src/core/workspace/services/workspace.service.ts
apps/client/src/features/group/{types,services,queries}/…
apps/client/src/features/group/components/{group-details,group-list}.tsx
apps/client/src/features/user/types/user.types.ts
apps/client/src/features/workspace/components/members/components/workspace-members-table.tsx
Schema and core wiring for group-driven workspace roles. The rules, the
guards and why owners sit outside all of it are in the bundle's own
README.md under group-role; this covers only what had to live in files
the parent repo owns.
Two columns. groups.workspace_role is the grant, users.group_role the
receipt. Both nullable, and nullable is the point: a NOT NULL default of
'member' on the first would demote every admin in the workspace the instant
the migration ran. The receipt's CHECK admits only member/admin — an owner
is never group-managed, so 'owner' is not a state it can hold.
Three EE hooks, in core's own require() + ModuleRef idiom, at the
points where group membership changes: addUsersToGroupBatch,
removeUserFromGroup, deleteGroup. Each skips unmarked groups, and each
falls through its try/catch to a no-op without the bundle, so unpatched
upstream behaviour is exactly what you get.
One guard that needs no hook. updateWorkspaceUserRole refuses a manual
role change for a user carrying a receipt, reading the column directly — no
EE lookup, and without the bundle the column is never written, so the
condition is never true. Promotion to OWNER is the deliberate exception and
clears the receipt in the same statement, so the lock releases immediately
rather than at the next recompute.
The two repo hunks add one column each to baseFields, which is all it takes
to get both values out to the client. The db.d.ts hunks are the matching
types; that file is kysely-codegen output, so an upstream regeneration drops
them and this patch puts them back — same arrangement as 0005.
Note this patch and 0008 edit the same two group services, which is what prompted the stacking rules above.
Client cache. The groups list gets a Role column, and the role mutation
writes the server's response straight into the cache with setQueryData +
refetchQueries rather than invalidateQueries. That is not a style
preference: main.tsx sets refetchOnMount: false with a five-minute
staleTime globally, so invalidation only refetches queries mounted at that
moment. The groups list usually isn't, so it kept a stale copy — and
useGetGroupsQuery has an effect that seeds every list item into
["group", id], which then overwrote the correct value on the detail page.
The role read "Not assigned" again and, with no refetch on mount, never
recovered. Any future mutation touching group or member data wants the same
treatment; useDeleteGroupMutation already used refetchQueries for this
reason.
Regenerated for 7bef7b1a. Upstream's fix: db lock operations (5792fc7c)
moved the role change into a transaction and extracted the owner count into
validateLastWorkspaceOwner, so both hunks in workspace.service.ts lost
their context. The guard and the owner-promotion clear are unchanged in intent,
re-seated inside the new executeTx block.
Regenerating this one is a trap worth writing down. Only the drifted file's
section was replaced, not the whole patch. A plain
git diff -- <all 0009 files> looks right and is wrong twice over:
git diffcompares against HEAD, and HEAD does not have 0008 applied — so for the four files 0008 and 0009 share, the diff comes back containing both patches' changes. Applying 0008 then 0009 then conflicts with itself.git diffignores untracked files, so the migration 0009 creates vanished from the patch silently — the exact failureverify-patches.shstep 2 exists to catch, and the reasongit add -Nis in "When one stops applying" above.
The safe procedure when one file in a stacked patch drifts: reset to pristine,
hand-edit only that file, git diff -- <that file> alone, and splice the
result over the corresponding section of the existing patch. 0008 does not
touch workspace.service.ts, which is what made the isolated diff exact here.
0010-openapi-swagger.patch
apps/server/nest-cli.json
apps/server/package.json
apps/server/src/main.ts
pnpm-lock.yaml
pnpm-workspace.yaml
Serves an OpenAPI document and Swagger UI for all 223 routes — 98 from this
bundle, 125 from core. The configuration itself lives in the bundle
(openapi/setup-openapi.ts); this patch is only the four lines of wiring the
parent repo has to hold.
main.ts gets the usual require() + try/catch hook, placed after the
global prefix, pipes and interceptors so the document describes the app as it
is actually served. Without the bundle it falls through and there are no docs.
nest-cli.json enables the @nestjs/swagger CLI plugin, which is what
makes this worth doing at all: it reads TypeScript types, class-validator
decorators and JSDoc at build time and emits schema metadata for every DTO —
including core's, without editing a single core file. A @IsUUID() becomes
format: uuid, an @IsIn([...]) becomes an enum, and a doc comment becomes
the field description.
The lockfile is substituted, not patched. @nestjs/swagger is not in
upstream's package.json, so the lockfile has to gain it — but a diff
against pnpm-lock.yaml is the most rebase-fragile thing this directory could
carry. It is generated, alphabetically ordered, and upstream's routine
"chore: package updates" commits rewrite exactly the lines the hunks need as
context; it would break on nearly every bump, and hand-merging a lockfile
conflict is miserable.
So the whole file ships as lockfile/pnpm-lock.yaml and the wrapper
Dockerfile copies it over upstream's before pnpm install. A copy always
applies. And it cannot go wrong quietly: a stale lockfile fails at the very
next step, pnpm install --frozen-lockfile, which is exactly the loud failure
you want. Regenerate with scripts/gen-lockfile.sh after any upstream move.
pnpm-workspace.yaml is here for a non-obvious reason: @nestjs/swagger
pulls in @scarf/scarf, which has a postinstall script, and pnpm 11 refuses
to proceed when an unapproved build script appears — ERR_PNPM_IGNORED_BUILDS.
That fails pnpm install --frozen-lockfile --prod in the Docker installer
stage, so the image will not build without this. Scarf is install-time
telemetry, so the answer is false; the entry has to be a deliberate
true/false either way, and pnpm writes a placeholder demanding one.
Regenerated for 5b854645. No conflict, only drift: upstream's package
updates (8913d20a) and MCP OAuth (e56de8eb) moved the context around all
four hunks, so the patch still applied under GNU patch but only with fuzz,
and git apply — which apply-patches.sh tries first — rejected it outright.
Regenerated so both agree. Worth knowing that fuzz is not automatically benign
here: a fuzzy hunk against a package.json dependency list can attach itself
in the wrong place. This one landed correctly (@nestjs/swagger in its
alphabetical slot), which was checked rather than assumed.