2 Patches
Ulysia edited this page 2026-10-06 16:54:31 +02:00

Mirrored from patches/README.md at commit f31ec6a. 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 --revert walks 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.sh applies 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 status could never be clean in the "pristine" state. Reverting the stack moved license-details.tsx away from HEAD, so the tree everyone described as pristine was dirty by construction.
  • Regenerating 0002 the obvious way silently dropped the edition string and caption, because git diff saw them as already in HEAD. 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.sh cannot catch this. Its completeness check compares against HEAD, 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:

  • .gitattributes pins this repo to LF (.ps1 excepted). 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 diff with no argument compares against the INDEX; git diff HEAD compares 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. prepare stages the lower patches so the index is the right baseline.
  • Created files. git diff ignores untracked files, so a patch that adds one loses it silently. write runs git add -N for 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:

  1. No time could be entered. Both used Mantine's DatePicker, which is date-only and has no time field, so the includeTime property option had nothing to act on. Switched to InlineDateTimePicker when includeTime is set, with timeFormat wired to its TimePicker.

  2. 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 stored 00:00Z, which the display then rendered with local getHours() as 02: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 preventDefault and stopPropagation: the tree row's + lives inside a <Link>, and without both, opening the menu also navigates to that page;
  • IBase calls the title name while buildPageUrl reads title, 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 diff compares 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 diff ignores untracked files, so the migration 0009 creates vanished from the patch silently — the exact failure verify-patches.sh step 2 exists to catch, and the reason git add -N is 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.